frontend phase 10

This commit is contained in:
hamid
2026-07-10 12:51:53 +03:30
parent 40cc1d163b
commit ccfa27aff6
32 changed files with 2151 additions and 3 deletions
@@ -160,6 +160,109 @@ function seed(): void {
createdAt: new Date().toISOString(),
sessions: [{ ...makeSession(70021, 1, 0, '15840000'), scheduledTimeStart: '15:00:00', scheduledTimeEnd: '19:00:00' }],
},
// Mid-engagement multi-session booking (f10 refund demo): session 1 is completed-and-verified (locked,
// stays payout-eligible) while sessions 2 & 3 are un-started and > 24h out — so the cancellation flow
// shows a mixed refundable/locked breakdown at the free-cancellation tier out of the box.
{
id: 5003,
bookingRequestId: 9003,
status: 'in_progress',
nurseId: NURSE_ID,
nurseName: NURSE_NAME,
patientId: 903,
patientName: 'آقای کریمی',
variantId: 13,
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت سالمند — شیفت روز', priceUnit: 'per_day' }),
customerAddressId: 803,
addressSnapshotJson: JSON.stringify({
title: 'منزل',
city: 'تهران',
district: 'پونک',
line: 'بلوار عدل، کوچه سوم، پلاک ۸',
postalCode: '1477889900',
}),
grossPriceIrr: '30000000',
balinyaarCommissionIrr: '3600000',
nursePayoutAmount: '26400000',
pspFeeAmount: '600000',
platformFeeRate: 0.12,
sessionCount: 3,
scheduledDate: isoDate(3),
scheduledTimeStart: '09:00:00',
scheduledTimeEnd: '13:00:00',
confirmedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
completedAt: null,
cancelledAt: null,
cancelledBy: null,
cancellationReason: null,
cancellationPolicyCode: null,
cancellationRefundPercentage: null,
refundableAmountIrr: null,
disputeWindowEndsAt: null,
createdAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
sessions: [
{
...makeSession(70031, 1, -1, '8800000'),
status: 'completed',
evvStatus: 'completed',
checkInAt: new Date(Date.now() - 86_400_000).toISOString(),
checkOutAt: new Date(Date.now() - 72_000_000).toISOString(),
payoutEligibleAt: new Date(Date.now() + DISPUTE_WINDOW_HOURS * 3_600_000).toISOString(),
checkInAddressMatch: true,
},
makeSession(70032, 2, 3, '8800000'),
makeSession(70033, 3, 5, '8800000'),
],
},
// Already-cancelled booking whose refund FAILED (f10 refund-status demo): the customer sees the
// needs-attention / contact-support state (never a retry — retry is admin-only, DEFERRED to f15). Its
// failed refund is seeded in the refunds mock; here it just carries the cancellation snapshot b9 stamps.
{
id: 5004,
bookingRequestId: 9004,
status: 'cancelled',
nurseId: NURSE_ID,
nurseName: NURSE_NAME,
patientId: 904,
patientName: 'خانم صادقی',
variantId: 14,
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت پس از جراحی', priceUnit: 'per_session' }),
customerAddressId: 804,
addressSnapshotJson: JSON.stringify({
title: 'منزل',
city: 'تهران',
district: 'جنت‌آباد',
line: 'خیابان لاله، پلاک ۲۲، واحد ۳',
postalCode: '1476612345',
}),
grossPriceIrr: '12000000',
balinyaarCommissionIrr: '1440000',
nursePayoutAmount: '10560000',
pspFeeAmount: '240000',
platformFeeRate: 0.12,
sessionCount: 1,
scheduledDate: isoDate(-3),
scheduledTimeStart: '10:00:00',
scheduledTimeEnd: '14:00:00',
confirmedAt: new Date(Date.now() - 5 * 86_400_000).toISOString(),
completedAt: null,
cancelledAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
cancelledBy: 'customer',
cancellationReason: 'changed_mind',
cancellationPolicyCode: 'partial_under_24h',
cancellationRefundPercentage: 0.5,
refundableAmountIrr: '12000000',
disputeWindowEndsAt: null,
createdAt: new Date(Date.now() - 6 * 86_400_000).toISOString(),
sessions: [
{
...makeSession(70041, 1, -3, '10560000'),
status: 'cancelled',
scheduledTimeStart: '10:00:00',
scheduledTimeEnd: '14:00:00',
},
],
},
];
care[5001] = {
@@ -461,3 +564,52 @@ export function mockInsertConvertedBooking(seed: ConvertedBookingSeed): BookingD
bookings = [booking, ...bookings];
return cloneBooking(booking);
}
/**
* Mock-only read for the refunds domain (f10): the booking + its sessions (a safe clone), so the refunds
* mock can resolve the cancellation tier by lead time and per-session refundability without the
* viewer-masking `getBookingDetail`. Throws `404` if the booking is unknown. NOT part of the `BookingsApi`
* seam — only `services/refunds`' mock imports it.
*/
export function mockGetBookingForRefund(bookingId: number): BookingDetailDto {
return cloneBooking(findBooking(bookingId));
}
/** The cancellation snapshot the refunds mock writes onto a booking when a customer cancels (f10). */
export interface CancelBookingSnapshot {
cancelledBy: string;
cancellationReason: string | null;
cancellationPolicyCode: string;
cancellationRefundPercentage: number;
refundableAmountIrr: string;
/** The un-started sessions being cancelled; completed-and-verified sessions stay payout-eligible. */
cancelledSessionIds: number[];
}
/**
* Mock-only cancellation bridge (f10): flip a booking to `cancelled` and stamp the cancellation snapshot
* the b9 `BookingDetailDto` already declares (currently only ever read, never written). Mirrors the
* in-place mutation pattern of `checkOutVisit` — it mutates the live store object (a reference into
* `bookings`), so the next `getBookingDetail`/`listBookings` reflects it once the cancel mutation
* invalidates the caches. Only still-`scheduled` sessions in `cancelledSessionIds` are marked `cancelled`
* (per-remaining-session cancellation). NOT part of the `BookingsApi` seam.
*/
export function mockMarkBookingCancelled(
bookingId: number,
snapshot: CancelBookingSnapshot,
): BookingDetailDto {
const booking = findBooking(bookingId);
booking.status = 'cancelled';
booking.cancelledAt = new Date().toISOString();
booking.cancelledBy = snapshot.cancelledBy;
booking.cancellationReason = snapshot.cancellationReason;
booking.cancellationPolicyCode = snapshot.cancellationPolicyCode;
booking.cancellationRefundPercentage = snapshot.cancellationRefundPercentage;
booking.refundableAmountIrr = snapshot.refundableAmountIrr;
for (const session of booking.sessions) {
if (snapshot.cancelledSessionIds.includes(session.id) && session.status === 'scheduled') {
session.status = 'cancelled';
}
}
return cloneBooking(booking);
}
@@ -0,0 +1,96 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import { ApiError } from '@/lib/api/errors';
import type {
CancelBookingInput,
CancellationPolicyPreview,
RefundChannel,
RefundStatus,
RefundSummary,
RefundsApi,
} from '../types';
const BOOKINGS = '/api/v1/bookings';
const REFUNDS = '/api/v1/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<ApiEnvelope<CancellationPolicyPreview>>(
`${BOOKINGS}/${bookingId}/cancellation_policy`,
),
),
cancelBooking: async ({ bookingId, sessionIds, reasonCategory, reasonNotes }: CancelBookingInput) =>
toSummary(
unwrap(
await clientFetch<ApiEnvelope<RefundStatusWire>>(`${BOOKINGS}/${bookingId}/cancel`, {
method: 'POST',
body: JSON.stringify({ sessionIds, reasonCategory, reasonNotes }),
}),
),
),
getRefundByBooking: async (bookingId: number) => {
try {
return toSummary(
unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${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<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_REFUNDS_MOCK } from '../constants';
import type { RefundsApi } from '../types';
import { refundsClientApi } from './clientApi';
import { refundsMockApi } from './mockApi';
/**
* The selected `RefundsApi` implementation — the single seam the hooks import. Selection is by config
* (`USE_REFUNDS_MOCK`), never by scattered `if (mock)` checks.
*/
export const refundsApi: RefundsApi = USE_REFUNDS_MOCK ? refundsMockApi : refundsClientApi;
+261
View File
@@ -0,0 +1,261 @@
import { parseIrr, sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import { mockGetBookingForRefund, mockMarkBookingCancelled } from '@/services/bookings/apis/mockApi';
import { BNPL_REFUND_ETA_BUSINESS_DAYS, MOCK_POLICY_TIERS } from '../constants';
import {
isBookingCancellable,
isTerminalRefundStatus,
type CancelBookingInput,
type CancellationPolicyCode,
type CancellationPolicyPreview,
type CancellationSessionPreview,
type RefundChannel,
type RefundSummary,
type RefundsApi,
} from '../types';
const MOCK_LATENCY_MS = 350;
// Integer-only rate math: fractions as parts-per-10000 so the money path never touches a float.
// (BigInt via the constructor — the tsconfig target predates ES2020 literals, matching utils/money.ts.)
const RATE_SCALE = BigInt(10_000);
const ZERO = BigInt(0);
function fractionPpm(fraction: number): bigint {
return BigInt(Math.round(fraction * Number(RATE_SCALE)));
}
/**
* Which channel a booking's refund runs through. In production this is derived from the original payment
* method; the mock pins booking 5002 to BNPL so the ~710-business-day ETA banner is demoable, and defaults
* everything else (incl. f9-converted bookings, which were paid by card) to `psp_card`.
*/
const CHANNEL_BY_BOOKING: Record<number, RefundChannel> = {
5002: 'bnpl_revert',
};
function channelFor(bookingId: number): RefundChannel {
return CHANNEL_BY_BOOKING[bookingId] ?? 'psp_card';
}
/**
* Integer day difference between a `YYYY-MM-DD` date and today, both on the **UTC calendar day** — the
* bookings mock seeds dates via `new Date().toISOString().slice(0,10)` (UTC), so the tier resolution must
* use the same basis or a positive-offset timezone (e.g. Asia/Tehran +3:30, just after local midnight)
* would compute a "today" session as -1 day and flip the tier from partial to no-show.
*/
function daysUntil(dateStr: string): number {
const todayKey = new Date().toISOString().slice(0, 10);
const target = Date.parse(`${dateStr}T00:00:00Z`);
const today = Date.parse(`${todayKey}T00:00:00Z`);
return Math.round((target - today) / 86_400_000);
}
/** Resolves the tier code from the earliest un-started session's lead time (mock stand-in for the server). */
function policyCodeForLead(days: number): CancellationPolicyCode {
if (days >= 1) return 'free_24h';
if (days === 0) return 'partial_under_24h';
return 'customer_no_show';
}
/** The BNPL customer cash-back ETA: N business days out, Fridays skipped (product: ~710 business days). */
function businessDaysFromNow(days: number): string {
const d = new Date();
let added = 0;
while (added < days) {
d.setDate(d.getDate() + 1);
if (d.getDay() !== 5) added += 1; // getDay() 5 = Friday
}
return d.toISOString().slice(0, 10);
}
/** A plausible masked (last-4) external reference for a non-card revert — opaque, never parsed. */
function maskedReference(bookingId: number): string {
return `••••••${String(bookingId % 10000).padStart(4, '0')}`;
}
/**
* Resolve the cancellation preview from the shared f8 bookings store: which sessions are refundable
* (un-started) vs locked (completed-and-verified, still payout-eligible), the tier by lead time, and the
* refund-vs-fee split decomposed across the two fee legs. All money is BigInt; `refundAmount + fee =
* refundableGross` by construction, so the disclosure's `PriceBreakdown` reconciles to the rial.
*/
function computePreview(bookingId: number): CancellationPolicyPreview {
const booking = mockGetBookingForRefund(bookingId); // throws 404 if unknown
const channel = channelFor(bookingId);
const sessions: CancellationSessionPreview[] = booking.sessions.map((s) => ({
bookingSessionId: s.id,
sessionIndex: s.sessionIndex,
scheduledDate: s.scheduledDate,
refundable: s.status === 'scheduled',
reasonCode: s.status === 'scheduled' ? 'un_started' : s.status,
}));
const refundableSessions = booking.sessions.filter((s) => s.status === 'scheduled');
const cancellable = isBookingCancellable(booking.status) && refundableSessions.length > 0;
// Proportional decomposition: the refundable slice of the payout leg is exact (Σ per-session payout);
// its commission share is proportional. refundableGross = refundablePayout + refundableCommission.
const totalPayout = parseIrr(booking.nursePayoutAmount);
const commission = parseIrr(booking.balinyaarCommissionIrr);
const refundablePayout = refundableSessions.reduce((acc, s) => acc + parseIrr(s.visitPayoutAmount), ZERO);
const refundableCommission = totalPayout > ZERO ? (commission * refundablePayout) / totalPayout : ZERO;
const refundableGross = refundablePayout + refundableCommission;
const earliestDate = refundableSessions
.map((s) => s.scheduledDate)
.sort()
.at(0);
const policyCode = policyCodeForLead(earliestDate ? daysUntil(earliestDate) : 0);
const tier = MOCK_POLICY_TIERS[policyCode];
const ppm = fractionPpm(tier.refundFraction);
const nursePayoutRefunded = (refundablePayout * ppm) / RATE_SCALE;
const platformFeeRefunded = (refundableCommission * ppm) / RATE_SCALE;
const refundAmount = nursePayoutRefunded + platformFeeRefunded;
const feeAmount = refundableGross - refundAmount; // the retained remainder — reconciles by construction
return {
bookingId,
cancellable,
cancellationPolicyCode: policyCode,
refundPercentageApplied: tier.refundFraction,
feePercentage: Math.round((1 - tier.refundFraction) * 100) / 100,
refundAmountIrr: refundAmount.toString(),
feeAmountIrr: feeAmount.toString(),
refundableAmountIrr: refundableGross.toString(),
platformFeeRefundedIrr: platformFeeRefunded.toString(),
nursePayoutRefundedIrr: nursePayoutRefunded.toString(),
appliesTo: refundableSessions.length === booking.sessions.length ? 'whole_booking' : 'remaining_sessions',
leadTimeLabel: tier.leadTimeLabel,
refundChannel: channel,
expectedCustomerRefundEta: channel === 'bnpl_revert' ? businessDaysFromNow(BNPL_REFUND_ETA_BUSINESS_DAYS) : null,
refundableSessionIds: refundableSessions.map((s) => s.id),
sessions,
};
}
/** The mock's stored refund — a `RefundSummary` plus a read counter that drives the BNPL walk. */
interface MockRefund extends RefundSummary {
reads: number;
}
let nextRefundId = 7001;
const refundsByBooking: Record<number, MockRefund> = {};
// Seed: a FAILED refund on the already-cancelled booking 5004, so the refund-status screen shows the
// needs-attention / contact-support state out of the box (retry is admin-only — DEFERRED to f15).
refundsByBooking[5004] = {
id: nextRefundId++,
bookingId: 5004,
refundStatus: 'failed',
refundChannel: 'psp_card',
totalRefundedIrr: '6000000',
expectedCustomerRefundEta: null,
externalRevertReference: maskedReference(5004),
refundPercentageApplied: 0.5,
cancellationPolicyCode: 'partial_under_24h',
platformFeeRefundedIrr: '720000',
nursePayoutRefundedIrr: '5280000',
createdAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
completedAt: null,
reads: 0,
};
function toRefundSummary(refund: MockRefund): RefundSummary {
const { reads: _reads, ...summary } = refund;
return { ...summary };
}
/**
* Accelerated mock reconciliation so a BNPL refund's stepper visibly walks *submitted → on its way →
* completed* as the status poll ticks (a real BNPL revert takes ~710 business days). A card refund is
* already `succeeded` at creation — nothing to advance. Forward-only, and stops once terminal.
*/
function advanceRefund(refund: MockRefund): void {
if (refund.refundChannel !== 'bnpl_revert' || isTerminalRefundStatus(refund.refundStatus)) return;
refund.reads += 1;
if (refund.reads >= 4) {
refund.refundStatus = 'succeeded';
refund.completedAt = new Date().toISOString();
} else {
refund.refundStatus = 'processing';
}
}
/**
* In-memory mock behind the `RefundsApi` seam — the whole customer cancel + refund surface b11 doesn't
* serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the
* customer status → REQ-019/020/021). It reads the shared f8 bookings store to resolve the tier + per-
* session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache reflects
* it after invalidation), enforces the outside-policy `409`, and drives the refund through the customer
* steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls). Swap to the
* real `clientApi` once REQ-019/020/021 land (`USE_REFUNDS_MOCK = false`).
*/
export const refundsMockApi: RefundsApi = {
resolveCancellationPolicy: async (bookingId) => {
await sleep(MOCK_LATENCY_MS);
return computePreview(bookingId);
},
cancelBooking: async ({ bookingId, sessionIds, reasonCategory, reasonNotes }: CancelBookingInput) => {
await sleep(MOCK_LATENCY_MS);
const preview = computePreview(bookingId);
if (!preview.cancellable) {
throw new ApiError(409, 'Booking cannot be cancelled', 'not_cancellable');
}
if (sessionIds && sessionIds.some((id) => !preview.refundableSessionIds.includes(id))) {
// Never offer to refund a session the policy marks non-refundable (completed-and-verified).
throw new ApiError(409, 'Session is not refundable', 'session_not_refundable');
}
const channel = preview.refundChannel;
const now = new Date().toISOString();
mockMarkBookingCancelled(bookingId, {
cancelledBy: 'customer',
cancellationReason: reasonNotes?.trim() || reasonCategory,
cancellationPolicyCode: preview.cancellationPolicyCode,
cancellationRefundPercentage: preview.refundPercentageApplied,
refundableAmountIrr: preview.refundableAmountIrr,
cancelledSessionIds: preview.refundableSessionIds,
});
const refund: MockRefund = {
id: nextRefundId++,
bookingId,
// Card refunds succeed immediately; BNPL/manual sit in the reconciliation window (start approved →
// processing → succeeded so the customer sees the walk).
refundStatus: channel === 'psp_card' ? 'succeeded' : 'approved',
refundChannel: channel,
totalRefundedIrr: preview.refundAmountIrr,
expectedCustomerRefundEta: preview.expectedCustomerRefundEta,
externalRevertReference: channel === 'psp_card' ? null : maskedReference(bookingId),
refundPercentageApplied: preview.refundPercentageApplied,
cancellationPolicyCode: preview.cancellationPolicyCode,
platformFeeRefundedIrr: preview.platformFeeRefundedIrr,
nursePayoutRefundedIrr: preview.nursePayoutRefundedIrr,
createdAt: now,
completedAt: channel === 'psp_card' ? now : null,
reads: 0,
};
refundsByBooking[bookingId] = refund;
return toRefundSummary(refund);
},
getRefundByBooking: async (bookingId) => {
await sleep(MOCK_LATENCY_MS);
const refund = refundsByBooking[bookingId];
if (!refund) return null; // no refund (e.g. not cancelled) — a clean empty state, not an error
advanceRefund(refund);
return toRefundSummary(refund);
},
getRefund: async (refundId) => {
await sleep(MOCK_LATENCY_MS);
const refund = Object.values(refundsByBooking).find((r) => r.id === refundId);
if (!refund) throw new ApiError(404, 'Refund not found', 'not_found');
advanceRefund(refund);
return toRefundSummary(refund);
},
};
+58
View File
@@ -0,0 +1,58 @@
import type { CancellationPolicyCode } from './types';
/**
* When true, the refunds domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `RefundsApi` seam.
*
* **Mock is primary this phase.** b11 shipped the refund lifecycle **admin-only**: the only
* customer-visible surface is `GET refunds/{id}/status` (thin: status/channel/amount/ETA/masked ref).
* There is **no** customer cancel command, **no** cancellation-policy preview, **no** refund-by-booking
* lookup, and the customer status carries **no** fee-leg decomposition — all filed as REQ-019/020/021.
* So the whole cancel + policy-disclosure + fee-split surface is mocked behind this seam. The mock reads
* the shared f8 bookings store (lead time + per-session refundability), flips the booking to `cancelled`
* on confirm (so the booking-detail cache reflects it), and drives a refund through
* `submitted → on_its_way → completed` (card immediate; BNPL processing with an ETA). Flip to `false`
* once REQ-019/020/021 land — no hook/component change.
*/
export const USE_REFUNDS_MOCK = true;
/**
* The cancellation preview depends on `now` vs the booking start (the resolved tier moves as the visit
* approaches), so keep it short-lived — never serve a stale tier that under/over-states the fee.
*/
export const POLICY_PREVIEW_STALE_TIME = 10 * 1000;
/**
* Refund status is read-heavy and mostly stable between visits; a modest stale window avoids a refetch on
* re-entry, while the poll (below) keeps a non-terminal refund fresh.
*/
export const REFUND_STATUS_STALE_TIME = 15 * 1000;
export const REFUND_STATUS_GC_TIME = 5 * 60 * 1000;
/**
* The refund-status poll runs **only while the refund is non-terminal** (`requested`/`approved`/
* `processing`); it stops at `succeeded`/`failed`/`rejected`. A calm, fixed interval — a refund moves on
* the order of days (BNPL) or is already terminal (card), so there is no need for tight backoff.
*/
export const REFUND_STATUS_POLL_INTERVAL_MS = 5 * 1000;
/**
* Mock-only policy tiers. The product doc pins the shape (free > 24h = 100%/0%; partial < 24h ≈ 50%;
* customer no-show up to 100% charge) but flags the 50% as **illustrative/config**, so these are the
* mock's stand-in figures until the backend serves `cancellation_policies`. `refundFraction` is 01.
*/
export const MOCK_POLICY_TIERS: Record<
CancellationPolicyCode,
{ refundFraction: number; leadTimeLabel: 'gt_24h' | 'lt_24h' | 'started' }
> = {
free_24h: { refundFraction: 1, leadTimeLabel: 'gt_24h' },
partial_under_24h: { refundFraction: 0.5, leadTimeLabel: 'lt_24h' },
customer_no_show: { refundFraction: 0, leadTimeLabel: 'started' },
};
/**
* The BNPL customer cash-back window the mock projects onto `expectedCustomerRefundEta` — the product's
* ~710 business-day truth, Fridays skipped (see `cancellation-and-payout.md`). Surface it honestly;
* never imply the money is back instantly.
*/
export const BNPL_REFUND_ETA_BUSINESS_DAYS = 10;
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { invalidateAfterCancellation } from '../invalidations';
import type { CancelBookingInput } from '../types';
/**
* Submit a customer-initiated cancellation. On success the returned refund is primed into its `byBooking`
* key and the affected booking + refund caches are invalidated (so the booking-detail screen reflects the
* new cancelled/refund state without a manual refetch, and the refund-status screen renders warm). A `409`
* (outside-policy / already-cancelled / nothing-refundable) surfaces inline via `mutation.error` — the
* fetch layer already toasts 401/403/5xx, so this hook never double-toasts.
*/
export function useCancelBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CancelBookingInput) => refundsApi.cancelBooking(input),
onSuccess: (refund, input) => invalidateAfterCancellation(queryClient, input.bookingId, refund),
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { refundKeys } from '../keys';
import { POLICY_PREVIEW_STALE_TIME } from '../constants';
/**
* Resolve the cancellation policy for a booking **before** the customer confirms — the tier (by lead time),
* its refund % + fee %, the concrete refund-vs-fee amounts, and the per-session refundable/locked
* breakdown. Short `staleTime` because the tier depends on `now` vs the booking start (a stale preview
* could under/over-state the fee); enabled only when a booking id is present.
*/
export function useCancellationPolicyPreview(bookingId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: refundKeys.policyPreview(bookingId ?? -1),
queryFn: () => refundsApi.resolveCancellationPolicy(bookingId as number),
enabled: (options?.enabled ?? true) && bookingId != null && bookingId > 0,
staleTime: POLICY_PREVIEW_STALE_TIME,
});
}
@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { refundKeys } from '../keys';
import {
REFUND_STATUS_GC_TIME,
REFUND_STATUS_POLL_INTERVAL_MS,
REFUND_STATUS_STALE_TIME,
} from '../constants';
import { isTerminalRefundStatus } from '../types';
/**
* The customer's read-only refund status for a booking. Polls (`refetchInterval`) **only while the refund
* is non-terminal** (`requested`/`approved`/`processing`) and **stops** at `succeeded`/`failed`/`rejected`
* — and never polls the empty state (no refund → `null`). A modest `staleTime`/`gcTime` means re-entering
* the screen doesn't re-hit the network needlessly; the cancel mutation primes this key so the first render
* is warm. `data` is `null` when the booking has no refund.
*/
export function useRefundStatus(bookingId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: refundKeys.byBooking(bookingId ?? -1),
queryFn: () => refundsApi.getRefundByBooking(bookingId as number),
enabled: (options?.enabled ?? true) && bookingId != null && bookingId > 0,
staleTime: REFUND_STATUS_STALE_TIME,
gcTime: REFUND_STATUS_GC_TIME,
refetchInterval: (query) => {
const refund = query.state.data;
if (!refund) return false;
return isTerminalRefundStatus(refund.refundStatus) ? false : REFUND_STATUS_POLL_INTERVAL_MS;
},
});
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Refunds domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview';
export { useCancelBooking } from './hooks/useCancelBooking';
export { useRefundStatus } from './hooks/useRefundStatus';
@@ -0,0 +1,22 @@
import type { QueryClient } from '@tanstack/react-query';
import { bookingKeys } from '@/services/bookings/keys';
import { refundKeys } from './keys';
import type { RefundSummary } from './types';
/**
* The one cache transition a successful cancellation causes: the booking flipped `cancelled` and a refund
* now exists. Prime the fresh refund into its `byBooking` key (so the refund-status screen renders warm,
* no first-render spinner) and invalidate exactly the affected keys — the booking detail (its status/note
* changed), the bookings lists (the row moved to cancelled), the policy preview (no longer cancellable),
* and the refund's `byBooking` — never a blanket refetch. Called from `useCancelBooking`.
*/
export function invalidateAfterCancellation(
queryClient: QueryClient,
bookingId: number,
refund: RefundSummary,
): void {
queryClient.setQueryData(refundKeys.byBooking(bookingId), refund);
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
queryClient.invalidateQueries({ queryKey: refundKeys.policyPreview(bookingId) });
}
+18
View File
@@ -0,0 +1,18 @@
/**
* React Query key factory for the refunds domain (hierarchical, per the `services/{domain}` pattern).
* The cancellation preview and the refund status are keyed by the **booking** id (the customer reaches a
* refund through its booking, never through an admin-only refund id); `detail` keys the by-refund-id read
* used by the real `refunds/{id}/status` route.
*/
export const refundKeys = {
all: ['refunds'] as const,
policyPreviews: () => [...refundKeys.all, 'policy_preview'] as const,
policyPreview: (bookingId: number) => [...refundKeys.policyPreviews(), bookingId] as const,
byBookings: () => [...refundKeys.all, 'by_booking'] as const,
byBooking: (bookingId: number) => [...refundKeys.byBookings(), bookingId] as const,
details: () => [...refundKeys.all, 'detail'] as const,
detail: (refundId: number) => [...refundKeys.details(), refundId] as const,
};
+195
View File
@@ -0,0 +1,195 @@
import type { BookingSessionStatus, BookingStatus } from '@/services/bookings/types';
/**
* Refunds & cancellation domain (b11 contract `dev/contracts/domains/refunds-invoices.md`). This is the
* **customer** half of the refund story: resolve the applicable cancellation policy by lead time, disclose
* the fee/refund split before confirming, request the cancellation, then follow the read-only refund
* status. The admin refund console (create/approve, leg-split editor, clawback banner, retry) is DEFERRED
* to f15-b15.
*
* Load-bearing semantics (contract + product `07-cancellation-and-refunds.md` / `cancellation-and-payout.md`):
* - **Refunds are admin-only.** The customer can *request* a cancellation and *read* the refund's progress;
* it can never self-issue money. The copy reflects the admin-approved, ticket-linked reality.
* - **Money is IRR integer, on the wire as a digit-string.** Parse/format only via the `@/utils` BigInt
* helpers; Toman is display-only. The refund is the decomposition of `gross = commission + payout` —
* render `platformFeeRefundedIrr` / `nursePayoutRefundedIrr` as served; never recompute the split.
* - **Disclose the fee/refund % BEFORE confirm.** The policy (resolved by lead time + actor) and its
* refund % + fee % must be on screen and acknowledged before the cancellation can be submitted.
* - **BNPL is asynchronous.** For `bnpl_revert`, surface the `expectedCustomerRefundEta` (~710 business
* days) honestly — the money returns *through the provider*, never instantly, never Balinyaar → customer.
* - **Per-session, not all-or-nothing.** Only un-started sessions are refundable; completed-and-verified
* sessions stay payout-eligible and render as locked.
* - **Never render a label off a raw enum code** — codes map to i18n keys in both locales.
*/
/**
* `refunds.status` (b11 contract enum, forward-only). A card refund goes `approved → succeeded`
* immediately; a BNPL/manual refund sits in `processing` until the async customer cash-back reconciles.
* The customer-facing UI maps these six codes onto three steps (see `refundCustomerStep`).
*/
export type RefundStatus = 'requested' | 'approved' | 'processing' | 'succeeded' | 'failed' | 'rejected';
/** `refunds.refund_channel` (b11). The data-model's `manual_bank` is served as the canonical `manual`. */
export type RefundChannel = 'psp_card' | 'bnpl_revert' | 'manual';
/** The three customer-facing refund steps the six contract statuses collapse onto. */
export const CUSTOMER_REFUND_STEP_ORDER = ['submitted', 'on_its_way', 'completed'] as const;
export type CustomerRefundStep = (typeof CUSTOMER_REFUND_STEP_ORDER)[number];
/**
* Maps the contract `RefundStatus` onto the customer's mental model: *submitted → on its way → completed*,
* with `failed`/`rejected` collapsing to a distinct error state (never a fourth happy step).
*/
export function refundCustomerStep(status: RefundStatus): CustomerRefundStep | 'failed' {
switch (status) {
case 'requested':
case 'approved':
return 'submitted';
case 'processing':
return 'on_its_way';
case 'succeeded':
return 'completed';
case 'failed':
case 'rejected':
return 'failed';
}
}
/** Zero-based `activeStep` for the three-step refund stepper (only meaningful for the non-`failed` steps). */
export function refundStepIndex(status: RefundStatus): number {
const step = refundCustomerStep(status);
if (step === 'failed') return CUSTOMER_REFUND_STEP_ORDER.length - 1;
return CUSTOMER_REFUND_STEP_ORDER.indexOf(step);
}
/** `succeeded` (completed) or `failed`/`rejected` (dead) — nothing left to poll. */
export function isTerminalRefundStatus(status: RefundStatus): boolean {
return status === 'succeeded' || status === 'failed' || status === 'rejected';
}
/**
* Proposed cancellation-policy tier codes (REQ-020). The product docs describe the tiers (free > 24h,
* partial < 24h, customer no-show) but pin **no** wire code-names, so these are the client-mock codes the
* UI maps to i18n keys; when the backend defines the real `cancellation_policy_code` set the map updates.
* **Never** render a label off the raw code.
*/
export type CancellationPolicyCode = 'free_24h' | 'partial_under_24h' | 'customer_no_show';
/** The lead-time bucket that resolved the tier — drives an explanatory i18n label, not the money. */
export type CancellationLeadTime = 'gt_24h' | 'lt_24h' | 'started';
/** Whether the whole booking or only the remaining (un-started) sessions are being cancelled. */
export type CancellationScope = 'whole_booking' | 'remaining_sessions';
/** Why a session is refundable or locked — maps to an i18n reason chip. `un_started` ⇔ refundable. */
export type CancellationSessionReason = 'un_started' | BookingSessionStatus;
/** One session's refundability in the cancellation preview (refundable ⇔ un-started; locked otherwise). */
export interface CancellationSessionPreview {
bookingSessionId: number;
sessionIndex: number;
/** ISO date `YYYY-MM-DD`. */
scheduledDate: string;
refundable: boolean;
/** `un_started` when refundable; otherwise the blocking session status (`completed`/`in_progress`/…). */
reasonCode: CancellationSessionReason;
}
/**
* The resolved cancellation preview shown BEFORE confirm (REQ-020 — not served by b11 yet, mock-primary).
* `refundAmountIrr + feeAmountIrr = refundableAmountIrr` by construction, so the disclosure's
* `PriceBreakdown` (refund-vs-fee split) reconciles to the rial. The fee-leg decomposition
* (`platformFeeRefundedIrr` / `nursePayoutRefundedIrr`) is served, never recomputed client-side.
*/
export interface CancellationPolicyPreview {
bookingId: number;
/** `false` when nothing is refundable (already cancelled/completed, or no un-started sessions). */
cancellable: boolean;
cancellationPolicyCode: CancellationPolicyCode;
/** 01 fraction of the refundable amount returned to the customer. */
refundPercentageApplied: number;
/** 01 fraction retained as the cancellation fee/penalty (`= 1 - refundPercentageApplied`). */
feePercentage: number;
/** IRR digit-string — the amount refunded to the customer. */
refundAmountIrr: string;
/** IRR digit-string — the amount retained as the fee. */
feeAmountIrr: string;
/** IRR digit-string — the base being decided (`refundAmountIrr + feeAmountIrr`; the un-started gross). */
refundableAmountIrr: string;
/** Decomposition of `refundAmountIrr` across the two fee legs (served, never recomputed). */
platformFeeRefundedIrr: string;
nursePayoutRefundedIrr: string;
appliesTo: CancellationScope;
leadTimeLabel: CancellationLeadTime;
/** The channel the refund will run through — drives the ETA preview wording. */
refundChannel: RefundChannel;
/** Populated only for `bnpl_revert` (the ~710 business-day window); a date `YYYY-MM-DD`. */
expectedCustomerRefundEta: string | null;
/** The refundable session ids the confirm submits (all un-started sessions). */
refundableSessionIds: number[];
sessions: CancellationSessionPreview[];
}
/** The customer's stated reason category for cancelling — maps to an i18n label, never rendered raw. */
export type CancelReasonCategory =
| 'changed_mind'
| 'schedule_conflict'
| 'found_other_care'
| 'other';
/** `POST bookings/{bookingId}/cancel` input (REQ-019 — customer-initiated; not served by b11 yet). */
export interface CancelBookingInput {
bookingId: number;
/** The un-started sessions to cancel (whole-remaining by default); omitted = all refundable. */
sessionIds?: number[];
reasonCategory: CancelReasonCategory;
reasonNotes?: string;
}
/**
* The customer-facing refund view. A superset of the thin b11 `GET refunds/{id}/status` payload
* (`{ id, bookingId, status, refundChannel, amount, expectedCustomerRefundEta, reference }`) — the mock
* fills the whole shape; the real `clientApi` maps the thin contract and leaves the decomposition fields
* `null` until REQ-021 exposes them to the customer. The UI renders the fee-leg split only when present.
*/
export interface RefundSummary {
id: number;
bookingId: number;
refundStatus: RefundStatus;
refundChannel: RefundChannel;
/** IRR digit-string — the total refunded to the customer (the contract's `amount`). */
totalRefundedIrr: string;
/** Populated for `bnpl_revert` (the ~710 business-day window); a date `YYYY-MM-DD`. */
expectedCustomerRefundEta: string | null;
/** Opaque, **masked** (last 4 only) external reference — never parse it. */
externalRevertReference: string | null;
/** --- Fee-leg decomposition + policy snapshot (REQ-021: `null` on the real path until served). --- */
refundPercentageApplied: number | null;
cancellationPolicyCode: string | null;
platformFeeRefundedIrr: string | null;
nursePayoutRefundedIrr: string | null;
createdAt: string | null;
completedAt: string | null;
}
/**
* Whether a booking can still be cancelled by the customer (has an un-started remainder). `confirmed` or
* `in_progress` are candidates; the resolved preview reports `cancellable: false` if no un-started session
* actually remains. Terminal/settled states (`completed`/`closed`/`disputed`/`cancelled`/`pending_payment`)
* are never customer-cancellable here.
*/
export function isBookingCancellable(status: BookingStatus): boolean {
return status === 'confirmed' || status === 'in_progress';
}
/**
* The refunds API seam — the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_REFUNDS_MOCK`), never scattered `if (mock)` checks.
*/
export interface RefundsApi {
resolveCancellationPolicy(bookingId: number): Promise<CancellationPolicyPreview>;
cancelBooking(input: CancelBookingInput): Promise<RefundSummary>;
/** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */
getRefundByBooking(bookingId: number): Promise<RefundSummary | null>;
getRefund(refundId: number): Promise<RefundSummary>;
}