Skip to content

fix(payment): suppress checkout trial_period_days when promotion code is applied (#23300) - #23740

Open
ducanh3005 wants to merge 1 commit into
BasedHardware:mainfrom
ducanh3005:fix/issue-23300-promo-trial-minimum-amount
Open

ducanh3005 wants to merge 1 commit into
BasedHardware:mainfrom
ducanh3005:fix/issue-23300-promo-trial-minimum-amount

Conversation

@ducanh3005

Copy link
Copy Markdown
Contributor

Summary

Closes #23300

Suppress trial_period_days when creating a subscription Checkout session with a resolved promotion code so Stripe's minimum_amount restriction is evaluated against the subscription's first-period invoice rather than a $0 free-trial invoice.

Root Cause

Single-use first-year Unlimited promotion codes carry a minimum_amount: 26900 ($269.00 USD) restriction:

  1. The currency-conversion half (GBP/EUR Adaptive Pricing falling below $269.00 USD) was resolved by pinning adaptive_pricing={'enabled': False} in fix(payment): pin promo-code checkout presentment to USD so the $269 minimum passes (#23299) #23557 / fix(payment): charge the displayed currency on every Stripe checkout (Play rejection of 1504) #23604.
  2. However, when a checkout session is requested with trial_days (e.g., 7-day trial) alongside a promotion code, setting subscription_data.trial_period_days makes the initial Checkout invoice $0.00 due at checkout. Stripe evaluates the promotion code's minimum_amount against that $0.00 initial invoice and rejects the valid code with:
    This code is valid, but your order doesn't meet the minimum amount.

Changes

  • backend/routers/payment.py (create_checkout_session_endpoint): Pass trial_period_days=None when resolved_checkout_promo_id is present while preserving the trial_unavailable guard for returning customers.
  • backend/utils/stripe.py (create_subscription_checkout_session): Only set session_params['subscription_data']['trial_period_days'] when promotion_code_id is not provided. No raw coupon fallback is introduced, preserving Stripe-side promotion-code restrictions and redemption tracking (test_upgrade_uses_promotion_code_id_not_coupon).
  • backend/tests/unit/test_payment_checkout_trial.py: Added unit tests verifying trial suppression on both create_checkout_session_endpoint and create_subscription_checkout_session when a promotion code is applied.

Failure-Class

Failure-Class: none

Product invariants affected

  • none

Testing

  • pytest backend/tests/unit/test_payment_checkout_trial.py backend/tests/unit/test_payment_promotion_codes.py backend/tests/unit/test_payment_promo_adaptive_pricing.py

Payout Wallet (Base / BSC / EVM): 0xdF4Ff9D2f45f99f11A90b16F277D4B7EddDB72A5

@ducanh3005

Copy link
Copy Markdown
Contributor Author

Submitted standalone fix for #23300 (suppressing trial_period_days when a promotion code is applied so Stripe evaluates minimum_amount against the initial invoice subtotal rather than $0, without raw coupon fallback).

Payout Wallet (Base / BSC / EVM): 0xdF4Ff9D2f45f99f11A90b16F277D4B7EddDB72A5

@ducanh3005
ducanh3005 force-pushed the fix/issue-23300-promo-trial-minimum-amount branch 2 times, most recently from b990c3a to 5ec0c9b Compare October 26, 2026 17:01
@Git-on-my-level

Copy link
Copy Markdown
Member

The technical fix is correct and tightly scoped — one open product question below needs a maintainer call before merge.

Findings:

  • backend/routers/payment.py:658 — trial_period_days = None if resolved_checkout_promo_id else (request.trial_days or None): does what it says, and the trial_unavailable guard for existing customers (line 656) is untouched. I verified this merges cleanly over current main, which added the new_buyer_single_plan_violation guard (main payment.py:741) after this branch's fork point — no conflict, and the merge-gate suite is green.
  • backend/utils/stripe.py:82-91 — the helper-level if not promotion_code_id guard is redundant with the router change but fine as belt-and-suspenders for future callers. discounts stays attached server-side and allow_promotion_codes is not enabled alongside it, so no new redemption surface.
  • backend/tests/unit/test_payment_checkout_trial.py — both new tests are meaningful: the endpoint test asserts the promo id passes through AND the trial is dropped; the helper test asserts trial_period_days is absent from subscription_data while discounts is set. The create=True patch of new_buyer_single_plan_violation (line 92) is dead scaffolding at this branch point — the guard only arrives via main on merge — harmless, but a one-line comment would help.

Product question: this module's own docstring says a client that asked for a trial was "promised something this checkout cannot give, so refuse rather than charge at once" — that is the rationale for trial_unavailable. This change introduces the same silent promise-break for promo users: a client that requested trial_days=7 gets an immediate (discounted) charge once a promo code is applied, with no signal back. For the 100%-off first-year Unlimited codes from #23300 the user pays nothing either way, but any partial promo + trial combination will charge at once where the app promised a trial. Accept silently (this PR), return a flag so clients can adjust copy, or reject the combination — that is a billing/UX call, not a correctness one.

Performance: no new API calls (the PromotionCode.list lookup already existed). Security: no new surface. Blast radius: only promo-code checkouts; trial-only and promo-only flows are unchanged.

USER IMPACT: users applying a promo code at checkout no longer hit "your order doesn't meet the minimum amount" — the code now applies; the tradeoff is the free trial is silently skipped and the discounted charge starts immediately.

Still want as a reviewer: a maintainer decision on the silent trial suppression, and a short code comment at payment.py:658 recording it.

Leaving the billing/UX tradeoff above for human maintainer review before merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added backend Backend Task (python) needs-maintainer-review Needs a human maintainer to sign off before merge labels Oct 26, 2026
@Git-on-my-level

Copy link
Copy Markdown
Member

Verdict: the fix is correct, minimal, and well tested; one billing-behavior design point below needs maintainer sign-off before merge.

Correctness (verified at head 5ec0c9b):

  • backend/routers/payment.py:658 — trial_period_days = None if resolved_checkout_promo_id else (request.trial_days or None). resolved_checkout_promo_id is always bound at lines 639-644 before this line, and the trial_unavailable guard at line 656 still checks request.trial_days, so returning-customer trial rejection is unchanged. Good.
  • backend/utils/stripe.py — create_subscription_checkout_session now skips subscription_data.trial_period_days whenever promotion_code_id is set, with an accurate comment explaining the $0-invoice/minimum_amount interaction. This is a second, defensive suppression layer consistent with the router change.
  • backend/tests/unit/test_payment_checkout_trial.py — two focused tests pin both layers (endpoint passes trial_period_days=None with a resolved promo; helper omits trial_period_days from subscription_data while keeping discounts). The importlib.reload guard in the second test is a pragmatic isolation measure; fine as-is.

UX (material): a checkout that combines a promotion code with a requested trial now silently drops the trial — the user is charged the discounted first invoice immediately instead of after the trial. Previously Stripe rejected the whole checkout ("order doesn't meet the minimum amount"), so this strictly improves on a hard failure, but note the suppression applies to ALL promo codes, not only minimum_amount-restricted ones. If you want the narrower behavior, it could be scoped to codes whose minimum_amount is set (or handled by catching the Stripe error), and/or the client could surface "promo replaces trial" messaging.

Security: no new auth, secrets, dependencies, or workflows; promo-code resolution logic is pre-existing and untouched.

Blast radius: only checkouts that pass both promotion_code and trial_days; all other checkout paths are byte-identical behavior.

USER IMPACT: users who apply a promo code at checkout will be charged the discounted first invoice immediately rather than starting with a free trial — checkout succeeds instead of erroring.

What I'd still want: a maintainer decision on whether unconditional trial suppression (vs. only for minimum_amount codes) is the intended billing behavior, and whether the client should tell the user the trial was replaced by the promo.

Leaving for human maintainer review: the charge-immediately-vs-trial tradeoff is a billing/product decision on a revenue surface, which is why needs-maintainer-review stays.


Feedback generated by Omi's AI maintainer assistant on behalf of the maintainers.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added the positive-signal Automation verified a genuine fix/quality contribution label Oct 27, 2026
@Git-on-my-level

Copy link
Copy Markdown
Member

Verdict: the production change is correct, nicely scoped, and well tested — leaving for maintainer sign-off on one product trade-off, not on correctness.

  • backend/routers/payment.py:658 — trial_period_days = None if resolved_checkout_promo_id else (request.trial_days or None) is safe: resolved_checkout_promo_id is unconditionally initialized at line 639 and only set after a valid PromotionCode lookup, and the trial_unavailable guard for returning customers (line 656) still runs first. Correct ordering.
  • backend/utils/stripe.py:83-86 — skipping subscription_data.trial_period_days when promotion_code_id is present is good defense-in-depth. I confirmed the payment router is the only production caller of create_subscription_checkout_session, so no other call site changes behavior.
  • backend/tests/unit/test_payment_checkout_trial.py — both new tests are meaningful: the endpoint test asserts the promo id still flows through while trial is suppressed, and the helper test asserts discounts is set and trial_period_days is absent from subscription_data. Backend unit suite is green on this head.

UX / product trade-off to confirm: suppression is keyed on ANY resolved promotion code, not only minimum_amount-restricted ones. Before this change a promo+trial combination could succeed (user got both); now the trial is silently dropped whenever a code is applied, and the client gets no signal its trial_days was ignored. That is the right fix direction for #23300, but it is a billing-behavior decision a maintainer should confirm as intended.

Security/blast radius: payment/billing surface, but the change only removes a subscription_data field — no new money paths, no raw coupon fallback, redemption tracking via promotion_code preserved.

USER IMPACT: subscribers who apply a promo code at checkout will be charged (at the discounted price) immediately instead of after a free trial period.

Still wanted: (1) maintainer confirmation that dropping the trial for all promo codes — not just minimum-amount codes — is the intended product behavior; (2) optionally a log line or response hint when trial_days is suppressed so support can explain charges. Human maintainer review is needed for the billing trade-off above before merge.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@aryanorastar aryanorastar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved 5ec0c9bc2e12c8d6ff344a7526658d1d220a7f47. backend/routers/payment.py on main still sets trial_period_days = request.trial_days or None beside a promotion code. This head suppresses both and Hygiene succeeded.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend Task (python) needs-maintainer-review Needs a human maintainer to sign off before merge positive-signal Automation verified a genuine fix/quality contribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unlimited first-year promo code rejected with 'minimum amount' in GBP / trial checkout

3 participants