import { clientFetch } from '@/lib/api/client'; import { unwrap, type ApiEnvelope } from '@/lib/api/types'; import { ApiError } from '@/lib/api/errors'; import type { AdminRefundResult, CancelBookingInput, CancellationPolicyPreview, InitiateRefundInput, RefundChannel, RefundPreview, RefundStatus, RefundSummary, RefundsApi, } from '../types'; const BOOKINGS = '/api/v1/bookings'; const REFUNDS = '/api/v1/refunds'; const ADMIN_REFUNDS = '/api/v1/admin_refunds'; /** * The thin b11 customer refund payload (`GET refunds/{id}/status`) — the only refund shape the contract * exposes to a customer. The fee-leg decomposition + policy snapshot live on the admin-only * `RefundListItem`, so this maps into `RefundSummary` with those fields `null` until REQ-021 serves them. */ interface RefundStatusWire { id: number; bookingId: number; status: RefundStatus; refundChannel: RefundChannel; amount: string; expectedCustomerRefundEta: string | null; reference: string | null; } function toSummary(wire: RefundStatusWire): RefundSummary { return { id: wire.id, bookingId: wire.bookingId, refundStatus: wire.status, refundChannel: wire.refundChannel, totalRefundedIrr: wire.amount, expectedCustomerRefundEta: wire.expectedCustomerRefundEta, externalRevertReference: wire.reference, // REQ-021: the customer status carries no decomposition/policy/timestamps yet — the fee-split section // is hidden until these are served (the mock fills them so the transparency split demos end-to-end). refundPercentageApplied: null, cancellationPolicyCode: null, platformFeeRefundedIrr: null, nursePayoutRefundedIrr: null, createdAt: null, completedAt: null, }; } /** * Real HTTP implementation of the `RefundsApi` seam. Only `getRefund` maps a **published** b11 route * (`GET refunds/{id}/status`, tenancy-scoped); the other three target contract gaps the frontend filed * (which is why the domain stays mock-primary — see `constants.ts`): * - `resolveCancellationPolicy` → REQ-020 (`GET bookings/{id}/cancellation_policy`): b9 snapshots the * policy only *after* a cancel; there is no pre-cancel preview resolving the tier by current lead time * + per-session refundability. * - `cancelBooking` → REQ-019 (`POST bookings/{id}/cancel`): b11 refunds are admin-only, no customer path. * - `getRefundByBooking` → REQ-021 (`GET refunds/by_booking/{id}`): the customer cannot obtain a refund * id from the admin-only worklist, so it needs to reach its refund from the booking. `404` = no refund. * * NOT the primary implementation this phase (`USE_REFUNDS_MOCK = true`). */ export const refundsClientApi: RefundsApi = { resolveCancellationPolicy: async (bookingId: number) => unwrap( await clientFetch>( `${BOOKINGS}/${bookingId}/cancellation_policy`, ), ), cancelBooking: async ({ bookingId, sessionIds, reasonCategory, reasonNotes }: CancelBookingInput) => toSummary( unwrap( await clientFetch>(`${BOOKINGS}/${bookingId}/cancel`, { method: 'POST', body: JSON.stringify({ sessionIds, reasonCategory, reasonNotes }), }), ), ), getRefundByBooking: async (bookingId: number) => { try { return toSummary( unwrap(await clientFetch>(`${REFUNDS}/by_booking/${bookingId}`)), ); } catch (error) { // No refund for this booking (e.g. not cancelled) is a clean empty state, not a failure. if (error instanceof ApiError && error.status === 404) return null; throw error; } }, getRefund: async (refundId: number) => toSummary(unwrap(await clientFetch>(`${REFUNDS}/${refundId}/status`))), // REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no // read-only preview route), yet the admin console must disclose the split before initiating. Filed as a // proposed `GET api/v1/admin_refunds/preview?booking_id=&ticket_id=`; the mock serves it today. getRefundPreview: async (bookingId: number, ticketId: number | null) => { const params = new URLSearchParams({ booking_id: String(bookingId) }); if (ticketId != null) params.set('ticket_id', String(ticketId)); return unwrap( await clientFetch>(`${ADMIN_REFUNDS}/preview?${params.toString()}`), ); }, // Create + immediately execute a ticket-linked refund (b11 `POST api/v1/admin_refunds`). The response is // already `AdminRefundResult`-shaped (refundId/status/channel/decomposed legs/eta/clawbackId). initiateRefund: async (input: InitiateRefundInput) => unwrap( await clientFetch>(ADMIN_REFUNDS, { method: 'POST', body: JSON.stringify({ bookingId: input.bookingId, ticketId: input.ticketId, refundPercentage: input.refundPercentage, refundChannel: input.refundChannel, reasonCategory: input.reasonCategory, reasonNotes: input.reasonNotes, }), }), ), // REQ-035: approve/reject a refund. b11 has no separate approve/reject step — `POST admin_refunds` both // creates and executes — so a failed-channel retry and an explicit rejection are proposed as // `POST api/v1/admin_refunds/{id}/approve` and `.../{id}/reject`; the mock serves both today. approveRefund: async (refundId: number) => unwrap( await clientFetch>(`${ADMIN_REFUNDS}/${refundId}/approve`, { method: 'POST', }), ), // REQ-035: see approveRefund. Reject records a reason and moves the refund to `rejected`. rejectRefund: async (refundId: number, reason: string) => { unwrap( await clientFetch>(`${ADMIN_REFUNDS}/${refundId}/reject`, { method: 'POST', body: JSON.stringify({ reason }), }), ); }, };