improvement(billing): ledger-only usage + period-advance cycle close - #7078
improvement(billing): ledger-only usage + period-advance cycle close#7078icecrasher321 wants to merge 14 commits into
Conversation
…iod advance Usage is now the attributed usage_log ledger everywhere: the userStats baselines (currentPeriodCost / currentPeriodCopilotCost), the includeLegacyBaseline compatibility flag, the pro-snapshot join/leave machinery, and departedMemberUsage accrual are removed from all read and write paths. Cycle rollover (final sub-threshold overage collection, billedOverageThisPeriod reset, last-period bookkeeping) moves off the invoice.finalized payload parsing — dead for org subscriptions since May — onto a period-advance sweep with a durable per-subscription close marker (subscription.last_closed_period_start), transaction-enlisted Stripe outbox invoicing, and stamp-matched ledger sums. Enterprise closes are bookkeeping-only; reporting-anchor orgs advance the marker alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Greptile SummaryThe PR makes attributed usage-ledger rows the sole source of billing usage and replaces invoice-triggered rollover with durable period-advance cycle closing.
Confidence Score: 5/5The PR appears safe to merge because the previously reported billing-cycle and refresh failures are resolved and no blocking failure remains. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/billing/cycle-close.ts | Implements durable period-advance closing, stamped-ledger aggregation, transactional overage settlement, bookkeeping, and marker claiming; the reviewed prior issues are resolved at current HEAD. |
| apps/sim/lib/billing/threshold-billing.ts | Defers settlement while an elapsed period remains open and revalidates the expected period under the tracker lock before mutation. |
| apps/sim/lib/billing/credits/daily-refresh.ts | Aligns refresh membership with billing entity and period stamps while clamping late rows into the elapsed period’s final day. |
| apps/sim/lib/billing/core/usage.ts | Removes legacy baseline accounting so attributed ledger entries provide current-period usage. |
| apps/sim/app/api/cron/billing-cycle-close/route.ts | Adds an authenticated, Redis-serialized background entry point for periodic cycle-close sweeps. |
| packages/db/migrations/0305_add_subscription_last_closed_period_start.sql | Adds the nullable durable close marker used to initialize and replay cycle closing safely. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Subscription period advances] --> B[Cron sweep finds lagging close marker]
B --> C[Read elapsed-period ledger stamps]
C --> D[Compute refresh-adjusted overage]
D --> E[Lock tracker and organization state]
E --> F{Marker already advanced?}
F -->|Yes| G[No-op]
F -->|No| H[Apply credits]
H --> I[Enqueue remaining Stripe overage]
I --> J[Write last-period bookkeeping]
J --> K[Reset billed-overage tracker]
K --> L[Claim close marker]
M[Threshold settlement] --> N{Close marker current?}
N -->|No| O[Defer until sweep closes period]
N -->|Yes| P[Lock tracker and revalidate period]
P --> Q[Settle current-period threshold overage]
Reviews (14): Last reviewed commit: "fix(billing): pair the overage tracker w..." | Re-trigger Greptile
…hold billing on close currency Cycle close now unions current members with every actor holding org-stamped ledger rows in the closed period, so a departed member's daily-refresh consumption offsets the final overage exactly like their billed usage. Threshold billing defers with a pending-cycle-close no-op while a subscription's close marker lags its current period, so the shared billedOverageThisPeriod tracker can never mix an elapsed period's settlements with the new period's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai review |
@icecrasher321 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 37 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
…lled usage Threshold settlement revalidates the close marker and period under the tracker lock via the same isSubscriptionCycleCloseCurrent predicate the preflight uses, so a rollover between check and transaction aborts as a concurrent-state retry instead of settling against the wrong cycle. Terminal bookkeeping claims the close marker with its tracker reset, so a deletion racing an in-flight sweep close serializes through the one marker and the loser rolls back rather than re-billing settled overage; anchored enterprise deletions only claim the marker since their windows derive from the anchor, not Stripe bounds. A close with overage due but missing Stripe identifiers now defers loudly instead of claiming the marker and silently forgiving the money, the closed window's start derives from the ledger's own period stamps so anchor-day drift cannot misalign the refresh window, calculateSubscriptionOverage unions departed ledger actors into the org refresh deduction like the close does, and blocked accounts report their real ledger usage while staying blocked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai review |
@icecrasher321 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 37 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
…-bearing A close with overage due but no owner-role member now defers loudly like the missing-Stripe-identifier case instead of claiming the marker and silently forgiving the money. The stamp-drift test pins the marker before the stamped boundary so only the ledger-stamp lookup can produce the asserted window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai review |
@icecrasher321 I have started the AI code review. It will take a few minutes to complete. |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai review |
@icecrasher321 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 38 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
Organization threshold billing now reads the period ledger per user and unions the actors holding org-attributed rows with the current roster before computing refresh deductions — the same actor set calculateSubscriptionOverage and the cycle close use — so a departed member's usage cannot be settled without their daily-refresh offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai review |
@icecrasher321 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 38 files
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
…d delete vestigial refresh bounds Subscription deletion now claims the close marker from the fresh subscription row before computing or charging final overage, serializing with the cycle-close sweep so both paths can never bill the same period — an in-flight close fails its guarded claim and rolls back, and the deletion settles against the row's real period instead of a possibly stale webhook payload. The per-user refresh bounds machinery is deleted outright: its only source was proPeriodCostSnapshotAt, which this PR stopped writing, and ledger entity stamps already scope refresh to org-attributed rows — a joiner's pre-join usage is user-stamped and can never enter the org refresh scan, while a departed member's org-stamped rows participate exactly like a current member's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai review |
@icecrasher321 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 40 files
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
…periods before deletion settlement The refresh actor-list threading (userIds + departed-actor unions) violated the entity-stamp principle the ledger sums already follow: org-attributed rows from departed members counted in pooled usage but never consumed refresh on the monitor/resolved-usage paths. Daily refresh now scopes rows by the same write-time entity and period stamps as the ledger — no actor list anywhere — which deletes the unions, the rollup memberIds, and the org-specific refresh variant. Deletion settlement now closes any elapsed-but-unclosed period (grace bypassed — no later sweep revisits a canceled sub) before claiming the terminal period, so a deletion racing the sweep can no longer jump the marker past an unsettled period and silently forgive its final overage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@cubic-dev-ai review |
@icecrasher321 I have started the AI code review. It will take a few minutes to complete. |
…y in the deduction Refresh membership is now the entity/period stamps alone — identical to the ledger sums it offsets. A row written after the rollover but stamped to the elapsed period (attribution frozen at run start) is billed by the stamp-based close, so it must consume refresh too; created-at now only assigns the day bucket, clamped into the period, instead of excluding the row entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@cubic-dev-ai review |
@icecrasher321 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 40 files
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
…in test clocks billedOverageThisPeriod only ever holds collections for the period that began at the close marker — the threshold gate blocks settlement whenever the marker lags. Both consumers now honor that pairing: a close that skipped forgiven periods counts nothing from the tracker against the period it bills, and the deletion settlement ignores the tracker when the marker was still lagging at claim time. Ignoring is provably safe in both cases because a lagging marker means no current-period collections exist. The cycle-close and daily-refresh suites pin the system clock: their grace and window checks compare fixed period fixtures against Date.now(), which made them dependent on the host date. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@cubic-dev-ai review |
@icecrasher321 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
2 issues found across 40 files
Confidence score: 2/5
apps/sim/lib/billing/cycle-close.tscan advance the terminal marker past an intervening rollover and silently skip a billing period, causing incorrect close processing; re-read and validate the marker before claiming the terminal period.apps/sim/lib/billing/webhooks/subscription.tsmisclassifies Enterprise reporting subscriptions that usesubscription.billingInterval, so deletion bookkeeping can apply Stripe-period behavior incorrectly; handle both interval sources before recording deletion state.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/lib/billing/cycle-close.ts">
<violation number="1" location="apps/sim/lib/billing/cycle-close.ts:232">
P1: When another rollover commits between the elapsed-period close and deletion's terminal claim, `claimTerminalPeriod` jumps the marker to the newest `periodStart` and silently skips the intervening period. Re-read and close every lagging period before claiming the terminal period, or reject the terminal claim unless the marker is current.</violation>
</file>
<file name="apps/sim/lib/billing/webhooks/subscription.ts">
<violation number="1" location="apps/sim/lib/billing/webhooks/subscription.ts:317">
P2: When an Enterprise reporting subscription stores its interval in `subscription.billingInterval` rather than `metadata.reportingPeriodInterval`, deletion bookkeeping treats it as a Stripe-period subscription because these calls omit `billingInterval`. Pass the stored billing interval through to `writeFinalPeriodBookkeeping` so reporting-anchor subscriptions remain bookkeeping no-ops.</violation>
</file>
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
| const markerWasCurrent = | ||
| !!row.lastClosedPeriodStart && | ||
| row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime() | ||
| await claimCloseMarker(tx, subscriptionId, row.periodStart) |
There was a problem hiding this comment.
P1: When another rollover commits between the elapsed-period close and deletion's terminal claim, claimTerminalPeriod jumps the marker to the newest periodStart and silently skips the intervening period. Re-read and close every lagging period before claiming the terminal period, or reject the terminal claim unless the marker is current.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/billing/cycle-close.ts, line 232:
<comment>When another rollover commits between the elapsed-period close and deletion's terminal claim, `claimTerminalPeriod` jumps the marker to the newest `periodStart` and silently skips the intervening period. Re-read and close every lagging period before claiming the terminal period, or reject the terminal claim unless the marker is current.</comment>
<file context>
@@ -0,0 +1,830 @@
+ const markerWasCurrent =
+ !!row.lastClosedPeriodStart &&
+ row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime()
+ await claimCloseMarker(tx, subscriptionId, row.periodStart)
+ return { periodStart: row.periodStart, periodEnd: row.periodEnd, markerWasCurrent }
+ })
</file context>
| periodStart: subscription.periodStart ?? null, | ||
| periodEnd: subscription.periodEnd ?? null, | ||
| ...settlementPeriod, | ||
| metadata: subscription.metadata, |
There was a problem hiding this comment.
P2: When an Enterprise reporting subscription stores its interval in subscription.billingInterval rather than metadata.reportingPeriodInterval, deletion bookkeeping treats it as a Stripe-period subscription because these calls omit billingInterval. Pass the stored billing interval through to writeFinalPeriodBookkeeping so reporting-anchor subscriptions remain bookkeeping no-ops.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/billing/webhooks/subscription.ts, line 317:
<comment>When an Enterprise reporting subscription stores its interval in `subscription.billingInterval` rather than `metadata.reportingPeriodInterval`, deletion bookkeeping treats it as a Stripe-period subscription because these calls omit `billingInterval`. Pass the stored billing interval through to `writeFinalPeriodBookkeeping` so reporting-anchor subscriptions remain bookkeeping no-ops.</comment>
<file context>
@@ -299,15 +284,37 @@ export async function handleSubscriptionDeleted(
- periodStart: subscription.periodStart ?? null,
- periodEnd: subscription.periodEnd ?? null,
+ ...settlementPeriod,
+ metadata: subscription.metadata,
})
</file context>
What
Makes the attributed
usage_logledger the sole source of truth for usage, and replaces the dead invoice-driven cycle rollover with a period-advance close.Ledger-only usage
currentPeriodCost/currentPeriodCopilotCostbaselines are read and written by nothing: removed from usage math (usage.ts,organization.ts,billing.ts,usage-monitor.ts), threshold billing, admin dashboard aggregates, org member routes, logger email context, and the admin manual-adjust route (now a warned no-op).includeLegacyBaseline(all call sites), the pro-snapshot join/leave machinery (proPeriodCostSnapshotcapture/restore/merge),departedMemberUsageaccrual, and the leave-time baseline wipe — ledger entity/period stamps make all of them redundant.@deprecated.Period-advance cycle close (
lib/billing/cycle-close.ts)invoice.finalizedhandler,resetUsageForSubscription, and the invoice line-period parsing are deleted — that path silently dropped every org subscription's invoice (billing_reason / payload-shape sensitivity), so team cycle closes have not run since May.subscription.last_closed_period_start, migration0305, additive) lagsperiod_start→ close the elapsed period. Ledger sums are matched on write-time period stamps.billedOverageThisPeriodreset;lastPeriodCost/lastPeriodCopilotCostwritten from ledger sums; marker claimed. Crash-safe, replay-safe./api/cron/billing-cycle-closeevery 6h (helm + docker/crontab; parity check passes). Subscription deletion useswriteFinalPeriodBookkeeping(final billing unchanged).Ops (already done / separate)
cron-jobs.ts, path/api/cron/billing-cycle-close,0 */6 * * *).user_statscounters + the two baselines + snapshot pair +departedMemberUsage+workflow_execution_logs.cost).Verification
turbo run type-check: 26/26 · fullapps/simsuite: 32,452 passed · newcycle-close.test.ts(10 tests)check:api-validation✓ ·check:cron-parity✓ ·check:migrations(backward-compatible) ✓ ·check:boundaries✓🤖 Generated with Claude Code