improvement(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join, outbox service#4219
Conversation
… Stripe, transfer storage on org join
Route every billing decision (usage limits, credits, storage, rate
limit, threshold billing, webhooks, UI permissions) through the
subscription's `referenceId` instead of plan-name heuristics. Fixes
the production state where a `pro_6000` subscription attached to an
organization was treated as personal Pro by display/edit code while
execution correctly enforced the org cap.
Scope
- Add `isOrgScopedSubscription(sub, userId)` (pure) and
`isSubscriptionOrgScoped(sub)` (async DB-backed) helpers. One is
used wherever a user perspective is available; the other in webhook
handlers that only have a subscription row.
- Replace plan-name scope checks in ~20 files: usage/limit readers,
credits balance + purchase, threshold billing, storage limits +
tracking, rate limiter, invoice + subscription webhooks, seat
management, membership join/leave, `switch-plan` admin gate,
admin credits/billing routes, copilot 402 handler, UI subscription
settings + permissions + sidebar indicator, React Query types.
Plan sync
- Add `syncSubscriptionPlan(subscriptionId, currentPlan, planFromStripe)`
called from `onSubscriptionComplete` and `onSubscriptionUpdate` so
the DB `plan` column heals on every Stripe event. Pro->Team upgrades
previously updated price, seats, and referenceId but left `plan`
stale — this is what produced the `pro_6000`-on-org row.
Priority + grace period
- `getHighestPrioritySubscription` now prefers org over personal
within each tier (Enterprise > Team > Pro, org > personal at each).
A user with a `cancelAtPeriodEnd` personal Pro who joins a paid org
routes pooled resources to the org through the grace window.
- `calculateSubscriptionOverage` personal-Pro branch reads user_stats
directly (bypassing priority) and bills only `proPeriodCostSnapshot`
when the user joined a paid org mid-cycle, so post-join org usage
isn't double-charged on the personal Pro's final invoice.
`resetUsageForSubscription` mirrors this: preserves
`currentPeriodCost` / `currentPeriodCopilotCost` when
`proPeriodCostSnapshot > 0` so the org's next cycle-close captures
post-join usage correctly.
Uniform base-price formula
- `basePrice × (seats ?? 1)` everywhere: `getOrgUsageLimit`,
`updateOrganizationUsageLimit`, `setUsageLimitForCredits`,
`calculateSubscriptionOverage`, threshold billing,
`syncSubscriptionUsageLimits`, `getOrganizationBillingData`.
Admin dashboard math now agrees with enforcement math.
Storage transfer on join
- Invitation-accept flow moves `user_stats.storageUsedBytes` into
`organization.storageUsedBytes` inside the same transaction when
the org is paid.
- `syncSubscriptionUsageLimits` runs a bulk-backfill version so
members who joined before this fix, or orgs that upgraded from
free to paid after members joined, get pulled into the org pool
on the next subscription event. Idempotent.
UX polish
- Copilot 402 handler differentiates personal-scoped ("increase your
usage limit") from org-scoped ("ask an owner or admin to raise the
limit") while keeping the `increase_limit` action code the parser
already understands.
- Duplicate-subscription error on team upgrade names the existing
plan via `getDisplayPlanName`.
- Invitation-accept invalidates subscription + organization React
Query caches before redirect so settings doesn't flash the user's
pre-join personal view.
Dead code removal
- Remove unused `calculateUserOverage`, and the following fields on
`SubscriptionBillingData` / `getSimplifiedBillingSummary` that no
consumer in the monorepo read: `basePrice`, `overageAmount`,
`totalProjected`, `tierCredits`, `basePriceCredits`,
`currentUsageCredits`, `overageAmountCredits`, `totalProjectedCredits`,
`usageLimitCredits`, `currentCredits`, `limitCredits`,
`lastPeriodCostCredits`, `lastPeriodCopilotCostCredits`,
`copilotCostCredits`, and the `organizationData` subobject. Add
`metadata: unknown` to match what the server returns.
Notes for the triggering customer
- The `pro_6000`-on-org row self-heals on the next Stripe event via
`syncSubscriptionPlan`. For the one known customer, a direct
UPDATE is sufficient:
`UPDATE subscription SET plan='team_6000' WHERE id='aq2...' AND plan='pro_6000'`.
Made-with: Cursor
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview Refactors pooled-usage math to be org-aware and non-O(N): adds shared org aggregation helpers, applies daily-refresh with per-member bounds for mid-cycle org joins, changes overage calculations to avoid double-counting pooled usage, and updates subscription selection to prefer org-scoped subs within a tier (e.g., during personal Pro cancel-at-period-end grace windows). Introduces a transactional outbox system for Stripe reconciliation: adds cron processing endpoint plus admin list/requeue APIs, and migrates cancel-at-period-end flows (invitation accept, admin subscription cancel) to enqueue outbox events instead of calling Stripe inline; also adds idempotency keys to seat updates and admin cancels. Additionally transfers users’ Reviewed by Cursor Bugbot for commit fca19e8. Configure here. |
Greptile SummaryThis PR routes all billing scope decisions through the subscription's
Confidence Score: 4/5Safe to merge with the finalizeInvoice retry-safety concern addressed; no financial data loss in any scenario, only operational noise from false-positive dead-letter events. The scope-routing refactor and outbox pattern are both sound. The one reliability concern — finalizeInvoice not being idempotent on retries — means a specific network-timeout race can dead-letter an outbox event even though Stripe successfully finalized and is collecting the invoice. No money is lost, but the admin outbox dashboard would show false-positive failures, and the style violations (instanceof Error vs toError()) are present across several new files. apps/sim/lib/billing/webhooks/outbox-handlers.ts — finalizeInvoice idempotency; apps/sim/lib/core/outbox/service.ts — toError() pattern Important Files Changed
Sequence DiagramsequenceDiagram
participant App as App (execution logger)
participant TB as ThresholdBilling
participant DB as DB Transaction
participant OB as outbox_event
participant Worker as Outbox Worker
participant Stripe as Stripe
App->>TB: usage crosses threshold
TB->>DB: BEGIN TRANSACTION
DB->>DB: SELECT userStats FOR UPDATE
DB->>DB: UPDATE billedOverageThisPeriod
DB->>OB: INSERT outbox_event pending
DB->>TB: COMMIT (atomic)
Worker->>OB: SELECT FOR UPDATE SKIP LOCKED
OB-->>Worker: claimed rows, status=processing
Worker->>Stripe: invoices.create with idempotencyKey
Stripe-->>Worker: invoice id
Worker->>Stripe: invoiceItems.create with idempotencyKey
Worker->>Stripe: invoices.finalizeInvoice (no idempotencyKey)
Stripe-->>Worker: finalized
Worker->>Stripe: invoices.pay if open
Worker->>OB: UPDATE status=completed via lease CAS
alt Transient failure
Worker->>OB: UPDATE status=pending with backoff
end
alt Max attempts exceeded
Worker->>OB: UPDATE status=dead_letter
end
Reviews (2): Last reviewed commit: "address comments" | Re-trigger Greptile |
|
bugbot run |
|
bugbot run |
|
bugbot run |
|
bugbot run |
|
bugbot run |
|
@greptile |
|
bugbot run |
|
bugbot run |
|
bugbot run |
|
bugbot run |
|
bugbot run |
| organizationId, | ||
| bytes: bytesToTransfer, | ||
| }) | ||
| } |
There was a problem hiding this comment.
Storage transfer runs even without personal Pro subscription
Low Severity
The storage transfer block (lines 421–447) is inside the if (orgIsPaid) block but outside the if (personalPro) check. This means storage is transferred from the user to the org even when the user has no personal Pro subscription — e.g. a free-tier user accepting an org invite. The addUserToOrganization helper in membership.ts has the same structure, so this appears intentional per the PR author's note, but it's worth noting this is a behavioral change from the old code where the entire billing block was wrapped in a try-catch that only ran for Pro users.
Reviewed by Cursor Bugbot for commit fca19e8. Configure here.
There was a problem hiding this comment.
this didn't exist before, no regression
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit fca19e8. Configure here.
| currentPeriodCost: toNumber(currentPeriodCost), | ||
| currentPeriodCopilotCost: toNumber(currentPeriodCopilotCost), | ||
| lastPeriodCopilotCost: toNumber(lastPeriodCopilotCost), | ||
| } |
There was a problem hiding this comment.
Redundant org member pooling queries in new code
Low Severity
Both aggregateOrgMemberStats (new in billing.ts) and getPooledOrgCurrentPeriodCost (new in usage.ts) execute nearly identical member LEFT JOIN userStats WHERE organizationId = ? queries, summing currentPeriodCost over all org members. The former adds copilot cost columns; the latter is a strict subset. Having two independent query functions for the same base data risks drift in null-handling or join semantics and doubles the DB round-trips when both are called in the same request path (e.g., the individual billing summary for org-scoped users calls getUserUsageData → getPooledOrgCurrentPeriodCost then aggregateOrgMemberStats).
Additional Locations (1)
Reviewed by Cursor Bugbot for commit fca19e8. Configure here.


Summary
referenceIdinstead of plan-name heuristics. Fixes the production state where apro_6000subscription attached to an organization was treated as personal Pro by display/edit code while execution correctly enforced the org cap.Type of Change
Testing
Tested using Stripe CLI
Checklist