DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two actors want the same reward row, and only the one that gets it back from the UPDATE may call Stripe

Munchable pays contributors in discount, not cash. Photograph the label of a product we have no ingredient data for, and once it is accepted it takes a slice off your next bill. The public promise is on our terms page, section 9: a percentage per accepted new product, a number of products in a month that makes the bill free, and a line that matters more than any of it, "they are not money, have no cash value, cannot be transferred, exchanged or paid out".

That page is the spec. This post is about the part underneath it, which is a ledger that has to apply a discount to a subscription exactly once, through a payment system that can succeed in ways your database never hears about.

The shape of the problem

A reward is one row per contributor per calendar month. Earning it is a counting job. Spending it means putting a coupon on a Stripe subscription. Between those two facts sit all the interesting cases:

  • The contributor earned a reward and has no subscription at all. Nothing to discount, and the reward must not be lost.
  • They are in the middle of checking out right now, and the discount is riding on that session.
  • The apply call reached Stripe, Stripe did it, and our function died before writing the row back.
  • The apply call failed because the subscription was cancelled yesterday.
  • The apply call failed because Stripe had a bad minute.

The last two look identical at the call site and need opposite handling.

Five states, and one of them is not a failure

export type RewardStatus = 'earned' | 'applying' | 'applied' | 'no_subscription' | 'failed';

/** Allowed transitions. 'applied' is terminal. */
export const REWARD_TRANSITIONS = {
  earned: ['applying', 'no_subscription'],
  no_subscription: ['applying', 'earned'],
  failed: ['applying', 'no_subscription'],
  applying: ['applied', 'earned', 'failed', 'no_subscription'],
  applied: [],
} as const;
Enter fullscreen mode Exit fullscreen mode

no_subscription is the state that earns its place. The naive design has three states and treats "this person is not paying us" as an error, which means your error dashboard fills up every month with the entirely normal case of someone who contributed before they subscribed. Here it is a waiting room: the row sits in no_subscription indefinitely, the checkout route knows to look for rows in it, and nothing about it is alarming.

applied has no outgoing edges, which is the only property of this table that money depends on.

Two actors, one row

Two pieces of code want to act on a reward row: the monthly settlement job, and the checkout webhook when someone subscribes while holding banked rewards. They can easily run in the same second.

There is no lock. The claim is the write:

/** Single-actor claim: only the caller that gets the row back proceeds. */
async function claim(id: string, allowed: readonly RewardStatus[]): Promise<boolean> {
  const rows = await db
    .update(contributionRewards)
    .set({ status: 'applying', updatedAt: new Date() })
    .where(and(eq(contributionRewards.id, id), inArray(contributionRewards.status, [...allowed])))
    .returning({ id: contributionRewards.id });
  return rows.length > 0;
}
Enter fullscreen mode Exit fullscreen mode

One UPDATE ... WHERE status IN (...) RETURNING. Two callers race, one gets a row back, the other gets an empty array and goes home. Every status write in the job uses the same shape, with the expected previous status in the WHERE, so a write can never land on a row that moved underneath it.

The earn side gets its idempotency from the schema instead: a unique constraint on (user, period) and onConflictDoNothing, so running the settlement twice in one month inserts nothing the second time.

"applying" is a state that can be abandoned

A process that claims a row and then dies leaves the row in applying forever. So applying carries an age:

/** An 'applying' row older than this belongs to a function that died mid-call. */
export const APPLYING_STALE_MS = 15 * 60 * 1000;
Enter fullscreen mode Exit fullscreen mode

Younger than that: skip it, somebody is probably working. Older: it is ours to reclaim. That is a heuristic and it is allowed to be one, because of the next part.

Reconcile before you retry

This is the rule that makes the whole thing safe, and it is the one most retry loops skip. Before re-applying anything, ask Stripe whether the previous attempt actually worked:

if ((stale || status === 'failed') && row.stripeSubscriptionId) {
  try {
    if (await rewardAlreadyOnSubscription(row.stripeSubscriptionId, row.id)) {
      // It landed. Mark applied, do not apply again.
    }
  } catch {
    // Fall through to a normal attempt; the idempotency key still guards it.
  }
}
Enter fullscreen mode Exit fullscreen mode

Two layers, and they are deliberately redundant. The reconcile read is the cheap, legible layer. The idempotency key on the apply call is the guarantee, and the key is the row id, which means "apply reward row X" is the same request forever no matter which process sends it or how many times.

The catch is the honest part. A failed reconcile read does not stop the run, because the second layer is load bearing on its own.

The checkout case, which has three answers

A reward can be attached to a Checkout Session, so the discount comes off the first invoice. When the settlement job later finds such a row, "did this session complete" has three answers and each needs a different move:

  • consumed: the coupon came off that invoice. Mark the row applied without touching Stripe again. This is a backstop for a webhook whose reward step died, and it runs before we even read the entitlement, so it heals the case where the whole webhook died.
  • open: the session is still in progress. Touch nothing. Do not flip status, do not apply under an in-flight session.
  • expired unpaid: the reward was never taken. Drop the stale session link and fall through to normal handling, so the value is not lost.

That "do nothing" branch is the one you only write after reading a support ticket about it.

Numbers that are rules

Two arithmetic facts matter more than they look.

A bill cannot be more than free, so the percentage is capped. And when someone has several banked months, the checkout picks them oldest first, while the running total is still under the cap. A month that would push the total past 100% is left in the ledger rather than being partly consumed:

A row that would push the total past the cap is left for settlement so none of its value is lost.

Rounding a reward to "well, that one is used up" is how you turn a goodwill scheme into a complaint.

Timing, and why the job runs late

The settlement runs on the eighth of the month, for the month before, not on the first. The week is deliberate: contributions can be reported and withdrawn by other scanners, and eligibility is evaluated at read time, so a product that turns out to be junk stops qualifying before any money moves. Credit is also not granted for every kind of contribution, and there are anti-farming limits we do not publish in detail, for obvious reasons.

The run is bounded on both ends: caps on how many contributors and rows one invocation handles, and a wall-clock deadline well inside the function's limit so it stops starting Stripe calls before the platform can kill it mid-request. A job that gets killed between "Stripe applied the coupon" and "we wrote the row" is exactly the state the reconcile pass exists to clean up, and the cheapest way to handle that state is to enter it less often.

What the row is allowed to know

The reward row holds a user, a period, a count, a percentage and a coupon id. No barcode, no verdict, no condition, nothing about health. The counting queries return per-day counts and nothing else leaves them.

That is not an implementation detail, it is a promise we printed: our privacy policy lists those exact fields, which means the schema is externally constrained. If a future feature wants to put a barcode on a reward row, the blocker is not a code review, it is a published document.

See it

  • munchable.app/terms, section 9, is the contract this code implements, including the parts about reversal and about the scheme ending.
  • munchable.app/privacy lists the stored reward fields.
  • The whole thing only exists because of label capture: scan something we do not have at app.munchable.app and photograph the ingredients panel.

If you want the earlier posts on the surrounding machinery: the nightly jobs around this one failed silently for a while thanks to a Date object, and the checkout this discount lands on retries exactly once.

Top comments (0)