-
Notifications
You must be signed in to change notification settings - Fork 3.5k
improvement(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join, outbox service #4219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
3f6142e
2a4a417
c1e39ac
2bb951d
62727f0
e561c3b
02161e1
d2049d2
420e1df
1e0e5e4
fca19e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,17 +14,18 @@ import { | |
| workspaceInvitation, | ||
| } from '@sim/db/schema' | ||
| import { createLogger } from '@sim/logger' | ||
| import { and, eq, inArray } from 'drizzle-orm' | ||
| import { and, eq, inArray, sql } from 'drizzle-orm' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { z } from 'zod' | ||
| import { getEmailSubject, renderInvitationEmail } from '@/components/emails' | ||
| import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' | ||
| import { getSession } from '@/lib/auth' | ||
| import { hasAccessControlAccess } from '@/lib/billing' | ||
| import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' | ||
| import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers' | ||
| import { requireStripeClient } from '@/lib/billing/stripe-client' | ||
| import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers' | ||
| import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' | ||
| import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' | ||
| import { enqueueOutboxEvent } from '@/lib/core/outbox/service' | ||
| import { getBaseUrl } from '@/lib/core/utils/urls' | ||
| import { generateId } from '@/lib/core/utils/uuid' | ||
| import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' | ||
|
|
@@ -328,8 +329,6 @@ export async function PUT( | |
| } | ||
| } | ||
|
|
||
| let personalProToCancel: any = null | ||
|
|
||
| await db.transaction(async (tx) => { | ||
| await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId)) | ||
|
|
||
|
|
@@ -342,8 +341,7 @@ export async function PUT( | |
| createdAt: new Date(), | ||
| }) | ||
|
|
||
| // Snapshot Pro usage and cancel Pro subscription when joining a paid team | ||
| try { | ||
| { | ||
| const orgSubs = await tx | ||
| .select() | ||
| .from(subscriptionTable) | ||
|
|
@@ -356,7 +354,7 @@ export async function PUT( | |
| .limit(1) | ||
|
|
||
| const orgSub = orgSubs[0] | ||
| const orgIsPaid = orgSub && isOrgPlan(orgSub.plan) | ||
| const orgIsPaid = orgSub && isPaid(orgSub.plan) | ||
|
|
||
| if (orgIsPaid) { | ||
| const userId = session.user.id | ||
|
|
@@ -393,8 +391,9 @@ export async function PUT( | |
| .update(userStats) | ||
| .set({ | ||
| proPeriodCostSnapshot: currentProUsage, | ||
| currentPeriodCost: '0', // Reset so new usage is attributed to team | ||
| currentPeriodCopilotCost: '0', // Reset copilot cost for new period | ||
| proPeriodCostSnapshotAt: new Date(), | ||
| currentPeriodCost: '0', | ||
| currentPeriodCopilotCost: '0', | ||
| }) | ||
| .where(eq(userStats.userId, userId)) | ||
|
|
||
|
|
@@ -405,19 +404,48 @@ export async function PUT( | |
| }) | ||
| } | ||
|
|
||
| // Mark for cancellation after transaction | ||
| if (personalPro.cancelAtPeriodEnd !== true) { | ||
| personalProToCancel = personalPro | ||
| if (personalPro.cancelAtPeriodEnd !== true && personalPro.stripeSubscriptionId) { | ||
| await tx | ||
| .update(subscriptionTable) | ||
| .set({ cancelAtPeriodEnd: true }) | ||
| .where(eq(subscriptionTable.id, personalPro.id)) | ||
|
|
||
| await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { | ||
| stripeSubscriptionId: personalPro.stripeSubscriptionId, | ||
| subscriptionId: personalPro.id, | ||
| reason: 'member-joined-paid-org', | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| const storageRows = await tx | ||
| .select({ storageUsedBytes: userStats.storageUsedBytes }) | ||
| .from(userStats) | ||
| .where(eq(userStats.userId, userId)) | ||
| .for('update') | ||
| .limit(1) | ||
|
|
||
| const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0 | ||
| if (bytesToTransfer > 0) { | ||
| await tx | ||
| .update(organization) | ||
| .set({ | ||
| storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`, | ||
| }) | ||
| .where(eq(organization.id, organizationId)) | ||
|
|
||
| await tx | ||
| .update(userStats) | ||
| .set({ storageUsedBytes: 0 }) | ||
| .where(eq(userStats.userId, userId)) | ||
|
|
||
| logger.info('Transferred personal storage bytes to org pool on join', { | ||
| userId, | ||
| organizationId, | ||
| bytes: bytesToTransfer, | ||
| }) | ||
| } | ||
|
icecrasher321 marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Storage transfer runs even without personal Pro subscriptionLow Severity The storage transfer block (lines 421–447) is inside the Reviewed by Cursor Bugbot for commit fca19e8. Configure here.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this didn't exist before, no regression |
||
| } | ||
| } catch (error) { | ||
| logger.error('Failed to handle Pro user joining team', { | ||
| userId: session.user.id, | ||
| organizationId, | ||
| error, | ||
| }) | ||
| // Don't fail the whole invitation acceptance due to this | ||
| } | ||
|
|
||
| // Auto-assign to permission group if one has autoAddNewMembers enabled | ||
|
|
@@ -557,44 +585,6 @@ export async function PUT( | |
| } | ||
| } | ||
|
|
||
| // Handle Pro subscription cancellation after transaction commits | ||
| if (personalProToCancel) { | ||
| try { | ||
| const stripe = requireStripeClient() | ||
| if (personalProToCancel.stripeSubscriptionId) { | ||
| try { | ||
| await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, { | ||
| cancel_at_period_end: true, | ||
| }) | ||
| } catch (stripeError) { | ||
| logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', { | ||
| userId: session.user.id, | ||
| subscriptionId: personalProToCancel.id, | ||
| stripeSubscriptionId: personalProToCancel.stripeSubscriptionId, | ||
| error: stripeError, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| await db | ||
| .update(subscriptionTable) | ||
| .set({ cancelAtPeriodEnd: true }) | ||
| .where(eq(subscriptionTable.id, personalProToCancel.id)) | ||
|
|
||
| logger.info('Auto-cancelled personal Pro at period end after joining paid team', { | ||
| userId: session.user.id, | ||
| personalSubscriptionId: personalProToCancel.id, | ||
| organizationId, | ||
| }) | ||
| } catch (dbError) { | ||
| logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', { | ||
| userId: session.user.id, | ||
| subscriptionId: personalProToCancel.id, | ||
| error: dbError, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| if (status === 'accepted') { | ||
| try { | ||
| await syncUsageLimitsFromSubscription(session.user.id) | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.