Skip to content

improvement(billing): migrate hot path writes away from user_stats#4768

Open
icecrasher321 wants to merge 2 commits into
stagingfrom
improvement/billing-ledge
Open

improvement(billing): migrate hot path writes away from user_stats#4768
icecrasher321 wants to merge 2 commits into
stagingfrom
improvement/billing-ledge

Conversation

@icecrasher321
Copy link
Copy Markdown
Collaborator

Summary

Migrate billing away from hot path writes to user_stats table. Source of truth becomes usage logs.

Type of Change

  • Other: Performance

Testing

WiP

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)

@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped May 28, 2026 9:35am

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 28, 2026

PR Summary

High Risk
Touches payment-critical paths (usage limits, overage, threshold billing, invoice resets) with a dual ledger + user_stats model; incorrect period attribution or cutover math could mis-bill or block usage.

Overview
Billing hot paths now append to usage_log onlyrecordUsage no longer updates user_stats (no additionalStats, no per-event currentPeriodCost / copilot / token / execution counters). Writes are scoped with billing entity + period, deterministic eventKeys, and onConflictDoNothing for idempotency; callers pass explicit keys/references where needed (e.g. update-cost idempotency, wand, voice, enrichment).

Period totals are read from the ledger via getBillingPeriodUsageCost / getBillingPeriodUsageCostByUser, combined with user_stats as a pre-cutover baseline in usage monitors, overage, threshold billing, org dashboards, and daily-refresh (refresh queries can filter by billing entity/period).

Schema adds usage_log billing scope columns, event_key, indexes, and a partial unique constraint (migration 0216).

Removed side effects: MCP copilot call counter updates, workflow lastActive bumps on run stats, execution trigger counters on recordUsage.

Invoice / subscription lifecycle resets and period-close overage now use Stripe invoice line periods and ledger sums when rolling lastPeriodCost and subtracting period usage from stats.

Reviewed by Cursor Bugbot for commit 05d3bab. Configure here.

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 3 potential issues.

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 05d3bab. Configure here.

eq(usageLog.billingEntityId, billingEntity.id),
eq(usageLog.billingPeriodStart, periodStart)
)
: undefined
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Daily refresh excludes pre-migration usage log rows

Medium Severity

The new billingEntityFilter uses eq() on columns (billingEntityType, billingEntityId, billingPeriodStart) that are NULL for all pre-migration usageLog rows. Since SQL equality with NULL returns false, every pre-existing usage log entry is invisible to daily refresh computation. All callers now pass billingEntity, so this filter is always active. During the billing period spanning deployment, days before the migration contribute zero to the refresh calculation, significantly under-deducting refresh credits and inflating effective usage — potentially causing paid users to hit limits prematurely.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 05d3bab. Configure here.

return {
percentUsed: 0,
isWarning: false,
isExceeded: false,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ledger usage ignored when userStats row missing

Low Severity

When no userStats row exists, checkUsageStatus returns currentUsage: 0 without querying getBillingPeriodUsageCost. Since recordUsage no longer writes to userStats at all, the ledger is now the sole record of post-migration usage. A user with ledger-tracked usage but a missing userStats row would be reported as having zero usage, bypassing usage limits entirely.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 05d3bab. Configure here.

.catch((error) => {
logger.error('Failed to track MCP copilot call', { error, userId })
})
logger.debug('MCP copilot call tracked via request logs', { userId })
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dead no-op function trackMcpCopilotCall still called

Low Severity

trackMcpCopilotCall was gutted to a single debug log but is still invoked at the call site. The function no longer tracks anything meaningful — it's dead code that adds confusion about whether MCP calls are actually being counted anywhere.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 05d3bab. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 28, 2026

Greptile Summary

This PR migrates billing writes off the user_stats hot path: recordUsage now appends attributed rows to usage_log (with idempotent eventKey dedup) instead of contending on user_stats row locks, and all billing readers (usage monitor, overage calculation, threshold billing, invoice finalization) now sum the user_stats baseline with a ledger aggregation over the new billing_entity_type/id + billing_period_start/end columns.

  • Schema: adds five nullable columns to usage_log (event_key, billing_entity_type, billing_entity_id, billing_period_start, billing_period_end), a partial unique index on event_key, a composite billing entity+period index, and a NOT VALID check constraint enforcing all-or-none billing scope; several user_stats columns are marked deprecated.
  • Migration: resolveBillingContext resolves the billing entity and period per write; getBillingPeriodUsageCost / getBillingPeriodUsageCostByUser aggregate the ledger for reads; resetUsageForSubscription now captures baseline + ledger into lastPeriodCost at period rollover.
  • Removal: per-event increments to execution counters (totalManualExecutions, trigger counters, totalTokensUsed, lastActive, MCP copilot call counters) are no longer written; those columns are marked deprecated in the schema.

Confidence Score: 3/5

The enterprise usage reset in handleInvoiceFinalized was previously unconditional; the new guard makes it contingent on successfully parsing the billing period from the Stripe webhook, creating a silent failure mode for enterprise customers.

A malformed or structurally unexpected invoice would leave enterprise currentPeriodCost accumulating indefinitely across billing periods. The baseline+ledger read pattern is architecturally correct and double-counting has been verified, but the sentinel duplication and index-sensitive eventKey are code-quality risks that warrant a follow-up before the WiP label is removed.

apps/sim/lib/billing/webhooks/invoices.ts (enterprise reset regression), apps/sim/lib/billing/core/usage-log.ts (sentinel duplication, eventKey ordering)

Important Files Changed

Filename Overview
apps/sim/lib/billing/webhooks/invoices.ts Adds getSubscriptionLinePeriod helper and threads billing period through resetUsageForSubscription; the new early-return guard on missing invoicePeriod silently skips the unconditional enterprise usage reset that existed before this PR.
apps/sim/lib/billing/core/usage-log.ts Core billing rewrite: removes userStats hot-path writes, adds billing entity/period attribution, introduces event-key idempotency, and exposes new ledger aggregation helpers. The OPEN_BILLING_PERIOD sentinel is defined here but duplicated in two other files.
apps/sim/lib/billing/calculations/usage-monitor.ts Migrates usage reads to baseline+ledger pattern; splits into helper functions. No double-counting issues found; org usage correctly uses org-entity ledger, personal users use user-entity ledger.
apps/sim/lib/billing/core/usage.ts Augments getPooledOrgCurrentPeriodCost to return lastPeriodCost; adds ledger usage on top of userStats baseline for both user and org reads; passes billingEntity through to computeDailyRefreshConsumed calls.
apps/sim/lib/billing/core/billing.ts calculateSubscriptionOverage now adds ledger usage from usage_log to the userStats baseline for both org and personal billing paths; passes billingEntity to refresh computations. Logic looks correct.
packages/db/schema.ts Adds billingEntityType enum, new attribution columns to usage_log, unique partial index on eventKey, composite index on billing entity+period, and a check constraint enforcing all-or-none billing scope columns.
packages/db/migrations/0216_dapper_the_spike.sql Migration adds the five new usage_log columns, two indexes, and a NOT VALID check constraint. NOT VALID is correct here to avoid locking on a large table; constraint will be validated separately.

Comments Outside Diff (2)

  1. apps/sim/lib/billing/core/usage-log.ts, line 474-480 (link)

    P2 stableEventKey encodes array index, making idempotency order-sensitive

    The index parameter in the hash ensures entries at different positions get different keys, which is correct for same-call deduplication. However, if a caller retries after a partial failure and reconstructs the entries array in a different order (even accidentally), each entry's eventKey changes, the ON CONFLICT DO NOTHING guard is bypassed, and the same charge is inserted twice. The hash should instead be anchored to a stable, content-derived identifier (e.g., executionId + source + description + category) or callers should pass an explicit eventKey for every retryable path.

  2. apps/sim/lib/billing/core/usage.ts, line 731-736 (link)

    P2 defaultBillingPeriod sentinel defined in three separate files

    The same {start: new Date(0), end: new Date(Date.UTC(9999, 11, 31))} sentinel is re-declared as OPEN_BILLING_PERIOD in usage-log.ts, defaultBillingPeriod() in usage-log.ts, defaultBillingPeriod() in usage.ts, and defaultBillingPeriod() in usage-monitor.ts. The aggregation query in getBillingPeriodUsageCost uses exact timestamp equality on billingPeriodStart/billingPeriodEnd, so if any one of these four definitions drifts, free-plan ledger queries will silently return 0. OPEN_BILLING_PERIOD is already exported from usage-log.ts — importing it in the other two files would eliminate the risk.

Reviews (1): Last reviewed commit: "fix period start and end sot" | Re-trigger Greptile

Comment on lines +954 to +961
const invoicePeriod = getSubscriptionLinePeriod(invoice, stripeSubscriptionId)
if (!invoicePeriod) {
logger.error('Missing subscription line period on subscription cycle invoice', {
invoiceId: invoice.id,
stripeSubscriptionId,
})
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Early return silently skips enterprise usage reset

Before this PR, the enterprise reset (isEnterprise branch) was unconditional — it always ran on subscription_cycle invoices regardless of whether period bounds were available. The new invoicePeriod guard is placed before that check, so if getSubscriptionLinePeriod returns null for any reason (unexpected Stripe invoice structure, missing stripeSubscriptionId match on any line item), the entire function exits and enterprise usage is never reset. Over time, currentPeriodCost would accumulate across billing periods and enterprise users would be incorrectly blocked or see inflated usage.

Consider separating the guard so enterprise plans can still fall back to a period-less reset:

if (!invoicePeriod) {
  logger.error('Missing subscription line period on subscription cycle invoice', { ... })
  if (isEnterprise(sub.plan)) {
    await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
  }
  return
}

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