DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four cards on our pricing page, five product names in our analytics, and one function that has to agree with the webhook

Open cogniprep.app/pricing and count the things you can buy. There are four cards: Provider Access, Scores and Reports, Interview Practice and Assessment Centre. Scroll to the FAQ and there is a fifth question, "Is there a bundle if I want scores too?", which is a real product and does not have a card.

So the catalogue is four cards and five things. That gap is where this post lives.

Two vocabularies for one purchase

When a checkout completes, two entirely separate things need to know what happened.

The webhook needs to grant an entitlement. It is answering: which capability do I switch on for this account? It wants flags, because the flags are what it branches on, and because a single purchase can switch on more than one thing.

The analytics need to record a purchase. It is answering: which product did this person buy? It wants one name, because a funnel chart has one dimension and a purchase has to sit in exactly one bucket.

Those are not the same question, and the honest answer is that neither vocabulary is wrong. Flags are right for granting. A single name is right for counting. The mistake is letting each consumer derive its own answer from the raw session, because then "how many bundles did we sell" has two possible answers depending on who you ask.

So there is one module whose only job is the translation, with its reason written at the top:

The routes describe the purchase with flag-style keys because that is what the webhook's entitlement grant dispatches on. The server-side purchase events want the same information as a single product name instead, using the same names as the client's payment:checkout_started so the funnel joins up. This module is the one place that translation lives, so the grant and the analytics can never disagree about what a given session sold.

The five names, and why one of them is not a card

export type CheckoutProduct =
  | 'premium'
  | 'interview_credits'
  | 'exercises'
  | 'provider_unlock'
  // The "Provider + Scores" bundle: a provider unlock and the Premium upgrade
  // bought together in one checkout. Tracked separately from a plain unlock so
  // the bundle's take-rate is measurable.
  | 'provider_unlock_bundle';
Enter fullscreen mode Exit fullscreen mode

The fifth name exists for a measurement reason rather than a product reason. A bundle purchase and a plain unlock purchase grant overlapping things and cost different amounts, and if both are recorded as provider_unlock then the only way to tell them apart afterwards is by price, which breaks the moment there is a discount or a different currency. A name is cheaper than a reconstruction.

The same five strings are used by the client-side payment:checkout_started event and by the server-side purchase event. That is the whole point of a shared type: a funnel from "started checkout" to "purchased" is a join on a string, and a join on a string that two files spell differently silently reports a 0 percent conversion rate.

Order matters, and this is the trap

Here is the shape of the resolver, with the branch that catches people out:

export function sessionProduct(session: SessionLike): SessionProduct | null {
  const md = session.metadata ?? {};

  // The bundle carries BOTH the provider flag and the scores flag, and it is a
  // bundle, not a scores upgrade. So the provider branch has to come first.
  if (md.provider_access === 'true' && md.provider) {
    return {
      product: md.premium_upgrade === 'true' ? 'provider_unlock_bundle' : 'provider_unlock',
      provider: md.provider,
      /* ... */
    };
  }
  if (md.premium_upgrade === 'true') {
    return { product: 'premium', provider: null, /* ... */ };
  }
  if (md.assessment_centre === 'true') { /* ... */ }
  if (md.pack_id) { /* ... */ }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

The flags are not mutually exclusive. A bundle session carries the provider flag and the upgrade flag, because it genuinely grants both. So an if chain that checks the upgrade flag first classifies every bundle sale as a plain scores upgrade, and the bundle's take rate reads as zero forever.

This is the generic hazard in any flags-to-category mapping and it has nothing to do with Stripe. A sequence of if statements over non-exclusive booleans encodes a priority order, and that order is load-bearing information that nothing in the type system records. Reorder the branches during a tidy-up and the behaviour changes with no compile error and no failing test unless someone wrote one specifically about the overlap.

Two ways to make it safe. Write the test that asserts the overlapping case, which is three lines and the thing we have. Or make the priority explicit by scoring each branch and taking the max, which is more code than this problem deserves. What you should not do is leave an unordered-looking if chain whose order is a secret.

Returning null is a real case, not a defensive habit

// Resolves the product a session sold, or null when the metadata does not
// describe one we know (a foreign session on the same Stripe account, or an
// older shape that predates these keys).
Enter fullscreen mode Exit fullscreen mode

Both of those happen in practice and it is worth naming them, because return null at the end of a resolver usually means "I ran out of ideas" and here it means two specific things.

A Stripe account can have more than one thing creating sessions. Payment links, a test session someone made in the dashboard, an integration you added later. All of those produce completed-checkout events on the same account, and none of them carry your metadata. The webhook will see them.

And metadata is append-only in practice. A session created eighteen months ago has whatever keys the code wrote then. If you rename a key, every historical session keeps the old one, so any function reading session metadata is reading a union of every shape you have ever written. null is the honest answer for "this is a shape I do not recognise", and it is better than a default, because a default quietly files unknown purchases under a real product name.

Worth being explicit about why trusting this metadata is fine, since "read the category out of the request" would be a terrible pattern anywhere else: a buyer never sets these keys. The checkout routes stamp them server-side when they create the session, and the webhook verifies Stripe's signature before reading anything. The metadata is trusted because we wrote it and Stripe handed it back, not because it arrived.

The currency field that is deliberately not copied

One more decision in the same area, and it is the sort that only looks important after it has bitten someone. The purchase event records amount and currency as what Stripe actually charged, plus the catalogue amount it was converted from, as separate fields.

Keeping them separate is the whole trick. The catalogue price and the charged price are different numbers in different units, and the single most common way to corrupt a revenue table is to store one number and label it with the other side's currency. A field named amount with no currency beside it is a bug with a delay on it.

The related rule: never attach a currency label from one source to an amount from another. If you have a price in pence and a session currency of EUR, you do not have a price in euro cents. You have two facts that must stay apart until something converts one into the other on purpose.

What this module is really for

It is twenty lines that could be inlined into either consumer. The argument for it being a module is not reuse, it is that two consumers must agree and neither is in charge.

That is a recognisable shape once you look for it:

  • the thing that changes state, and the thing that reports on state
  • the webhook and the funnel
  • the email that gets sent, and the log line saying it was sent
  • the entitlement granted, and the receipt shown

Whenever two systems describe one event in different vocabularies, the translation is a thing, and it wants a name and a file. Written twice it is a divergence with a date on it. Written once it is just a definition.

The catalogue all five names map to is at cogniprep.app/pricing, and the bundle that does not have a card is the fourth FAQ entry on that page.

Top comments (0)