DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our checkout retries once, and most of the code is about refusing to retry

Notifio sells a one-time licence for £20. Customers in the EU are routed through Stripe as merchant of record, so Stripe calculates, collects and remits their VAT and carries the liability for it. Everywhere else, including the UK where we are based, that is deliberately off. I wrote about why the allowlist is shaped the way it is in Merchant of record for 27 countries, and deliberately off for our own.

This post is about the failure mode that routing creates, and the single retry that exists because of it. Most of the code is about the conditions under which that retry must not happen.

The failure mode

Merchant of record is a few extra fields merged into the Checkout Session:

return {
  enabled: true,
  session: { managed_payments: { enabled: true } },
  productData: { tax_code: MANAGED_PAYMENTS_TAX_CODE },
  priceData: { tax_behavior: MANAGED_PAYMENTS_TAX_BEHAVIOR },
};
Enter fullscreen mode Exit fullscreen mode

When they are absent every spread is a no-op and the session is created exactly as it was before the feature existed. That part is nicely inert.

The problem is the account-level preconditions. Those fields are only accepted if merchant of record is activated on the Stripe account, the merchant-of-record terms of service have been accepted in the dashboard, and the tax code is on Stripe's eligible list. If any of that is not true, Stripe does not degrade: it rejects the request. And because the fields are added by country, "rejects the request" means every session for a customer in the EU fails, while the UK and US checkout keeps working perfectly.

That asymmetry is what makes it dangerous. A tax optimisation misconfiguration presents as a total checkout outage for one continent, and as a healthy green dashboard for the one you happen to live in. The master switch is read at call time rather than import time:

export function isManagedPaymentsEnabled(): boolean {
  return process.env.MANAGED_PAYMENTS_ENABLED === "true";
}
Enter fullscreen mode Exit fullscreen mode

which is right for testability, and also means an env var can be flipped on in production before the dashboard work behind it is finished. The retry exists because that sequencing mistake is extremely easy to make and very hard to notice.

The retry

export async function createCheckoutSession(
  params: Stripe.Checkout.SessionCreateParams,
  context: { managedPayments: boolean; country?: string | null } = { managedPayments: false }
): Promise<Stripe.Checkout.Session> {
  try {
    return await stripe.checkout.sessions.create(params);
  } catch (error) {
    if (context.managedPayments && isManagedPaymentsRejection(error)) {
      // ... log loudly ...
      return await stripe.checkout.sessions.create(stripManagedPayments(params));
    }
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two conditions, and they are doing different jobs.

context.managedPayments is the caller telling us whether the merchant-of-record fields were actually merged into these params. It is not inferred from the params, because the retry is only meaningful if there is something to strip, and a UK session that fails for an unrelated reason must not take a second trip to Stripe's API for no reason.

isManagedPaymentsRejection(error) is the one that matters, and it is where the care went.

Deciding that merchant of record is what was rejected

export function isManagedPaymentsRejection(error: unknown): boolean {
  if (!error || typeof error !== "object") return false;

  const e = error as { type?: unknown; code?: unknown; param?: unknown; message?: unknown };

  // Only request-validation errors describe a bad/unsupported parameter. Anything
  // else (card, rate limit, connection, generic API) is not a MoR routing issue.
  if (e.type !== undefined && e.type !== "StripeInvalidRequestError") return false;

  // An explicit `managed_payments` param is an unambiguous signal on its own.
  const param = typeof e.param === "string" ? e.param : "";
  if (param === "managed_payments" || param.startsWith("managed_payments")) return true;

  // Message matching is looser, so only trust it for a real Stripe invalid-request
  // error, not for an arbitrary Error that happens to contain the phrase.
  if (e.type !== "StripeInvalidRequestError") return false;
  const message = typeof e.message === "string" ? e.message : "";
  return /managed[_\s]?payments/i.test(message);
}
Enter fullscreen mode Exit fullscreen mode

Three things in here are load-bearing.

The error type gate comes first. A retry that strips a field and resubmits is a way of swallowing an error. If a StripeCardError or a StripeConnectionError could reach the retry, we would quietly replace "your card was declined" with a second attempt at the same decline, and the buyer would see a slower version of the same failure with none of the explanation. Only a request-validation error is about a parameter being wrong, so only a request-validation error can justify removing a parameter.

There are two signals, with different trust levels. An explicit param of managed_payments is structured data from Stripe about which field it objected to, and it is accepted on its own. A message substring match is a guess, so it is only consulted after the type has been confirmed a second time. Stripe's error messages are prose, and prose changes.

It is duck-typed on purpose. No instanceof Stripe.errors.StripeInvalidRequestError anywhere. That is partly because error identity through a wrapped SDK is not something I want to bet a checkout path on, and mostly because this way the whole function is a pure predicate over a plain object, so the tests for it are a list of object literals ({ type, param, message }, and a handful of things that must return false) rather than an exercise in constructing SDK error instances.

Stripping is deterministic, not a deep search

function stripManagedPayments(
  params: Stripe.Checkout.SessionCreateParams
): Stripe.Checkout.SessionCreateParams {
  const { managed_payments: _managedPayments, ...rest } = params;
  if (!rest.line_items) return rest;

  rest.line_items = rest.line_items.map((item) => {
    if (!item.price_data) return item;
    const { tax_behavior: _taxBehavior, ...priceData } = item.price_data;
    if (priceData.product_data) {
      const { tax_code: _taxCode, ...productData } = priceData.product_data;
      return { ...item, price_data: { ...priceData, product_data: productData } };
    }
    return { ...item, price_data: priceData };
  });

  return rest;
}
Enter fullscreen mode Exit fullscreen mode

This is not a generic "remove all tax fields" walk. It removes exactly three keys at exactly three known paths, because exactly one function adds them. The pairing is the invariant: whatever managedPaymentsCheckout merges in, this removes, and nothing else. A recursive scrub would be shorter and would also delete a field some unrelated future feature happens to name tax_behavior.

The sale completes, and the hole is recorded

The retry means the customer buys the thing. It also means an EU sale went through with no VAT collected on it, which is a real liability, not a cosmetic problem. So the retry is loud:

console.error(
  "[checkout] Managed Payments session rejected by Stripe, retrying without MoR",
  { country: context.country ?? null, stripe: stripeInfo }
);
Sentry.captureMessage("Managed Payments checkout rejected, retried without MoR", {
  level: "error",
  tags: { area: "checkout" },
  extra: { country: context.country ?? null, stripe: stripeInfo },
});
Enter fullscreen mode Exit fullscreen mode

and the webhook independently catches the consequence rather than trusting the log to be read:

export function isPossibleMoRMisroute(
  country: string | null | undefined,
  taxAmount: number | null | undefined
): boolean {
  if (!country) return false;
  if ((taxAmount ?? 0) > 0) return false;
  return MANAGED_PAYMENTS_COUNTRIES.has(country.trim().toUpperCase());
}
Enter fullscreen mode Exit fullscreen mode

A completed sale whose billing country is in the EU allowlist with zero tax on it gets flagged for review. That also catches the other route to the same state, which is a customer whose IP said one country and whose billing address said another. The IP decides the routing at session creation; the billing address is what Stripe actually taxes on. Those can disagree, and I wrote about that gap in The price resolved the country one way, and the tax resolved it another.

It is review-only and will occasionally flag a legitimate EU business purchase with a valid VAT number under reverse charge. For a consumer product at £20, a false positive in a review queue costs nothing and a missed liability costs a conversation with an accountant.

While we were in there: which errors belong to the buyer

The thing this wrapper replaces, and the thing a route does by default, is catch everything and return a flat 500 with "Failed to create checkout session". That throws away Stripe's entire diagnosis, so a checkout outage is opaque exactly when you need it not to be. In its place, a small taxonomy:

switch (info.type) {
  // The buyer can act on these, so surface Stripe's own message.
  case "StripeCardError":
    return { status: 402, message: info.message || "Your card was declined." };
  // Too many requests to Stripe, tell the client to retry shortly.
  case "StripeRateLimitError":
    return { status: 429, message: "Payment service is busy. Please try again in a moment." };
  // Network / upstream availability, transient and retryable.
  case "StripeConnectionError":
  case "StripeAPIError":
    return { status: 503, message: "Payment service is temporarily unavailable. Please try again shortly." };
  // StripeInvalidRequestError / StripeAuthenticationError / anything else is a
  // server-side configuration bug: do not expose specifics to the client.
  default:
    return { status: 500, message: fallbackMessage };
}
Enter fullscreen mode Exit fullscreen mode

The dividing line is not severity, it is ownership. A card decline is the buyer's situation and Stripe has already written a better message about it than I would, so it is passed through verbatim with a 402. Rate limiting and upstream trouble are nobody's fault and are temporary, so the status code says "come back" rather than "something is broken". An invalid-request or authentication error is my bug, and the buyer learns nothing useful from its details, so it stays a generic 500 and a full entry in the logs.

Three statuses that tell a client something actionable, and one that deliberately tells it nothing.

The general shape

An optional optimisation that can hard-fail should have a path back to the unoptimised version, and that path needs to be narrower than you first want to make it. Mine is:

  • retry only when the optional thing was actually applied
  • retry only when the error is specifically about the optional thing
  • undo by removing the exact keys you added, not by searching for things that look like them
  • make the degraded success noisy, and detect its consequence somewhere else as well
  • never let the retry become a way for an unrelated error to disappear

You can see the checkout this sits behind on the pricing page, and the VAT position it implements is written out on the terms page.

Top comments (1)

Collapse
 
axiru profile image
Axiru •

Narrowing the merchant-of-record retry to InvalidRequestError on managed_payments, and only when those fields were merged, keeps an unrelated Stripe error from opening a second checkout.

The client can still time out after Stripe already created the first session. A fresh create then opens a second unpaid checkout for the same licence.

When create returns unknown after Stripe may already have opened the session, do you block a new session for that order until you see one open or none?