16 KiB
Flow — cancellation-and-refunds
Last verified: 2026-08-02 against commit
c841bde
Actor(s): customer (cancel + watch the refund) · admin (dispute refund, settlement) · Status: mocked Client: mock · Server: partial Business source: product/business/07-cancellation-and-refunds.md · product/payments/cancellation-and-payout.md Integration: docs/integration/domains/refunds.md
What it does
A customer who can no longer take a booked visit cancels it, is told before confirming exactly how much comes back and how much is kept, and then watches the money return on a status screen. An admin can also issue a refund off a dispute ticket. The platform never edits a payment — it posts a reversal, and if the nurse was already paid it raises a clawback the payout engine nets off her next batch.
The trap this file exists to expose: the server half is real, live and correct — every customer read
(preview · by-booking · status) was probed today and returns the full fee-split. The one write
(POST bookings/{id}/cancel) is trace-only, never fired (step 6). The client seam is still mocked
(USE_REFUNDS_MOCK = true, refunds/constants.ts:18), and
its real half is stale enough that flipping the flag today would render 10000% refund. Details below.
Screens
| Step | Route | Component / notes |
|---|---|---|
| Entry | /fa/bookings/[id] |
«لغو رزرو» CTA — bookings/[id]/page.tsx:96 → bookingCancelPath (routes.ts:159) |
| 1 — disclose | /fa/bookings/[id]/cancel |
Two-step. Step 0 shows off-ramps (reschedule/support → a ticket, real reschedule is deferred), then CancellationPolicyDisclosure, reason select, and an explicit acknowledgement checkbox that gates «ادامه» (cancel/page.tsx:146-217) |
| 2 — confirm | same route, step=1 |
Restates refund vs fee in Toman, then useCancelBooking → routes to refund status (:124-136) |
| 3 — track | /fa/bookings/[id]/refund_status |
RefundStatusCard off useRefundStatus(bookingId) (refund_status/page.tsx:28); the poll gate lives in the hook — it runs only while non-terminal, at REFUND_STATUS_POLL_INTERVAL_MS = 5s (constants.ts:38) |
| Wallet tab | /fa/wallet → «استردادها» |
WalletRefunds.tsx:19 calls useMyRefunds() — no such route exists (REQ-048); empty on the real path |
| Admin | /fa/admin/tickets/[id] |
RefundPanel (admin/tickets/[id]/page.tsx:207) — preview → initiate → approve/reject. The only admin refund surface. |
| Admin | /fa/admin/finance |
Group hub only — links to payouts. No refunds console, no clawback screen, no settlement-confirm screen. |
API
| Call | Endpoint | Notes |
|---|---|---|
| Pre-cancel preview | GET bookings/{id}/cancellation_policy |
BookingsController.cs:74-77 → GetCancellationPolicyPreviewQuery — live, probed |
| Cancel + refund | POST bookings/{id}/cancel |
BookingsController.cs:67-71 → CancelBookingAndRefundCommand (cancel → freeze snapshot → CreateRefundCommand → return status) |
| Refund by booking | GET refunds/by_booking/{bookingId} |
RefundsController.cs:29 — live, probed |
| Refund by id | GET refunds/{id}/status |
RefundsController.cs:23 |
| Admin create+execute | POST admin_refunds |
AdminRefundsController.cs:33 — DynamicPermission → 403 for seeded admins |
| Admin settle / fail | POST admin_refunds/{id}/confirm_settlement · /mark_failed |
same controller, same 403 |
| Clawback write-off | POST admin_clawbacks/{id}/write_off |
no client screen at all |
| Tier admin | GET admin_cancellation_policies/list · POST /upsert |
the runtime-editable tier surface — exists in swagger, 403 for seeded admins, no client screen |
| Phantom (client-only) | admin_refunds/preview · /{id}/approve · /{id}/reject · refunds/my |
REQ-035 / REQ-048 — absent from swagger, 404 on flip |
Shapes: docs/integration/domains/refunds.md.
Rules that must hold
| Rule | Value / source |
|---|---|
Cancellation tiers are DB rows, seeded by HasData |
CancellationPolicyConfig.cs:30-58 — standard_24h (customer, ≥24h, 100%) · standard_inside_24h (customer, open lower bound <24h, 50%) · nurse_no_show (nurse, 100%, FeeRate=0) · admin_cancellation (admin, 100%). Editable at runtime, never retroactive |
| The applied rate is snapshotted onto the booking, then onto the refund | CancellationHelper.ResolvePolicy → Bookings.CancellationPolicyCode / CancellationRefundPercentage → Refund.RefundPercentageApplied (CreateRefundCommand.Handler.cs:102-103) |
| A refund is a reversal leg, never a mutation | LedgerPosting.RefundReversalPrePayout — DEBIT platform_revenue + DEBIT nurse_payable / CREDIT refund_payable (LedgerPosting.cs:95-114) |
| Pre-payout vs post-payout clawback fork | INursePayoutStatus.IsNursePaidForBookingAsync picks RefundReversalPrePayout vs ClawbackReversalPostPayout (debits nurse_clawback_receivable) and writes a nurse_clawbacks row (Handler.cs:124-143) |
| Fee-leg decomposition is served, never split client-side | ResolveDecomposition pro-rates each frozen leg (Handler.cs:172-192); the preview does the same at GetCancellationPolicyPreviewQuery.Handler.cs:59-64 |
| VAT is on commission only | Invoice.VatIrr = round(PlatformCommissionIrr × VatRate), rate 0.10 (Invoice.cs:38-41). There is no VAT ledger account — VAT is an invoice line, so the reversal touches it only implicitly |
Σ refunded ≤ captured |
summed under lock(booking:{id}:refund); a breach is a clean 409 (Handler.cs:45,78-81) |
| Crash-window: persist the refund before the channel call | Handler.cs:106-114 |
| Only un-started sessions are refundable | preview marks refundable = Status == Scheduled (Query.Handler.cs:44); MVP always cancels all of them |
| Channel decides mechanics | manual if a bank ref is supplied, else bnpl_revert for a BNPL gateway, else psp_card (Handler.cs:194-199); BNPL ETA = bnpl_refund_eta_business_days = 10 business days |
| Every admin refund hangs off a ticket | auto-opens a refund ticket when none is passed (Handler.cs:54-62) |
How to test
- Log in as 09120000010 (customer Sara) — see testing-setup.md.
- Open
/fa/bookings/7/refund_status. Expect (UI, mocked): the mock's own fixture, not booking 7's real refund. Expect (API truth, probed today):GET /api/v1/refunds/by_booking/7→id 1 · status succeeded · psp_card · amount 2000000 · platformFeeRefundedIrr 300000 · nursePayoutRefundedIrr 1700000 · refundPercentageApplied 100.00 · cancellationPolicyCode standard_24h. The masked reference renders as••••••••••ef-1. - Post-payout clawback case:
GET /api/v1/refunds/by_booking/8→id 3 · succeeded · amount 250000 · payout leg 212500. That 212500 is nurse 1's outstanding clawback — cross-checked live:GET /api/v1/nurse_payouts/earnings_balance(nurse 09120000001) →clawbackOutstandingIrr "212500" · paidTotalIrr 3187500 · netPayableBalanceIrr 8500000.refundPercentageAppliedandcancellationPolicyCodeare null here because it was an admin refund, not a policy cancellation. - Pre-cancel preview on a stale booking:
GET /api/v1/bookings/7/cancellation_policy→cancellable:false · standard_inside_24h · refundPercentageApplied 50.00 · all amounts "0" · sessions[0].reasonCode "cancelled". PASS = the screen shows the «قابل لغو نیست» card. - Pre-cancel preview on a live booking: booking 10 (confirmed, 2026-08-12) →
cancellable:true · standard_24h · refundPercentageApplied 100.00 · feePercentage 0.00 · refundAmountIrr 250000 · platformFeeRefundedIrr 37500 · nursePayoutRefundedIrr 212500 · leadTimeLabel at_least_24h. That 37500/212500 split is exactly the 15% commission rule. This is the one genuinely cancellable booking in the world today — the 2026-07-26 seed is 7 days stale and every other customer booking iscancelledorcompleted. - UNVERIFIED: the actual
POST bookings/10/cancelwas not fired — it is irreversibly destructive to the only cancellable booking left and other agents share this world. Trace only. - Admin: log in as 09120000021 (finance).
/fa/admin/financeshows only a payouts tile. Open/fa/admin/tickets/{id}on a booking-linked ticket to reachRefundPanel— it renders off the mock.GET /api/v1/admin_refundsreturns 403 with the finance token and with thesuper_admintoken (09120000020); so doesGET /api/v1/admin_cancellation_policies/list. All probed.
Known gaps
USE_REFUNDS_MOCK = true(refunds/constants.ts:18) — every cancel screen, refund status and admin refund panel shows fabricated data while a working server sits behind it.- H-06 CONFIRMED, still unticked (hardening/issues.md:64). refunds/apis/mockApi.ts:3 imports
mockGetBookingForRefund/mockMarkBookingCancelledfrom the bookings mock (used at:88,:312), butUSE_BOOKINGS_MOCK = false. The bookings mock store only ever holds its own seeds (5001–5005) plus ids it converts at runtime — a real booking id such as 7 or 10 never enters it, somockGetBookingForRefund404s inside the policy preview. This is a live cross-domainmockApiimport and the flow is broken end-to-end today. CHANNEL_BY_BOOKING(mockApi.ts:37) pinsbnpl_revertto fixture id 5002, so every real booking silently demos aspsp_card.- Percent-scale defect (flip-blocker). The wire sends
refundPercentageApplied: 100.00/feePercentage: 50.00(0–100, probed), butCancellationPolicyDisclosure.tsx:19-37runstoPercent(x) = round(x * 100)on the documented 0–1 fraction → renders 10000% refund the moment the flag flips. - The client throws away six fields the server now serves.
RefundStatusWire(clientApi.ts:25-33) does not declareplatformFeeRefundedIrr,nursePayoutRefundedIrr,refundPercentageApplied,cancellationPolicyCode,createdAt,completedAt, andtoSummary(:46-51) hardcodes all six tonull— so the fee-split transparency section can never render. All six were returned live today. REQ-021 is delivered; the client does not know. - Enum drift, three fields. Client
CancellationPolicyCode = free_24h | partial_under_24h | customer_no_show(types.ts:76) vs the seeded rowsstandard_24h | standard_inside_24h | nurse_no_show | admin_cancellation;CancellationLeadTime = gt_24h | lt_24h | startedvs the wire'sat_least_24h | less_than_24h;appliesTois typedCancellationScope(whole_booking | remaining_sessions) but the wire sends the actor"customer". Every one of these drives an i18n key lookup → missing-key on flip. preview.refundableSessionIds(cancel/page.tsx:129) does not exist onCancellationPolicyPreviewDto— the wire servessessions[]only. On the real path it isundefined; harmless today because the server defaults to all un-started sessions, but the type lies.- The
clientApi.tsdoc-block is stale and misleading (:55-67): it states REQ-019/020/021 are contract gaps. All three routes exist and returned 200 today. OnlygetMyRefunds,getRefundPreview,approveRefund,rejectRefundremain phantom. - Every admin refund endpoint is 403 for the seeded
super_admin/financeaccounts (DynamicPermissiongrants on the literal roleadmin). Confirmed live forGET /api/v1/admin_refunds. The whole admin half is unreachable even after the flag flips. - REQ-035 not delivered:
POST admin_refundscreates and executes in one call. There is no preview, approve or reject route, soRefundPanel's three-step console has no real backend. - REQ-048 not delivered: no
GET refunds/my, so the wallet «استردادها» tab renders empty on the real path. - No admin console for settlement.
confirm_settlement,mark_failedandadmin_clawbacks/{id}/write_offhave server handlers but zero client screens — amanual-channel refund can be created and then never confirmed through the UI. /fa/admin/financeis a hub with a single payouts tile — no refunds, no clawbacks, no invoice issue.expectedCustomerRefundEtais hardcodednullin the preview (Query.Handler.cs:83-84), so the BNPL 10-business-day window is invisible before confirming — exactly where the honesty matters most.customer_no_showhas no seeded row. The product doc's "up to 100% charge" tier does not exist inCancellationPolicies; a customer no-show falls through tostandard_inside_24h(50% back).- No credit note / invoice reversal on refund.
Invoicecarries noRefundIdandCreateRefundCommandHandlerissues none, so a refunded booking's VAT-bearing commission invoice stays as issued. - The client's in-memory mocks (22
services/*/apis/mockApi.tsmodules) are module-scoped and reset on reload/HMR — a mocked cancel demo does not survive the navigation torefund_status.