9.3 KiB
Contract — Refunds, clawbacks & invoices (backend phase b11)
One-line: the outbound money leg — an admin reverses a captured booking payment across both fee legs (posting the balanced ledger reversal, forking on whether the nurse was already paid), and issues the commission invoice with VAT. Customers can only read their refund status + invoice. Assumes
../conventions/api-conventions.md+../conventions/money-and-types.md. Machine schema:../openapi/swagger.v1.json.
Status: live as of backend-phase-b11 · Frontend consumer: frontend-phase-f10-b11
All money is IRR Rials, integer, on the wire as a string of digits ("10000000"). Refunds are admin-only
— there is no customer refund-initiation path. Internal account_types are never exposed. Timestamps are UTC
ISO-8601; expected_customer_refund_eta is a date ("2026-08-24").
Enums used
refund_status(refunds.status):requested|approved|processing|succeeded|failed|rejected. A card refund goesapproved → succeededimmediately; a BNPL/manual refund sits inprocessinguntil the async customer cash-back reconciles. Forward-only.refund_channel(refunds.refund_channel):psp_card|bnpl_revert|manual. (The data-model'smanual_bankis stored/served as the canonicalmanual.)clawback_status(nurse_clawbacks.status):pending|recovered|written_off. This phase only ever createspendingand supportswritten_off;recoveredis set by b13 payout netting.moadian_status(invoices.moadian_status):pending|submitted|registered|failed. Mock leaves a new invoicepending.
Endpoints
POST api/v1/admin_refunds
- Purpose: create (and immediately execute) a refund on a booking with a captured payment.
- Auth: admin (dynamic-permission) · Rate-limited: yes (sensitive) · Idempotency key: internal (booking + transaction + cumulative amount) — a retried channel call never double-refunds.
- Request body:
Supply either
{ "bookingId": 42, "ticketId": null, "refundPercentage": 1.0, "platformFeeRefundedIrr": null, "nursePayoutRefundedIrr": null, "reasonCategory": "customer_request", "reasonNotes": "shortened visit", "adminNotes": null, "manualBankReference": null }refundPercentage(0–1 fraction, pro-rata across the booking's commission/payout legs) or the explicitplatformFeeRefundedIrr+nursePayoutRefundedIrrlegs (both together). If neither is given the booking's b9 cancellation snapshot percentage is used.manualBankReferenceforces themanualchannel. - Success
200payload (data):For BNPL:{ "refundId": 7, "bookingId": 42, "status": "succeeded", "refundChannel": "psp_card", "amount": "10000000", "platformFeeRefundedIrr": "1500000", "nursePayoutRefundedIrr": "8500000", "expectedCustomerRefundEta": null, "clawbackId": null }status: "processing",refundChannel: "bnpl_revert",expectedCustomerRefundEta: "2026-08-24". Post-payout:clawbackIdis set (apendingnurse_clawbacksrow + a support alert were created). - Failure cases:
400invalid amount/legs or missing percentage ·401unauth ·403non-admin ·404no captured payment for the booking ·409Σ refunded > captured(over-refund) ·400channel refused. - Notes: whole money-path runs under
lock(booking:{id}:refund); posts the balanced ledger reversal via b10's helper; therefund_payable ↔ escrow_heldclearing posts immediately for a succeeded card refund and is deferred to reconciliation for BNPL/manual.ticketIdis required only whenrefund_ticket_requiredconfig is on (off until b15). Notifies the customer.
GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=
- Purpose: admin refund worklist — projected + paginated (
pagedefault 1,pageSizedefault 20 / max 100). - Auth: admin · Success
200(data):PagedResult<RefundListItem>(see shapes) — channel, decomposed legs, status,expectedCustomerRefundEta, the policy snapshot.
POST api/v1/admin_clawbacks/{id}/write_off
- Purpose: mark a
pendingnurse clawback uncollectable; posts the balancingDEBIT bad_debt / CREDIT nurse_clawback_receivablecorrection and setsresolved_at. - Auth: admin · Rate-limited: yes · Request body:
{ "reason": "uncollectable" } - Success
200(data):true. Failure:404not found ·409not pending.
POST api/v1/admin_invoices
- Purpose: issue the booking's official commission invoice. Idempotent per booking (re-issue returns the same).
- Auth: admin · Rate-limited: yes · Request body:
{ "bookingId": 42 } - Success
200(data):Invoice(see shapes) — sequentialinvoiceNumber,vatIrr = round(commission × vat_rate)on the commission line only,moadianStatus: "pending",moadianReferenceNumber: null. - Failure:
404booking not found.
GET api/v1/refunds/{id}/status (customer-visible)
- Purpose: the customer-facing status of their own refund.
- Auth: authenticated; tenancy-scoped to the booking's customer — another customer's refund is a clean
404. - Success
200(data):The external reference is masked (last 4 only).{ "id": 7, "bookingId": 42, "status": "processing", "refundChannel": "bnpl_revert", "amount": "10000000", "expectedCustomerRefundEta": "2026-08-24", "reference": "••••••ab12" } - Failure:
401unauth ·404not found / not the caller's.
GET api/v1/invoices/{booking_id} (customer/admin)
- Purpose: the booking's invoice. Auth: authenticated — the owning customer or an admin (else
404). - Success
200(data):Invoice(see shapes), withpdfUrlwhen a PDF is stored.
Shared shapes
RefundListItem:id(int),bookingId(int),paymentTransactionId(int),amount/platformFeeRefundedIrr/nursePayoutRefundedIrr(IRR digit-strings),refundChannel(enum),status(enum),refundPercentage(decimal),reasonCategory(string?),cancellationPolicyCode(string?),refundPercentageApplied(decimal?),expectedCustomerRefundEta(date?),gatewayRefundReference(string?),externalRevertReference(string?),processedAt(datetime?),createdAt(datetime).Invoice:id(int),bookingId(int),invoiceNumber(string, unique/sequential),issuingEntityType(platform|partner_center),grossIrr/platformCommissionIrr(IRR digit-strings),bnplCommissionIrr(digit-string?),vatRate(decimal),vatIrr(IRR digit-string),moadianReferenceNumber(string?),moadianStatus(enum?),pdfUrl(string?),issuedAt(datetime).
Load-bearing rules the client must honour
- Money is IRR integer, on the wire as a digit-string. Never coerce to a JS number for math.
- Refunds are admin-only. The only customer-visible surface is
refunds/{id}/status— there is no self-service refund initiation. - A card refund is immediate (
succeeded, no ETA); a BNPL refund isprocessingwith anexpectedCustomerRefundEta~7–10 business days out — surface it as "on its way, ~N days". - VAT is on the platform commission only — never the nurse payout.
- External references are masked in the customer status view.
Changelog
- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice).
Refinement phase 3 additions (REQ-019/020/021 — customer refunds)
POST api/v1/bookings/{id}/cancel(customer) — cancels the booking (freezing the policy snapshot) AND opens its refund in one call →RefundStatusDto. Body{ reasonCategory, reasonNotes?, sessionIds? }(MVP cancels all un-started sessions;sessionIdsis accepted for forward-compat).GET api/v1/bookings/{id}/cancellation_policy(customer) — pre-cancel disclosure: resolves the applicable policy by current lead time + per-session refundability →{ bookingId, cancellable, cancellationPolicyCode, refundPercentageApplied, feePercentage, refundAmountIrr, feeAmountIrr, refundableAmountIrr, platformFeeRefundedIrr, nursePayoutRefundedIrr, appliesTo, leadTimeLabel, refundChannel, expectedCustomerRefundEta (null in preview), sessions: [{ bookingSessionId, sessionIndex, scheduledDate, refundable, reasonCode }] }.refundAmountIrr + feeAmountIrr = refundableAmountIrr.GET api/v1/refunds/by_booking/{bookingId}(customer) — the booking's latest refund status (404 if none).RefundStatusDtogainsplatformFeeRefundedIrr,nursePayoutRefundedIrr,refundPercentageApplied,cancellationPolicyCode,createdAt,completedAt(the fee-leg transparency split).- Canonical
cancellation_policy_codeset (seeded, stable — the frontend'sfree_24h/partial_under_24h/customer_no_showwere invented):standard_24h(customer ≥24h → full refund),standard_inside_24h(customer <24h → partial),nurse_no_show(nurse-initiated → full refund + penalty),admin_cancellation(admin → full refund). Per-sessionreasonCode:un_startedwhen refundable, else the blocking session status.