Skip to content

fix(billing): restore JSON-safe license billing context - #2311

Merged
johnyeocx merged 2 commits into
mainfrom
hotfix/license-billing-context-json-safe
Jul 20, 2026
Merged

fix(billing): restore JSON-safe license billing context#2311
johnyeocx merged 2 commits into
mainfrom
hotfix/license-billing-context-json-safe

Conversation

@johnyeocx

@johnyeocx johnyeocx commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • restore the full dangling integration-fix commit, including the related changes that were no longer reachable from a branch
  • serialize license billing context with plain records and arrays instead of Map and Set so deferred checkout JSONB round-trips preserve projected license IDs and assigned-seat counts
  • retain the recovered sandbox Stripe guard, deleted-org and deleted-feature race handling, product-copy cache bypass, split events DB test lookup, and associated integration assertions

Incident context

The production deployment contained the affected code path. The observed projectedPlanLicenseIds.has crash occurred once in a sandbox organization; no live-mode occurrences were found during the incident check.

Verification

  • bun -F @autumn/server ts
  • focused Biome checks for the license billing hotfix files
  • targeted stripe-checkout-license-quantity integration test was attempted, but the local test server was unavailable at localhost:8080, so the test stopped at setup with ConnectionRefused before reaching assertions

Recovery provenance

Recovered dangling commit: b4331e347286393a4d3f5c62ab74036486fa8bf3
Applied hotfix commit: b140fed


Summary by cubic

Restore JSON‑safe license billing context to stop sandbox checkout crashes and preserve projected license IDs and seat counts across JSONB round‑trips. Also reinstate missing guards and fixes around Stripe setup, deleted org/feature races, and product copy reads.

  • Bug Fixes
    • Make customer license billing context JSON‑safe: Map/Set → plain objects/arrays. Update callers to use index access and includes/push; fix row resolution to use includes. Prevents the projectedPlanLicenseIds.has crash after JSON persistence.
    • Stripe: skip product resource initialization when no Stripe account is connected (e.g. fresh sandbox sub‑orgs) via isStripeConnected.
    • Queue worker: if the org was deleted after queuing, log a warning and skip the job instead of throwing.
    • Features: handle read→update races by returning null when no row was updated; clear cache only when an update happened.
    • Product copy: bypass the products cache by using inIds to reliably read copied products immediately after copy.
    • Tests/infra: read events from the split Neon events DB when configured; enable variant preview_update; update prepaid and variant tests (tier bounds, expectations, negative balances on zero‑quantity prepaid, license copy asserts).

Written for commit cacdd9c. Summary will update on new commits.

Review in cubic

Greptile Summary

This hotfix restores a dangling integration commit and patches a production crash (projectedPlanLicenseIds.has TypeError) caused by Map and Set instances not surviving a JSONB round-trip in deferred checkout metadata.

  • [Bug fixes] CustomerLicenseBillingContext fields changed from Map/Set to plain Record/string[] so the struct serialises cleanly to JSONB; all call-sites updated (setupCustomerLicenseBillingContext, applyCustomerLicenseTransitions, resolveLicenseBillingRowsThroughDefinition, and the two line-item helpers).
  • [Bug fixes] FeatureService.update now returns null early when the DB update matches zero rows, preventing a crash when a feature is deleted between read and update; createWorkerContext logs a warning and skips gracefully when a queued job's org no longer exists; initStripeResourcesForProducts adds an isStripeConnected guard for fresh sandbox sub-orgs.
  • [Bug fixes] handleCopyProducts passes inIds to the post-copy listFull call to bypass a stale cache snapshot; test event reads are routed through a new eventsDb() helper that mirrors the server's split-DB resolution.

Confidence Score: 4/5

The core billing fix is correct and well-scoped; the worker context change introduces a silent failure risk on transient DB errors.

The Map/Set → Record/array migration is consistent across all call-sites and the type definition, and the FeatureService race fix is straightforward. The one concern is in createWorkerContext.ts: the bare try/catch wrapping OrgService.getWithFeatures will absorb transient database errors as if the org were deleted, causing queued jobs to be silently skipped instead of retried. OrgService already exposes an allowNotFound option that would scope the graceful-skip to the not-found case only while letting real errors propagate.

server/src/queue/createWorkerContext.ts — the bare catch block should be replaced with the allowNotFound option on OrgService.getWithFeatures

Important Files Changed

Filename Overview
shared/models/billingModels/context/customerLicenseBillingContext.ts Type definition updated from Map/Set to Record/string[] to make the struct JSON-safe for JSONB persistence; the change is the anchor for the entire hotfix
server/src/internal/billing/v2/setup/customerLicenseBillingContext/setupCustomerLicenseBillingContext.ts Construction updated to use Object.fromEntries and [] instead of new Map/new Set, matching the updated type; logic is unchanged
server/src/internal/billing/v2/compute/customerLicenseTransitions/applyCustomerLicenseTransitions.ts Switched from Set.add to array push with an includes-based dedup guard; semantics preserved, Map.get replaced with object index access
server/src/internal/billing/v2/utils/lineItems/resolveLicenseBillingRowsThroughDefinition.ts Type updated from Set to string[], Set.has replaced with Array.includes; import style cleaned up to use type import
server/src/queue/createWorkerContext.ts Deleted-org race condition now handled gracefully, but the bare try/catch swallows all exceptions including transient DB errors, risking silent job loss on infrastructure failures
server/src/internal/features/FeatureService.ts Early return added before clearOrgCache when updatedFeatures is empty, fixing a latent crash (accessing index 0 of empty array) when a feature is deleted between read and update
server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts inIds added to the post-copy ProductService.listFull call to bypass the cache and avoid reading a stale pre-copy snapshot
server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts Added isStripeConnected guard to skip Stripe resource initialisation for fresh sandbox sub-orgs that have no Stripe account yet
server/tests/integration/balances/utils/events/getCustomerEvents.ts Extracted eventsDb helper that resolves to the split Neon events DB when configured, so test assertions read from the same DB the server wrote to
server/tests/integration/licenses/catalog-update/license-sandbox-copy.test.ts New integration test for sandbox copy of plan license links; directly exercises the action layer without a dev server

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Deferred checkout triggered] --> B[setupCustomerLicenseBillingContext]
    B --> C["Build context with\nRecord<string,number> + string[]"]
    C --> D[Persist context to JSONB metadata]
    D --> E[JSONB round-trip preserves plain objects]
    E --> F[applyCustomerLicenseTransitions]
    F --> G["projectedPlanLicenseIds.push + includes dedup"]
    G --> H[resolveLicenseBillingRowsThroughDefinition]
    H --> I["includes check → select projected or persisted rows"]
    I --> J[customerLicenseToLineItems / ToStripeItemSpecs]
    J --> K[Correct seat counts billed]

    style D fill:#f9f,stroke:#333
    style E fill:#f9f,stroke:#333
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Deferred checkout triggered] --> B[setupCustomerLicenseBillingContext]
    B --> C["Build context with\nRecord<string,number> + string[]"]
    C --> D[Persist context to JSONB metadata]
    D --> E[JSONB round-trip preserves plain objects]
    E --> F[applyCustomerLicenseTransitions]
    F --> G["projectedPlanLicenseIds.push + includes dedup"]
    G --> H[resolveLicenseBillingRowsThroughDefinition]
    H --> I["includes check → select projected or persisted rows"]
    I --> J[customerLicenseToLineItems / ToStripeItemSpecs]
    J --> K[Correct seat counts billed]

    style D fill:#f9f,stroke:#333
    style E fill:#f9f,stroke:#333
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
server/src/queue/createWorkerContext.ts:34-38
**Bare catch swallows transient DB errors**

The bare `try/catch` around `OrgService.getWithFeatures` treats every thrown exception — including database connection timeouts, network errors, or Drizzle query failures — identically to a deleted-org `RecaseError`. When a transient error fires, `orgData` is set to `null`, the job logs a warning, and returns silently. The queue never sees the error, so the job is not retried and the work is permanently dropped.

`OrgService.getWithFeatures` already has an `allowNotFound: true` option that returns `null` specifically for the not-found case while letting real errors propagate. Using that option keeps the intended graceful-skip behaviour for deleted orgs without silently discarding jobs on infrastructure failures.

```suggestion
	// Fetch org with features once for all items. A missing org means it was
	// deleted after the job was queued (common in tests) — skip, don't fail.
	const orgData = await OrgService.getWithFeatures({
		db,
		orgId,
		env,
		allowNotFound: true,
	});
```

Reviews (1): Last reviewed commit: "chore(billing): 🤖 clean recovered hotfi..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

@johnyeocx
johnyeocx requested a review from ay-rod as a code owner July 20, 2026 16:28
@capy-ai

capy-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

Comment on lines +34 to +38
try {
orgData = await OrgService.getWithFeatures({ db, orgId, env });
} catch {
orgData = null;
}

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 Bare catch swallows transient DB errors

The bare try/catch around OrgService.getWithFeatures treats every thrown exception — including database connection timeouts, network errors, or Drizzle query failures — identically to a deleted-org RecaseError. When a transient error fires, orgData is set to null, the job logs a warning, and returns silently. The queue never sees the error, so the job is not retried and the work is permanently dropped.

OrgService.getWithFeatures already has an allowNotFound: true option that returns null specifically for the not-found case while letting real errors propagate. Using that option keeps the intended graceful-skip behaviour for deleted orgs without silently discarding jobs on infrastructure failures.

Suggested change
try {
orgData = await OrgService.getWithFeatures({ db, orgId, env });
} catch {
orgData = null;
}
// Fetch org with features once for all items. A missing org means it was
// deleted after the job was queued (common in tests) — skip, don't fail.
const orgData = await OrgService.getWithFeatures({
db,
orgId,
env,
allowNotFound: true,
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/queue/createWorkerContext.ts
Line: 34-38

Comment:
**Bare catch swallows transient DB errors**

The bare `try/catch` around `OrgService.getWithFeatures` treats every thrown exception — including database connection timeouts, network errors, or Drizzle query failures — identically to a deleted-org `RecaseError`. When a transient error fires, `orgData` is set to `null`, the job logs a warning, and returns silently. The queue never sees the error, so the job is not retried and the work is permanently dropped.

`OrgService.getWithFeatures` already has an `allowNotFound: true` option that returns `null` specifically for the not-found case while letting real errors propagate. Using that option keeps the intended graceful-skip behaviour for deleted orgs without silently discarding jobs on infrastructure failures.

```suggestion
	// Fetch org with features once for all items. A missing org means it was
	// deleted after the job was queued (common in tests) — skip, don't fail.
	const orgData = await OrgService.getWithFeatures({
		db,
		orgId,
		env,
		allowNotFound: true,
	});
```

How can I resolve this? If you propose a fix, please make it concise.

@johnyeocx
johnyeocx merged commit 86829b0 into main Jul 20, 2026
17 checks passed
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