frontend phase 9
This commit is contained in:
@@ -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}`)),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 (D1–D5); 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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user