Skip to content

release - #3189

Merged
charlietlamb merged 52 commits into
mainfrom
dev
Sep 1, 2026
Merged

release#3189
charlietlamb merged 52 commits into
mainfrom
dev

Conversation

@charlietlamb

@charlietlamb charlietlamb commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary by cubic

Adds pending status for deferred customer products so plans awaiting payment are visible and recoverable before the invoice or checkout is paid, and fixes related bugs in deferred billing, attach trials, and usage carryover.

New Features

  • Deferred attaches insert a pending customer product that grants nothing; paying the invoice or completing checkout promotes it to active in place.
  • Pending plans are editable and cancellable, and the dashboard shows a live payment link and a Pending badge.
  • Adds metadata_id to customer products to link pending rows to their deferred billing plan (migration 0073).
  • Attach trials now default to reverting to the previous plan on end, with an "On trial end" select replacing the old checkbox.
  • Generated plan-version changes preserve the customer's current price and allowances.

Bug Fixes

  • RevenueCat renewals now fully uncancel a past-due product that was also cancelled, clearing stale cancellation fields.
  • Analytics 1bc billing cycle ranges keep exact Stripe period boundaries instead of gap-multiplied windows.
  • Stopped reporting idle SQS workers to Sentry.
  • Editing a plan's grant to unlimited no longer bills covered usage, and keeps overage already accrued before the edit.
  • Lazy usage-window resets no longer stampede when concurrent workers roll the same window.
  • Pending rows no longer leak into migration filters: only attach-conflict checks treat them as relevant.

Written for commit 2deaaae. Summary will update on new commits.

Review in cubic

SirTenzin and others added 30 commits August 31, 2026 13:54
handleRenewal branches on status, so a product that was both cancelled and
past due only ever took the past-due branch. markCustomerProductActive writes
{ status: Active } and nothing else, leaving canceled, canceled_at and
ended_at from the earlier CANCELLATION intact — the uncancel branch that
clears them sits below and is unreachable.

Route the past-due recovery through uncancel when the product is also
cancelled. uncancelCustomerProduct already sets status: Active on top of
clearing the three fields, so it is a strict superset and both paths dispatch
the same AttachScenario.Renew webhook.
…cancellation

fix(revenuecat): clear cancellation when a past-due product renews
A deferred plan existed only as a frozen billing plan in metadata until its
invoice was paid, so nothing appeared on the customer while payment was
outstanding and there was no record of what had been billed for.

Insert the customer product when the invoice is raised, as a new Pending
status linked to the deferred plan by metadata_id. Pending grants nothing:
it stays out of ACTIVE_STATUSES, so entitlement checks and balances are
unchanged, and it joins RELEVANT_STATUSES so a second attach of the same
plan is still rejected while the first is in flight.

Paying the invoice promotes the row in place rather than inserting a new
one, carrying over the Stripe subscription and schedule ids. Voiding the
invoice expires it, skipping rows a success-path webhook already linked to
a subscription.

No existing status fits: Scheduled is activated by starts_at and would go
live unpaid, and PastDue grants entitlements.
Deferred checkout sessions returned early without a metadata id, so only
the invoice paths inserted a pending customer product. A plan bought
through Stripe Checkout stayed invisible until the session completed,
which is the most common way a deferred plan is created.

Return the metadata id from the checkout deferral so the existing gate in
executeBillingPlan inserts the row, promote it when the session completes,
and expire it when the session is abandoned.

Promotion now clears only the rows it promoted rather than emptying
insertCustomerProducts, so Stripe optional items folded in at completion
still get inserted.

Collapse the two expiry loops in the checkout-expired handler into a
shared expireCustomerProducts, and take a ctx across all three pending
helpers so the deferral paths read the same way.
A deferred attach with a custom plan failed with a customer_prices foreign
key violation: the pending insert wrote customer_prices referencing custom
prices that only executeAutumnBillingPlan persisted, which the deferred
path never reaches.

Extract insertCustomCatalogRows and call it from both paths, so custom
prices, entitlements and free trials exist before any customer row
references them. Both services upsert on conflict, so promotion inserting
them again is a no-op.
The deferred invoice path creates the Stripe subscription before it defers,
so every pending row carries a subscription id. The shared expiry loop
borrowed the checkout guard that skips rows with one — treating "already
completed" as the only reason a subscription could be linked — so voiding
the invoice left the pending row in place forever.

Make that guard opt-in and keep it for checkout-expired, where a linked
subscription really does mean the success path claimed the row. A pending
row is unpaid by its status, so it expires regardless.
Expiring pending rows ran inside the void's try block, so a failure there
was caught by the handler that classifies Stripe void errors — skipping the
metadata delete and leaving the row to be retried forever.

Expiring pending rows is cleanup: log a failure and carry on, so the void
and its metadata delete stand on their own.
Adding Pending to RELEVANT_STATUSES reached far past attach conflicts: the
migration filter compiler builds its customer scope from the same constant,
so every compiled filter widened its status list and unpaid plans would
have been swept into migrations.

Introduce ATTACH_CONFLICT_STATUSES for the one question that needs pending
rows — is this plan already attached — and leave RELEVANT_STATUSES as it
was. The billing context loads the wider set so the check can see them.
expireCustomerProducts took a boolean that changed which rows it touched,
so the function's job depended on a flag rather than its input. Only the
checkout-expired handler knows that a linked subscription means the success
path already claimed the row.

Filter there and pass the rows to expire, leaving the helper to do one
thing to everything it is given.
The plans table offered Update, Cancel and Transfer on a pending row. A
pending plan is a frozen billing plan replayed on payment, so an update
would not survive, and cancelling left the checkout session live for the
customer to pay against a plan that no longer exists.

Gate all three, matching the subscription sheet.
Cancelling routes through updateSubscription, which builds a plan against a
Stripe subscription the pending row has no meaningful relationship to. A
pending plan is a frozen billing plan awaiting payment, so cancelling it is
a discard, not a subscription change.

Short-circuit cancel for pending rows: expire the checkout session and void
the invoice so the customer can no longer pay for a plan that is gone, drop
the metadata holding the frozen plan, and expire the row.

Update and Transfer stay hidden on pending rows, since neither survives the
replay on payment.
Expiring a pending row wrote by id with no status check. Cancel holds the
billing lock but invoice.paid does not, so a payment landing mid-cancel
could be promoted to Active and then immediately overwritten as Expired,
leaving a paid customer with no plan.

Add CusProductService.expireIfPending, a conditional write that only
expires a row still awaiting payment, and use it from both the cancel
discard and the metadata-driven expiry.

Cancel also swallowed Stripe failures while still dropping the row, so a
failed void left an invoice the customer could still pay against a plan
that no longer exists. Let those propagate, skipping the close entirely
when the session or invoice is already terminal.

findPendingCustomerProduct ignored entity_id, so cancelling an entity's
plan could discard a different entity's pending row for the same product.
Promotion forced every row Active, so a deferred create-schedule granted
access to future phases the moment its payment landed. The plan already
decides each row's status when it is built, so restore that instead of
overwriting it — a future phase stays Scheduled.

multiUpdate never honoured deferral: executeMultiSubscriptionBillingPlan
ran the Autumn plan regardless, inserting active plans before payment.
Insert pending rows and return, matching executeBillingPlan.

insertPendingCustomerProducts now takes the autumn plan it actually uses,
so both call sites pass the same shape.
Rollovers are skipped for any plan that is not yet live, pending included,
so a deferred attach dropped them and nothing put them back on payment.

Scheduled plans have the same gap and already close it: activateScheduled
reapplies rollovers as the plan starts. Promotion is that same moment for a
pending plan, so reuse the helper there.
Taking the first deferred result marked the entire merged plan pending, so
products belonging to a subscription that succeeded were held back too.
Defer only when every subscription deferred.

Reapply rollovers to the persisted row rather than the planned one, which
still holds them in memory and would insert a second copy, and only for
rows going live — a phase promoted to Scheduled gets them on activation.

Add a seed scenario for ten planless customers, five carrying entities, for
testing pending plans by hand.
A pending plan gave no way to reach the invoice or checkout session holding
it up. Add an internal route that resolves the linked metadata to a live
Stripe URL, and show it in the subscription sheet.

Resolved on read rather than stored, so the link reflects the session or
invoice as it stands now and returns null once it is no longer payable.
The public API is untouched.
The cancel sheet previewed every cancellation against a live subscription,
so a pending plan showed "No active subscription found" and its confirm
button never enabled — the preview it waited on could not succeed.

Skip the preview for pending plans and drop the cancel-mode, refund and
proration sections with it: discarding a plan awaiting payment has no
timing or proration to choose. The sheet explains what cancelling does and
the button reads "Discard Pending Plan".
The dashboard cancels through billing.update with a cancel_action, not the
cancel route, so the pending short-circuit never ran and setup failed
resolving a subscription the plan does not have: "No active subscription
found with customer_product_id ...".

Discard in the billing.update handler too, where the dashboard lands.
The payment-link route loaded a customer product by id alone, so anyone
with Customers.Read who knew another tenant's cus_prod_ id could read that
tenant's open checkout URL or hosted invoice URL.

Resolve through the path customer under the request org and env, and
require the pending row to belong to it.
Most of the comments added with the pending status said what the next line
already said. Keep the few that carry a reason a reader cannot infer — why
reapply runs against the persisted row, why a partial deferral cannot be
held pending — and delete the rest.
discardPendingCustomerProduct hand-rolled its own void and expire, and the
payment-link route retrieved through a raw client. Use the operations that
already exist: expireStripeCheckoutSession verifies a session that lost the
expire race rather than assuming failure, and voidStripeInvoiceIfOpen syncs
the Autumn invoice row, which the local copy skipped.
A pending plan could not be edited: findTargetCustomerProduct resolves
against RELEVANT_STATUSES, which excludes Pending, so setup failed looking
for a subscription the plan does not have.

Model it the way a scheduled plan is already modelled — a row that is not
live is disposable. Resolve pending rows as update targets, then discard
and re-run the attach that created the plan rather than pricing a change
against a subscription. The frozen plan is never edited because it is
replaced, so the new invoice and the new row cannot disagree.

The replacement keeps the original created_at: it stands in for the same
plan, not a new one.

The update sheet warns that saving replaces the plan, so the payment link
already sent to the customer stops working.
Every edit replaced the plan, so a change that bills the same still voided
the customer's invoice — and the sheet showed "No changes to billing" and
"a new link is issued" side by side.

Reuse FIELDS_WITH_BILLING_CHANGES, which already decides whether a request
touches billing, to pick the path: an edit that rebills replaces the plan,
anything else runs as a normal update and leaves the payment alone. The
warning follows the same signal, so the two notices can no longer disagree.
Deciding whether a pending edit needs a new invoice by looking at which
fields were sent was wrong: the dashboard always sends customize, so a free
feature reissued the payment and the sheet showed "No changes to billing"
next to "a new link is issued".

Ask the question the rest of the system already asks. productsAreSame
returns onlyEntsChanged, which getAttachBranch uses for the same decision
and which the sheet's own banner reads from, so both sides now agree by
construction. Quantities are compared alongside it, as the sheet does.
hasNoBillingChanges means "there are changes, none of which bill", so it is
false before anything is edited — and the warning showed on opening the
sheet, when saving would do nothing at all.

Require a pending edit that actually bills.
Three tests shared fixed customer ids, so their rows landed on whichever
seeded customer held that id and looked like plans vanishing from a real
customer's page. Cover the edit path against a customer that has a default
plan, which is the case where leaving the customer plan-less would matter.
fix(analytics): preserve billing cycle boundaries
charlietlamb and others added 6 commits September 1, 2026 16:03
Strip the explanatory comments added with the pending work and carry the
meaning in names instead.

- reuse PendingUpdateResult rather than restating the shape inline
- extract inheritPendingCreatedAt from the loop it described
- name the attach preview replacementPreview
- whollyDeferred -> awaitingPayment, folded into the guard it feeds
Comparing purchased quantities per feature is a general question, not a
pending-plan one. Move it into featureOptionUtils under a compare/ group,
matching the convert/ and find/ groups already there.

pendingPlanRebills drops its local copy.
…ucts

feat: show deferred plans as pending before payment
Review caught the previous guard being too blunt. existingUsage.usage is an
aggregate of usage the old grant covered and usage that had already exceeded
it, so skipping the whole deduction forgave real overage: 150 used against a
100 grant billed $0 instead of $25.

Track the accrued slice separately — accruedOverage, summed from the negative
balance on each source row — and split the carry. The unlimited grant absorbs
what its allowance covered; priced rows keep what was already owed. This also
leaves the unlimited row's usage counter populated rather than at zero.

Both cases are covered, and both go red without the split.
fix: stop reporting idle SQS workers to Sentry
fix(billing): don't bill usage a grant covered when it becomes unlimited
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

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

2 Skipped Deployments
Project Deployment Actions Updated
checkout Ignored Ignored Sep 1, 2026 5:22pm UTC
landing-page Ignored Ignored Sep 1, 2026 5:22pm UTC

Request Review

The "Revert to previous plan after trial ends" checkbox opened unchecked,
so attaching a trial onto an existing subscription billed on end by
default. Default to revert wherever the control is shown, and replace the
checkbox with an "On trial end" select whose Bill option carries a tooltip
explaining that the billing cycle resets and the customer is charged in
full.

The request body is unchanged when the control is hidden: without an
active subscription (or on a multi-plan attach) there is nothing to revert
to, so the sent value is forced back to "bill".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +33 to +38
for (const customerProduct of pendingCustomerProducts) {
const plannedCustomerProduct =
autumnBillingPlan.insertCustomerProducts?.find(
(planned) => planned.id === customerProduct.id,
);
if (!plannedCustomerProduct) continue;

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.

High: Non-billing pending edits replace the row but leave deferred metadata pointing at the old product id, so payment never promotes the plan.

Customer attaches a paid plan with invoice/checkout deferred (enable_product_immediately: false), gets a Pending row linked to metadata M. They then edit only entitlements (or otherwise take the no-rebill path in updatePendingCustomerProduct), which runs normal updateSubscription: expire the Pending row and insert a new one with a fresh id, then relinkPendingPayment copies metadata_id M onto the new row. Metadata M still stores the original insertCustomerProducts with the expired id. On invoice paid / checkout completed, promotePendingCustomerProducts requires planned.id === pending.id; that match fails, so the live Pending row stays Pending. executeAutumnBillingPlan then tries to insert the planned products under the old id, which still exists as Expired, so the insert hits a primary-key conflict and the webhook fails—payment succeeded but the plan never activates. Fix by either rewriting deferred metadata’s planned products to the relinked ids (and current shape) on no-rebill replace, or promoting every Pending row for the metadata id without requiring a planned-id match and dropping/skipping stale planned inserts.

Open in Capy Review

ay-rod and others added 4 commits September 1, 2026 18:06
Default trial-on-end to revert, swap the checkbox for a select
cusProductToExistingUsages now reports accruedOverage alongside usage, so the
four toEqual shape assertions in the credit-rate carry-over suite were failing
on the added key. All four cases have a positive source balance, so the
expected value is 0.

Fixes the Unit Tests job on the dev -> main release PR.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

40 issues found and verified against the latest diff

Confidence score: 1/5

  • The multi-subscription deferred billing paths in insertPendingCustomerProducts.ts and executeMultiSubscriptionBillingPlan.ts can associate every pending product with the first subscription, preventing later webhooks from promoting the others and potentially causing retries or incorrect billing state. Preserve each product-to-subscription metadata association.
  • discardPendingCustomerProduct.ts can delete metadata and expire pending products even when checkout is completed or the invoice is already paid, risking loss of recoverable billing state. Return without cleanup in those terminal states.
  • The deferred checkout flow in handleCheckoutSessionMetadataV2.ts can remove promoted IDs before phases are built, causing persistDeferredCreateSchedule to fail; promotePendingCustomerProducts.ts can also skip no-rebill replacements and leave stale inserts. Preserve the original IDs and ensure replacements are promoted.
  • Pending-plan lookups in findPendingCustomerProduct.ts, updatePendingPlanIfAny.ts, and discardPendingPlanIfAny.ts are capped by the product limit, so existing plans can be treated as absent; combined with duplicate custom-row insertion in insertPendingCustomerProducts.ts, this can trigger wrong subscription actions or webhook failures. Use unpaginated lookups and make catalog creation idempotent.
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="server/src/internal/billing/v2/execute/insertPendingCustomerProducts.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/insertPendingCustomerProducts.ts:19">
P1: When a deferred plan contains custom catalog rows, the payment webhook inserts those same rows a second time and fails on their existing IDs. Make catalog creation idempotent or prevent the deferred execution path from re-inserting rows already created here.</violation>

<violation number="2" location="server/src/internal/billing/v2/execute/insertPendingCustomerProducts.ts:26">
P1: When a multi-subscription update defers payment for more than one subscription, this assigns every pending product to the first invoice's metadata ID. Preserve the product-to-subscription metadata association and insert each plan's products with its own deferred metadata ID.</violation>
</file>

<file name="server/src/internal/billing/v2/execute/executeMultiSubscriptionBillingPlan.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/executeMultiSubscriptionBillingPlan.ts:65">
P1: When multiple subscriptions defer payment, this tags every pending product with the first subscription's metadata ID. Later invoice webhooks cannot promote the other subscriptions' products and may retry inserting rows that already exist. Insert each subscription's pending products with its matching metadata ID and sub-scoped Autumn plan.</violation>

<violation number="2" location="server/src/internal/billing/v2/execute/executeMultiSubscriptionBillingPlan.ts:65">
P2: When multiple subscriptions are all deferred, this branch links every pending row to only the first subscription's metadata id. Rows belonging to other subscriptions are not promoted when their invoices are paid. Associate each subscription's pending rows with its own deferredMetadataId before inserting them.</violation>

<violation number="3" location="server/src/internal/billing/v2/execute/executeMultiSubscriptionBillingPlan.ts:71">
P2: When all subscription plans defer, this return skips storing Autumn invoice line items. Mirror the deferred path in `executeBillingPlan` and trigger invoice-line-item persistence for each deferred result before returning.</violation>
</file>

<file name="server/src/internal/billing/v2/execute/discardPendingCustomerProduct.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/discardPendingCustomerProduct.ts:22">
P1: When the checkout session is completing or already completed, `expireStripeCheckoutSession` returns `false`, but this function still deletes the metadata and expires the pending product. Return without cleanup when expiration returns false, so the successful payment webhook can promote the product.</violation>

<violation number="2" location="server/src/internal/billing/v2/execute/discardPendingCustomerProduct.ts:34">
P1: When the retrieved invoice is already `paid`, `voidStripeInvoiceIfOpen` does nothing, but this function still deletes the metadata and expires the pending product. Preserve the metadata and return for paid invoices so invoice completion can promote the product.</violation>

<violation number="3" location="server/src/internal/billing/v2/execute/discardPendingCustomerProduct.ts:37">
P2: If `expireIfPending` fails after the metadata delete, the pending row retains a dangling metadata ID and metadata-driven cleanup cannot find it. Expire the customer product before deleting its metadata.</violation>
</file>

<file name="server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts">

<violation number="1" location="server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts:169">
P1: For a deferred create-schedule checkout, this call removes the promoted IDs before `persistDeferredCreateSchedule` builds phases, causing `Deferred create_schedule phases did not match billing plan`. Preserve the original product IDs for schedule persistence or persist before mutating the plan.</violation>

<violation number="2" location="server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts:169">
P2: After promotion, the reward loop sees an empty `insertCustomerProducts` array and skips `grantCheckoutReward` for paid products. Preserve the promoted products for reward dispatch or dispatch rewards from the promotion result.</violation>
</file>

<file name="server/src/internal/billing/v2/execute/promotePendingCustomerProducts.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/promotePendingCustomerProducts.ts:38">
P1: When a pending plan is replaced by a no-rebill update, this `continue` skips the replacement because deferred metadata still contains the old product ID. The stale planned insert then reaches `executeAutumnBillingPlan`, which can hit the expired row’s primary key while the paid replacement remains Pending. Rewrite the deferred planned IDs during relinking or promote the replacement and remove the stale insert before execution.</violation>

<violation number="2" location="server/src/internal/billing/v2/execute/promotePendingCustomerProducts.ts:40">
P1: When checkout expiration races completion, this ID-only update can promote a row after the expiration path marked it `Expired`. Make promotion conditional on `status = Pending`, and only reapply rollovers when that conditional write actually updates a row.

(Based on your team's feedback about conditional pending-row transitions.)</violation>
</file>

<file name="server/src/internal/billing/v2/execute/findPendingCustomerProduct.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/findPendingCustomerProduct.ts:16">
P1: When a customer has more than the org product limit of pending rows, this helper never sees pending products outside the capped snapshot, so update and cancellation treat an existing pending plan as absent. Fetch pending products with an unpaginated customer/status/entity/product query instead of relying on `getFull`'s capped customer snapshot.</violation>

<violation number="2" location="server/src/internal/billing/v2/execute/findPendingCustomerProduct.ts:18">
P2: findPendingCustomerProduct relies on CusService.getFull, which caps customer-products at the default limit of 15. Customers with more than 15 pending rows cannot reliably update or cancel a later pending plan. Use an unpaginated pending-product lookup instead of getFull.</violation>
</file>

<file name="server/src/internal/billing/v2/execute/updatePendingPlanIfAny.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/updatePendingPlanIfAny.ts:16">
P1: When a customer has more than the configured product limit of pending rows, this lookup truncates before matching `params.plan_id`, so the handler falls through to the normal subscription update. Use an unpaginated pending-product lookup for this path.

(Based on your team's feedback about unpaginated pending-product lookup.)</violation>
</file>

<file name="server/src/internal/billing/v2/execute/discardPendingPlanIfAny.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/discardPendingPlanIfAny.ts:16">
P1: When a customer has more than the configured customer-product limit, this lookup can miss the requested pending plan and continue into active-subscription cancellation. Use an unpaginated pending-product lookup for this path.

(Based on your team's feedback about unpaginated pending cancellation.)</violation>

<violation number="2" location="server/src/internal/billing/v2/execute/discardPendingPlanIfAny.ts:25">
P1: When a payment promotion activates the row after the lookup, conditional expiry does nothing but this function still reports that it discarded the plan. Propagate the conditional-expiry result and continue normal cancellation when the row is no longer pending.

(Based on your team's feedback about conditional pending expiry.)</violation>
</file>

<file name="server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts:61">
P1: When a deferred `create_schedule` contains pending products, this call removes every promoted product from the plan before `persistDeferredCreateSchedule` runs, causing the webhook to throw instead of creating the schedule. Preserve the promoted product IDs for deferred phase persistence while preventing `executeAutumnBillingPlan` from inserting those rows again.</violation>

<violation number="2" location="server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts:61">
P1: When invoice payment races checkout expiration or cancellation, this call can resurrect an already-expired pending product because promotion rechecks no status before writing. Make promotion conditional on `status = Pending` and only remove IDs from the plan after the conditional update succeeds.</violation>
</file>

<file name="server/src/cron/invoiceCron/runInvoiceCron.ts">

<violation number="1" location="server/src/cron/invoiceCron/runInvoiceCron.ts:86">
P1: When expiration hits a database failure, this catch logs it and returns normally, so the caller deletes metadata and permanently loses the retry path for the pending rows. Propagate the error or delete metadata only after expiration succeeds.</violation>

<violation number="2" location="server/src/cron/invoiceCron/runInvoiceCron.ts:111">
P2: When this cron expires a pending product, it updates Postgres without invalidating the full-subject Redis cache. Cached customer reads can therefore continue returning the product as pending until another invalidation; invalidate the affected customer cache during this cleanup.</violation>
</file>

<file name="shared/utils/cusProductUtils/featureOptionUtils/compareFeatureOptions/featureOptionsAreSame.ts">

<violation number="1" location="shared/utils/cusProductUtils/featureOptionUtils/compareFeatureOptions/featureOptionsAreSame.ts:10">
P1: When both options omit `internal_feature_id`, this comparison treats every candidate as the same feature because `undefined === undefined` (and `null === null`) succeeds. Guard non-null IDs before comparing, then fall back to `feature_id`.</violation>

<violation number="2" location="shared/utils/cusProductUtils/featureOptionUtils/compareFeatureOptions/featureOptionsAreSame.ts:10">
P1: When both options have a nullish `internal_feature_id`, `matchesFeature` returns true via `undefined === undefined`, so different `feature_id` values can be treated as the same feature. Require a non-null internal ID before comparing it, or otherwise match by `feature_id`.</violation>

<violation number="3" location="shared/utils/cusProductUtils/featureOptionUtils/compareFeatureOptions/featureOptionsAreSame.ts:24">
P2: When the arrays contain duplicate feature identities, `find` reuses the first candidate for every current duplicate, so a changed duplicate quantity can be reported unchanged. Consume each matched candidate, or compare keyed multisets, before declaring the quantities equal.</violation>
</file>

<file name="server/src/internal/billing/v2/execute/relinkPendingPayment.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/relinkPendingPayment.ts:17">
P2: When a customer has more pending products than the configured customer-product limit, this lookup can omit the matching replacement and leave the payment metadata on the old row. Query pending products by customer and internal product without pagination instead of relying on `CusService.getFull`'s capped result.

(Based on your team's feedback about unpaginated pending-product lookups.)</violation>

<violation number="2" location="server/src/internal/billing/v2/execute/relinkPendingPayment.ts:27">
P2: When updating a pending product for an entity, this search does not preserve the entity identity, so it can miss the linked replacement or relink another entity's pending product with the same plan. Pass the source entity to the lookup and require `candidate.internal_entity_id` to match `customerProduct.internal_entity_id`.</violation>

<violation number="3" location="server/src/internal/billing/v2/execute/relinkPendingPayment.ts:32">
P1: If invoice-paid promotion runs between this pending read and the write, this unconditional update restores stale payment metadata on an already active product. Update only while the row is still `Pending` and treat a zero-row conditional update as a no-op.

(Based on your team's feedback about conditional writes for pending rows.)</violation>
</file>

<file name="server/src/internal/billing/v2/execute/updatePendingCustomerProduct.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/updatePendingCustomerProduct.ts:35">
P1: When `params.cancel_action` is `"uncancel"`, this branch discards the pending row and returns before `billingActions.updateSubscription` can remove the active cancellation. Allow `uncancel` through this path and discard only for actual cancellation actions.</violation>
</file>

<file name="server/src/internal/billing/v2/execute/buildPendingReattachParams.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/buildPendingReattachParams.ts:20">
P1: When an update changes only one prepaid feature on a pending plan, this nullish selection drops the other feature quantities. Merge requested quantities with `billingContext.featureQuantities` before reattaching, so omitted features retain their pending values instead of being initialized to zero.</violation>

<violation number="2" location="server/src/internal/billing/v2/execute/buildPendingReattachParams.ts:38">
P2: When the original deferred attach used an invoice template, reattaching its pending product loses the template footer and memo. Preserve the original invoice settings or template identity instead of rebuilding invoice mode with only finalize, immediacy, and terms.</violation>
</file>

<file name="server/src/internal/billing/v2/execute/expirePendingCustomerProducts.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/expirePendingCustomerProducts.ts:15">
P1: When the expired and completed webhooks overlap, this unconditional update can overwrite a row after completion has linked its subscription, leaving a paid product `Expired`. Make the expiration write conditional on the row still matching the abandoned state, including its expected status and empty `subscription_ids`.

(Based on your team's feedback about conditional pending expiration.)</violation>
</file>

<file name="server/src/internal/billing/v2/execute/executeBillingPlan.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/executeBillingPlan.ts:52">
P1: When a deferred plan contains custom prices, this call inserts those catalog rows before payment, then deferred completion inserts the same rows again and hits a duplicate-key error. Make custom-catalog insertion idempotent or skip it during deferred completion.</violation>
</file>

<file name="server/src/internal/customers/internalHandlers/handleGetPendingPaymentLink.ts">

<violation number="1" location="server/src/internal/customers/internalHandlers/handleGetPendingPaymentLink.ts:15">
P2: When a customer has more pending products than the configured limit, this endpoint returns `{ url: null }` for valid products outside that page. `CusService.getFull` applies the 15-row `cusProductLimit` before `.find`; use an unpaginated, customer-scoped pending-product lookup instead.

(Based on your team's feedback about unpaginated pending-product lookup.) [0b6f0ad8-6e7a-455e-bac5-ea433539a939]</violation>
</file>

<file name="server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts">

<violation number="1" location="server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts:43">
P2: When a customer has more than 15 pending products, this new pending-plan lookup can miss the requested row because `findPendingCustomerProduct` relies on `CusService.getFull`'s default page limit. The handler then updates the active subscription instead, or fails, rather than updating the pending plan; use an unpaginated pending-product lookup before adding this fallback path.

(Based on your team's feedback about unpaginated pending-product lookups.)</violation>
</file>

<file name="shared/drizzle/0073_milky_wilson_fisk.sql">

<violation number="1" location="shared/drizzle/0073_milky_wilson_fisk.sql:1">
P2: When the concurrent index build is cancelled or fails, the preceding column alteration remains committed, so retrying this pending migration fails on `metadata_id` already existing. Split the column alteration and index build into independently retryable migrations, or make the retry path handle the partially applied schema.</violation>
</file>

<file name="server/src/internal/customers/cancel/handleCancelV2.ts">

<violation number="1" location="server/src/internal/customers/cancel/handleCancelV2.ts:62">
P2: When a customer has more pending products than the `getFull` page size, this lookup can miss the requested pending plan and the cancel route falls through to normal cancellation. Use an unpaginated pending-product lookup for this path.</violation>

<violation number="2" location="server/src/internal/customers/cancel/handleCancelV2.ts:69">
P2: When a pending plan is discarded, this early return omits `customer_id` and `product_id` that the normal `/cancel` response returns. Return the same fields so clients receive a stable response shape.

(Based on your team's feedback about preserving API response contracts.)</violation>
</file>

<file name="server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.ts">

<violation number="1" location="server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.ts:55">
P1: When a session-linked row is still `Pending`, this call can overwrite a concurrent `invoice.paid` promotion and leave the paid product `Expired`. Route pending rows through a conditional `expireIfPending` update, and reserve the unconditional update for rows that are intentionally active.

(Based on your team's feedback about conditional expiry of pending rows.)</violation>
</file>

<file name="vite/src/views/customers2/components/sheets/usePendingPaymentLink.ts">

<violation number="1" location="vite/src/views/customers2/components/sheets/usePendingPaymentLink.ts:16">
P2: When the dashboard switches organization, environment, or sandbox while this sheet remains mounted, this query can reuse a cached payment URL from the previous context because the shared query key is unscoped. Build the key with `useQueryKeyFactory()` so the link is fetched and cached per current context.</violation>
</file>

<file name="server/tests/scenarios/customers/customer-entity-seed-scenario.test.ts">

<violation number="1" location="server/tests/scenarios/customers/customer-entity-seed-scenario.test.ts:28">
P1: The single module-level `seedProduct` object is reused across all 10 loop iterations of `initScenario`, and each iteration passes the same object reference into `s.products(...)` → `initProductsV0` → `addPrefixToProducts`. If `addPrefixToProducts` mutates product ids in place (the common implementation in this repo prefixes each product's `id`/item ids), then after the first iteration `seedProduct.id` has already been prefixed to `seed-pro_seed`, and every later pass prefixes it again (`seed-pro_seed_seed`, ...). That makes the second and third customers attach to stale, progressively double-prefixed product ids instead of the intended shared `seed` product, and re-running the scenario compounds it. Clone the product (or the prefixed items) per customer before it enters `initScenario`, or create the shared product once outside the loop and reference that id.

(If `addPrefixToProducts` is confirmed to clone rather than mutate in place, this finding does not apply — verify against `server/tests/utils/testProductUtils/testProductUtils.ts`.)</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

newCusProducts: insertCustomerProducts.map((customerProduct) => ({
...customerProduct,
status: CusProductStatus.Pending,
metadata_id: metadataId,

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: When a multi-subscription update defers payment for more than one subscription, this assigns every pending product to the first invoice's metadata ID. Preserve the product-to-subscription metadata association and insert each plan's products with its own deferred metadata ID.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/insertPendingCustomerProducts.ts, line 26:

<comment>When a multi-subscription update defers payment for more than one subscription, this assigns every pending product to the first invoice's metadata ID. Preserve the product-to-subscription metadata association and insert each plan's products with its own deferred metadata ID.</comment>

<file context>
@@ -0,0 +1,29 @@
+		newCusProducts: insertCustomerProducts.map((customerProduct) => ({
+			...customerProduct,
+			status: CusProductStatus.Pending,
+			metadata_id: metadataId,
+		})),
+	});
</file context>

Comment on lines +65 to +71
if (awaitingPayment && stripeResults[0].deferredMetadataId) {
await insertPendingCustomerProducts({
ctx,
autumnBillingPlan,
metadataId: stripeResults[0].deferredMetadataId,
});
return stripeResults;

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: When multiple subscriptions defer payment, this tags every pending product with the first subscription's metadata ID. Later invoice webhooks cannot promote the other subscriptions' products and may retry inserting rows that already exist. Insert each subscription's pending products with its matching metadata ID and sub-scoped Autumn plan.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/executeMultiSubscriptionBillingPlan.ts, line 65:

<comment>When multiple subscriptions defer payment, this tags every pending product with the first subscription's metadata ID. Later invoice webhooks cannot promote the other subscriptions' products and may retry inserting rows that already exist. Insert each subscription's pending products with its matching metadata ID and sub-scoped Autumn plan.</comment>

<file context>
@@ -57,6 +58,19 @@ export const executeMultiSubscriptionBillingPlan = async ({
+		stripeResults.length > 0 &&
+		stripeResults.every((result) => result.deferred);
+
+	if (awaitingPayment && stripeResults[0].deferredMetadataId) {
+		await insertPendingCustomerProducts({
+			ctx,
</file context>
Suggested change
if (awaitingPayment && stripeResults[0].deferredMetadataId) {
await insertPendingCustomerProducts({
ctx,
autumnBillingPlan,
metadataId: stripeResults[0].deferredMetadataId,
});
return stripeResults;
if (awaitingPayment) {
for (const [index, result] of stripeResults.entries()) {
if (!result.deferredMetadataId) continue;
await insertPendingCustomerProducts({
ctx,
autumnBillingPlan:
stripeBillingPlans[index].autumnBillingPlan ?? autumnBillingPlan,
metadataId: result.deferredMetadataId,
});
}
return stripeResults;
}

Comment on lines +22 to +25
await expireStripeCheckoutSession({
ctx,
checkoutSessionId: metadata.stripe_checkout_session_id,
});

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: When the checkout session is completing or already completed, expireStripeCheckoutSession returns false, but this function still deletes the metadata and expires the pending product. Return without cleanup when expiration returns false, so the successful payment webhook can promote the product.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/discardPendingCustomerProduct.ts, line 22:

<comment>When the checkout session is completing or already completed, `expireStripeCheckoutSession` returns `false`, but this function still deletes the metadata and expires the pending product. Return without cleanup when expiration returns false, so the successful payment webhook can promote the product.</comment>

<file context>
@@ -0,0 +1,44 @@
+		const metadata = await MetadataService.get({ db: ctx.db, id: metadataId });
+
+		if (metadata?.stripe_checkout_session_id) {
+			await expireStripeCheckoutSession({
+				ctx,
+				checkoutSessionId: metadata.stripe_checkout_session_id,
</file context>
Suggested change
await expireStripeCheckoutSession({
ctx,
checkoutSessionId: metadata.stripe_checkout_session_id,
});
const expired = await expireStripeCheckoutSession({
ctx,
checkoutSessionId: metadata.stripe_checkout_session_id,
});
if (!expired) return;

metadata.stripe_invoice_id,
);

await voidStripeInvoiceIfOpen({ ctx, stripeInvoice });

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: When the retrieved invoice is already paid, voidStripeInvoiceIfOpen does nothing, but this function still deletes the metadata and expires the pending product. Preserve the metadata and return for paid invoices so invoice completion can promote the product.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/discardPendingCustomerProduct.ts, line 34:

<comment>When the retrieved invoice is already `paid`, `voidStripeInvoiceIfOpen` does nothing, but this function still deletes the metadata and expires the pending product. Preserve the metadata and return for paid invoices so invoice completion can promote the product.</comment>

<file context>
@@ -0,0 +1,44 @@
+				metadata.stripe_invoice_id,
+			);
+
+			await voidStripeInvoiceIfOpen({ ctx, stripeInvoice });
+		}
+
</file context>
Suggested change
await voidStripeInvoiceIfOpen({ ctx, stripeInvoice });
if (stripeInvoice.status === "paid") return;
await voidStripeInvoiceIfOpen({ ctx, stripeInvoice });

billingContext: updatedDeferredData.billingContext,
});

await promotePendingCustomerProducts({

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: For a deferred create-schedule checkout, this call removes the promoted IDs before persistDeferredCreateSchedule builds phases, causing Deferred create_schedule phases did not match billing plan. Preserve the original product IDs for schedule persistence or persist before mutating the plan.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts, line 169:

<comment>For a deferred create-schedule checkout, this call removes the promoted IDs before `persistDeferredCreateSchedule` builds phases, causing `Deferred create_schedule phases did not match billing plan`. Preserve the original product IDs for schedule persistence or persist before mutating the plan.</comment>

<file context>
@@ -165,6 +166,13 @@ const executeCheckoutSessionMetadataV2 = async ({
 		billingContext: updatedDeferredData.billingContext,
 	});
 
+	await promotePendingCustomerProducts({
+		ctx,
+		autumnBillingPlan: updatedDeferredData.billingPlan.autumn,
</file context>

const replacement = fullCustomer.customer_products.find(
(candidate) =>
candidate.id !== customerProduct.id &&
candidate.internal_product_id === customerProduct.internal_product_id,

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.

P2: When updating a pending product for an entity, this search does not preserve the entity identity, so it can miss the linked replacement or relink another entity's pending product with the same plan. Pass the source entity to the lookup and require candidate.internal_entity_id to match customerProduct.internal_entity_id.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/relinkPendingPayment.ts, line 27:

<comment>When updating a pending product for an entity, this search does not preserve the entity identity, so it can miss the linked replacement or relink another entity's pending product with the same plan. Pass the source entity to the lookup and require `candidate.internal_entity_id` to match `customerProduct.internal_entity_id`.</comment>

<file context>
@@ -0,0 +1,40 @@
+	const replacement = fullCustomer.customer_products.find(
+		(candidate) =>
+			candidate.id !== customerProduct.id &&
+			candidate.internal_product_id === customerProduct.internal_product_id,
+	);
+
</file context>

}): Promise<FullCusProduct | undefined> => {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,

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.

P2: findPendingCustomerProduct relies on CusService.getFull, which caps customer-products at the default limit of 15. Customers with more than 15 pending rows cannot reliably update or cancel a later pending plan. Use an unpaginated pending-product lookup instead of getFull.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/findPendingCustomerProduct.ts, line 18:

<comment>findPendingCustomerProduct relies on CusService.getFull, which caps customer-products at the default limit of 15. Customers with more than 15 pending rows cannot reliably update or cancel a later pending plan. Use an unpaginated pending-product lookup instead of getFull.</comment>

<file context>
@@ -0,0 +1,33 @@
+}): Promise<FullCusProduct | undefined> => {
+	const fullCustomer = await CusService.getFull({
+		ctx,
+		idOrInternalId: customerId,
+		inStatuses: [CusProductStatus.Pending],
+		withEntities: false,
</file context>

enabled: true,
enable_plan_immediately: invoiceMode.enableProductImmediately,
finalize: invoiceMode.finalizeInvoice,
net_terms_days: invoiceMode.daysUntilDue,

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.

P2: When the original deferred attach used an invoice template, reattaching its pending product loses the template footer and memo. Preserve the original invoice settings or template identity instead of rebuilding invoice mode with only finalize, immediacy, and terms.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/buildPendingReattachParams.ts, line 38:

<comment>When the original deferred attach used an invoice template, reattaching its pending product loses the template footer and memo. Preserve the original invoice settings or template identity instead of rebuilding invoice mode with only finalize, immediacy, and terms.</comment>

<file context>
@@ -0,0 +1,41 @@
+			enabled: true,
+			enable_plan_immediately: invoiceMode.enableProductImmediately,
+			finalize: invoiceMode.finalizeInvoice,
+			net_terms_days: invoiceMode.daysUntilDue,
+		},
+	} as AttachParamsV1;
</file context>

entityId: entity_id,
});

if (discarded) return c.json({ success: true });

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.

P2: When a pending plan is discarded, this early return omits customer_id and product_id that the normal /cancel response returns. Return the same fields so clients receive a stable response shape.

(Based on your team's feedback about preserving API response contracts.)

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/customers/cancel/handleCancelV2.ts, line 69:

<comment>When a pending plan is discarded, this early return omits `customer_id` and `product_id` that the normal `/cancel` response returns. Return the same fields so clients receive a stable response shape.

(Based on your team's feedback about preserving API response contracts.) </comment>

<file context>
@@ -58,6 +59,15 @@ export const handleCancelV2 = createRoute({
+			entityId: entity_id,
+		});
+
+		if (discarded) return c.json({ success: true });
+
 		const billingContext = await setupUpdateSubscriptionBillingContext({
</file context>

stripeResults.length > 0 &&
stripeResults.every((result) => result.deferred);

if (awaitingPayment && stripeResults[0].deferredMetadataId) {

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.

P2: When multiple subscriptions are all deferred, this branch links every pending row to only the first subscription's metadata id. Rows belonging to other subscriptions are not promoted when their invoices are paid. Associate each subscription's pending rows with its own deferredMetadataId before inserting them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/executeMultiSubscriptionBillingPlan.ts, line 65:

<comment>When multiple subscriptions are all deferred, this branch links every pending row to only the first subscription's metadata id. Rows belonging to other subscriptions are not promoted when their invoices are paid. Associate each subscription's pending rows with its own deferredMetadataId before inserting them.</comment>

<file context>
@@ -57,6 +58,19 @@ export const executeMultiSubscriptionBillingPlan = async ({
+		stripeResults.length > 0 &&
+		stripeResults.every((result) => result.deferred);
+
+	if (awaitingPayment && stripeResults[0].deferredMetadataId) {
+		await insertPendingCustomerProducts({
+			ctx,
</file context>

@capy-ai capy-ai Bot left a comment

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.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy Review

fix: prevent lazy usage-window reset stampedes
@vercel
vercel Bot temporarily deployed to staging – autumn-vite September 1, 2026 17:08 Inactive
…erve-terms

fix(billing): preserve customer terms across plan versions

@capy-ai capy-ai Bot left a comment

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.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy Review

…verage-unit

test(billing): assert accruedOverage in carry-over shape expectations
@vercel
vercel Bot temporarily deployed to staging – autumn-vite September 1, 2026 17:22 Inactive

@capy-ai capy-ai Bot left a comment

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.

Capy found no issues.

Open in Capy Review

@charlietlamb
charlietlamb merged commit f34e200 into main Sep 1, 2026
30 of 31 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.

4 participants