Tour Dash
Report · 8 September 2026

The First Live Booking, and Calling It Off

A beta agent posted a showing, somebody applied, and the Book button would not work. Chasing that produced three findings in a row: the payouts gate was doing its job, Connect had never actually been switched on at Stripe, and once it was, the marketplace took its first real $55 off a real card. Which surfaced the thing it still could not do. There was a written cancellation policy and not one line of code enforcing it, and no function in the repository had ever called stripe.refunds.create. That is now built and deployed.

Scope Connect activation, first live charge, cancellations and refunds Status Deployed; one live booking outstanding
01

Three walls, one after another

Each was hiding the next. Worth recording in order, because only the first one was visible from inside the product.

What was seenWhat it actually was
The agent could not book the applicantCorrect behaviour, not a bug. The applicant's profile had stripe_payouts_enabled: false, so the Book button was disabled with the reason printed underneath it. Booking is paying, and there was nowhere to send the money. Both layers refused: the dialog before the click, and stripe-checkout before the card.
Setting up payouts failed with a red toast"Edge Function returned a non-2xx status code" is what the browser shows for any 500. The function logs had the real sentence from Stripe: you can only create new accounts if you've signed up for Connect. Every stripe_account_id in the database was null because nobody could ever have gotten through.
Connect had never been activatedA dashboard signup on the platform account, not a code change. The function was correct the whole time. Four configuration choices came with it, and three of them would have quietly broken code that already exists.
02

The Connect settings, and why each one

Stripe asks four questions during activation. The answers were already decided by code written weeks ago, so the job was reading the code rather than weighing preferences.

QuestionAnswerBecause
OnboardingHosted by Stripeaccounts.create({type:'express'}) plus accountLinks.create is the hosted flow by definition. Building our own would have put local KYC compliance and chargeback liability on us for a form we have not written.
Account dashboardExpress Dashboardstripe-connect-status already calls accounts.createLoginLink, which exists only for the Express Dashboard. Choosing embedded components would leave that call with nothing to link into; the catch would swallow it and the "View your payouts" button and the wallet's "Payouts on Stripe" link would silently stop rendering.
Loss liabilityPlatformCheckout builds a separate charge and transfer: no transfer_data, no on_behalf_of. The card is charged on Tour Dash's own account, so we are merchant of record whatever this setting says. Stripe's liability option covers charges made on the connected account, and we make none of those.

The pattern in all three: the architecture had already answered the question, and the only way to get it wrong was to answer from preference instead of from the code.

03

The first live booking

Both sides onboarded through Stripe Express within two minutes of Connect being switched on. The agent booked at the applicant's counter-offer rather than the posted price, which is the behaviour the counter-proposal work shipped for.

StepAmountRecord
Posted at$45.00Home showings, 12 September
Booked at the counter-offer$55.00Charged to the agent's card, livemode: true
Tour Dash keeps$10.50$5 flat plus 10%, so 19.1% at this price
Contractor's share$44.50Held by the platform until the showing is marked complete
Stripe processing−$1.90Roughly 2.9% + 30c, paid out of our cut

Everything downstream of the money fired correctly and in order: the payment row went to paid, the application to approved, the listing to filled, and a confirmed appointment appeared for Friday 12 September at 4:30pm. That is the full pay-to-book sequence working against a live card for the first time, which is the milestone the Stripe Connect report ended by asking for.

It is also real money between two real people, and it is still outstanding. Whether it stands as the first booking or gets refunded is a decision, not a technicality.

04

The policy nothing enforced

Settings has carried a three-tier cancellation policy for some time. Reading the code behind it turned up nothing at all.

What Settings promisedWhat the code did
"Open the job under Posted jobs, edit it and set it to cancelled"A bare column update. No database trigger on listings, appointments or payments read it.
24 hours or more: refunded in fullNo refund. stripe.refunds.create appeared nowhere in the codebase.
Under 24 hours: half kept for the contractorNothing computed a tier; there was no notion of when a showing started.
After the start time: no refundSame. The appointment stayed confirmed and the money stayed parked.

The one half that did work was the receiving end. onChargeRefunded in the webhook has always handled a refund correctly, marking the payment and writing the wallet line. It was built to react to a refund and never to cause one, so a cancellation meant refunding by hand in the Stripe dashboard and then fixing two database rows.

05

The policy as arithmetic

Worked against the live $55 booking. Refund plus kept always equals the amount charged, and the contractor's share plus ours always equals what was kept.

SituationBack to the agentTo the contractorTo Tour Dash
Agent cancels, 3 days out$55.00——
Agent cancels, exactly 24h out$55.00——
Agent cancels, 6h out$27.50$27.50—
Nobody shows, cancelled after the start—$44.50$10.50
Contractor withdraws, 6h out$55.00——
Booking with no date set$55.00——

Two rows there are rulings rather than transcriptions of the policy, and both are worth arguing with if they read wrong.

A contractor who withdraws refunds the agent in full, whatever the clock says. The late-cancellation penalty exists to protect the contractor from an agent who drops them at the last minute. Charging the agent for the contractor's own change of mind inverts it. Settings already tells contractors that withdrawing after being booked counts as a no-show, and the consequence for that belongs in their record rather than in the agent's wallet.

The kept half goes entirely to the contractor, and Tour Dash takes nothing. That is what the policy's own wording argues for: the money is kept because they turned down other work, which makes it compensation owed to them and not a fee we earned. We still pay Stripe's processing on the refunded half, so a cancellation costs us a little, which is the right shape for something that should be rare. The other reading is one constant away, and what it does to the rate is written next to it: the ordinary fee on a kept $27.50 is $7.75, which is 28% for a showing nobody attended and a hard line to defend to a contractor.

A no-show is deliberately outside that choice. Past the start time the money splits exactly as a completed showing would, because from the contractor's side that is what it was: they travelled, they waited, and the only thing missing is a signature.

06

How the cancellation runs

A new Edge Function, stripe-cancel-booking, and a dialog on both sides of the booking.

DecisionWhy it is that way
Money first, rows secondThe opposite order to checkout, on purpose. There, money moves first and the database follows, because a booking without a payment is a contractor working for nothing. Here the refund goes out before the rows change, because a refunded card with a live appointment is a support ticket while a cancelled appointment against a refund that never fired is theft.
Which side is cancelling comes from the JWTNever from the request body. The answer changes the refund, so it is not something a client gets to assert.
Both Stripe calls are idempotentKeyed on the payment id, so a double-click, a retry or two people cancelling the same booking produce one refund and one transfer.
A released payment is refusedOnce the transfer is out, the contractor's bank has it. Reversing that needs a negative balance on their account, which is a different and much uglier conversation. The button is not offered where the function would refuse.
One implementation of the arithmeticThe tier maths lives in src/lib/fees.ts and is generated into the Edge Function copy by the same sync that guards the fee. The figure on the confirm button and the figure Stripe returns cannot drift.
The agent's cancelled job closes; the contractor's reopensA contractor pulling out leaves the agent still wanting the showing, so the post goes back on the board and the others who applied can be booked. An agent calling it off is the job itself going away.

The dialog is one component serving both sides, because the decision has the same shape from either chair and only the outcome differs. Cancelling is not a yes/no question, it is a question whose answer depends on what time it is, so the refund figure is the largest thing on the surface and the confirm button repeats it. The preview runs the same function the server runs, but the server decides from the appointment's own start time, so a tab left open overnight can show a stale number and still cannot produce a wrong ledger.

07

A live-money bug found on the way past

Not the task, but it would have paid somebody twice.

onChargeRefunded set a payment's status from the refunded amount alone: full refund meant refunded, anything less meant paid. That was right while every partial refund was a goodwill gesture on a booking that still stood. It stops being right the moment a late cancellation exists, because a late cancellation is a partial refund: half back to the agent, half to the contractor, booking over. The webhook would have walked the payment back to paid, and stripe-release acts on exactly that status, so marking the dead showing complete would have transferred the contractor their full share a second time. The handler can no longer move a payment out of refunded or released.

08

Where it stands

PieceStatusDetail
ConnectLiveActivated on the platform account. Hosted onboarding, Express Dashboard, platform loss liability.
First live bookingPaid$55.00 charged, $44.50 held, appointment confirmed for 12 September. Not yet released.
stripe-cancel-bookingDeployedv2, active. Refund, transfer, appointment, listing, application, notifications.
stripe-webhookDeployedv6, active, JWT verification off. Carries the double-payout fix.
Cancel and withdraw dialogCommittedAgent side on Posted jobs, contractor side in the booked-job dialog. Typechecks, builds, lints. The demo shows neither, and still makes zero Supabase requests.
Seen working in a browserNot yetThe dialog has not been opened against real data. The maths is verified directly, including the daylight-saving correction in both directions, but the rendered surface is unreviewed.
Dispute handlingOpenNo charge.dispute.created case anywhere. See below.
Connect webhook endpointNot registeredNo account.updated events have ever arrived. Payout status refreshes only when somebody loads Settings.
09

What is still open

Three things, in the order they will cost something.

Disputes. Tour Dash is merchant of record on every booking, so a disputed showing is pulled from our balance along with Stripe's dispute fee. Nothing in the system hears about it: there is no charge.dispute.created handler, and no reversal path once the contractor has been paid. The damaging sequence is an agent booking, marking complete, the contractor's share reaching their bank, and a dispute arriving months later. On $45 showings a couple of those is a losing trade. The fix is small, a webhook case that flags the payment, plus a decision about whether release should wait out a window rather than firing the instant somebody taps complete.

The Connect webhook endpoint. Only the platform endpoint is registered. Onboarding still completed because stripe-connect-status writes the payout booleans itself on every poll, which is why it was built that way, but it means a Stripe restriction on somebody's account goes unnoticed until they happen to open Settings.

The timezone assumption. A showing's date and time are two bare columns with nothing recording where that is, and "24 hours before the job" has no meaning without an instant. BOOKING_TIMEZONE assumes New Jersey, which is correct today and wrong the first day Tour Dash sells into a second zone. At that point it becomes a column on listings and the constant becomes the fallback for rows written before it.