Files
baya-monorepo/dev/contracts/domains/refunds-invoices.md
T
2026-07-13 17:03:45 +03:30

12 KiB
Raw Blame History

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 goes approved → succeeded immediately; a BNPL/manual refund sits in processing until the async customer cash-back reconciles. Forward-only.
  • refund_channel (refunds.refund_channel): psp_card | bnpl_revert | manual. (The data-model's manual_bank is stored/served as the canonical manual.)
  • clawback_status (nurse_clawbacks.status): pending | recovered | written_off. This phase only ever creates pending and supports written_off; recovered is set by b13 payout netting.
  • moadian_status (invoices.moadian_status): pending | submitted | registered | failed. Mock leaves a new invoice pending.

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:
    {
      "bookingId": 42,
      "ticketId": null,
      "refundPercentage": 1.0,
      "platformFeeRefundedIrr": null,
      "nursePayoutRefundedIrr": null,
      "reasonCategory": "customer_request",
      "reasonNotes": "shortened visit",
      "adminNotes": null,
      "manualBankReference": null
    }
    
    Supply either refundPercentage (01 fraction, pro-rata across the booking's commission/payout legs) or the explicit platformFeeRefundedIrr + nursePayoutRefundedIrr legs (both together). If neither is given the booking's b9 cancellation snapshot percentage is used. manualBankReference forces the manual channel.
  • Success 200 payload (data):
    {
      "refundId": 7,
      "bookingId": 42,
      "status": "succeeded",
      "refundChannel": "psp_card",
      "amount": "10000000",
      "platformFeeRefundedIrr": "1500000",
      "nursePayoutRefundedIrr": "8500000",
      "expectedCustomerRefundEta": null,
      "clawbackId": null
    }
    
    For BNPL: status: "processing", refundChannel: "bnpl_revert", expectedCustomerRefundEta: "2026-08-24". Post-payout: clawbackId is set (a pending nurse_clawbacks row + a support alert were created).
  • Failure cases: 400 invalid amount/legs or missing percentage · 401 unauth · 403 non-admin · 404 no captured payment for the booking · 409 Σ refunded > captured (over-refund) · 400 channel refused.
  • Notes: whole money-path runs under lock(booking:{id}:refund); the refund row is persisted (approved) before the external channel executes (crash-window fix), then the balanced ledger reversal posts via b10's helper; the refund_payable ↔ escrow_held clearing posts immediately for a succeeded card refund and is deferred to reconciliation for BNPL/manual (settled later via confirm_settlement, below). ticketId is optional — one is auto-opened when omitted (b15), so a refund is always ticket-anchored. Notifies the customer.

POST api/v1/admin_refunds/{id}/confirm_settlement

  • Purpose: reconciliation confirmed the customer cash-back for a processing BNPL/manual refund — transitions it processing → succeeded, stamps the settled instant, and posts the deferred refund_payable ↔ escrow_held clearing in the same commit. (Also reached automatically by the BNPL provider cash-back callback.)
  • Auth: admin · Rate-limited: yes (sensitive) · Idempotent: a replay against an already-succeeded refund is a no-op success (the clearing never posts twice).
  • Request body: none (id in the route).
  • Success 200 (data): RefundSettlement{ "refundId": 7, "bookingId": 42, "status": "succeeded", "completedAt": "2026-08-12T10:00:00Z" }.
  • Failure: 404 refund not found · 409 refund not in processing (e.g. still approved, already failed).

POST api/v1/admin_refunds/{id}/mark_failed

  • Purpose: reconciliation reported the BNPL/manual customer cash-back did not land — transitions the processing refund to failed. No ledger moves (the clearing was never posted for a processing refund).
  • Auth: admin · Rate-limited: yes · Idempotent: a replay against an already-failed refund is a no-op.
  • Request body: { "reason": "bank_rejected" } (optional).
  • Success 200 (data): RefundSettlement (as above, status: "failed"). Failure: 404 · 409 not processing.

GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=

  • Purpose: admin refund worklist — projected + paginated (page default 1, pageSize default 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 pending nurse clawback uncollectable; posts the balancing DEBIT bad_debt / CREDIT nurse_clawback_receivable correction and sets resolved_at.
  • Auth: admin · Rate-limited: yes · Request body: { "reason": "uncollectable" }
  • Success 200 (data): true. Failure: 404 not found · 409 not 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) — sequential invoiceNumber, vatIrr = round(commission × vat_rate) on the commission line only, moadianStatus: "pending", moadianReferenceNumber: null.
  • Failure: 404 booking 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):
    { "id": 7, "bookingId": 42, "status": "processing", "refundChannel": "bnpl_revert",
      "amount": "10000000", "expectedCustomerRefundEta": "2026-08-24", "reference": "••••••ab12" }
    
    The external reference is masked (last 4 only).
  • Failure: 401 unauth · 404 not 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), with pdfUrl when 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 is processing with an expectedCustomerRefundEta ~710 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-6 — added POST admin_refunds/{id}/confirm_settlement + .../mark_failed (the BNPL/manual processing → succeeded/failed settlement, RefundSettlement shape), so the deferred refund_payable ↔ escrow_held clearing is now reachable. Refunds are persisted before the channel call (crash-window fix). The refund_ticket_required gate was retired (a refund ticket is always auto-opened). Forward-dep FKs added on refunds.ticket_id, nurse_clawbacks.original_payout_id/recovered_in_payout_id, invoices.partner_center_id.

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; sessionIds is 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).
  • RefundStatusDto gains platformFeeRefundedIrr, nursePayoutRefundedIrr, refundPercentageApplied, cancellationPolicyCode, createdAt, completedAt (the fee-leg transparency split).
  • Canonical cancellation_policy_code set (seeded, stable — the frontend's free_24h/partial_under_24h/ customer_no_show were 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-session reasonCode: un_started when refundable, else the blocking session status.