Files
baya-monorepo/docs/flows/cancellation-and-refunds.md
T
2026-08-02 17:18:36 +03:30

16 KiB
Raw Blame History

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:96bookingCancelPath (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-77GetCancellationPolicyPreviewQuerylive, probed
Cancel + refund POST bookings/{id}/cancel BookingsController.cs:67-71CancelBookingAndRefundCommand (cancel → freeze snapshot → CreateRefundCommand → return status)
Refund by booking GET refunds/by_booking/{bookingId} RefundsController.cs:29live, probed
Refund by id GET refunds/{id}/status RefundsController.cs:23
Admin create+execute POST admin_refunds AdminRefundsController.cs:33DynamicPermission403 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-58standard_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.ResolvePolicyBookings.CancellationPolicyCode / CancellationRefundPercentageRefund.RefundPercentageApplied (CreateRefundCommand.Handler.cs:102-103)
A refund is a reversal leg, never a mutation LedgerPosting.RefundReversalPrePayoutDEBIT 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

  1. Log in as 09120000010 (customer Sara) — see testing-setup.md.
  2. 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/7id 1 · status succeeded · psp_card · amount 2000000 · platformFeeRefundedIrr 300000 · nursePayoutRefundedIrr 1700000 · refundPercentageApplied 100.00 · cancellationPolicyCode standard_24h. The masked reference renders as ••••••••••ef-1.
  3. Post-payout clawback case: GET /api/v1/refunds/by_booking/8id 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. refundPercentageApplied and cancellationPolicyCode are null here because it was an admin refund, not a policy cancellation.
  4. Pre-cancel preview on a stale booking: GET /api/v1/bookings/7/cancellation_policycancellable:false · standard_inside_24h · refundPercentageApplied 50.00 · all amounts "0" · sessions[0].reasonCode "cancelled". PASS = the screen shows the «قابل لغو نیست» card.
  5. 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 is cancelled or completed.
  6. UNVERIFIED: the actual POST bookings/10/cancel was not fired — it is irreversibly destructive to the only cancellable booking left and other agents share this world. Trace only.
  7. Admin: log in as 09120000021 (finance). /fa/admin/finance shows only a payouts tile. Open /fa/admin/tickets/{id} on a booking-linked ticket to reach RefundPanel — it renders off the mock. GET /api/v1/admin_refunds returns 403 with the finance token and with the super_admin token (09120000020); so does GET /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/mockMarkBookingCancelled from the bookings mock (used at :88, :312), but USE_BOOKINGS_MOCK = false. The bookings mock store only ever holds its own seeds (50015005) plus ids it converts at runtime — a real booking id such as 7 or 10 never enters it, so mockGetBookingForRefund 404s inside the policy preview. This is a live cross-domain mockApi import and the flow is broken end-to-end today.
  • CHANNEL_BY_BOOKING (mockApi.ts:37) pins bnpl_revert to fixture id 5002, so every real booking silently demos as psp_card.
  • Percent-scale defect (flip-blocker). The wire sends refundPercentageApplied: 100.00 / feePercentage: 50.00 (0100, probed), but CancellationPolicyDisclosure.tsx:19-37 runs toPercent(x) = round(x * 100) on the documented 01 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 declare platformFeeRefundedIrr, nursePayoutRefundedIrr, refundPercentageApplied, cancellationPolicyCode, createdAt, completedAt, and toSummary (:46-51) hardcodes all six to null — 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 rows standard_24h | standard_inside_24h | nurse_no_show | admin_cancellation; CancellationLeadTime = gt_24h | lt_24h | started vs the wire's at_least_24h | less_than_24h; appliesTo is typed CancellationScope (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 on CancellationPolicyPreviewDto — the wire serves sessions[] only. On the real path it is undefined; harmless today because the server defaults to all un-started sessions, but the type lies.
  • The clientApi.ts doc-block is stale and misleading (:55-67): it states REQ-019/020/021 are contract gaps. All three routes exist and returned 200 today. Only getMyRefunds, getRefundPreview, approveRefund, rejectRefund remain phantom.
  • Every admin refund endpoint is 403 for the seeded super_admin/finance accounts (DynamicPermission grants on the literal role admin). Confirmed live for GET /api/v1/admin_refunds. The whole admin half is unreachable even after the flag flips.
  • REQ-035 not delivered: POST admin_refunds creates and executes in one call. There is no preview, approve or reject route, so RefundPanel'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_failed and admin_clawbacks/{id}/write_off have server handlers but zero client screens — a manual-channel refund can be created and then never confirmed through the UI.
  • /fa/admin/finance is a hub with a single payouts tile — no refunds, no clawbacks, no invoice issue.
  • expectedCustomerRefundEta is hardcoded null in 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_show has no seeded row. The product doc's "up to 100% charge" tier does not exist in CancellationPolicies; a customer no-show falls through to standard_inside_24h (50% back).
  • No credit note / invoice reversal on refund. Invoice carries no RefundId and CreateRefundCommandHandler issues none, so a refunded booking's VAT-bearing commission invoice stays as issued.
  • The client's in-memory mocks (22 services/*/apis/mockApi.ts modules) are module-scoped and reset on reload/HMR — a mocked cancel demo does not survive the navigation to refund_status.