DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four Stripe products nothing ever buys, and a script that refuses to run against a live key

CogniPrep sells four things, all one-time payments, all digital: access to one assessment provider's games, a feedback upgrade, interview practice, and the assessment centre exercise library. You can see all four priced on the pricing page.

None of them is a Stripe Price at checkout time. Every Checkout Session we create builds its line item inline:

line_items: [{
  price_data: {
    currency: charge.currency,
    product_data: { name, description, ...mp.productData },
    unit_amount: charge.unitAmount,
    ...mp.priceData,
  },
  quantity: 1,
}]
Enter fullscreen mode Exit fullscreen mode

The reason is discounts. A school code, an affiliate link and a provider with a small suite all land on a different unit_amount, and inline price_data lets one code path produce any of them. Pre-made Price objects would mean a Price per amount per currency per programme, which is a combinatorial pile of objects to keep in step with the app.

So our Stripe account should contain no Products at all. It contains four, and pnpm mp:create-products is the script that puts them there.

Why create objects nothing reads

Two reasons, neither of them "the checkout needs them".

The first is an activation gate. Stripe's Managed Payments (where Stripe becomes merchant of record and handles the indirect tax) will not activate on an account that has no Product carrying an eligible tax code. The requirement is about the account, not about any particular session, so four Products sitting there satisfy it while the sessions carry on being built inline with the same tax code attached via product_data.tax_code.

The second is reporting. Stripe's dashboard groups by Product. With nothing but inline price_data, every sale is its own anonymous line, and answering "how much Interview Access did we sell" means exporting and grouping by a name string. Canonical Products give the dashboard something to aggregate on, even when the sessions do not reference them.

That is the whole justification, and it is worth stating plainly because the alternative reading is that someone built a catalogue and forgot to wire it up. A comment at the top of the file says so:

These Products are the canonical reference / activation-gate satisfiers,
not a switch away from dynamic pricing.
Enter fullscreen mode Exit fullscreen mode

The script is a reconciler, not a creator

It runs more than once, by definition: whenever a name, description or price changes. So it is written as an upsert keyed on something stable.

Stripe Products have no natural key you control, so the key goes in metadata:

const result = await stripe.products.search({
  query: `metadata['mp_key']:'${mpKey}'`,
  limit: 1,
});
Enter fullscreen mode Exit fullscreen mode

with a list plus filter fallback, because search is not enabled on every account. Find by mp_key, update if present, create if not. Re-running is then free, and the script never leaves a second "Premium Upgrade" behind.

Prices are a different problem, because a Stripe Price is immutable. You cannot edit the amount; you can only create another one and stop using the old. So the price step is find-or-create rather than upsert, matched on all three fields that have to agree:

const match = prices.data.find(
  (p) =>
    p.currency === 'gbp' &&
    p.unit_amount === spec.amount &&
    p.tax_behavior === MANAGED_PAYMENTS_TAX_BEHAVIOR
);
Enter fullscreen mode Exit fullscreen mode

Match on amount alone and you silently keep a Price whose tax_behavior is wrong, which is exactly the field that decides whether the figure on our page is the figure on the card. Tax behaviour is inclusive for us, so the listed price already contains any VAT and the buyer is never shown one number and charged a larger one.

The guard that earns its lines

This script writes to whatever account the key belongs to. Run it with production credentials by accident and you have created four Products and up to four Prices in the live account, and Prices cannot be deleted, only archived.

So it does not trust the operator:

const isTestKey = key.startsWith('sk_test_') || key.startsWith('rk_test_');
if (!isTestKey && process.env.ALLOW_LIVE !== 'true') {
  fail(
    `Refusing to run: STRIPE_SECRET_KEY looks like a LIVE key (${key.slice(0, 8)}…). ` +
      `This script creates products. Use a test key, or set ALLOW_LIVE=true to override.`
  );
}
Enter fullscreen mode Exit fullscreen mode

Three small decisions in there that I would copy into any script that writes to a payment provider. It defaults to refusing rather than to asking, because a prompt in a script that sometimes runs in CI is a prompt that eventually gets answered by nobody. The override is a separate environment variable rather than a flag, so it cannot be supplied by a shell-history arrow key. And the error prints the first eight characters of the key, which is enough to recognise which key you handed it and not enough to be a secret in a terminal log.

We use the same shape in the affiliate payout command, which cannot move money at all and only records that money was moved. The pattern generalises: when a script's blast radius is bigger than its usefulness, make the dangerous mode the one you have to spell out.

The wart, stated

The prices in the script are copied by hand from the app's constants, and a comment says so:

Prices mirror the source-of-truth constants (kept in sync by hand):
  Provider Access Token  £15.00  (PROVIDER_TOKEN_PRICE)
  Premium Upgrade        £10.00  (PREMIUM_UPGRADE_PACK)
  ...
Enter fullscreen mode Exit fullscreen mode

Importing them would be correct and would also drag the app's module graph, and its environment variables, into a standalone tsx script. We took the duplication, with the comment naming the file to change, because the failure mode is a stale figure in a dashboard nobody charges from. If these Products were ever what the checkout referenced, that trade would flip immediately, and that is the sentence I would want a future reader to find.

Top comments (0)