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

9.3 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); posts the balanced ledger reversal 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. ticketId is required only when refund_ticket_required config is on (off until b15). Notifies the customer.

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 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.