115 lines
16 KiB
Markdown
115 lines
16 KiB
Markdown
# 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/business/07-cancellation-and-refunds.md) · [product/payments/cancellation-and-payout.md](../../product/payments/cancellation-and-payout.md)
|
||
**Integration:** [docs/integration/domains/refunds.md](../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](../../client/src/services/refunds/constants.ts)), 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](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx) → `bookingCancelPath` ([routes.ts:159](../../client/src/constants/routes.ts)) |
|
||
| 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](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx)) |
|
||
| 2 — confirm | same route, `step=1` | Restates refund vs fee in Toman, then `useCancelBooking` → routes to refund status ([:124-136](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx)) |
|
||
| 3 — track | `/fa/bookings/[id]/refund_status` | `RefundStatusCard` off `useRefundStatus(bookingId)` ([refund_status/page.tsx:28](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/refund_status/page.tsx)); the poll gate lives in the hook — it runs only while non-terminal, at `REFUND_STATUS_POLL_INTERVAL_MS = 5s` ([constants.ts:38](../../client/src/services/refunds/constants.ts)) |
|
||
| 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](../../client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx)) — 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](../../server/src/API/Baya.Web.Api/Controllers/V1/BookingsController.cs) → `GetCancellationPolicyPreviewQuery` — **live, probed** |
|
||
| Cancel + refund | `POST bookings/{id}/cancel` | [BookingsController.cs:67-71](../../server/src/API/Baya.Web.Api/Controllers/V1/BookingsController.cs) → `CancelBookingAndRefundCommand` (cancel → freeze snapshot → `CreateRefundCommand` → return status) |
|
||
| Refund by booking | `GET refunds/by_booking/{bookingId}` | [RefundsController.cs:29](../../server/src/API/Baya.Web.Api/Controllers/V1/RefundsController.cs) — **live, probed** |
|
||
| Refund by id | `GET refunds/{id}/status` | [RefundsController.cs:23](../../server/src/API/Baya.Web.Api/Controllers/V1/RefundsController.cs) |
|
||
| Admin create+execute | `POST admin_refunds` | [AdminRefundsController.cs:33](../../server/src/API/Baya.Web.Api/Controllers/V1/AdminRefundsController.cs) — `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](../integration/domains/refunds.md).
|
||
|
||
## Rules that must hold
|
||
|
||
| Rule | Value / source |
|
||
| --- | --- |
|
||
| **Cancellation tiers are DB rows, seeded by `HasData`** | [CancellationPolicyConfig.cs:30-58](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/CancellationPolicyConfig.cs) — `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](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
|
||
| **A refund is a reversal leg, never a mutation** | `LedgerPosting.RefundReversalPrePayout` — `DEBIT platform_revenue` + `DEBIT nurse_payable` / `CREDIT refund_payable` ([LedgerPosting.cs:95-114](../../server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs)) |
|
||
| **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](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
|
||
| **Fee-leg decomposition is served, never split client-side** | `ResolveDecomposition` pro-rates each frozen leg ([Handler.cs:172-192](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)); the preview does the same at [GetCancellationPolicyPreviewQuery.Handler.cs:59-64](../../server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs) |
|
||
| **VAT is on commission only** | `Invoice.VatIrr = round(PlatformCommissionIrr × VatRate)`, rate `0.10` ([Invoice.cs:38-41](../../server/src/Core/Baya.Domain/Entities/Invoices/Invoice.cs)). 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](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
|
||
| Crash-window: persist the refund **before** the channel call | [Handler.cs:106-114](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs) |
|
||
| Only **un-started** sessions are refundable | preview marks `refundable = Status == Scheduled` ([Query.Handler.cs:44](../../server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs)); 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](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)); 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](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
|
||
|
||
## How to test
|
||
|
||
1. Log in as **09120000010** (customer Sara) — see [testing-setup.md](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/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`.
|
||
3. 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`. `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_policy` →
|
||
`cancellable: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](../../client/src/services/refunds/constants.ts)) — 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](../../archive/post-phase/hardening/issues.md)). [refunds/apis/mockApi.ts:3](../../client/src/services/refunds/apis/mockApi.ts) 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 (5001–5005) 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](../../client/src/services/refunds/apis/mockApi.ts)) 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` (0–100, probed), but `CancellationPolicyDisclosure.tsx:19-37` runs `toPercent(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](../../client/src/services/refunds/apis/clientApi.ts)) 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](../../client/src/services/refunds/types.ts)) 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](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx)) 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](../../server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs)), 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`.
|