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.
Each was hiding the next. Worth recording in order, because only the first one was visible from inside the product.
| What was seen | What it actually was |
|---|---|
| The agent could not book the applicant | Correct 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 activated | A 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. |
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.
| Question | Answer | Because |
|---|---|---|
| Onboarding | Hosted by Stripe | accounts.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 dashboard | Express Dashboard | stripe-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 liability | Platform | Checkout 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.
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.
| Step | Amount | Record |
|---|---|---|
| Posted at | $45.00 | Home showings, 12 September |
| Booked at the counter-offer | $55.00 | Charged 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.50 | Held by the platform until the showing is marked complete |
| Stripe processing | −$1.90 | Roughly 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.
Settings has carried a three-tier cancellation policy for some time. Reading the code behind it turned up nothing at all.
| What Settings promised | What 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 full | No refund. stripe.refunds.create appeared nowhere in the codebase. |
| Under 24 hours: half kept for the contractor | Nothing computed a tier; there was no notion of when a showing started. |
| After the start time: no refund | Same. 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.
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.
| Situation | Back to the agent | To the contractor | To 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.
A new Edge Function, stripe-cancel-booking, and a dialog on both
sides of the booking.
| Decision | Why it is that way |
|---|---|
| Money first, rows second | The 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 JWT | Never from the request body. The answer changes the refund, so it is not something a client gets to assert. |
| Both Stripe calls are idempotent | Keyed 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 refused | Once 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 arithmetic | The 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 reopens | A 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.
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.
| Piece | Status | Detail |
|---|---|---|
| Connect | Live | Activated on the platform account. Hosted onboarding, Express Dashboard, platform loss liability. |
| First live booking | Paid | $55.00 charged, $44.50 held, appointment confirmed for 12 September. Not yet released. |
stripe-cancel-booking | Deployed | v2, active. Refund, transfer, appointment, listing, application, notifications. |
stripe-webhook | Deployed | v6, active, JWT verification off. Carries the double-payout fix. |
| Cancel and withdraw dialog | Committed | Agent 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 browser | Not yet | The 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 handling | Open | No charge.dispute.created case anywhere. See below. |
| Connect webhook endpoint | Not registered | No account.updated events have ever arrived. Payout status refreshes only when somebody loads Settings. |
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.