frontend phase 9

This commit is contained in:
hamid
2026-07-10 11:49:55 +03:30
parent cd6c2591a6
commit 40cc1d163b
49 changed files with 4130 additions and 20 deletions
@@ -14,13 +14,14 @@ const BASE = '/api/v1/booking_requests';
/**
* The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice`
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price).
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price) and `bookingId`
* (REQ-017: a `converted` request gives no way to reach the booking it became).
*/
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice'>;
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>;
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted `variantPrice` to `null`. */
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted fields to `null`. */
function toDto(wire: BookingRequestWireDto): BookingRequestDto {
return { ...wire, variantPrice: null };
return { ...wire, variantPrice: null, bookingId: null };
}
/**
@@ -36,6 +36,7 @@ function seedRow(overrides: Partial<BookingRequestDto>): BookingRequestDto {
return {
id,
status: 'pending_nurse_response',
bookingId: null,
nurseId: 1,
nurseName: 'مریم رضایی',
nurseRating: 4.9,
@@ -167,6 +168,7 @@ function buildFromContext(
return {
id,
status: 'pending_nurse_response',
bookingId: null,
nurseId: payload.nurseId,
nurseName: context?.nurseName ?? '',
nurseRating: context?.nurseRating ?? 0,
@@ -283,3 +285,20 @@ export const bookingRequestsMockApi: BookingRequestsApi = {
return updated;
},
};
/**
* Mock-only capture bridge (f9): the payment mock's stand-in for the server's webhook-confirm →
* `BookingFactory` step. Marks a paid request `converted` and stamps the client-augmented `bookingId`
* (REQ-017) so C5's terminal card and the checkout confirmation can deep-link the booking. NOT part of
* the `BookingRequestsApi` seam — no real endpoint does this from the client; only `services/payment`'s
* mock imports it.
*/
export function mockMarkBookingRequestConverted(id: number, bookingId: number): BookingRequestDto {
const dto = find(id);
if (dto.status !== 'accepted_awaiting_payment') {
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
}
const updated: BookingRequestDto = { ...dto, status: 'converted', bookingId };
store = store.map((row) => (row.id === id ? updated : row));
return updated;
}
@@ -59,10 +59,17 @@ export function isTerminalBookingRequestStatus(status: BookingRequestStatus): bo
* `variantPrice` is **client-augmented**: the contract DTO returns `variantLabel` + `variantPriceUnit`
* but no price (filed as REQ-013). The mock supplies it so the summary card can price the service; the
* real client leaves it `null` (the summary then hides the amount) until the field lands.
*
* `bookingId` is likewise **client-augmented** (filed as REQ-017): once a paid request converts, the b8
* DTO gives no way to reach the booking it became. The mock stamps it at capture so C5's `converted`
* card and the checkout confirmation can deep-link the booking; the real client leaves it `null` (those
* surfaces then fall back to the bookings list) until the field lands.
*/
export interface BookingRequestDto {
id: number;
status: BookingRequestStatus;
/** Client-augmented (REQ-017): the converted booking's id, or `null` until converted / on the real path. */
bookingId: number | null;
nurseId: number;
nurseName: string;
nurseRating: number;
@@ -380,3 +380,84 @@ export const bookingsMockApi: BookingsApi = {
return { ...updated };
},
};
// Converted bookings get ids above the seeded 5001/5002 range; their sessions above the seeded 700xx range.
let nextConvertedBookingId = 6001;
let nextConvertedSessionId = 80001;
/** What the payment mock knows about a captured request — enough to snapshot a faithful booking. */
export interface ConvertedBookingSeed {
bookingRequestId: number;
nurseId: number;
nurseName: string;
patientId: number;
patientName: string;
variantId: number;
variantLabel: string;
variantPriceUnit: string;
customerAddressId: number;
/** Pre-serialized address snapshot (customer view; the nurse view masks it), or `null` if unknown. */
addressSnapshotJson: string | null;
grossPriceIrr: string;
balinyaarCommissionIrr: string;
nursePayoutAmount: string;
pspFeeAmount: string | null;
platformFeeRate: number;
scheduledDate: string;
scheduledTimeStart: string;
scheduledTimeEnd: string;
}
/**
* Mock-only capture bridge (f9): the client stand-in for the server's webhook-confirm → `BookingFactory`
* step (b10) that creates & confirms the booking. Inserts a **confirmed** single-session booking built
* from the paid request's snapshot, so the f8 booking detail/list show the conversion immediately. NOT
* part of the `BookingsApi` seam — no real endpoint does this from the client; only `services/payment`'s
* mock imports it.
*/
export function mockInsertConvertedBooking(seed: ConvertedBookingSeed): BookingDetailDto {
const now = new Date().toISOString();
const bookingId = nextConvertedBookingId++;
const booking: BookingDetailDto = {
id: bookingId,
bookingRequestId: seed.bookingRequestId,
status: 'confirmed',
nurseId: seed.nurseId,
nurseName: seed.nurseName,
patientId: seed.patientId,
patientName: seed.patientName,
variantId: seed.variantId,
variantSnapshotJson: JSON.stringify({ displayName: seed.variantLabel, priceUnit: seed.variantPriceUnit }),
customerAddressId: seed.customerAddressId,
addressSnapshotJson: seed.addressSnapshotJson,
grossPriceIrr: seed.grossPriceIrr,
balinyaarCommissionIrr: seed.balinyaarCommissionIrr,
nursePayoutAmount: seed.nursePayoutAmount,
pspFeeAmount: seed.pspFeeAmount,
platformFeeRate: seed.platformFeeRate,
sessionCount: 1,
scheduledDate: seed.scheduledDate,
scheduledTimeStart: seed.scheduledTimeStart,
scheduledTimeEnd: seed.scheduledTimeEnd,
confirmedAt: now,
completedAt: null,
cancelledAt: null,
cancelledBy: null,
cancellationReason: null,
cancellationPolicyCode: null,
cancellationRefundPercentage: null,
refundableAmountIrr: null,
disputeWindowEndsAt: null,
createdAt: now,
sessions: [
{
...makeSession(nextConvertedSessionId++, 1, 0, seed.nursePayoutAmount),
scheduledDate: seed.scheduledDate,
scheduledTimeStart: seed.scheduledTimeStart,
scheduledTimeEnd: seed.scheduledTimeEnd,
},
],
};
bookings = [booking, ...bookings];
return cloneBooking(booking);
}
@@ -0,0 +1,79 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
import type {
CheckoutSummaryDto,
ConfirmGatewayReturnInput,
InitiatePaymentInput,
InitiatePaymentResult,
InvoiceDto,
PaymentApi,
PaymentOutcomeDto,
} from '../types';
const BOOKINGS = '/api/v1/bookings';
const BOOKING_REQUESTS = '/api/v1/booking_requests';
const INVOICES = '/api/v1/invoices';
/** The header b10's initiate (and b12's BNPL initiate) read the per-attempt idempotency key from. */
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/**
* Real HTTP implementation of the `PaymentApi` seam. `initiatePayment` and `getInvoice` map the
* published b10/b11 contracts 1:1. The other two calls cover contract gaps the frontend filed:
* - `getCheckoutSummary` targets the **REQ-016 proposed route** (no checkout-summary endpoint exists;
* the b8 request DTO is money-free, and the client must never derive commission/VAT itself).
* - `getPaymentOutcome`/`confirmGatewayReturn` poll the existing `booking_requests/get/{id}` and map its
* status (`converted` → succeeded, `payment_deadline_expired` → failed, else pending) — the b10
* contract has no client transaction read (REQ-017); the server verifies captures inside the webhook
* handler, so on return from the PSP there is nothing to "verify" client-side, only an outcome to read.
*
* NOT the primary implementation this phase (`USE_PAYMENT_MOCK = true`) — see `constants.ts` for why.
*/
export const paymentClientApi: PaymentApi = {
getCheckoutSummary: async (bookingRequestId: number) =>
// REQ-016 proposed slug (action-style, mirrors b8's `[controller]/[action]` routing). 404s until the
// backend delivers it — which is why the domain stays mock-primary.
unwrap(
await clientFetch<ApiEnvelope<CheckoutSummaryDto>>(
`${BOOKING_REQUESTS}/checkout_summary/${bookingRequestId}`,
),
),
initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) =>
unwrap(
await clientFetch<ApiEnvelope<InitiatePaymentResult>>(`${BOOKINGS}/${bookingRequestId}/payments`, {
method: 'POST',
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
}),
),
confirmGatewayReturn: async ({ bookingRequestId }: ConfirmGatewayReturnInput) =>
// The PSP hit the webhook before redirecting back — there is nothing to submit; read the outcome.
paymentClientApi.getPaymentOutcome(bookingRequestId),
getPaymentOutcome: async (bookingRequestId: number) => {
const request = unwrap(
await clientFetch<ApiEnvelope<Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>>>(
`${BOOKING_REQUESTS}/get/${bookingRequestId}`,
),
);
const outcome: PaymentOutcomeDto = {
bookingRequestId: request.id,
requestStatus: request.status,
transactionStatus:
request.status === 'converted'
? 'succeeded'
: request.status === 'payment_deadline_expired'
? 'failed'
: 'pending',
// REQ-017: the b8 DTO gives no way to reach the converted booking — the confirmation falls back
// to the bookings list until the field lands.
bookingId: null,
};
return outcome;
},
getInvoice: async (bookingId: number) =>
unwrap(await clientFetch<ApiEnvelope<InvoiceDto>>(`${INVOICES}/${bookingId}`)),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_PAYMENT_MOCK } from '../constants';
import type { PaymentApi } from '../types';
import { paymentClientApi } from './clientApi';
import { paymentMockApi } from './mockApi';
/**
* The selected `PaymentApi` implementation — the single seam the hooks import. Selection is by config
* (`USE_PAYMENT_MOCK`), never by scattered `if (mock)` checks.
*/
export const paymentApi: PaymentApi = USE_PAYMENT_MOCK ? paymentMockApi : paymentClientApi;
+267
View File
@@ -0,0 +1,267 @@
import { multiplyIrr, parseIrr, sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import { ROUTES } from '@/constants';
import {
bookingRequestsMockApi,
mockMarkBookingRequestConverted,
} from '@/services/bookingRequests/apis/mockApi';
import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
import {
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
MOCK_PLATFORM_FEE_RATE,
MOCK_VAT_RATE,
} from '../constants';
import type {
CheckoutSummaryDto,
ConfirmGatewayReturnInput,
InitiatePaymentInput,
InitiatePaymentResult,
InvoiceDto,
PaymentApi,
PaymentOutcomeDto,
PaymentTransactionStatus,
} from '../types';
const MOCK_LATENCY_MS = 350;
/** A request is a single visit until multi-session requests exist (b8 carries one date/time window). */
const SESSION_COUNT = 1;
// Integer-only rate math: rates 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 FEE_RATE_PPM = BigInt(Math.round(MOCK_PLATFORM_FEE_RATE * Number(RATE_SCALE)));
const VAT_RATE_PPM = BigInt(Math.round(MOCK_VAT_RATE * Number(RATE_SCALE)));
/**
* The C6 money decomposition, all BigInt (mirrors what REQ-016 asks the server to serve):
* commission (net) = gross × feeRate; VAT = commission × vatRate (b11 rule: VAT is computed ON the
* commission, the platform's taxable supply); service cost = the remainder (= the nurse payout leg).
* Rows reconcile by construction: service + commission + vat = gross, and the b10 three-amount
* invariant holds with balinyaarCommission = commission + vat.
*/
function splitAmounts(grossIrr: string) {
const gross = parseIrr(grossIrr);
const commissionNet = (gross * FEE_RATE_PPM) / RATE_SCALE;
const vat = (commissionNet * VAT_RATE_PPM) / RATE_SCALE;
const serviceCost = gross - commissionNet - vat;
return {
grossPriceIrr: gross.toString(),
commissionIrr: commissionNet.toString(),
vatIrr: vat.toString(),
serviceCostIrr: serviceCost.toString(),
balinyaarCommissionIrr: (commissionNet + vat).toString(),
nursePayoutAmount: serviceCost.toString(),
};
}
interface MockTransaction {
transactionId: number;
bookingRequestId: number;
idempotencyKey: string;
status: PaymentTransactionStatus;
gatewayReferenceCode: string;
grossPriceIrr: string;
bookingId: number | null;
}
// Module-level stores (one browser session) — the same singleton pattern as the f7/f8 mocks, so the
// checkout, the C5 poll, and the bookings list all observe the same capture.
let transactions: MockTransaction[] = [];
const invoices: Record<number, InvoiceDto> = {};
let nextTransactionId = 42_001;
let nextInvoiceId = 101;
function latestTransactionFor(bookingRequestId: number): MockTransaction | undefined {
return transactions.find((t) => t.bookingRequestId === bookingRequestId);
}
function requestGross(request: BookingRequestDto): string {
// The frozen gross the server would charge: variant price × session count, never client-supplied.
// A request without a price (REQ-013 unmet) must fail loudly — never a reconciling 0-rial checkout.
if (request.variantPrice == null) {
throw new ApiError(409, 'Request has no priced variant', 'unpriced_request');
}
return multiplyIrr(request.variantPrice, SESSION_COUNT);
}
function toOutcome(request: BookingRequestDto, transaction: MockTransaction | undefined): PaymentOutcomeDto {
return {
bookingRequestId: request.id,
requestStatus: request.status,
transactionStatus: transaction?.status ?? null,
bookingId: request.bookingId ?? transaction?.bookingId ?? null,
};
}
/** The webhook-confirm stand-in: flip the transaction, convert the request, insert the booking, invoice it. */
function capture(request: BookingRequestDto, transaction: MockTransaction): PaymentOutcomeDto {
const amounts = splitAmounts(transaction.grossPriceIrr);
const booking = mockInsertConvertedBooking({
bookingRequestId: request.id,
nurseId: request.nurseId,
nurseName: request.nurseName,
patientId: request.patientId,
patientName: request.patientName,
variantId: request.variantId,
variantLabel: request.variantLabel,
variantPriceUnit: request.variantPriceUnit,
customerAddressId: request.customerAddressId,
addressSnapshotJson: JSON.stringify({
title: request.addressTitle,
city: request.cityNameFa,
district: request.districtNameFa,
line: request.addressLine,
postalCode: request.postalCode,
}),
grossPriceIrr: amounts.grossPriceIrr,
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
nursePayoutAmount: amounts.nursePayoutAmount,
// The PSP's cut is a platform expense outside the three-amount split; 2% matches the f8 seeds.
pspFeeAmount: ((parseIrr(amounts.grossPriceIrr) * BigInt(200)) / RATE_SCALE).toString(),
platformFeeRate: MOCK_PLATFORM_FEE_RATE,
scheduledDate: request.requestedDate,
scheduledTimeStart: request.requestedTimeStart,
scheduledTimeEnd: request.requestedTimeEnd,
});
const converted = mockMarkBookingRequestConverted(request.id, booking.id);
transaction.status = 'succeeded';
transaction.bookingId = booking.id;
invoices[booking.id] = {
id: nextInvoiceId,
bookingId: booking.id,
invoiceNumber: `INV-${String(nextInvoiceId).padStart(10, '0')}`,
issuingEntityType: 'platform',
grossIrr: amounts.grossPriceIrr,
platformCommissionIrr: amounts.commissionIrr,
bnplCommissionIrr: null,
vatRate: MOCK_VAT_RATE,
vatIrr: amounts.vatIrr,
moadianReferenceNumber: null,
moadianStatus: 'pending',
pdfUrl: null,
issuedAt: new Date().toISOString(),
};
nextInvoiceId += 1;
return toOutcome(converted, transaction);
}
/**
* In-memory mock behind the `PaymentApi` seam — the missing conversion trigger between the f7 and f8
* mock stores. It serves the unserved C6 summary (REQ-016), plays the PSP + webhook roles (initiate →
* app-relative gateway-harness redirect → capture on the success return), enforces b10's idempotency
* semantics (same-key retry reuses the attempt; a repeat initiate after capture is a `409`; a replayed
* return is a no-op), and issues the b11-shaped invoice at capture. Swap to the real `clientApi` once
* the upstream domains are real and REQ-016/017 land (`USE_PAYMENT_MOCK = false`).
*/
export const paymentMockApi: PaymentApi = {
getCheckoutSummary: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const amounts = splitAmounts(requestGross(request));
const summary: CheckoutSummaryDto = {
bookingRequestId: request.id,
requestStatus: request.status,
nurseName: request.nurseName,
patientName: request.patientName,
variantLabel: request.variantLabel,
variantPriceUnit: request.variantPriceUnit,
sessionCount: SESSION_COUNT,
requestedDate: request.requestedDate,
requestedTimeStart: request.requestedTimeStart,
requestedTimeEnd: request.requestedTimeEnd,
paymentDeadlineAt: request.paymentDeadlineAt,
serviceCostIrr: amounts.serviceCostIrr,
commissionIrr: amounts.commissionIrr,
vatIrr: amounts.vatIrr,
vatRate: MOCK_VAT_RATE,
totalIrr: amounts.grossPriceIrr,
grossPriceIrr: amounts.grossPriceIrr,
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
nursePayoutAmount: amounts.nursePayoutAmount,
};
return summary;
},
initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const existing = latestTransactionFor(bookingRequestId);
if (request.status === 'converted' || existing?.status === 'succeeded') {
throw new ApiError(409, 'Request already paid', 'already_paid');
}
if (request.status !== 'accepted_awaiting_payment') {
// Covers the lapsed 30-minute window (`payment_deadline_expired`) and every other non-payable state.
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
}
// b10 idempotency: a retried start with the same `Idempotency-Key` reuses the attempt/reference.
if (existing && existing.idempotencyKey === idempotencyKey && existing.status === 'pending') {
return toInitiateResult(existing);
}
const transaction: MockTransaction = {
transactionId: nextTransactionId++,
bookingRequestId,
idempotencyKey,
status: 'pending',
gatewayReferenceCode: `mock-ref-${bookingRequestId}-${idempotencyKey.slice(0, 8)}`,
grossPriceIrr: requestGross(request),
bookingId: null,
};
transactions = [transaction, ...transactions];
return toInitiateResult(transaction);
},
confirmGatewayReturn: async ({ bookingRequestId, transactionId, outcome }: ConfirmGatewayReturnInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const transaction =
(transactionId != null ? transactions.find((t) => t.transactionId === transactionId) : undefined) ??
latestTransactionFor(bookingRequestId);
// Replays / double-taps converge (the webhook dedup analogue): if capture already happened, report it.
if (request.status === 'converted' || transaction?.status === 'succeeded') {
return toOutcome(request, transaction);
}
if (!transaction) throw new ApiError(404, 'Payment transaction not found', 'not_found');
if (outcome === 'failure' || request.status !== 'accepted_awaiting_payment') {
// A declined gateway, or the payment window lapsed while the customer was at the gateway.
transaction.status = 'failed';
return toOutcome(request, transaction);
}
return capture(request, transaction);
},
getPaymentOutcome: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
return toOutcome(request, latestTransactionFor(bookingRequestId));
},
getInvoice: async (bookingId) => {
await sleep(MOCK_LATENCY_MS);
const invoice = invoices[bookingId];
// b11: the invoice exists only once issued — a clean 404 until then (the UI shows "not issued yet").
if (!invoice) throw new ApiError(404, 'Invoice not issued', 'not_issued');
return { ...invoice };
},
};
function toInitiateResult(transaction: MockTransaction): InitiatePaymentResult {
const query = new URLSearchParams();
query.set(CHECKOUT_QUERY_TRANSACTION_ID, String(transaction.transactionId));
query.set(CHECKOUT_QUERY_REQUEST_ID, String(transaction.bookingRequestId));
return {
transactionId: transaction.transactionId,
// App-relative (the checkout prepends the locale); the real PSP returns an absolute https URL.
redirectUrl: `${ROUTES.CHECKOUT_GATEWAY}?${query.toString()}`,
gatewayReferenceCode: transaction.gatewayReferenceCode,
};
}
+54
View File
@@ -0,0 +1,54 @@
/**
* When true, the payment domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `PaymentApi` seam.
*
* **Mock is primary this phase.** The b10 `initiate` endpoint and the b11 `invoices/{bookingId}` read are
* live server-side, but the checkout cannot run real end-to-end from the client yet:
* - the accepted request being paid comes from the **mock-primary** `bookingRequests` store (f7), so a
* real initiate would reference an id that exists only in memory;
* - the contract serves **no checkout summary** (gross/commission/VAT for C6 — REQ-016) and **no
* client-readable transaction status** for the return poll (REQ-017);
* - nothing fires the PSP webhook in dev (b10's mock provider returns a fake redirect URL and the
* "webhook simulator" is a manual server-side POST), so a real payment would never confirm.
* The mock closes the loop: capture converts the f7 request, inserts a **confirmed** booking into the f8
* store, and issues the invoice — so C5 → C6 → gateway → confirmation → booking detail demos end-to-end.
* Flip to `false` once the upstream domains are real and REQ-016/017 land — no hook/component change.
*/
export const USE_PAYMENT_MOCK = true;
/**
* f11 wires the BNPL method screens (D1D5); until then C6 renders the «یا پرداخت اقساطی» seam as a
* clearly-deferred secondary (disabled + "coming soon"), never a dead button.
*/
export const BNPL_ENABLED = false;
/** Prices/deadlines can move between visits — keep the checkout summary short-lived. */
export const CHECKOUT_SUMMARY_STALE_TIME = 10 * 1000;
/** An issued invoice is immutable — cache it for the session. */
export const INVOICE_STALE_TIME = 60 * 60 * 1000;
/**
* The pending-callback poll ("PSP received ≠ cash in bank" — a pending state is normal, reflect it
* calmly). Starts at ~2s, grows geometrically, caps, and gives up after a bounded number of attempts —
* never a tight loop. Terminal statuses stop the poll regardless (see `usePaymentOutcome`).
*/
export const PAYMENT_OUTCOME_POLL_BASE_MS = 2 * 1000;
export const PAYMENT_OUTCOME_POLL_GROWTH = 1.5;
export const PAYMENT_OUTCOME_POLL_MAX_MS = 15 * 1000;
export const PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS = 40;
/** Checkout deep-link query params (C5 hands off `request_id`; the gateway round-trip carries the rest). */
export const CHECKOUT_QUERY_REQUEST_ID = 'request_id';
export const CHECKOUT_QUERY_TRANSACTION_ID = 'transaction_id';
export const CHECKOUT_QUERY_BOOKING_ID = 'booking_id';
export const CHECKOUT_QUERY_OUTCOME = 'outcome';
/**
* Mock-only money rates. `MOCK_PLATFORM_FEE_RATE` matches the f8 seeds' snapshotted `platformFeeRate`
* (0.12); `MOCK_VAT_RATE` is the current 10% VAT (product: VAT applies to **Balinyaar's commission
* only** and the rate is config-driven server-side — b11 serves `vatRate`/`vatIrr`; the client never
* derives tax on the real path).
*/
export const MOCK_PLATFORM_FEE_RATE = 0.12;
export const MOCK_VAT_RATE = 0.1;
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { CHECKOUT_SUMMARY_STALE_TIME } from '../constants';
/**
* The C6 payload — badge status, nurse/service mini-summary, and the served, reconciling money rows
* (service + commission + VAT = total). Short `staleTime`: the price/deadline can change between visits,
* and after a capture the summary's `requestStatus` must flip promptly (it is also invalidated by
* `invalidateAfterPaymentSuccess`). Enabled only when an id is present.
*/
export function useCheckoutSummary(bookingRequestId: number | undefined) {
return useQuery({
queryKey: paymentKeys.summary(bookingRequestId ?? -1),
queryFn: () => paymentApi.getCheckoutSummary(bookingRequestId as number),
enabled: bookingRequestId != null && bookingRequestId > 0,
staleTime: CHECKOUT_SUMMARY_STALE_TIME,
});
}
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { invalidateAfterPaymentSuccess } from '../invalidations';
import type { ConfirmGatewayReturnInput } from '../types';
/**
* Report the return from the gateway and read the outcome. On the real path the PSP already confirmed
* via the webhook, so this is a read; in the mock it is the capture trigger. When the outcome is already
* `succeeded`, the affected booking/request caches are invalidated here (the booking flips to confirmed
* by cache invalidation, never a blanket refetch); a still-`pending` outcome is primed into the outcome
* key so the pending-callback poll (`usePaymentOutcome`) starts from fresh data.
*/
export function useConfirmGatewayReturn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ConfirmGatewayReturnInput) => paymentApi.confirmGatewayReturn(input),
onSuccess: (outcome) => {
queryClient.setQueryData(paymentKeys.outcome(outcome.bookingRequestId), outcome);
if (outcome.transactionStatus === 'succeeded') {
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
}
},
});
}
@@ -0,0 +1,17 @@
import { useMutation } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import type { InitiatePaymentInput } from '../types';
/**
* Start a card payment for an accepted request. The caller owns the **per-attempt idempotency key**
* (generated once per attempt and reused across retries of that attempt — see the C6 page), because only
* the UI knows when a *new* attempt starts (after a failed/cancelled outcome). On success the caller
* navigates to `redirectUrl`. A `409` here means "already paid / already in progress / window lapsed" —
* the page converges (routes to the return surface to read the outcome) instead of toasting; other
* domain 4xx render inline via `mutation.error` (the fetch layer owns 401/403/5xx toasts).
*/
export function useInitiatePayment() {
return useMutation({
mutationFn: (input: InitiatePaymentInput) => paymentApi.initiatePayment(input),
});
}
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { ApiError } from '@/lib/api/errors';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { INVOICE_STALE_TIME } from '../constants';
/**
* The booking's commission invoice (b11). An issued invoice is immutable — long `staleTime`, no polling.
* A 404 is a domain state, not a transient failure ("not issued yet" — b11 issues via an admin action;
* auto-issue on capture is filed as REQ-018), so it renders as an empty state and is **not retried**.
*/
export function useInvoice(bookingId: number | undefined) {
return useQuery({
queryKey: paymentKeys.invoice(bookingId ?? -1),
queryFn: () => paymentApi.getInvoice(bookingId as number),
enabled: bookingId != null && bookingId > 0,
staleTime: INVOICE_STALE_TIME,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
});
}
@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import {
PAYMENT_OUTCOME_POLL_BASE_MS,
PAYMENT_OUTCOME_POLL_GROWTH,
PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS,
PAYMENT_OUTCOME_POLL_MAX_MS,
} from '../constants';
import { isTerminalPaymentOutcome } from '../types';
/**
* The pending-callback poll. "PSP received ≠ cash in bank" — a pending state after the gateway return is
* normal, so this polls **with geometric backoff** (base → ×growth → cap) and **stops** on any terminal
* outcome or after a bounded number of attempts — never a tight loop, never hammering the endpoint. The
* return surface offers a manual re-check once the budget is spent. `dataUpdateCount` is the attempt
* counter (it increments per successful fetch, which is exactly the cadence backoff should follow).
*/
export function usePaymentOutcome(bookingRequestId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: paymentKeys.outcome(bookingRequestId ?? -1),
queryFn: () => paymentApi.getPaymentOutcome(bookingRequestId as number),
enabled: (options?.enabled ?? true) && bookingRequestId != null && bookingRequestId > 0,
refetchInterval: (query) => {
const outcome = query.state.data;
if (outcome && isTerminalPaymentOutcome(outcome)) return false;
const attempt = query.state.dataUpdateCount;
if (attempt >= PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS) return false;
return Math.min(
PAYMENT_OUTCOME_POLL_BASE_MS * PAYMENT_OUTCOME_POLL_GROWTH ** attempt,
PAYMENT_OUTCOME_POLL_MAX_MS,
);
},
});
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Payment domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { useCheckoutSummary } from './hooks/useCheckoutSummary';
export { useInitiatePayment } from './hooks/useInitiatePayment';
export { useConfirmGatewayReturn } from './hooks/useConfirmGatewayReturn';
export { usePaymentOutcome } from './hooks/usePaymentOutcome';
export { useInvoice } from './hooks/useInvoice';
@@ -0,0 +1,27 @@
import type { QueryClient } from '@tanstack/react-query';
import { bookingKeys } from '@/services/bookings/keys';
import { bookingRequestKeys } from '@/services/bookingRequests/keys';
import { paymentKeys } from './keys';
/**
* The one cache transition a successful capture causes: the request flipped `converted` and a confirmed
* booking now exists. Invalidate exactly the affected keys — the request detail + inboxes, the bookings
* lists (a new row), the specific booking detail when known, and this request's checkout summary/outcome
* — never a blanket refetch (phase rule: the booking flips to confirmed *by cache invalidation*, no
* refetch storm). Shared by `useConfirmGatewayReturn` (immediate success) and the pending-callback poll
* (late success).
*/
export function invalidateAfterPaymentSuccess(
queryClient: QueryClient,
bookingRequestId: number,
bookingId: number | null,
): void {
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(bookingRequestId) });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
if (bookingId != null) {
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
}
queryClient.invalidateQueries({ queryKey: paymentKeys.summary(bookingRequestId) });
queryClient.invalidateQueries({ queryKey: paymentKeys.outcome(bookingRequestId) });
}
+14
View File
@@ -0,0 +1,14 @@
/**
* React Query key factory for the payment domain (hierarchical, per the `services/{domain}` pattern).
* Summary and outcome are keyed by the booking-request id (payment runs against the accepted request);
* the invoice is keyed by the booking id it belongs to.
*/
export const paymentKeys = {
all: ['payment'] as const,
summaries: () => [...paymentKeys.all, 'summary'] as const,
summary: (bookingRequestId: number) => [...paymentKeys.summaries(), bookingRequestId] as const,
outcomes: () => [...paymentKeys.all, 'outcome'] as const,
outcome: (bookingRequestId: number) => [...paymentKeys.outcomes(), bookingRequestId] as const,
invoices: () => [...paymentKeys.all, 'invoice'] as const,
invoice: (bookingId: number) => [...paymentKeys.invoices(), bookingId] as const,
};
+150
View File
@@ -0,0 +1,150 @@
import type { PriceUnit } from '@/services/catalog/types';
import { isTerminalBookingRequestStatus, type BookingRequestStatus } from '@/services/bookingRequests/types';
/**
* Payment domain — the checkout/card-capture layer (b10) plus the customer invoice read (b11). Shapes
* mirror the published contracts (`dev/contracts/domains/payments.md`, `refunds-invoices.md`; camelCase
* wire, `clientFetch` unwraps the `ApiEnvelope<T>`).
*
* Load-bearing semantics (contracts + phase §5):
* - **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number; parse with the
* `@/utils` BigInt helpers, display as Toman via `formatIrrToToman`. No float math on the money path.
* - **Payment is initiated against the accepted REQUEST, not a booking** — the `bookings` row is created
* & confirmed on capture (the PSP webhook), never on initiate.
* - **Idempotency is per attempt.** One stable `Idempotency-Key` (header) per payment attempt, reused
* across retries of that attempt; a NEW attempt gets a new key. A `409` on initiate means "already in
* progress / already captured" — a benign convergence, never an error toast.
* - **There is no client verify endpoint.** The server re-verifies inside the webhook handler; the client
* learns the outcome by polling (`getPaymentOutcome`). On the real path that poll maps the request
* status (`converted` → succeeded); a first-class transaction-status read is filed as REQ-017.
* - **VAT is on Balinyaar's commission only** (the platform's taxable supply) — never the nurse payout.
*/
/** `payment_transactions.status` (b10 contract enum): no other states cross the wire. */
export type PaymentTransactionStatus = 'pending' | 'succeeded' | 'failed';
/** `invoices.moadian_status` (b11) — the e-invoicing registration state, surfaced read-only. */
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
/** What the dev gateway round-trip reports back (mock harness); the real PSP signals via the webhook. */
export type GatewayReturnOutcome = 'success' | 'failure';
/**
* The C6 payload. **Not yet served by the backend** (REQ-016): the b8 `BookingRequestDto` is money-free
* and no checkout-summary endpoint exists, so the mock builds this and the real `clientApi` targets the
* proposed route until it lands. Display rows are **served, never client-derived** — in particular
* `vatIrr` (the client must not derive tax with a float rate) — and reconcile by construction:
* `serviceCostIrr + commissionIrr + vatIrr = totalIrr`, with `totalIrr = grossPriceIrr` and the b10
* three-amount invariant `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`.
*/
export interface CheckoutSummaryDto {
bookingRequestId: number;
/** C6 renders only for `accepted_awaiting_payment`; other statuses get a convergence/terminal card. */
requestStatus: BookingRequestStatus;
nurseName: string;
patientName: string;
variantLabel: string;
variantPriceUnit: PriceUnit;
sessionCount: number;
/** ISO date `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
/** Server-frozen UTC end of the 30-minute payment window. */
paymentDeadlineAt: string | null;
/** Display row: the nursing service itself (= the nurse payout leg). IRR digit-string. */
serviceCostIrr: string;
/** Display row: Balinyaar's commission **net of VAT**. IRR digit-string. */
commissionIrr: string;
/** Display row: VAT on the commission (the served figure — never derived client-side). */
vatIrr: string;
/** Snapshotted VAT rate (decimal, e.g. 0.1) — informational; the rial figure is `vatIrr`. */
vatRate: number;
/** Display total (= `grossPriceIrr`); the rows above sum to exactly this. */
totalIrr: string;
/** The b10 three-amount split (gross = commission + payout, guaranteed server-side). */
grossPriceIrr: string;
balinyaarCommissionIrr: string;
nursePayoutAmount: string;
}
/** `POST bookings/{bookingRequestId}/payments` input — the key travels as the `Idempotency-Key` header. */
export interface InitiatePaymentInput {
bookingRequestId: number;
/** Stable per payment attempt (reused across retries of the same attempt); a new attempt gets a new one. */
idempotencyKey: string;
}
/** `InitiatePaymentResult` (b10 swagger): where to send the customer, and the PSP reference. */
export interface InitiatePaymentResult {
transactionId: number;
/** Absolute PSP URL on the real path; an app-relative gateway-harness path from the mock. */
redirectUrl: string | null;
gatewayReferenceCode: string | null;
}
/**
* What the return-from-gateway surface reports: the harness outcome plus which transaction came back.
* On the real path the PSP has already hit the webhook before redirecting — the impl just re-reads the
* outcome; in the mock this IS the capture trigger (the client stand-in for the webhook confirm).
*/
export interface ConfirmGatewayReturnInput {
bookingRequestId: number;
transactionId: number | null;
outcome: GatewayReturnOutcome;
}
/**
* The pending-callback poll target. No client transaction read exists in the b10 contract (REQ-017), so
* the real path derives `transactionStatus` from the request status (`converted` → succeeded,
* `payment_deadline_expired` → failed, else pending) and `bookingId` stays `null` until the backend
* serves it; the mock knows both first-hand.
*/
export interface PaymentOutcomeDto {
bookingRequestId: number;
requestStatus: BookingRequestStatus;
transactionStatus: PaymentTransactionStatus | null;
/** The confirmed booking to link to (client-augmented; `null` on the real path until REQ-017 lands). */
bookingId: number | null;
}
/** succeeded/failed transaction, or a request that left the payable state — nothing left to poll. */
export function isTerminalPaymentOutcome(outcome: PaymentOutcomeDto): boolean {
return (
outcome.transactionStatus === 'succeeded' ||
outcome.transactionStatus === 'failed' ||
isTerminalBookingRequestStatus(outcome.requestStatus)
);
}
/** `InvoiceDto` (b11 swagger, `GET invoices/{bookingId}`) — flat totals; VAT is on the commission line only. */
export interface InvoiceDto {
id: number;
bookingId: number;
/** Sequential, gap-free human-facing reference (e.g. `INV-0000000001`) — treat as opaque. */
invoiceNumber: string;
issuingEntityType: 'platform' | 'partner_center';
grossIrr: string;
/** Balinyaar's commission — the VAT-relevant line (`vatIrr = round(commission × vatRate)`). */
platformCommissionIrr: string;
bnplCommissionIrr: string | null;
vatRate: number;
vatIrr: string;
moadianReferenceNumber: string | null;
moadianStatus: MoadianStatus | null;
pdfUrl: string | null;
issuedAt: string;
}
/**
* The payment API seam — the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_PAYMENT_MOCK`), never scattered `if (mock)` checks.
*/
export interface PaymentApi {
getCheckoutSummary(bookingRequestId: number): Promise<CheckoutSummaryDto>;
initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentResult>;
confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise<PaymentOutcomeDto>;
getPaymentOutcome(bookingRequestId: number): Promise<PaymentOutcomeDto>;
getInvoice(bookingId: number): Promise<InvoiceDto>;
}