The shape of the thing
We added marketplace payouts to our education platform: course authors connect a Stripe account, learners buy courses, the platform takes a cut and the author gets the rest. Standard Stripe Connect territory.
The overall design is a destination charge — the platform stays merchant of record, takes an application_fee_amount, and the remainder is transferred to the author's connected account:
transfer_data: { destination: connectedAccountId },
application_fee_amount: applicationFeeAmountGrosze,
That part took an afternoon. Here are the four things that didn't.
1. The fee base is the discounted amount, and you have to say so out loud
Authors can issue promo codes. So for any given sale there are two numbers: the list price and what the buyer actually paid. Charging your platform fee against the list price when the author discounted the sale means the author eats the entire discount plus a fee calculated on money nobody paid — and at a steep enough discount, the fee can exceed what they netted.
const unitAmountGrosze = Math.round(discountedAmountPln * 100);
const applicationFeeAmountGrosze = Math.round(
discountedAmountPln * PLATFORM_FEE_RATE * 100,
);
Both derive from the same discountedAmountPln. The rule worth writing into a comment (we did) is that there is exactly one authoritative amount per sale, and every downstream number is computed from it. Two amounts floating around means eventually one of them gets used in the wrong place, and that's a bug your authors report as theft.
2. A 100%-off code cannot go through Checkout at all
Stripe Checkout requires unit_amount > 0. A full-discount promo code produces a total of zero, and there is no "free Checkout session" to create.
You cannot fix this at the Stripe layer. It has to be a branch much earlier: if the discounted total is zero, skip payment entirely and grant the purchase directly, recording it with the same purchase record shape and a zero platform fee so downstream reporting doesn't have a hole in it.
The lesson generalizes past Stripe: the free path is a different code path, not a special case of the paid one. If you discover this after building the paid path, you will be tempted to fake a zero-amount payment to keep one flow. Don't — you'll be writing "if amount == 0 skip this" in five more places by the end.
3. PaymentIntent cannot see the Session's metadata
We reconcile purchases from webhooks. Success comes in on checkout.session.completed, failure on payment_intent.payment_failed. Naturally you attach your correlation IDs to the Checkout Session's metadata and read them in the handler.
That works for the success case and silently fails for the failure case: a PaymentIntent has its own metadata and does not inherit the Session's. The failure webhook arrives carrying nothing you can join on, and you discover this when you actually need to debug a failed payment.
The fix is to write the metadata twice — once on the session, once via payment_intent_data.metadata:
metadata, // read by checkout.session.completed
payment_intent_data: { metadata }, // read by payment_intent.payment_failed
Not elegant. Necessary. Worth knowing before rather than after.
4. default: null on a sparse unique index is a trap
Unrelated to payments, same release, too good not to include. Invite-only courses have an optional invite code with a sparse unique index — unique when present, absent otherwise. The schema declared it as:
@Prop({ default: null })
inviteCode?: string | null;
sparse skips documents where the field is missing. It does not skip documents where the field is present and set to null. With default: null, every new course got an explicitly-stored inviteCode: null, so the second course ever created collided with the first on E11000 duplicate key.
The fix is to remove the default entirely and let the field be absent. Generally: null is a value, undefined/absent is not, and every "optional unique field" bug I've seen comes from treating those as the same thing.
None of these are exotic. All four cost real debugging time, and three of them only show up on the failure or edge path — the one you exercise last and in production first.
Top comments (1)
Good list. The zero-total branch in particular is one people usually find the hard way.
A fifth, in the same family as your first: application fees are not refunded automatically. Straight from Stripe's docs, "Application fees aren't automatically refunded when issuing a refund. Your platform must explicitly refund the application fee or the connected account, the account on which the charge was created, loses that amount." You pass
refund_application_fee: trueon the refund, and the fee refund is proportional to the amount refunded.So the default behaviour is that the author absorbs the entire refund while the platform keeps its cut. On a course marketplace with any kind of refund window, that is the sort of thing an author notices before you do, and it arrives as a trust problem rather than a bug report.
Worth pairing with your point 1. If you are telling authors the fee is charged on the discounted amount, say in the same breath what happens to that fee when a learner refunds. Both halves are the same promise, and saying only the first one is how you end up having the second conversation on the back foot.