DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our India prices are a third of standard, so checkout authorises the card first and decides second

CogniPrep sells aptitude test practice. On 2 November it gained a second price region, nine countries that pay a flat step more than standard. On 3 November it gained a third one, and that is the interesting one, because it is priced below standard: India pays about a third of the standard prices. In the 90 days to 3 November, 31 checkouts were started from India at the standard prices and exactly one of them was paid.

That created a problem the higher region never had. The region comes from x-vercel-ip-country, which is a guess about where the request came from, and anybody can change where a request comes from. Misplacing a visitor into the higher region can only ever cost us a sale. Misplacing one into India hands out a 65 percent discount to anyone who can work a VPN.

So the discount is not enforced at display time at all. It is enforced at payment time, with the one fact about a buyer that a VPN cannot touch: the country their card was issued in.

Hold first, decide second

Stripe reports the issuing country on every charge. The catch is that you only get a charge once you have taken the money, and taking money you then have to give back costs the Stripe fee and leaves the buyer with a refund on their statement.

Checkout has an answer for that: capture_method: 'manual'. The card is authorised, the money is held, and nothing is actually taken until you capture. Cancel the PaymentIntent instead and the hold is released, at no cost to anyone.

Every Checkout Session we create goes through one wrapper:

export function withCardCountryCheck(
  params: Stripe.Checkout.SessionCreateParams
): Stripe.Checkout.SessionCreateParams {
  const country = requiredCardCountry(params.metadata);
  if (!country) return params;

  return {
    ...params,
    payment_method_types: ['card'],
    payment_intent_data: { ...params.payment_intent_data, capture_method: 'manual' },
    success_url: `${PUBLIC_URL}${CARD_CHECK_RETURN_PATH}?session_id={CHECKOUT_SESSION_ID}`,
    metadata: {
      ...params.metadata,
      [CARD_COUNTRY_METADATA_KEY]: country,
      [CARD_CHECK_SUCCESS_URL_KEY]: metadataUrl(params.success_url),
      [CARD_CHECK_CANCEL_URL_KEY]: metadataUrl(params.cancel_url),
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

requiredCardCountry reads the price_region already on the session's metadata and looks it up in a table with one entry in it:

export const CARD_COUNTRY_FOR_REGION: Partial<Record<PriceRegion, string>> = {
  india: 'IN',
};
Enter fullscreen mode Exit fullscreen mode

A region at or above standard has no entry, the wrapper returns its input unchanged, and nothing about the ordinary checkout path changes. That table is the whole feature flag.

Note payment_method_types: ['card']. The check reads a card's issuing country, so a payment method that is not a card cannot be checked. Apple Pay and Google Pay still appear, because they pay with a card and report its country.

The settle function runs twice on purpose

The wrapper also rewrites success_url to point at our own return route, and stuffs the success and cancel URLs the caller asked for into metadata. The buyer therefore comes back to us, not to the purchase's success page, and we settle the hold while they are mid-redirect.

But the buyer closing the tab must not leave money held, so the Stripe webhook settles it too. Both callers run the same function, and it is written to be safe when they run at once:

if (cardCountry === required) {
  const captured = await stripe.paymentIntents.capture(
    intent.id, {},
    { idempotencyKey: `card-check-capture-${intent.id}` }
  );
  return captured.status === 'succeeded'
    ? { status: 'captured', cardCountry }
    : { status: 'pending' };
}

await stripe.paymentIntents.cancel(
  intent.id, {},
  { idempotencyKey: `card-check-cancel-${intent.id}` }
);
return { status: 'refused', cardCountry };
Enter fullscreen mode Exit fullscreen mode

Two things make that concurrency-safe. The idempotency keys are derived from the PaymentIntent, so two callers send Stripe the same request and get the same answer rather than one of them erroring. And the branch is decided only by the charge's card country, so there is no state in our database the two callers could disagree about. Whichever runs first settles the payment; the other re-reads the PaymentIntent, finds it succeeded or canceled, and reports that.

An unknown country is refused, not waved through. The price is only for cards we can place.

The webhook had to learn that "unpaid" can mean "paid a second ago"

Access is granted in exactly one place, the webhook, and the settle call is ordered before it:

const cardCheck = await settleCardCountryCheck(session);

if (cardCheck.status === 'pending') {
  // Nothing has been granted or recorded, so a retry starts clean.
  throw new Error(`Card country check still pending for session ${session.id}`);
}

if (cardCheck.status === 'refused') {
  await releaseNurtureCodeByCheckoutSession(session.id);
  await captureCardCountryRefused(session, cardCheck.cardCountry);
  return Response.json({ received: true, refused: true });
}

// A session captured above was still `unpaid` when Stripe sent this event.
const paidSession: Stripe.Checkout.Session =
  cardCheck.status === 'captured' ? { ...session, payment_status: 'paid' } : session;
Enter fullscreen mode Exit fullscreen mode

That last bit is the one thing I would not have predicted. The session object arrives inside the event payload, so it is a snapshot from before we captured anything, and its payment_status is unpaid. Every downstream check that reads payment_status === 'paid' would have quietly skipped a successful purchase. Patching the field on a local copy is uglier than re-retrieving the session and more honest than ignoring it.

Throwing on pending is deliberate too. A 500 is how you ask Stripe to redeliver, and at that point nothing has been granted or recorded, so the retry starts from a clean state.

A refusal also hands back the discount code the buyer had reserved, exactly as an expired checkout does.

The return route is unauthenticated, which takes two guards

The buyer lands on /api/checkout/card-check?session_id=cs_... straight from Stripe's domain, and they may not have a session cookie with us. So the route requires no sign-in, and that is only safe because it does nothing an attacker would want. The session id is unguessable, settling is idempotent, and the outcome is decided by the card rather than by anything in the URL.

The redirect targets are the risk. They come out of the session's own metadata, and a route that redirects to a URL it read from somewhere is an open redirect waiting to happen, so every one of them is checked against our own origin first:

const sameOrigin = (url: string | undefined): string => {
  if (!url) return fallback;
  try {
    return new URL(url).origin === origin ? url : fallback;
  } catch {
    return fallback;
  }
};
Enter fullscreen mode Exit fullscreen mode

The second guard is Stripe's 500-character limit on a metadata value. A cancel URL is whatever page the buyer was on when they clicked buy, so it can in principle be long. One that does not fit falls back to the dashboard rather than failing the checkout, which is a trade I am happy with: the worst case is a buyer who lands on their dashboard instead of the game they came from.

What a refused buyer sees

Releasing the hold silently would be the worst possible outcome. The buyer sees a pending charge on their statement, no purchase, and no explanation.

So the route redirects them back to the page they came from with ?card_check=refused, and sets a cookie recording the region they were moved off:

export const REGION_OPT_OUT_COOKIE = 'cp_region_opt_out';
export const REGION_OPT_OUT_MAX_AGE = 60 * 60 * 24 * 30;
Enter fullscreen mode Exit fullscreen mode

The server price resolver reads it and puts them on the standard prices from then on, so the page they return to quotes a price their card can actually pay. That cookie is readable and clearable from JavaScript on purpose, which is unusual for anything touching money and fine here, because it can only ever raise a price. A dialog mounted in the root layout explains the hold, says it can take a few days to clear, and offers a button that deletes the cookie and reloads, for the buyer who does have an Indian card and used the wrong one.

The dialog reads the query parameter in an effect rather than through useSearchParams, like the other root-level trackers, so it needs no Suspense boundary, and it strips the parameter with replaceState so a reload or a shared link does not show the notice again.

See it

The return route is public, so you can watch its fallback behaviour without buying anything:

curl -sD - -o /dev/null "https://cogniprep.app/api/checkout/card-check?session_id=cs_test_123"
Enter fullscreen mode Exit fullscreen mode

You get 303 and location: https://cogniprep.app/dashboard. Drop the parameter entirely, or make it something that does not start with cs_, and you get the same thing: a junk session id is not an error page, it is a shrug and a redirect.

For the pricing side, cogniprep.app/pricing is rendered per request, so what it quotes depends on who asks. Fetch it with a Googlebot user agent and you get the standard prices in pounds, because crawlers are geolocated wherever their datacentre is and indexing dollar prices for a business that charges in pounds is its own bug:

curl -s -A "Googlebot/2.1" https://cogniprep.app/pricing | grep -o '£[0-9][0-9.]*' | sort | uniq -c
Enter fullscreen mode Exit fullscreen mode

That printed four £15, three £10, one £5 and one £20 when I ran it. Fetch the same URL with a browser user agent and you will get your own currency instead, and if you are in one of the nine countries on the higher prices, slightly bigger numbers.

The two alternatives I did not take

A Stripe Radar rule could block a card by issuing country, but Radar rules of that kind need a paid add-on, and all they can do is show the buyer a generic decline with no way back to a price they can pay.

Refunding after the fact is simpler to write and strictly worse: it costs the fee on every refusal, and a refund is slower and more alarming to the buyer than an authorisation they can be told about in a dialog.

Holding first and deciding second is the only version where a refusal is free for both sides. The failure mode, an authorisation nobody ever settles because the webhook keeps erroring, resolves itself: Stripe expires it after about seven days and the money is released.

14 tests cover the wrapper and the settle function, including the two that matter most, which are "releases the hold when the card country is unknown" and "never redirects off site".

Top comments (1)

Collapse
 
mohitgupta_in profile image
Mohit Gupta •

Hold first, decide second is a really clean way to make a refusal free for both sides, and patching payment_status on the snapshot is a great catch.

One thing from the India side (I'm building from India, with a merchant of record rather than Stripe): before blaming the price for 1 paid out of 31, it may be worth checking how many of those checkouts died at the payment step. Indian cards are often declined on foreign-currency charges unless the buyer has turned on international use, while the same purchase in INR goes through, and UPI is how most people here prefer to pay. Since the India price is now card-only, UPI buyers can't take it at all. Do you see the declines in Stripe for those 30, or did they just never reach the payment form?