DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A contributor reward is a Stripe coupon and not a balance credit, because a balance credit does not clear the VAT

Munchable maintains its own product database, and the fastest way it grows is people scanning things we do not have yet. Point the camera at an ingredients panel, the text is read on the spot, and the product is there for whoever scans that barcode next.

That is work, so it is paid for: every accepted new product is 2% off the contributor's next subscription month, and 50 of them in a calendar month makes the next month free. The app shows it both ways, as a percentage and in pence, because 2% of a ten pound subscription is 20p and "20p off next month" is a number a person can hold.

The reward itself turned out to be the most interesting engineering in the feature, and almost none of that is about counting.

The obvious mechanism is wrong

A reward is a discount on a future invoice. Stripe gives you two plausible ways to do that: a customer balance credit, or a coupon with percent_off and duration: 'once'.

The balance credit reads better. It is a cash amount, it sits on the customer, it is obviously composable. We rejected it for two reasons, and the second one is the one that matters.

Managed Payments. Munchable routes EU customers through Stripe's merchant-of-record setup, because being the merchant of record for VAT in 27 countries is not a thing a small app should attempt. Coupons and discounts are explicitly supported on Managed Payments Checkout Sessions and subscriptions. Customer balance credits are not documented there, and "undocumented under the thing we actually use" is not a foundation.

Tax. This is the killer. A percent-off coupon discounts the subtotal, and tax is computed on what is left, so 100% off is zero in every country, whatever the local rate. A fixed ten pound balance credit against a tax-inclusive ten pound line does not reliably clear that line: there is VAT sitting on top or inside it depending on the country, and the customer is left paying the remainder. "Your next month is free" would then be a lie specifically for the customers we route through the merchant-of-record path, which is to say most of them.

So: percent off. The money is never a cash value, never refundable, and never paid out. It is a discount or it is nothing.

Coupon ids are deterministic, so there is one per percent for the whole account

export function rewardCouponId(percent: number): string {
  if (!Number.isInteger(percent) || percent < 1 || percent > MAX_PERCENT) {
    throw new Error(`invalid_reward_percent:${percent}`);
  }
  return `${REWARD_COUPON_PREFIX}${percent}pct`;
}
Enter fullscreen mode Exit fullscreen mode

Coupons in Stripe are global objects, not per-customer ones. Minting a fresh coupon per reward would mean a steadily growing pile of single-use objects in the dashboard, one per contributor per month, forever. Keying on the percent instead means the account holds at most 100 reward coupons ever, and a contributor earning 34% in March shares munchable-reward-34pct with everybody else who earned 34%.

Creation is retrieve-first, create-on-404, retrieve-again-if-creation-races:

try {
  return (await client.coupons.retrieve(id)).id;
} catch (error) {
  if (!isNotFound(error)) throw error;
  // create, and if another instance won the race, retrieve again
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key is the reward row, not the request

Every reward lives as a row in a contribution_rewards table keyed on (user_id, period). That row id is what the Stripe call is keyed on:

  • The subscription update carries an idempotency key of reward:<rowId>.
  • The subscription's metadata is stamped with the same row id.

The second one is doing real work. If the API call times out, the client does not know whether the discount was attached, and a naive retry adds a second one. Because the row id is on the subscription, a retry can go and look for its own handiwork and reconcile instead of re-applying. Idempotency keys expire; metadata does not.

There is also a rule against compounding: if the subscription already carries an unconsumed reward discount, a new reward does not stack on top of it. It waits for the next settlement run. Two percent-off coupons on one invoice multiply in a way nobody intended, and "nobody intended" is how you end up discounting more than the price.

Two routes to the bill, and a Stripe rule that shapes one of them

Already subscribed. Settlement attaches the coupon to the live subscription, Stripe discounts the next invoice, and the once coupon is consumed.

Not yet subscribed. You can earn credit before you ever pay, which is the whole point: contributing is how a free user gets to premium. Those banked rewards ride on the Checkout Session itself as discounts: [{ coupon }], oldest month first, summed and capped at 100%.

That path comes with a constraint worth knowing if you are building anything similar: Stripe forbids discounts together with allow_promotion_codes on a session. You can hand the customer a discount, or you can let them type one in, not both. So a reward checkout has the promo-code box switched off, which is a product decision made entirely by somebody else's API validation.

The double-credit guard on that path is the fiddly bit. When a session is created, each carried reward row is stamped with its checkout_session_id but its status is left alone, so an abandoned checkout does not lock a reward out of existence. On completion the webhook marks the rows applied. If that marking ever fails, the monthly settlement job is the backstop: for any row carrying a session id it retrieves the session and, if it completed, marks the row applied without adding a second coupon, because the discount already came off that first invoice. An expired session is unlinked and handled normally. An open one is left alone for the webhook.

Settlement runs a week late on purpose

{ "path": "/api/cron/rewards-settle", "schedule": "0 4 8 * *" }
Enter fullscreen mode Exit fullscreen mode

04:00 UTC on the 8th, for the previous calendar month. The lag is the feature: a product is only credited if it is still servable at settlement time. Junk that other scanners report in the meantime drops out of the count before any money moves, which makes the reward a clawback-free design rather than one that has to chase discounts it should not have given.

The job is idempotent and runs in two passes.

  1. Earn. For each contributor with at least one qualifying product last month, exactly one row (user_id, period) with percent = min(100, 2 x count). Existing rows are left untouched, so running it twice changes nothing.
  2. Apply. Rows that are earned, waiting for a subscription, retryable after failure, or orphaned mid-application by a died run are claimed with a single statement:
UPDATE contribution_rewards SET status = 'applying'
WHERE status IN (...) RETURNING *
Enter fullscreen mode Exit fullscreen mode

Claiming by UPDATE ... RETURNING rather than selecting and then updating is what lets the cron job and the checkout webhook run at the same time without ever double-applying. Whoever gets the row back from the UPDATE owns it, and nobody else can.

What does not earn, and why the narrow definition is the feature

This is the only path in the app where a user action spends our money, so the definition of a qualifying contribution is deliberately tight.

  • The barcode must have been one Munchable held no ingredient data for at all. Corrections and improvements to an existing row earn contribution points, not credit.
  • One per barcode, ever, computed as the earliest revision per barcode by that contributor.
  • There is a daily cap on credited products, which blunts scripted bursts while leaving a genuine day of shopping untouched.
  • Only a real account earns. Anonymous device sessions can scan and contribute, but the contributions endpoint refuses them and the app does not show them the card.

There is also a server-side proof-of-work in the capture path: the OCR step issues a signed receipt over the account, a hash of the extracted text and the read confidence, and the contribution endpoint requires that receipt. The confidence the client sends alongside proves nothing on its own; the one inside the receipt is the one that counts, and it gates credit rather than saving. A genuine low-confidence read still saves the row, it simply does not earn.

One detail there that I like more than I expected: because the capture flow has no human edit step, a shared row is by construction an unedited machine read of a photographed label. Trust in the data and eligibility for the reward turn out to be the same property.

The reward row knows nothing about you

contribution_rewards holds a user id, a period, a count and a percent. No barcodes, no verdicts, no conditions. The Stripe coupon and subscription ids stored on the row are never returned to the client; the API response the app reads is { period, newProducts, percent, creditPence, status } and nothing else.

That falls out of the same rule the rest of the app follows: billing records and health data do not get to be in the same table.

See it

  • munchable.app has the pricing section, with the price resolved into your own currency from one authored figure per currency rather than converted at checkout.
  • munchable.app/licenses is the plain-English version of the contribution deal: you photograph a label, the photo is read and discarded, and the product becomes part of the database.
  • The progress card, the "+1 towards your free month" line on a successful capture, and the settled-month history live in the app itself. The web app is live at munchable.app/get-started; the two mobile builds are not published yet, so the store buttons on the site are honest placeholders that say so on hover and do nothing when clicked.

If there is one portable lesson in here, it is that tax decides your discount mechanism. Not your data model, not your API ergonomics, not which object reads more cleanly in the dashboard. Work out what "free" has to mean on an invoice in every country you sell in, and the implementation picks itself.

Top comments (0)