Skip to content

improvement(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join, outbox service#4219

Merged
icecrasher321 merged 11 commits intostagingfrom
fix/billing-scope-and-plan-sync
Apr 18, 2026
Merged

improvement(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join, outbox service#4219
icecrasher321 merged 11 commits intostagingfrom
fix/billing-scope-and-plan-sync

Conversation

@icecrasher321
Copy link
Copy Markdown
Collaborator

@icecrasher321 icecrasher321 commented Apr 18, 2026

Summary

  • 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.
  • Added outbox service to deal with failed stripe calls reconiciliation

Type of Change

  • Bug fix
  • Other: Code quality improvement

Testing

Tested using Stripe CLI

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

… 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
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Apr 18, 2026 5:41pm

Request Review

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 18, 2026

PR Summary

High Risk
High risk because it refactors core billing/usage-limit/overage calculations and shifts several Stripe-side effects (cancel-at-period-end, retries) to a new outbox workflow, which could affect enforcement and invoicing if edge cases are missed.

Overview
Fixes billing scoping by treating any subscription whose referenceId is an organization as org-scoped (including transferred pro_* plans), and updates server + UI decision points (usage limits, credit routing, plan switching permissions, portal context, warnings) to use this scope signal instead of plan-name heuristics.

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’ storageUsedBytes into the org pool on org-join and during subscription sync, and syncs DB subscription.plan from Stripe on checkout/update webhooks.

Reviewed by Cursor Bugbot for commit fca19e8. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 18, 2026

Greptile Summary

This PR routes all billing scope decisions through the subscription's referenceId (compared against the org table) instead of plan-name heuristics, fixing a production misclassification where a pro_* subscription attached to an org was treated as personal Pro. It also introduces a transactional outbox service for reliable Stripe calls — the billedOverageThisPeriod increment and the Stripe invoice enqueue now commit atomically, and personal-to-org storage transfer on join is wrapped in db.transaction() with FOR UPDATE row-locks.

  • The stripe.invoices.finalizeInvoice() call in the threshold-overage handler has no idempotency key and no guard against the "already finalized" error. In the rare case of a network timeout after Stripe processes the call, the outbox will retry, hit a 400, and eventually dead-letter the event — even though the invoice was correctly finalized and payment is being collected. Consider passing an idempotency key or catching the invoice_already_finalized error code.

Confidence Score: 4/5

Safe 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

Filename Overview
apps/sim/lib/billing/webhooks/outbox-handlers.ts New billing outbox handlers for Stripe cancel-at-period-end sync and threshold overage invoices; finalizeInvoice call is not retry-safe against network timeouts — would dead-letter even when the invoice was successfully finalized
apps/sim/lib/core/outbox/service.ts New transactional outbox service with SELECT FOR UPDATE SKIP LOCKED, exponential backoff, lease CAS, and stuck-row reaper — well-designed, but inline instanceof Error patterns should use toError() per project standards
apps/sim/lib/billing/subscriptions/utils.ts Adds isOrgScopedSubscription (pure referenceId comparison, O(1)) alongside the DB-query isSubscriptionOrgScoped; getEffectiveSeats now covers Pro plans (previously only Team)
apps/sim/lib/billing/organization.ts Storage transfer on org upgrade now wrapped in db.transaction() with FOR UPDATE row-lock, addressing the previous non-atomic snapshot concern; isPaid && !isEnterprise replaces the narrow isTeam gate for usage-limit sync
apps/sim/lib/billing/threshold-billing.ts Stripe invoice creation migrated to outbox pattern; billedOverageThisPeriod increment and outbox enqueue are now atomic within the same transaction; Stripe customer ID read directly from subscription row instead of a Stripe API roundtrip
apps/sim/lib/billing/organizations/membership.ts Stripe cancel-at-period-end sync moved into outbox (atomically within the membership transaction); per-user storage bytes transferred to org pool on admin-add; proPeriodCostSnapshotAt set on join for refresh-window slicing
packages/db/schema.ts New outbox_event table with composite (status, available_at) index for efficient polling; nullable pro_period_cost_snapshot_at column added to user_stats
apps/sim/lib/billing/core/billing.ts Overage calculation now routes via isSubscriptionOrgScoped (DB lookup) instead of plan-name checks; Pro branch reads userStats directly to avoid priority-lookup over-counting during cancel-at-period-end grace; delta-reset pattern added for both org and personal paths
apps/sim/lib/billing/webhooks/invoices.ts Payment-failure emails and usage-reset now use isSubscriptionOrgScoped (DB-authoritative); period-end delta-reset preserves post-join usage for users who joined an org mid-cycle; getCreditBalanceForEntity replaces direct inline queries

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (2): Last reviewed commit: "address comments" | Re-trigger Greptile

Comment thread apps/sim/lib/billing/organization.ts
Comment thread apps/sim/lib/billing/subscriptions/utils.ts Outdated
@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread apps/sim/lib/billing/credits/purchase.ts Outdated
Comment thread apps/sim/lib/billing/core/usage.ts
Comment thread apps/sim/lib/billing/webhooks/invoices.ts Outdated
@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread apps/sim/lib/billing/webhooks/invoices.ts
@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread apps/sim/lib/billing/validation/seat-management.ts
Comment thread apps/sim/lib/billing/core/billing.ts
@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

@icecrasher321
Copy link
Copy Markdown
Collaborator Author

@greptile

Comment thread apps/sim/lib/billing/calculations/usage-monitor.ts Outdated
Comment thread apps/sim/lib/billing/core/billing.ts
@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

@icecrasher321 icecrasher321 changed the title improvement(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join improvement(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join, outbox service Apr 18, 2026
Comment thread apps/sim/lib/billing/credits/purchase.ts Outdated
@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread apps/sim/lib/billing/credits/daily-refresh.ts
Comment thread apps/sim/lib/billing/calculations/usage-monitor.ts Outdated
@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

Comment thread apps/sim/lib/billing/calculations/usage-monitor.ts
@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

organizationId,
bytes: bytesToTransfer,
})
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fca19e8. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this didn't exist before, no regression

@icecrasher321
Copy link
Copy Markdown
Collaborator Author

bugbot run

@icecrasher321 icecrasher321 merged commit c246f5c into staging Apr 18, 2026
14 checks passed
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ 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),
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 getUserUsageDatagetPooledOrgCurrentPeriodCost then aggregateOrgMemberStats).

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fca19e8. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant