Skip to content

fix: prefer recurring prepaid over one-off for feature_quantities - #3243

Open
SirTenzin wants to merge 2 commits into
devfrom
capy/prefer-recurring-prepaid-over
Open

fix: prefer recurring prepaid over one-off for feature_quantities#3243
SirTenzin wants to merge 2 commits into
devfrom
capy/prefer-recurring-prepaid-over

Conversation

@SirTenzin

@SirTenzin SirTenzin commented Sep 3, 2026

Copy link
Copy Markdown
Member

Problem

cusProduct.options (FeatureOptions) is keyed by feature only. When a plan hosts both a recurring prepaid and a one-off prepaid price for the same feature, every consumer that resolves options or prices by feature (entToOptions, cusProductToFeatureOptions, findCusPriceByFeature) picks an arbitrary entry:

  • billing.attach with feature_quantities applied the quantity to both prices → double charge + double balance grant.
  • billing.update routed to ManualTopUp whenever any one-off prepaid matched the feature, even with a recurring prepaid present, and the complex-update guard misfired.

Fix: tie-break, recurring wins

Stop-gap while the options model stays feature-keyed: when feature_quantities targets a feature with multiple prepaid prices, the recurring price wins (shortest interval among recurring); the one-off contributes only its included usage. A feature with only a one-off prepaid is unaffected.

New shared resolver findPrepaidQuantityTargetPrice / isLosingPrepaidQuantityPrice (shared/utils/productUtils/priceUtils/findPrice/), applied at:

  • setupFeatureQuantitiesContext — only the winning price consumes the params entry (attach, createSchedule, multiAttach and sync inherit this)
  • initCustomerEntitlementBalance — losing prepaid ent grants allowance only
  • customerEntitlementToOptions — Stripe item specs for a losing price read no quantity
  • cusEntToPrepaidQuantity — invoice/line-item quantity is 0 for a losing price
  • setupUpdateSubscriptionIntent — ManualTopUp only when the tie-break target is the one-off (replaces customerProductHasOneOffPrepaidForFeature, now removed)
  • computeUpdateQuantityPlan / computeUpdateQuantityDetails — quantity updates resolve to the winning recurring price
  • handleOneOffErrors — complex-update guard only fires when the one-off is the target

Tests

  • manual-top-up/prepaid-tie-break.test.ts (integration, written red-first): attach on a mixed plan charges base + recurring only; update is absolute UpdateQuantity, not ManualTopUp delta; a one-off-only feature on the same plan still manual-tops-up.
  • unit/products/findPrepaidQuantityTargetPrice.test.ts: recurring-beats-one-off, shortest interval (incl. interval_count), lone one-off wins its own feature, cross-feature isolation.

Sweep of update-subscription/{manual-top-up,update-quantity,one-off-prepaid-preserve}, feature-quantity error tests, and the unit suite: all failures observed also reproduce with the change stashed (pre-existing on this environment), none introduced by this PR.


Summary by cubic

Fixes feature_quantities handling when a plan has both a recurring prepaid and a one-off prepaid price for the same feature. Previously, quantity was applied to both prices (double charge) or routed to the wrong update intent; now the recurring prepaid wins, and one-off-only features still work as before.

Changes

  • Adds findPrepaidQuantityTargetPrice and isLosingPrepaidQuantityPrice to resolve the winning price for a feature-keyed quantity.
  • Applies the tie-break in attach, update, and entitlement balance flows so only the winning price consumes the quantity; losing prepaid entitlements keep a zeroed options copy so renewal resets still run.
  • Removes customerProductHasOneOffPrepaidForFeature in favor of the new resolver.

Written for commit 34c4aaf. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR consistently resolves feature-level quantities to one prepaid price, preferring recurring prices over one-off prices and the shortest recurring interval when necessary.

  • Bug fixes: Prevents the same quantity from being charged and granted through both recurring and one-off prepaid prices.
  • Bug fixes: Routes mixed-price subscription updates through recurring quantity updates while preserving manual top-ups for one-off-only features.
  • Improvements: Reuses the selected prepaid entitlement throughout line-item and balance calculations.
  • Improvements: Adds integration and unit coverage for mixed prepaid prices, interval precedence, and feature isolation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
shared/utils/productUtils/priceUtils/findPrice/findPrepaidQuantityTargetPrice.ts Adds the shared feature-scoped resolver that prefers recurring prepaid prices and orders them by interval length.
server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts Resolves the exact winning customer price and threads its matching entitlement into quantity line-item computation.
server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionIntent.ts Selects manual top-up only when the resolved prepaid target is one-off.
server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts Prevents losing prepaid prices from consuming the feature-level quantity.
shared/utils/cusEntUtils/convertCusEntUtils/customerEntitlementToOptions.ts Supplies zeroed options for losing prepaid entitlements so the winning quantity does not leak into their billing representation.
server/tests/integration/billing/update-subscription/manual-top-up/prepaid-tie-break.test.ts Covers attach charges, absolute recurring updates, balances, and one-off-only manual top-ups.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Request[feature_quantities request] --> Resolver[Resolve prepaid target for feature]
    Resolver --> HasRecurring{Recurring prepaid exists?}
    HasRecurring -->|Yes| Shortest[Select shortest recurring interval]
    HasRecurring -->|No| OneOff[Select one-off prepaid]
    Shortest --> QuantityUpdate[Apply absolute quantity update]
    OneOff --> ManualTopUp[Apply manual top-up]
    Resolver --> Losing[Other prepaid prices]
    Losing --> IncludedOnly[Preserve included allowance without consuming quantity]
Loading

Reviews (2): Last reviewed commit: "fix: address review — zeroed options for..." | Re-trigger Greptile

@vercel

vercel Bot commented Sep 3, 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 3, 2026 12:49pm UTC
landing-page Ignored Ignored Sep 3, 2026 12:49pm UTC

Request Review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T11:46:47.136907Z 6f80e8b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f80e8b7e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread shared/utils/cusEntUtils/convertCusEntUtils/customerEntitlementToOptions.ts Outdated
Comment thread shared/utils/cusEntUtils/convertCusEntUtils/customerEntitlementToOptions.ts Outdated

@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.

All reported issues were addressed across 13 files

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

Re-trigger cubic

Comment thread shared/utils/cusEntUtils/convertCusEntUtils/customerEntitlementToOptions.ts Outdated
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