Skip to content

Enforce card-country regional price on the non-trial (default_incomplete) checkout path #1047

Description

@rdahis

Summary

The card-country regional-price enforcement (added in feat/regional-pricing, backend) runs only on the trial / SetupIntent checkout path. The non-trial default_incomplete path creates the subscription before a card is attached, so it can't read the card country and is not enforced. This leaves a narrow arbitrage gap for returning subscribers.

Background: the two checkout paths

StripeSubscribeMutation.mutate (backend/apps/account_payment/graphql.py) branches on trial eligibility:

  • Trial-eligible (new to the product) → a Stripe SetupIntent collects the card; the setup_intent.succeeded webhook (webhooks.py) then creates the subscription. The card is attached at that point, so _regional_price_id() reads the card country and swaps to the region-correct price before customer.subscribe(...). Enforced.
  • Not trial-eligible (already used the trial for this product) → the mutation calls customer.subscribe(price=price_id, payment_behavior="default_incomplete", ...) immediately, before a card exists. There is no card country to read, so no enforcement runs. Not enforced.

Impact

  • Affects only returning subscribers (those who already consumed the trial for that product and are re-subscribing).
  • Requires deliberately switching domains — the default experience already shows and charges the region-correct currency by domain (frontend id-based selection + trial-path enforcement).
  • Exploit example: a US customer who already used the BD Pro trial, re-subscribing on basedosdados.org (pt) instead of data-basis.org, is shown R$47 and charged R$47 rather than US$19.

Low severity (narrow population, deliberate action), but worth closing for completeness and consistency with the trial path.

Implementation options

A. Post-payment correction (webhook). After the default_incomplete subscription's first payment resolves (e.g. payment_intent.succeeded / invoice.paid, where the card is now known), read the card country, and if it doesn't match the price's region, correct the subscription. Downsides: correcting an already-created subscription means a price swap with proration (or cancel-and-recreate), which is fiddly and risky, and the customer may already have been charged the wrong amount once.

B. Route everyone through the SetupIntent path (preferred long-term). Drop the default_incomplete branch and always collect the card via SetupIntent first, so there is a single enforced flow and the card country is always known before customer.subscribe(...). This is a checkout-flow change (mutation + frontend confirmation handling), but it removes the second path entirely and makes enforcement unconditional. Requires care around returning-subscriber UX (no trial, immediate charge).

Reference points

  • backend/apps/account_payment/webhooks.pysetup_intent_succeeded, _regional_price_id, _card_country (the trial-path enforcement).
  • backend/apps/account_payment/graphql.pyStripeSubscribeMutation.mutate (the branch that selects trial vs default_incomplete).
  • backend/apps/account_payment/regional_pricing.pycountry_to_region, resolve_regional_price_id (pure, reusable by whichever path).

Acceptance

  • A returning (non-trial) subscriber checking out with a card whose country maps to a different region than the selected price is charged the region-correct price.
  • No regression on the trial path.
  • Tests covering the non-trial path enforcement (mirroring the existing mock-based tests in test_regional_pricing.py).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions