Every route a purchase can take, what Stripe does at each step, where the money sits afterwards, and how it gets back out again — refunds, damage charges, chargebacks, and what is still missing. Companion to the booking engine, which covers when things can be booked; this one covers what happens when someone actually pays.
Not every booking takes the money at checkout, but nearly every booking takes the card. Two dials decide what happens when the guest presses Continue: booking_mode, which says whose inventory this is, and confirm_policy, which says who has to agree before the money moves.
A product set to booking_mode: manual_request never reaches Stripe at all — it creates an unpaid requested row and takes no card, whatever its confirm_policy says. That is right for a product genuinely settled offline, and wrong for anything that expects to be paid: the seller approves and then has nothing to charge. A priced product that needs someone to say yes belongs on northblue_inventory with confirm_policy: approval, which is the middle route above.
Four stages between pressing Continue and holding a ticket — and one window in the middle where the seat is already yours but the money isn't ours yet.
Everything rides on a single Stripe primitive: the PaymentIntent. No Checkout Sessions, no saved cards, no subscriptions. One intent per purchase, charged and captured in the same instant, and its id — stripe_payment_intent_id — is the only Stripe artifact our database ever stores. It is the join key for everything that follows.
A booking confirms only when the intent reaches succeeded. Delayed methods like bank debits sit on processing for days — the guest would be told the payment didn't complete and hold no seat while their money is in flight. Anything added to the list must settle synchronously.
Both are tokenized cards, so they change nothing about settlement. Apple Pay shows on Apple devices; Google Pay everywhere it can. The trap: an unregistered domain hides Apple Pay silently — no button, no error. It must be registered per domain, per Stripe mode.
Test mode and live mode have separate keys, separate webhooks with separate signing secrets, and separate Apple Pay domain registrations. Going live means swapping all of them together — a live key with a test webhook secret quietly kills the fastest confirmation path.
The most important stretch in the system, and the one worth understanding properly. Between “Stripe took the money” and “the guest has a ticket” there are five things that must happen, three systems racing to make them happen, and exactly one that is allowed to win.
A card being charged does not confirm anything by itself. Three independent things try to close the booking. Whichever gets there first does the work; the others find it already done and stop.
Two things, and they arrive by different means. The email is the durable copy; the booking page is the live one that always reflects the current state.
Hi Maya, your payment went through and your spot is locked in.
Show this email at the experience.
A permanent page showing the current status, items, total and confirmation code. The link in the email points here. It is also where a refund or a later damage charge shows up — the total becomes a net, with the original price and what moved listed above it. The email is a snapshot; this page is the truth.
Staff check the emailed confirmation or the booking page. There is no QR code or scannable voucher yet — redemption is visual. Worth knowing before a partner assumes otherwise.
There is no “my bookings” lookup. If the email doesn't send and the guest closes the tab, they have no way back to their ticket. This is the strongest argument for setting the email key up before a pilot.
| Moment | If it fails | What saves it |
|---|---|---|
| Seats held | Slot already full | Guest is told before any card is touched. No money moves. |
| Card charged | Card declined | Booking is cancelled. If they retry with another card on the same attempt, the retry now settles correctly. |
| Stripe → us | Webhook misconfigured | Guest landing back confirms it; failing that, the 15-minute sweep does. |
| Guest closes tab | Nobody returns to the page | Webhook confirms it silently. Sweep is the third net. |
| Confirmation email | No email key configured | Nothing. The success page still claims an email was sent. Set the key. |
| After confirming | Guest wants a refund | One action in the order drawer: money back, seat freed, commission reversed, guest emailed. See §07. |
The confirmation screen tells every guest “a confirmation will be sent to your email.” If the email provider key is missing, that sentence is false and nothing is sent — silently, with no error anywhere. It is the single highest-value thing to configure before taking real money.
Every charge lands in one Stripe balance belonging to the platform and pays out to one bank account on Stripe's normal schedule. Money can now go back out of that balance too — refunds and the ledger that records them are built. One leg is still missing, and it is the expensive one: nothing pays a seller automatically.
A booking still stores what the guest paid in amount_cents. It now also stores commission_cents, seller_amount_cents and fee_rate_bps, written once when the booking confirms. The rate is stored alongside the figures because a rate that changes must not reach backwards and rewrite what a seller was already told they earned.
Seller analytics read amount_cents − refunded_cents, so a refunded booking stops inflating gross the moment the money goes back — and the fee is taken off what is actually left, at the booking's own rate. A refund reverses the commission in proportion: booking fee income on money we returned would be indefensible.
Sellers are still paid outside the system, but they can now see what they are owed: the seller dashboard's Finance page reads the ledger directly — every movement, their share of each, and a CSV to settle against. What is missing is only the moving of the money: Connect accounts, onboarding, and a transfer per settled booking.
Both kinds now exist, and they are not interchangeable. A capacity hold is a database row reserving the seat — the 15-minute one in §02. A card hold is a Stripe authorization: the guest's money is ring-fenced on their card and not taken. Which one a product gets is decided by a single dial, confirm_policy. Pay-now products are charged at checkout exactly as before. Products where somebody else has to say yes — a seller approving a request, or a min-party trip reaching its minimum — authorize instead, and the money moves only when the answer arrives. Keeping a card on file (§08) is a third thing again, and not a substitute: it gives permission to charge later, it reserves nothing.
| Capacity hold | Card hold / authorization | |
|---|---|---|
| what it reserves | The seat, bike, or spot | The guest's money |
| lives in | Our database — hold_expires_at | Stripe — an uncaptured PaymentIntent |
| applies to | Every product | approval and threshold only |
| lifetime | 15 minutes, self-expiring | ~7 days on a typical card |
| if abandoned | Seat frees itself, nothing to undo | Auth expires, money never moved |
| guest sees | Nothing | A pending charge on their statement |
Two of the three confirm policies do not decide anything at checkout. Under approval the seller has to accept — a helicopter tour, a private charter. Under threshold the departure has to fill — “the airport shuttle runs if four people book”. Charging in full at checkout meant the guest paid for a service nobody had yet agreed to provide, and a trip that never filled was cancelled by the platform while the platform held the money. Authorizing instead makes the money follow the decision.
When a min-party departure misses its minimum, the cutoff sweep releases every authorization on it before the refund pass runs — so for a booking that was only ever held, there is nothing to refund and no processing fee on a sale that never happened. Bookings that genuinely were charged (a pay-now product, or a legacy row from before this existed) still go through the full refund path in §07, which owns the ledger and the guest email. Two mechanisms, one outcome: the guest is never out of pocket for a trip that did not run.
A transfer is booked against a flight, not a clock. flight_airline, flight_number and flight_departs_at are their own columns on the booking — set by experiences.requires_flight_details, asked at checkout, and shown to the seller above the contact card, because on a transfer the flight is the booking. They are deliberately not packed into guest_notes: the note is the guest's own words, and a note is the one field a seller cannot sort tomorrow's runs by. Same shape as guest phone and room. See supabase/flight-details.sql.
Bike-style rentals need damage cover and late-return charges. What exists is the card saved at checkout, chargeable after the fact for a stated reason (§08) — not a deposit. Nothing is held, so there is no agreed ceiling and no pending charge on the guest's statement, and the guest's bank can still refuse the charge when it comes. A real deposit is authorize-at-pickup, capture-some-at-return: the machinery above is now the same shape, but pointing it at rentals is still unbuilt.
Giving money back used to be a two-half manual dance — refund in the Stripe dashboard, then remember to cancel the booking — and doing only one half left the system lying to somebody. It is now one action that does both halves, records the movement, reverses the platform's cut and emails the guest. What each guest gets back is decided by the product's own policy, in numbers.
cancellation_policy is prose a human reads; nothing could compute a refund from it. Each product now also carries refund_policy, the same promise as three numbers — so the seller sees the amount before they press the button, and every guest in the same situation gets the same answer.
| When they cancel | Default | What the guest gets |
|---|---|---|
| More than free_until_hours before the start | 48 h | 100% back Nothing has been committed on their behalf yet. |
| Inside that window, before the start | late_refund_bps 7500 | 75% back The guest forfeits 25%. The seat was held off sale too late to resell. |
| After it has started | cutoff_hours 0 | Nothing The experience was delivered, or the place was there and went unused. |
| We cancelled — called-off trip, seller cancelled the departure | — | 100% back, automatically The policy does not apply: the guest did not change their mind, we did. |
The policy fills the amount in; it is not a cage. A seller admin can type any figure up to what is left of the charge — a goodwill gesture on a no-show, a partial for a trip cut short by weather. What they cannot do is refund more than the guest paid, or refund a booking that is not theirs.
Five triggers. Four of them are now handled by the system; one still belongs to Stripe, because a chargeback is not a request we get to answer.
| Scenario | Who triggers it | What happens |
|---|---|---|
| Guest cancels in time | Guest, via the hotel desk or the seller | Built A seller admin refunds from the order drawer. The policy fills in the amount; a full refund frees the seat. |
| Guest cancels late / no-show | Guest, or nobody | Built Same action, and the policy quotes the reduced figure — or none — without anyone having to argue about it. |
| Seller cancels a departure | Seller, from the calendar | Automatic Cancelling a departure settles every booking on it in the same action: a card hold is released, a booking that really was charged is refunded in full, and the confirmation message says how many of each. Closing one still only stops new sales. |
| Min-party trip never fills | The system, at cutoff | Automatic The 15-minute sweep that calls the departure off settles everyone on it in the same pass — releasing the holds, refunding anyone charged up front. This was the worst gap on the page: charged in full, cancelled by us, money kept. |
| Card dispute / chargeback | Guest's bank | Recorded, not fought charge.dispute.* stamps the booking and writes a ledger row; a lost dispute cancels the booking so the seat is not held for money we no longer have. Submitting evidence is still done in Stripe. |
A refund returns the guest's money but not Stripe's processing fee — every refunded sale costs the platform that fee, which is exactly why the authorize-first path in §06 is worth having: a released authorization was never a sale, so there is no fee to lose. And refunds can race the payout: money already paid out is clawed back from the next one, which can briefly drive the balance negative.
There is no guest-facing cancel button. A guest who wants out emails the hotel desk, and somebody with a seller admin login does it for them. The policy being machine-readable is the prerequisite for self-service — that part now exists, the button does not.
Some money is only owed once the experience is over: a cracked mudguard, a bike back two hours late, an extra the guest agreed to on the day. Collecting it means charging a card that is no longer in front of anybody — which is a different problem to taking a payment, and has a different failure mode.
Checkout attaches a Stripe Customer and asks Stripe to keep the card. The payment step says plainly that the operator may charge it for damage or a late return, the Terms carry the same clause, and card_on_file_consent_at records the moment it was shown. A charge weeks later against a card nobody remembers agreeing to save is how a chargeback starts.
The platform takes no commission on a post-booking charge. Recovering the cost of a broken mudguard is not revenue to split, so seller_delta_cents is the entire amount and the commission delta is zero.
Only a seller admin can raise one, only against their own booking, only up to NORTHBLUE_MAX_EXTRA_CHARGE_CENTS — $2,000 by default — and only with a reason, because the guest is shown it. Every charge records who made it.
It is not a deposit. Nothing is held or authorized at checkout, so there is no ceiling agreed in advance and no pending charge on the guest's statement — the card is simply available if something is owed. A true damage deposit is the authorize-and-capture design in §06, and it is still unbuilt.
NORTHBLUE_CARD_ON_FILE=off stops checkout saving cards. Existing saved cards keep working; new bookings simply have none, and every charge against them falls back to an emailed invoice. The checkout disclosure follows the setting, so the sentence a guest reads is never shown for a card that was not kept.
Nine states a booking can sit in. “Seat taken” is the operational one — it decides whether the slot is still on sale to somebody else. Note what is not here: there is no “refunded” status. Whether money went back is refunded_cents, deliberately separate, because a partly refunded booking is still a live booking with a guest turning up.
| Status | In plain terms | Seat taken? |
|---|---|---|
| requested | Guest asked, seller must approve. The card is authorized, not charged — or untouched, on a product that takes no card. | No |
| pending_payment | Seats held, card not charged yet. Dies after 15 minutes. | Yes — until it expires |
| held | Min-party trip waiting for enough people to join. The card is authorized, not charged. | Yes |
| confirmed | Paid (or free) and booked. The guest has a real ticket. | Yes |
| completed | Seller marked it delivered after the fact. | — |
| cancelled | Called off by the seller, the card was declined, or it was refunded in full. Read refunded_cents to tell which. | No |
| released | An expired hold tidied away, or a request the seller declined. Any card hold is released. | No |
| abandoned | Nobody paid within a day. Written off. | No |
| called_off | Min-party trip that never reached its minimum. Holds are released and anything actually charged is refunded in full, both by the sweep that called it off. | No |
payment_state answers “what happened to the card”, separately from status, which answers “is the seat real”. It reads authorized while funds are held, captured once taken, released when a hold was dropped without ever charging, and refunded when money was taken and given back. The two can disagree legitimately — a confirmed booking whose departure has just filled is briefly confirmed + authorized, until the sweep captures it — which is precisely why they are not one column.
amount_cents is what the guest agreed to pay, refunded_cents is what went back, and extra_charged_cents is what was collected afterwards for damage or a late return. A full refund also flips the status to cancelled, because the seat has to go back on sale — but the status is the consequence, not the record.
There is no “seats left” number anywhere in the database. Availability is worked out live by subtracting the holds that still count. An expired hold stops counting the instant it expires — which is why capacity stays correct even if every cleanup job stops running.
Found in the pilot audit, all now fixed and covered by tests. Each one took a real card payment and produced no booking.
A group tour charged the card in full but filed the booking as held — a status the confirm step didn't recognise. The guest paid, was told their card was not charged, and the booking was written off a day later.
A declined card marked the booking cancelled. Stripe lets the guest try another card on the same attempt — that second payment went through, but the booking could never come back, and no safety net could see it.
The “skip payment when it's free” check only existed in the browser. A request sent directly could book a $44 ticket for nothing. Reproduced against real data, then closed with a 402.
Two housekeeping routines existed but were never called, so stale “awaiting payment” rows piled up forever and group trips never reached a decision. Both now run every 15 minutes.
Four questions, in this order.
The drawer does both halves — money back and the seat released — and records what happened. A dashboard refund still works and is synced back by the charge.refunded webhook, but it depends on that webhook being healthy, and it cannot record who did it or why.
Test and live are two parallel universes with nothing shared between them. Going live is not one switch — it is a set of them, several of which fail quietly if you flip one and forget another. npm run stripe:check asks Stripe about all of it and prints one list; this is what it is checking, and why each one matters.
| Switch | Where | What happens if it is wrong |
|---|---|---|
| Secret key sk_live_… | Vercel env | Still on the test key: real cards are declined, and nothing that happens is real. |
| Publishable key pk_live_… | Vercel env | Silent Mismatched modes mean the browser confirms an intent the server never created. The card form loads and then fails on submit. |
| Webhook endpoint + signing secret | Stripe → Developers → Webhooks, then Vercel env | Silent A live key with a test signing secret rejects every webhook with a 400. Bookings still confirm — fifteen minutes late, via the sweep — so the symptom is "sometimes slow", not "broken". Refund and dispute syncing simply never happens. |
| Enabled events | The same endpoint | Missing charge.refunded and the booking drifts from Stripe after any dashboard refund. Missing invoice.paid and a guest can pay an invoice that is never marked collected. |
| Apple Pay domain | npm run stripe:domains, with the live key | Silent No button, no error, nothing in the network tab — indistinguishable from a guest with no card set up. Register the apex and the www host: Apple treats them as different domains. |
| Account verified | Stripe onboarding | charges_enabled false refuses payments outright. payouts_enabled false is worse: money accumulates in the balance and never reaches the bank. |
| Statement descriptor | Stripe → Settings → Public details | An unrecognisable name on a bank statement is the most common cause of a chargeback. A guest who booked through a hotel may not remember NorthBlue at all. |
| Email provider key | Vercel env | Silent Without it no confirmation, refund notice or charge receipt sends — while the success page still promises one. An unexplained charge with no receipt is a dispute waiting to happen. |
| Fees migration | supabase/product-fees.sql | Without it no product can carry a fee: the editor's fee card saves minus its fees, checkout charges the tickets alone, and the airport transfer keeps advertising its curbside charge as a ticket. Nothing breaks — a fee the row cannot record is simply not taken. See §13. |
| Database migration | supabase/money-out.sql | Refunds and charges cannot record anything. The app is written to survive this — bookings keep working, the columns are simply absent — but every figure in §05 stays blank until it runs. |
A live key with a test webhook secret is the failure worth rehearsing, because everything looks fine. Guests pay, get their tickets, and nobody notices that confirmation is running fifteen minutes behind on a sweep — until a refund issued in the dashboard never reaches the booking, and the seat stays off sale with the money already gone.
Run npm run stripe:check with the live key and get to zero blocking items. Then take one real booking on a real card, refund it from the order drawer, and confirm three things: the money came back, the booking reads cancelled with the seat on sale, and both emails arrived. That single round trip exercises nearly every path on this page.
A product's price used to be its tickets and nothing else. That left a seller passing on an airport's curbside charge with one place to put it — a ticket option called “Airport entry & curbside fee” — which sat in the ticket list as if a guest could choose it, could be left out, and dragged the “From $X” headline down to the cheapest add-on: a $100 van advertised at the price of its $5 child seat. Fees are now their own thing, and there are two kinds, deliberately kept apart. Additional fees are the seller's — fixed amounts, per booking or per ticket, named by them, listed on the product page under the rates. The booking fee is NorthBlue's: 6% of the ticket subtotal, one rate for every product, which a seller can only switch on or off, and which appears at checkout and nowhere earlier.
A ticket is something the guest chooses; a fee is something they owe once they have. A child seat stays a ticket — optional, picked, counted. An airport's curbside charge is a fee: not optional, not picked, added to whatever they chose. The editor keeps them in separate cards for exactly that reason, and the “From $X” headline is built from the tickets alone.
Additional fees are listed under the rates with their basis — per booking or per ticket — so nobody meets them for the first time at the card form. The booking fee is the platform's, one rate for everyone, and belongs on the checkout total next to the button that agrees to it. It is never on the product page, the way a marketplace guest expects.
The booking fee is NorthBlue's whole and the 4% is taken on the rest, but fee_rate_bps is stamped as commission over gross — 8.97% in the example, not 400. Everything downstream multiplies the gross by that one number: a refund reversed at the nominal rate would hand the seller back a slice of a fee they were never paid.
Up to eight additional fees, each with a label, an amount and a basis. The booking fee is a checkbox — on or off — and the rate lives in one constant, BOOKING_FEE_BPS in src/shared/fees.ts. A negotiated per-seller rate would be a column next to booking_fee_enabled; nothing else would need to change.