DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our free week is one per account and one per card, and the card check is a search over Stripe metadata

Nakodo got a free trial last week: the first seven days of Pro, card taken up front, charged when the week ends unless you cancel first. The pricing page puts it in one FAQ line, "Yes: the first 7 days of Pro are free. Stripe takes your card when you start and charges it when the week ends", and that sentence is the entire product. The engineering is all in the word "one".

Two constants carry the policy, and every surface that mentions the trial interpolates them rather than spelling it out:

export const TRIAL_PLAN = "pro" satisfies PlanId;
export const TRIAL_DAYS = 7;

export const trialLabel = () => `Try ${PLANS[TRIAL_PLAN].name} free for ${TRIAL_DAYS} days`;
Enter fullscreen mode Exit fullscreen mode

Checkout, with the card

    subscription_data: {
      metadata: { app: APP_TAG, userId: user.id },
      ...(trial && { trial_period_days: TRIAL_DAYS, trial_settings: { end_behavior: { missing_payment_method: "cancel" } } }),
    },
Enter fullscreen mode Exit fullscreen mode

Two notes on that. Checkout collects a payment method for a trialing subscription by default, which is what we want: nobody has to come back and type a card in on day seven for the plan to carry on. And missing_payment_method: "cancel" is the answer to the case where it somehow has no card anyway, because the alternatives are to leave a subscription in a state that grants a paid plan without a way to bill it.

The submit button's own text changes, because "you will be charged" and "you will not be charged for a week" are not a detail to leave to the customer's memory:

message: trial
  ? `Nakodo contacts up to ${contactAllowance(limits)} for you. Nothing to pay for ${TRIAL_DAYS} days: cancel in Settings before then and you won't be charged.`
  : `Nakodo contacts up to ${contactAllowance(limits)} for you. Cancel any time in Settings.`,
Enter fullscreen mode Exit fullscreen mode

Plan changes after that first payment go through our own API calls rather than Stripe's customer portal, for reasons I wrote up in Nobody in our app ever sees the Stripe customer portal, and four verbs are the reason.

Two answers to "can this account have a free week", one cheap and one true

The offer appears in several places: the plan cards, the review page at the end of setup, the note when a Free month runs out. All of those are server-rendered pages, and none of them can afford a Stripe round trip to decide whether to say the word "free". So there are two functions.

The cheap one reads our own database:

export const trialOffered = cache(async (userId: string): Promise<boolean> => {
  const sub = await subscriptionForPage(userId);
  if (sub.effective.id !== "free" || hasLiveStripeSubscription(sub)) return false;
  if (!sub.stripeCustomerId) return true;
  const [paid] = await db
    .select({ id: auditLog.id })
    .from(auditLog)
    .where(and(eq(auditLog.userId, userId), eq(auditLog.action, "plan.changed"), sql`${auditLog.metadata}->>'billing' = 'stripe'`))
    .limit(1);
  return !paid;
});
Enter fullscreen mode Exit fullscreen mode

An account with no Stripe customer has obviously never paid. An account that has one might only have it because it opened Checkout and walked away, so the deciding question is whether a plan change was ever billed by Stripe, and that is a ->>'billing' = 'stripe' probe into the audit log. No has_had_trial column, no backfill, and nothing new to keep in sync, because the audit log already recorded every plan change before the trial existed.

The true one asks Stripe, once, at Checkout:

// At Checkout: the customer has never had a subscription, of any status.
export async function trialAllowed(customerId: string): Promise<boolean> {
  const { data } = await stripe().subscriptions.list({ customer: customerId, status: "all", limit: 1 });
  return data.length === 0;
}
Enter fullscreen mode Exit fullscreen mode

status: "all" with limit: 1 is the whole test: a cancelled subscription, an expired incomplete one, anything at all means no free week. The cheap function can be wrong in the direction of offering a trial that Checkout then does not apply, which is why the UI never promises a price inside Checkout that Checkout itself has not computed.

One per card, which Stripe will answer if you ask it the right way

Per-account is easy to work around: a second email address is free. The card is the part that is actually scarce, and Stripe gives every payment method a fingerprint that is stable across customers for the same card.

Where to keep it is the interesting decision. A table of used fingerprints would be a new store of payment data to secure, retain and delete. Instead the fingerprint is written onto the subscription that used it, in metadata, and Stripe's own search index becomes the lookup:

const CARD_KEY = "trialCard";

export async function guardTrialCard(subscription: Stripe.Subscription): Promise<void> {
  if (subscription.status !== "trialing" || subscription.metadata?.app !== APP_TAG || subscription.metadata[CARD_KEY]) return;
  const method = subscription.default_payment_method;
  const card = (typeof method === "string" ? await stripe().paymentMethods.retrieve(method) : method)?.card;
  if (!card?.fingerprint) return;
  const { data } = await stripe().subscriptions.search({ query: `metadata['${CARD_KEY}']:'${card.fingerprint}'`, limit: 10 });
  const before = data.some((s) => s.id !== subscription.id);
  await stripe().subscriptions.update(subscription.id, {
    metadata: { [CARD_KEY]: card.fingerprint },
    ...(before && { trial_end: "now", proration_behavior: "none" }),
  });
Enter fullscreen mode Exit fullscreen mode

Three properties fall out of that shape:

The early return makes it idempotent. It runs from the webhook on both customer.subscription.created and customer.subscription.updated, so it will see the same subscription several times, and the presence of its own metadata key is the "already handled" flag. No separate processed-events table for this one.

trial_end: "now" is a refusal that still sells the plan. The subscription is not cancelled and the customer is not shown an error: the free week ends immediately, the card is charged, and they have Pro. Someone who genuinely wanted the plan gets it. Someone cycling accounts for free weeks gets a bill.

proration_behavior: "none" because ending a trial early is not a mid-cycle plan change and there is nothing to prorate; without it Stripe would be entitled to do arithmetic nobody asked for.

And then the honest comment, which is the part I would want to read in someone else's codebase:

// Stripe's search lags by up to a minute, so two trials started that close
// together both stand.
Enter fullscreen mode Exit fullscreen mode

Search is an index, not a read of the live objects. Two sign-ups with the same card inside the same minute will both get a week. The fix would be our own fingerprint table with a unique constraint, which costs a new store of card-derived data to hold and delete, to close a hole worth two free weeks. We took the lag and wrote it down. When the guard does fire it leaves a trail, under the same audit action as any other billing change, with the reason in the metadata:

await audit({ action: "billing.updated", userId, entityType: "user", entityId: userId, metadata: { trialEnded: "card_used_before" } });
Enter fullscreen mode Exit fullscreen mode

The failed first charge gets no grace

The last trial-shaped bug is a plan that is past due. Our normal rule is generous: past_due keeps the paid plan while Stripe retries the card, because a paying customer whose card expired should not lose their campaigns over it. Applied to a trial, that rule hands out roughly three weeks of Pro to anyone whose card declines on day seven.

The mirror, which is a pure function from a Stripe subscription to our own row, carries the exception:

// Past due on the first period after a free trial: its first charge failed, so
// it was never paid for. Past due keeps a paying customer's plan while Stripe
// retries the card; this gets Free limits until the charge goes through, as
// unpaid does, or a free week would stretch into weeks of retries.
const neverPaid = (sub: Stripe.Subscription, item: Stripe.SubscriptionItem) =>
  sub.status === "past_due" && item.current_period_start === sub.trial_end;
Enter fullscreen mode Exit fullscreen mode

The comparison is the whole trick: if the current period started exactly when the trial ended, this is the first invoice after the free week, and it has not been paid. Any later past_due belongs to a customer who has paid at least once, and keeps the grace. One equality check, no extra column, and it is testable without Stripe or a database, which is why mirror.ts is pure in the first place.

Saying it out loud, everywhere

The last piece is not code so much as a rule: anywhere the trial is offered, the app has to say when the card is charged. One function writes that sentence, and every surface uses it.

export const trialChargeNote = (price: string, ends: Date) =>
  `Your card is charged ${price} on ${formatDay(ends)} unless you cancel in Settings before then.`;
Enter fullscreen mode Exit fullscreen mode

A free week whose end date is a surprise is a chargeback with a delay built in. The engineering above exists so the week can only be had once; this line exists so that having it once is not a trap.

The pricing page has the FAQ, and the sign-up form takes the card for the week on the way in.

Top comments (0)