Munchable owns its product catalogue, and users grow it. When you scan a barcode we do not have, you photograph the label and the reading becomes a row other people's scans will hit later. Contributing that data earns a discount: a few percent off your next month per accepted new product, and a full month's worth in a good month of scanning.
That is a one-line product decision and a surprisingly opinionated billing feature. This post is about the mechanism, which is a Stripe coupon rather than a customer balance credit, and about why that choice is a tax question before it is an engineering one.
The obvious mechanism is the wrong one
Stripe gives you a customer balance. Credit the balance, and the next invoice draws it down. It is the natural fit for "you have earned £2 off", it needs no coupon objects, and the ledger is right there in the dashboard.
We rejected it for two reasons.
One: it has to work under merchant of record. Munchable routes EU customers through Stripe Managed Payments, where Stripe is the merchant of record and handles VAT registration and remittance. Coupons and discounts are explicitly supported on Managed Payments checkout sessions and subscriptions. Customer balance credits are not documented there. A rewards mechanism that works on the direct path and not on the merchant-of-record path is not a rewards mechanism; it is a rewards mechanism plus a second one you have not written yet.
Two: tax and currency. Our prices are one fixed amount per currency, and every one is tax-inclusive: £10 in the UK, €12.99 in the euro area, and so on, with whatever VAT is due coming out of that figure rather than being added to it.
Now try to express "your next month is free" as a fixed-value credit. Which value? £10 does not clear a €12.99 line. Convert it, and you are back to FX rates moving between the promise and the invoice. And because the price is tax-inclusive, a credit sized to the net amount leaves the tax behind, so "free month" arrives as a bill for the VAT.
A percent-off coupon sidesteps all of it. The discount applies to the subtotal, tax is computed on what remains, and 100 percent off is zero in every country and every currency. No table, no conversion, nothing to keep in sync with the price list.
The general form of the lesson: the unit of your discount should match the unit of your promise. We promise a proportion of a month, so the discount is a proportion. Had we promised "£5 off", a balance credit would have been right and a coupon would have been the awkward one.
Deterministic coupon ids, because the cron will run again
Rewards are settled by a monthly cron. Crons retry, get redeployed mid-run, and occasionally run twice. So nothing in this path may be creatable twice.
Coupon ids are derived from the discount itself, in the form munchable-reward-<percent>pct, and creation is retrieve-first, create-on-404. Percentages come from a small set, so the account accumulates a handful of reusable coupon objects rather than one per reward, and two settlements that both want 20 percent off converge on the same object.
Applying a coupon to a subscription then carries an idempotency key derived from the reward row id, so a retry of the same row is a no-op at Stripe rather than a second discount.
Discounts do not stack multiplicatively, because people do not read them that way
If a subscription already carries an unconsumed reward discount, a new reward waits for the next settlement instead of compounding.
This is a product decision enforced in billing code. Two 50 percent discounts on one invoice do not make it free. Stripe would apply them in sequence and you would land at 75 percent off, which is arithmetically defensible and completely unlike what a person believes they were promised. Queuing is both simpler and truer: one discount per invoice, the rest wait their turn.
Two paths to the bill, and the second one is the interesting one
Already subscribed. Settlement attaches the coupon to the live subscription. Stripe discounts the next invoice, and a duration: 'once' coupon is consumed on use.
Not subscribed yet. This is the case that makes the feature worth having: somebody scans a shelf of products before they have ever paid. Those rewards bank, and when they do subscribe, the banked rows ride on the Checkout Session itself:
discounts: [{ coupon }]
oldest month first, summed and capped at 100 percent.
There is a Stripe constraint hiding in that line, and it is the kind you only find at runtime: discounts and allow_promotion_codes are mutually exclusive on a Checkout Session. You cannot pre-apply a coupon and also offer a promo code box. So a reward checkout turns promo-code entry off, which is a deliberate, documented consequence rather than a missing feature.
Not applying the same reward twice, across a webhook and a cron
Two independent processes can mark a reward as used: the Checkout completion webhook, and the settlement cron. Both must be able to fail without losing or duplicating a discount.
The checkout route stamps each carried reward row with the checkout_session_id, and deliberately leaves the row's status unchanged, so an abandoned checkout does not lock a reward away. The webhook marks rows applied on completion. If that step ever fails, settlement is the backstop: for any row carrying a session id it retrieves the session, and
-
completemeans the discount already came off that first invoice, so the row is marked applied without attaching a second coupon, -
expiredmeans the session never became a subscription, so the row is unlinked and handled normally, -
openis left alone, because the webhook may still be coming.
The part of that design I like most is that it is written down with its residual risk rather than as a claim of perfection: two checkout sessions that both complete would each carry the coupon, and the only reason that is safe is that it would also mean two subscriptions for one account, which the one-entitlement-per-user model already prevents. Knowing where your guarantee actually comes from is better than believing the code is airtight.
Claim rows with one UPDATE, not with a read and a write
The cron and the webhook can touch the same row concurrently, so claiming is done as a single statement:
UPDATE ... SET status = 'applying' WHERE status IN ('earned', 'no_subscription', 'failed') RETURNING ...
Select-then-update would have been readable and wrong. The RETURNING clause makes the claim and the read one atomic act, so two runners cannot both believe they own a row. Statuses move earned to applying to applied, with no_subscription for someone who has not paid yet and failed for a retryable error. Rows stuck in applying by a process that died are reclaimable on the next run, which is the piece people forget: a claimed-but-unfinished state needs an owner or it is a leak.
Settlement runs a week late on purpose
The cron is monthly, on the 8th, at 04:00 UTC. The lag is the feature. A contributed product is only credited if it is still servable at settlement time, so a row that other scanners have since reported as junk drops out of the count before any money moves.
Credit is also narrow by design. Only the first revision for a barcode earns, and only once, ever. Corrections to an existing product earn contribution points but no money, because a correction is not a new row in the catalogue. There is a daily ceiling on credited products so a scripted burst cannot mint a free year, and captures have to clear machine gates before they qualify at all. Anonymous device sessions earn nothing.
Every one of those rules exists because this is the only path in the product where a user action leads to a reduction in what they pay. That is the definition of a spend path, and a spend path a stranger can trigger should be the narrowest thing in your codebase.
The rewards table is a privacy hazard, so it holds almost nothing
This is the part that is specific to a health app and that I would want anyone building something similar to think about.
Munchable's whole architecture is built so the server never learns your conditions. A rewards table is exactly the kind of well-meaning feature that quietly undoes that: it is tempting to store which barcodes earned the credit, for support, for disputes, for a nice history screen.
So the row is (user_id, period, count, percent) and a status. No barcodes. No health data. The Stripe coupon and subscription ids stored alongside are never returned to the client, and the API response the app reads carries only the period, the number of new products, the percent, the pence figure and the status.
You can build the whole progress UI from that. "23 of 50 new products this month" needs a count, not a list.
Everything tunable is in one file
The percent per product, the threshold for a free month, the cap, and the daily limit are named constants in a single module rather than literals scattered through settlement, checkout and UI code. Changing the offer is a one-line change, and the pence figure shown to users is derived from the percent rather than typed separately, so the two can never disagree.
Go and look
- The pricing section on munchable.app is the amount the percentage applies to. It is fixed per currency and tax-inclusive, which is the constraint that ruled out a fixed-value credit. Load it from another country and the figure changes to that currency's own authored price, not a conversion.
- The licences page explains the contribution side: what happens when you photograph a label, and what becomes of the photo.
- The landing page's "Not on file? Photograph the label" section is the flow that earns the reward in the first place.
- The reward itself shows up inside the app, on the home screen and in the profile, as progress towards a free month rather than as a balance. That wording is the mechanism showing through, and it is the honest description of what the coupon does.
Top comments (0)