frontend phase 11

This commit is contained in:
hamid
2026-07-10 13:55:42 +03:30
parent ccfa27aff6
commit 67c028562e
43 changed files with 3275 additions and 24 deletions
+108
View File
@@ -0,0 +1,108 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import type {
AcceptBnplScheduleInput,
BnplApi,
BnplEligibilityResult,
BnplOptions,
BnplOrderStatus,
BnplSchedule,
BnplSettlementResult,
CheckEligibilityInput,
GetBnplScheduleInput,
IssueBnplTokenInput,
IssueBnplTokenResult,
WalletInstallmentPlan,
} from '../types';
const CHECKOUT_BNPL = '/api/v1/checkout_bnpl';
/** The header b12's `initiate` reads the per-attempt idempotency key from. */
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/**
* Real HTTP implementation of the `BnplApi` seam. `checkEligibility` and `issueBnplToken` map the published
* b12 contract 1:1, and the order read **by its own id** (`GET checkout_bnpl/{id}`) is contract-correct; the
* rest cover contract gaps the frontend filed:
* - `getBnplOptions` (D1/D2 provider+plan list, per-plan monthly/down-payment/total) — **REQ-022**: the
* contract serves no options endpoint. Targets a proposed slug; 404s until it lands.
* - `getBnplSchedule` (D4 repayment table) — **REQ-022**: the contract explicitly does not model the
* customer's repayment schedule. Targets a proposed slug; 404s until it lands.
* - `acceptBnplSchedule` (settle-on-return) — the real path settles inside the provider **webhook**
* (the client is never trusted), so on return there is nothing to submit; it reads the order status and
* maps it (`settled` → success, `converted` request → success). **REQ-024** asks for a customer-readable
* `bookingId` on the order.
* - `getWalletInstallments` (D5 provider-reported status) — **REQ-024**: no wallet/installment-status read
* exists (the repayment schedule is provider-owned and out of the contract's scope). Targets a proposed
* slug; 404s until it lands.
* - `getBnplOrder(bookingRequestId)` (the return poll) — **REQ-024**: `GET checkout_bnpl/{id}` is keyed by
* the ORDER id, not the request id, and there is no customer *by-request* order lookup; the poll holds
* the request id, not the order id. Targets a proposed by-request slug; 404s until it lands. (The
* settle-on-return path below reads the order **by its own id**, which IS contract-correct.)
*
* NOT the primary implementation this phase (`USE_BNPL_MOCK = true`) — see `constants.ts` for why.
*/
/** Read a BNPL order by its own id — the contract-correct `GET checkout_bnpl/{id}` (id = bnplTransactionId). */
const readOrderById = async (orderId: number): Promise<BnplOrderStatus> =>
unwrap(await clientFetch<ApiEnvelope<BnplOrderStatus>>(`${CHECKOUT_BNPL}/${orderId}`));
export const bnplClientApi: BnplApi = {
getBnplOptions: async (bookingRequestId: number) =>
// REQ-022 proposed slug (owner-scoped). 404s until the backend delivers it — the domain stays mock-primary.
unwrap(await clientFetch<ApiEnvelope<BnplOptions>>(`${CHECKOUT_BNPL}/options/${bookingRequestId}`)),
checkEligibility: async ({ bookingRequestId, providerCode, nationalId, mobile, consent }: CheckEligibilityInput) =>
unwrap(
await clientFetch<ApiEnvelope<BnplEligibilityResult>>(`${CHECKOUT_BNPL}/eligibility`, {
method: 'POST',
// The contract body is `{ bookingRequestId, providerCode }`; the D3 KYC inputs (nationalId/mobile/
// consent) are sent for the provider credit check (REQ-023 — the contract eligibility does not yet
// accept them). Extra fields are ignored server-side until the KYC step is defined.
body: JSON.stringify({ bookingRequestId, providerCode, nationalId, mobile, consent }),
}),
),
getBnplSchedule: async ({ bookingRequestId, providerCode, planId }: GetBnplScheduleInput) =>
// REQ-022 proposed slug. 404s until the backend serves the repayment schedule.
unwrap(
await clientFetch<ApiEnvelope<BnplSchedule>>(
`${CHECKOUT_BNPL}/schedule/${bookingRequestId}?provider=${providerCode}&plan=${planId}`,
),
),
issueBnplToken: async ({ bookingRequestId, providerCode, idempotencyKey }: IssueBnplTokenInput) =>
unwrap(
await clientFetch<ApiEnvelope<IssueBnplTokenResult>>(`${CHECKOUT_BNPL}/initiate`, {
method: 'POST',
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
body: JSON.stringify({ bookingRequestId, providerCode }),
}),
),
acceptBnplSchedule: async ({ bookingRequestId, bnplTransactionId }: AcceptBnplScheduleInput) => {
// The provider settled via webhook before redirecting back — nothing to submit; read the order by its
// OWN id (`GET checkout_bnpl/{id}`, carried back as `transaction_id`). Fall back to the by-request read
// only if the order id wasn't carried through the handoff.
const order =
bnplTransactionId != null ? await readOrderById(bnplTransactionId) : await bnplClientApi.getBnplOrder(bookingRequestId);
const result: BnplSettlementResult = {
bnplTransactionId: order.id,
bookingRequestId,
// REQ-024: the b12 order gives no request status; the return page falls back to the order status.
requestStatus: order.status === 'settled' ? 'converted' : 'accepted_awaiting_payment',
status: order.status,
bookingId: order.bookingId,
providerCode: order.providerCode,
};
return result;
},
getBnplOrder: async (bookingRequestId: number) =>
// REQ-024 gap (see header): b12 has no customer *by-request* order lookup; the poll needs one. Proposed slug.
unwrap(await clientFetch<ApiEnvelope<BnplOrderStatus>>(`${CHECKOUT_BNPL}/by_request/${bookingRequestId}`)),
getWalletInstallments: async () =>
// REQ-024 proposed slug (owner-scoped, provider-reported). 404s until the backend serves it.
unwrap(await clientFetch<ApiEnvelope<WalletInstallmentPlan[]>>(`${CHECKOUT_BNPL}/wallet_installments`)),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_BNPL_MOCK } from '../constants';
import type { BnplApi } from '../types';
import { bnplClientApi } from './clientApi';
import { bnplMockApi } from './mockApi';
/**
* The selected `BnplApi` implementation — the single seam the hooks import. Selection is by config
* (`USE_BNPL_MOCK`), never by scattered `if (mock)` checks.
*/
export const bnplApi: BnplApi = USE_BNPL_MOCK ? bnplMockApi : bnplClientApi;
+539
View File
@@ -0,0 +1,539 @@
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 { MOCK_PLATFORM_FEE_RATE, MOCK_VAT_RATE } from '@/services/payment/constants';
import {
BNPL_QUERY_PLAN,
BNPL_QUERY_PROVIDER,
BNPL_QUERY_REQUEST_ID,
BNPL_QUERY_TRANSACTION_ID,
MOCK_CREDIT_CEILING_IRR,
MOCK_NOT_ELIGIBLE_LAST_DIGIT,
} from '../constants';
import type {
AcceptBnplScheduleInput,
BnplApi,
BnplEligibilityResult,
BnplInstallmentRow,
BnplInstallmentStatus,
BnplOptions,
BnplOrderStatus,
BnplPlanOption,
BnplProvider,
BnplSchedule,
BnplSettlementResult,
BnplStatus,
CheckEligibilityInput,
GetBnplScheduleInput,
IssueBnplTokenInput,
IssueBnplTokenResult,
ProviderCode,
WalletInstallmentPlan,
} 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: 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)));
}
/** `YYYY-MM-DD` for a whole-month offset from today (mock schedule dates — runs client-side, `new Date()` is fine). */
function isoMonthsFromNow(months: number): string {
const d = new Date();
d.setMonth(d.getMonth() + months);
return d.toISOString().slice(0, 10);
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* A plan template — the provider-config a real BNPL adapter owns. `feePercent` is the customer-facing plan
* fee (0 = بدون سود / interest-free, matching the interest-free-to-customer model for the ۴-قسط plans);
* the wireframe shows دیجی‌پی's longer terms carrying a small fee (کارمزد ۴٪/۹٪). All amounts the client
* shows are computed HERE (the server's job) and served as digit-strings — the client never derives them.
*/
interface PlanTemplate {
planId: string;
termMonths: number | null;
installmentCount: number;
feePercent: number;
downPaymentPercent: number;
}
interface ProviderTemplate {
providerCode: ProviderCode;
plans: PlanTemplate[];
}
// Provider/plan set as **data** (per phase §Out-of-scope: treat the provider list as data, no multi-provider
// routing UI). دیجی‌پی (۳/۶/۱۲ ماهه), اسنپ‌پی (۴ قسط بدون سود), اقساط بالین‌یار (طرح داخلی) — matching the
// wireframe. Shapes/fees follow product/payments/bnpl-landscape.md; never hardcode a fee in the UI.
const PROVIDER_TEMPLATES: ProviderTemplate[] = [
{
providerCode: 'digipay',
plans: [
{ planId: 'digipay_3m', termMonths: 3, installmentCount: 3, feePercent: 0, downPaymentPercent: 0.2 },
{ planId: 'digipay_6m', termMonths: 6, installmentCount: 6, feePercent: 0.04, downPaymentPercent: 0.2 },
{ planId: 'digipay_12m', termMonths: 12, installmentCount: 12, feePercent: 0.09, downPaymentPercent: 0.2 },
],
},
{
providerCode: 'snapppay',
plans: [{ planId: 'snapppay_4', termMonths: null, installmentCount: 4, feePercent: 0, downPaymentPercent: 0.25 }],
},
{
providerCode: 'balinyaar',
plans: [{ planId: 'balinyaar_4', termMonths: null, installmentCount: 4, feePercent: 0, downPaymentPercent: 0.25 }],
},
];
/** The frozen order gross the provider finances: variant price × session count, never client-supplied. */
function requestGross(request: BookingRequestDto): string {
if (request.variantPrice == null) {
throw new ApiError(409, 'Request has no priced variant', 'unpriced_request');
}
return multiplyIrr(request.variantPrice, SESSION_COUNT);
}
/** Split a plan's amounts (all BigInt): total (= gross + customer-facing fee), down-payment, regular installment. */
function planAmounts(grossIrr: string, plan: PlanTemplate) {
const gross = parseIrr(grossIrr);
const total = gross + (gross * fractionPpm(plan.feePercent)) / RATE_SCALE;
const downPayment = (total * fractionPpm(plan.downPaymentPercent)) / RATE_SCALE;
const financed = total - downPayment;
const regular = plan.installmentCount > 0 ? financed / BigInt(plan.installmentCount) : ZERO;
// The final installment absorbs the integer-division remainder so the rows sum to `total` exactly.
const last = financed - regular * BigInt(Math.max(plan.installmentCount - 1, 0));
return { total, downPayment, regular, last };
}
function toPlanOption(grossIrr: string, plan: PlanTemplate): BnplPlanOption {
const { total, downPayment, regular } = planAmounts(grossIrr, plan);
return {
planId: plan.planId,
termMonths: plan.termMonths,
installmentCount: plan.installmentCount,
feePercent: plan.feePercent,
downPaymentPercent: plan.downPaymentPercent,
monthlyAmountIrr: regular.toString(),
downPaymentIrr: downPayment.toString(),
totalIrr: total.toString(),
};
}
/** Build the served repayment rows (down-payment today + N monthly installments; last absorbs the remainder). */
function buildScheduleRows(grossIrr: string, plan: PlanTemplate): BnplInstallmentRow[] {
const { downPayment, regular, last } = planAmounts(grossIrr, plan);
const rows: BnplInstallmentRow[] = [
{ sequence: 0, kind: 'down_payment', dueDate: todayIso(), amountIrr: downPayment.toString() },
];
for (let i = 1; i <= plan.installmentCount; i += 1) {
const amount = i === plan.installmentCount ? last : regular;
rows.push({ sequence: i, kind: 'installment', dueDate: isoMonthsFromNow(i), amountIrr: amount.toString() });
}
return rows;
}
function findPlan(providerCode: ProviderCode, planId: string): PlanTemplate {
const provider = PROVIDER_TEMPLATES.find((p) => p.providerCode === providerCode);
const plan = provider?.plans.find((p) => p.planId === planId);
if (!plan) throw new ApiError(400, 'Unknown BNPL plan', 'unknown_plan');
return plan;
}
// The b8 booking amounts a settled order confirms (a settled BNPL order = a card payment net-of-fee, so the
// nurse payout is invariant to method — same split as the f9 capture). Commission net + VAT = balinyaar cut.
const FEE_RATE_PPM = fractionPpm(MOCK_PLATFORM_FEE_RATE);
const VAT_RATE_PPM = fractionPpm(MOCK_VAT_RATE);
function bookingAmounts(grossIrr: string) {
const gross = parseIrr(grossIrr);
const commissionNet = (gross * FEE_RATE_PPM) / RATE_SCALE;
const vat = (commissionNet * VAT_RATE_PPM) / RATE_SCALE;
const payout = gross - commissionNet - vat;
return {
grossPriceIrr: gross.toString(),
balinyaarCommissionIrr: (commissionNet + vat).toString(),
nursePayoutAmount: payout.toString(),
pspFeeAmount: ((gross * BigInt(200)) / RATE_SCALE).toString(),
};
}
interface MockBnplTx {
bnplTransactionId: number;
paymentTransactionId: number;
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
idempotencyKey: string;
status: BnplStatus;
externalPaymentToken: string;
orderAmountIrr: string;
totalIrr: string;
installmentCount: number;
schedule: BnplInstallmentRow[];
bookingId: number | null;
createdAt: string;
}
// Module-level stores (one browser session) — same singleton pattern as the f7/f8/f9 mocks, so the C6
// branch, the return settle, and the Wallet all observe the same order.
let transactions: MockBnplTx[] = [];
let walletPlans: WalletInstallmentPlan[] = [];
let nextBnplTxId = 71_001;
let nextPaymentTxId = 72_001;
function latestTxFor(bookingRequestId: number): MockBnplTx | undefined {
return transactions.find((t) => t.bookingRequestId === bookingRequestId);
}
const providerEarlyPayUrl = (providerCode: ProviderCode, txId: number): string =>
`https://provider.example/${providerCode}/installments/${txId}/early-pay`;
/**
* Seed one active plan so D5 shows a provider-reported outstanding balance out of the box (like the refunds
* mock seeds a failed refund). A settled دیجی‌پی ۶-ماهه: قسط ۱ پرداخت‌شده, قسط ۲ سررسید نزدیک, ۳–۶ آینده —
* the paid/due/future mix the wireframe shows. Outstanding = Σ of the not-yet-paid rows.
*/
function seedWallet(): void {
const rows: BnplInstallmentRow[] = [
{ sequence: 0, kind: 'down_payment', dueDate: isoMonthsFromNow(-1), amountIrr: '4660000', status: 'paid' },
{ sequence: 1, kind: 'installment', dueDate: isoMonthsFromNow(-1), amountIrr: '4040000', status: 'paid' },
{ sequence: 2, kind: 'installment', dueDate: isoMonthsFromNow(0), amountIrr: '4040000', status: 'due_soon' },
{ sequence: 3, kind: 'installment', dueDate: isoMonthsFromNow(1), amountIrr: '4040000', status: 'upcoming' },
{ sequence: 4, kind: 'installment', dueDate: isoMonthsFromNow(2), amountIrr: '4040000', status: 'upcoming' },
{ sequence: 5, kind: 'installment', dueDate: isoMonthsFromNow(3), amountIrr: '4040000', status: 'upcoming' },
{ sequence: 6, kind: 'installment', dueDate: isoMonthsFromNow(4), amountIrr: '4040000', status: 'upcoming' },
];
const outstanding = rows
.filter((r) => r.status !== 'paid')
.reduce((acc, r) => acc + parseIrr(r.amountIrr), ZERO);
const nextDue = rows.find((r) => r.status === 'due_soon') ?? rows.find((r) => r.status === 'upcoming') ?? null;
walletPlans = [
{
bnplTransactionId: 70_900,
bookingId: 5001,
providerCode: 'digipay',
status: 'settled',
serviceLabel: 'مراقبت سالمند — شیفت روز',
outstandingBalanceIrr: outstanding.toString(),
installmentCount: 6,
nextDueDate: nextDue?.dueDate ?? null,
nextAmountIrr: nextDue?.amountIrr ?? null,
installments: rows,
earlyPayUrl: providerEarlyPayUrl('digipay', 70_900),
createdAt: isoMonthsFromNow(-1),
},
];
}
seedWallet();
/** Provider-reported due status for a freshly settled plan: down-payment paid, next installment due-soon, rest upcoming. */
function toWalletRows(schedule: BnplInstallmentRow[]): BnplInstallmentRow[] {
return schedule.map((row) => {
let status: BnplInstallmentStatus;
if (row.kind === 'down_payment') status = 'paid';
else if (row.sequence === 1) status = 'due_soon';
else status = 'upcoming';
return { ...row, status };
});
}
/** The settle side effect: convert the request, insert the confirmed booking (net-of-fee), seed the Wallet plan. */
function settle(request: BookingRequestDto, tx: MockBnplTx): BnplSettlementResult {
const amounts = bookingAmounts(tx.orderAmountIrr);
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,
pspFeeAmount: amounts.pspFeeAmount,
platformFeeRate: MOCK_PLATFORM_FEE_RATE,
scheduledDate: request.requestedDate,
scheduledTimeStart: request.requestedTimeStart,
scheduledTimeEnd: request.requestedTimeEnd,
});
const converted = mockMarkBookingRequestConverted(request.id, booking.id);
tx.status = 'settled';
tx.bookingId = booking.id;
const walletRows = toWalletRows(tx.schedule);
const outstanding = walletRows
.filter((r) => r.status !== 'paid')
.reduce((acc, r) => acc + parseIrr(r.amountIrr), ZERO);
const nextDue = walletRows.find((r) => r.status !== 'paid') ?? null;
walletPlans = [
{
bnplTransactionId: tx.bnplTransactionId,
bookingId: booking.id,
providerCode: tx.providerCode,
status: 'settled',
serviceLabel: request.variantLabel,
outstandingBalanceIrr: outstanding.toString(),
installmentCount: tx.installmentCount,
nextDueDate: nextDue?.dueDate ?? null,
nextAmountIrr: nextDue?.amountIrr ?? null,
installments: walletRows,
earlyPayUrl: providerEarlyPayUrl(tx.providerCode, tx.bnplTransactionId),
createdAt: new Date().toISOString(),
},
...walletPlans,
];
return toSettlementResult(converted, tx);
}
function toSettlementResult(request: BookingRequestDto, tx: MockBnplTx): BnplSettlementResult {
return {
bnplTransactionId: tx.bnplTransactionId,
bookingRequestId: request.id,
requestStatus: request.status,
status: tx.status,
bookingId: request.bookingId ?? tx.bookingId,
providerCode: tx.providerCode,
};
}
function toInitiateResult(tx: MockBnplTx): IssueBnplTokenResult {
const query = new URLSearchParams();
query.set(BNPL_QUERY_TRANSACTION_ID, String(tx.bnplTransactionId));
query.set(BNPL_QUERY_REQUEST_ID, String(tx.bookingRequestId));
query.set(BNPL_QUERY_PROVIDER, tx.providerCode);
query.set(BNPL_QUERY_PLAN, tx.planId);
return {
bnplTransactionId: tx.bnplTransactionId,
paymentTransactionId: tx.paymentTransactionId,
status: tx.status,
externalPaymentToken: tx.externalPaymentToken,
// App-relative (the wizard prepends the locale); the real provider returns an absolute https URL.
redirectUrl: `${ROUTES.CHECKOUT_BNPL_GATEWAY}?${query.toString()}`,
};
}
/**
* In-memory mock behind the `BnplApi` seam — the whole D1D5 installment surface b12 doesn't serve
* (no provider/plan options, no repayment schedule, no provider-reported Wallet status → REQ-022/023/024).
* It reads the frozen request gross from the shared f7 store, plays the provider (eligibility verdict,
* plan math, token/redirect), and on settle (the down-payment-cleared analogue of the webhook) runs the
* SAME conversion bridge f9 uses — a settled BNPL order is a card payment net-of-fee — then seeds a
* provider-reported Wallet plan. Swap to the real `clientApi` once the upstream is real and the REQs land
* (`USE_BNPL_MOCK = false`).
*/
export const bnplMockApi: BnplApi = {
getBnplOptions: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const gross = requestGross(request);
const providers: BnplProvider[] = PROVIDER_TEMPLATES.map((p) => ({
providerCode: p.providerCode,
tagline: null,
plans: p.plans.map((plan) => toPlanOption(gross, plan)),
}));
const options: BnplOptions = {
bookingRequestId: request.id,
requestStatus: request.status,
orderAmountIrr: gross,
// The §1 branch flag — mock always eligible; D3 is the real gate. Never a client-side threshold.
bnplEligible: true,
providers,
};
return options;
},
checkEligibility: async ({ bookingRequestId, providerCode, nationalId }: CheckEligibilityInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const gross = parseIrr(requestGross(request));
const ceiling = parseIrr(MOCK_CREDIT_CEILING_IRR);
// Deterministic declined branches so a tester can exercise both (§7 step 3): last digit 0 → declined;
// an order above the ceiling → ceiling_exceeded. The real credit check is the provider's — the client
// only surfaces the verdict, never pre-judges it.
if (nationalId.endsWith(MOCK_NOT_ELIGIBLE_LAST_DIGIT)) {
return declined('not_eligible');
}
if (gross > ceiling) {
return declined('ceiling_exceeded');
}
const provider = PROVIDER_TEMPLATES.find((p) => p.providerCode === providerCode);
const installmentCount = provider?.plans[0]?.installmentCount ?? 4;
const result: BnplEligibilityResult = {
eligibilityStatus: 'eligible',
isEligible: true,
installmentCount,
planSummary: null,
creditCeilingIrr: MOCK_CREDIT_CEILING_IRR,
};
return result;
},
getBnplSchedule: async ({ bookingRequestId, providerCode, planId }: GetBnplScheduleInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const plan = findPlan(providerCode, planId);
const gross = requestGross(request);
const rows = buildScheduleRows(gross, plan);
const { total, downPayment } = planAmounts(gross, plan);
const schedule: BnplSchedule = {
bookingRequestId,
providerCode,
planId,
totalIrr: total.toString(),
downPaymentIrr: downPayment.toString(),
installmentCount: plan.installmentCount,
rows,
};
return schedule;
},
issueBnplToken: async ({ bookingRequestId, providerCode, planId, idempotencyKey }: IssueBnplTokenInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const existing = latestTxFor(bookingRequestId);
if (request.status === 'converted' || existing?.status === 'settled') {
throw new ApiError(409, 'Request already paid', 'already_paid');
}
if (request.status !== 'accepted_awaiting_payment') {
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
}
// b12 idempotency: a retried initiate with the same key reuses the token/order.
if (existing && existing.idempotencyKey === idempotencyKey && existing.status === 'token_issued') {
return toInitiateResult(existing);
}
const plan = findPlan(providerCode, planId);
const gross = requestGross(request);
const { total } = planAmounts(gross, plan);
const bnplTransactionId = nextBnplTxId++;
const tx: MockBnplTx = {
bnplTransactionId,
paymentTransactionId: nextPaymentTxId++,
bookingRequestId,
providerCode,
planId,
idempotencyKey,
status: 'token_issued',
externalPaymentToken: `mock-bnpl-token-${gross}-bnpl-br-${bookingRequestId}`,
orderAmountIrr: gross,
totalIrr: total.toString(),
installmentCount: plan.installmentCount,
schedule: buildScheduleRows(gross, plan),
bookingId: null,
createdAt: new Date().toISOString(),
};
transactions = [tx, ...transactions];
return toInitiateResult(tx);
},
acceptBnplSchedule: async ({ bookingRequestId, bnplTransactionId, outcome }: AcceptBnplScheduleInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const tx =
(bnplTransactionId != null ? transactions.find((t) => t.bnplTransactionId === bnplTransactionId) : undefined) ??
latestTxFor(bookingRequestId);
// Replays / double-taps converge (the webhook-dedup analogue): if settlement already happened, report it.
if (request.status === 'converted' || tx?.status === 'settled') {
return toSettlementResult(request, tx ?? fallbackTx(bookingRequestId));
}
if (!tx) throw new ApiError(404, 'BNPL order not found', 'not_found');
if (outcome === 'failure' || request.status !== 'accepted_awaiting_payment') {
// Provider declined the order, or the payment window lapsed during the handoff.
tx.status = 'failed';
return toSettlementResult(request, tx);
}
return settle(request, tx);
},
getBnplOrder: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const tx = latestTxFor(bookingRequestId);
if (!tx) throw new ApiError(404, 'BNPL order not found', 'not_found');
const order: BnplOrderStatus = {
id: tx.bnplTransactionId,
paymentTransactionId: tx.paymentTransactionId,
bookingId: request.bookingId ?? tx.bookingId,
providerCode: tx.providerCode,
status: tx.status,
eligibilityStatus: 'eligible',
orderAmountIrr: tx.orderAmountIrr,
settledAmountIrr: tx.status === 'settled' ? bookingAmounts(tx.orderAmountIrr).nursePayoutAmount : null,
bnplCommissionIrr: tx.status === 'settled' ? bookingAmounts(tx.orderAmountIrr).balinyaarCommissionIrr : null,
currency: 'IRR',
installmentCount: tx.installmentCount,
settledAt: tx.status === 'settled' ? new Date().toISOString() : null,
createdAt: tx.createdAt,
};
return order;
},
getWalletInstallments: async () => {
await sleep(MOCK_LATENCY_MS);
return walletPlans.map((p) => ({ ...p, installments: p.installments.map((r) => ({ ...r })) }));
},
};
function declined(status: 'not_eligible' | 'ceiling_exceeded'): BnplEligibilityResult {
return {
eligibilityStatus: status,
isEligible: false,
installmentCount: 0,
planSummary: null,
creditCeilingIrr: null,
};
}
/** A minimal tx stand-in for a settlement replay where the request already converted but no tx row survives. */
function fallbackTx(bookingRequestId: number): MockBnplTx {
return {
bnplTransactionId: 0,
paymentTransactionId: 0,
bookingRequestId,
providerCode: 'digipay',
planId: 'digipay_6m',
idempotencyKey: '',
status: 'settled',
externalPaymentToken: '',
orderAmountIrr: '0',
totalIrr: '0',
installmentCount: 0,
schedule: [],
bookingId: null,
createdAt: new Date().toISOString(),
};
}
+58
View File
@@ -0,0 +1,58 @@
/**
* When true, the BNPL domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `BnplApi`
* seam.
*
* **Mock is primary this phase.** b12 ships the eligibility/initiate/webhook/settle endpoints server-side,
* but the D1D5 checkout cannot run real end-to-end from the client yet:
* - the accepted request being financed comes from the **mock-primary** `bookingRequests` store (f7), so a
* real `initiate` would reference an id that exists only in memory (same reason f9 payment is mock-primary);
* - the contract serves **no provider/plan options** (D1/D2), **no repayment schedule** (D4 — the contract
* explicitly does not model the customer's repayment schedule), and **no provider-reported installment
* status** for the Wallet (D5) → REQ-022/023/024;
* - nothing fires the provider webhook in dev, so a real order would never settle.
* The mock closes the loop: the settle (down-payment cleared) converts the f7 request, inserts a **confirmed**
* booking into the f8 store (the SAME bridge f9 uses — a settled BNPL order is a card payment net-of-fee),
* and seeds a Wallet installment plan — so C6 → D1 → … → D4 → confirmation → D5 demos end-to-end. Flip to
* `false` once the upstream domains are real and REQ-022/023/024 land — no hook/component change.
*/
export const USE_BNPL_MOCK = true;
/** Options/eligibility/schedule are short-lived (prices/deadlines move between visits). */
export const BNPL_OPTIONS_STALE_TIME = 10 * 1000;
export const BNPL_SCHEDULE_STALE_TIME = 30 * 1000;
/** The provider-reported Wallet status is moderately fresh — a plan does not change second-to-second. */
export const BNPL_WALLET_STALE_TIME = 60 * 1000;
/** The settle poll after the provider return (mirrors f9's bounded backoff — «received ≠ settled»). */
export const BNPL_ORDER_POLL_BASE_MS = 2 * 1000;
export const BNPL_ORDER_POLL_GROWTH = 1.5;
export const BNPL_ORDER_POLL_MAX_MS = 15 * 1000;
export const BNPL_ORDER_POLL_MAX_ATTEMPTS = 40;
/** BNPL checkout deep-link query params (C6 hands off `request_id`; the handoff round-trip carries the rest). */
export const BNPL_QUERY_REQUEST_ID = 'request_id';
export const BNPL_QUERY_TRANSACTION_ID = 'transaction_id';
export const BNPL_QUERY_PROVIDER = 'provider';
export const BNPL_QUERY_PLAN = 'plan';
export const BNPL_QUERY_OUTCOME = 'outcome';
/** The `method` flag the reused f9 confirmation reads to render «پرداخت‌شده با اقساط». */
export const CHECKOUT_QUERY_METHOD = 'method';
export const CHECKOUT_METHOD_BNPL = 'bnpl';
/**
* National-ID format: 10 digits (client-side validation only — the real credit check is the provider's;
* D3 surfaces the provider's verdict, it never pre-judges eligibility).
*/
export const NATIONAL_ID_LENGTH = 10;
export const NATIONAL_ID_PATTERN = /^\d{10}$/;
/**
* Mock-only credit ceiling + declined-path triggers (see `apis/mockApi.ts`). The ceiling is what a real
* provider would return per its own credit model; the client never derives it. Declined paths are keyed
* deterministically off the entered national ID so a tester can exercise both branches (§7 step 3).
*/
export const MOCK_CREDIT_CEILING_IRR = '2000000000'; // 200,000,000 Toman (matches the contract example)
/** A national ID whose last digit is 0 → `not_eligible`; otherwise eligible unless the order exceeds the ceiling. */
export const MOCK_NOT_ELIGIBLE_LAST_DIGIT = '0';
@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { invalidateAfterBnplSettlement } from '../invalidations';
import { isBnplSettlementSuccess, type AcceptBnplScheduleInput } from '../types';
/**
* The settle-on-return mutation (the down-payment-cleared analogue of the provider webhook): reported once
* from the return surface. On a successful settlement it **invalidates the booking + checkout + Wallet
* queries** so the confirmed booking isn't refetched stale and D5 reflects the new plan (never a blanket
* refetch). The booking then confirms exactly as the card path — the return page routes to the reused f9
* confirmation.
*/
export function useAcceptBnplSchedule() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: AcceptBnplScheduleInput) => bnplApi.acceptBnplSchedule(input),
onSuccess: (result) => {
if (isBnplSettlementSuccess(result)) {
invalidateAfterBnplSettlement(queryClient, result.bookingRequestId, result.bookingId);
}
},
});
}
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { bnplKeys } from '../keys';
import { BNPL_OPTIONS_STALE_TIME } from '../constants';
/**
* D1/D2 — the payable amount + the provider/plan set for an accepted request. Short `staleTime`
* (the price/deadline can move between visits). Enabled only when an id is present.
*/
export function useBnplOptions(bookingRequestId: number | undefined) {
return useQuery({
queryKey: bnplKeys.options(bookingRequestId ?? -1),
queryFn: () => bnplApi.getBnplOptions(bookingRequestId as number),
enabled: bookingRequestId != null && bookingRequestId > 0,
staleTime: BNPL_OPTIONS_STALE_TIME,
});
}
@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { bnplKeys } from '../keys';
import {
BNPL_ORDER_POLL_BASE_MS,
BNPL_ORDER_POLL_GROWTH,
BNPL_ORDER_POLL_MAX_ATTEMPTS,
BNPL_ORDER_POLL_MAX_MS,
} from '../constants';
import { isTerminalBnplStatus } from '../types';
/**
* The BNPL order read + settle poll after the provider return. "Received ≠ settled" — settlement is not
* instant, so this polls with **geometric backoff** (base → ×growth → cap) and **stops** on any terminal
* status (`settled`/`reverted`/`cancelled`/`failed`) or after a bounded number of attempts — never a tight
* loop. The return surface offers a manual re-check once the budget is spent.
*/
export function useBnplOrder(bookingRequestId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: bnplKeys.order(bookingRequestId ?? -1),
queryFn: () => bnplApi.getBnplOrder(bookingRequestId as number),
enabled: (options?.enabled ?? true) && bookingRequestId != null && bookingRequestId > 0,
refetchInterval: (query) => {
const order = query.state.data;
if (order && isTerminalBnplStatus(order.status)) return false;
const attempt = query.state.dataUpdateCount;
if (attempt >= BNPL_ORDER_POLL_MAX_ATTEMPTS) return false;
return Math.min(BNPL_ORDER_POLL_BASE_MS * BNPL_ORDER_POLL_GROWTH ** attempt, BNPL_ORDER_POLL_MAX_MS);
},
});
}
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { bnplKeys } from '../keys';
import { BNPL_SCHEDULE_STALE_TIME } from '../constants';
import type { ProviderCode } from '../types';
/**
* D4 — the served repayment schedule (down-payment + N installments, amounts + due dates) for the chosen
* plan. **Amounts are server-served** (the client never derives a schedule amount); only Shamsi date
* display is a client concern. Enabled once provider + plan are chosen.
*/
export function useBnplSchedule(
bookingRequestId: number | undefined,
providerCode: ProviderCode | undefined,
planId: string | undefined,
) {
const enabled = bookingRequestId != null && bookingRequestId > 0 && providerCode != null && planId != null;
return useQuery({
queryKey: bnplKeys.schedule(bookingRequestId ?? -1, providerCode ?? '', planId ?? ''),
queryFn: () =>
bnplApi.getBnplSchedule({
bookingRequestId: bookingRequestId as number,
providerCode: providerCode as ProviderCode,
planId: planId as string,
}),
enabled,
staleTime: BNPL_SCHEDULE_STALE_TIME,
});
}
@@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import type { CheckEligibilityInput } from '../types';
/**
* D3 — the provider credit check. The consent checkbox gates the submit at the call site (no consent, no
* mutation). The result (`eligible` / `not_eligible` / `ceiling_exceeded`) drives the D3 panel; a declined
* verdict is not an error — it renders the declined panel + the card fall-back, not a toast. The fetch
* layer owns 401/403/5xx; only domain 4xx (e.g. a token-expired) would surface inline via `mutation.error`.
*/
export function useCheckEligibility() {
return useMutation({
mutationFn: (input: CheckEligibilityInput) => bnplApi.checkEligibility(input),
});
}
@@ -0,0 +1,16 @@
import { useMutation } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import type { IssueBnplTokenInput } from '../types';
/**
* D4 final action — start the BNPL order and issue the provider token/redirect (contract `initiate`). The
* caller owns the **per-attempt idempotency key** (a retried initiate reuses the same token; a new attempt
* gets a new key), mirroring the f9 card initiate. On success the caller follows `redirectUrl` (the provider
* handoff). A `409` here means "already paid / already in progress / window lapsed" — the wizard converges
* (reads the order) instead of toasting; other domain 4xx render inline.
*/
export function useIssueBnplToken() {
return useMutation({
mutationFn: (input: IssueBnplTokenInput) => bnplApi.issueBnplToken(input),
});
}
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { bnplApi } from '../apis';
import { bnplKeys } from '../keys';
import { BNPL_WALLET_STALE_TIME } from '../constants';
/**
* D5 — the customer's active installment plans, **provider-reported** (outstanding balance, next due date,
* per-installment due list). Moderate `staleTime`: a plan does not change second-to-second, and it is
* invalidated on settlement. This is a **read of provider status**, never a Balinyaar-managed ledger —
* "early pay" hands off to the provider (D5), it is not a Balinyaar transaction.
*/
export function useWalletInstallments() {
return useQuery({
queryKey: bnplKeys.walletInstallments(),
queryFn: () => bnplApi.getWalletInstallments(),
staleTime: BNPL_WALLET_STALE_TIME,
});
}
+11
View File
@@ -0,0 +1,11 @@
/**
* BNPL domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis/constants directly from their files when needed.
*/
export { useBnplOptions } from './hooks/useBnplOptions';
export { useCheckEligibility } from './hooks/useCheckEligibility';
export { useBnplSchedule } from './hooks/useBnplSchedule';
export { useIssueBnplToken } from './hooks/useIssueBnplToken';
export { useAcceptBnplSchedule } from './hooks/useAcceptBnplSchedule';
export { useBnplOrder } from './hooks/useBnplOrder';
export { useWalletInstallments } from './hooks/useWalletInstallments';
+21
View File
@@ -0,0 +1,21 @@
import type { QueryClient } from '@tanstack/react-query';
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
import { bnplKeys } from './keys';
/**
* The cache transition a settled BNPL order causes: the request flipped `converted`, a confirmed booking
* now exists, and a new provider-reported installment plan is live. A settled BNPL order is, to Balinyaar,
* a card payment net-of-fee — so it invalidates exactly the same booking/request/checkout keys the card
* capture does (reusing `invalidateAfterPaymentSuccess`), **plus** the BNPL order + the Wallet installment
* status (so D5 reflects the new plan) — never a blanket refetch. Shared by `useAcceptBnplSchedule` and the
* return-page settle poll.
*/
export function invalidateAfterBnplSettlement(
queryClient: QueryClient,
bookingRequestId: number,
bookingId: number | null,
): void {
invalidateAfterPaymentSuccess(queryClient, bookingRequestId, bookingId);
queryClient.invalidateQueries({ queryKey: bnplKeys.order(bookingRequestId) });
queryClient.invalidateQueries({ queryKey: bnplKeys.walletInstallments() });
}
+15
View File
@@ -0,0 +1,15 @@
/**
* React Query key factory for the BNPL domain (hierarchical, per the `services/{domain}` pattern).
* Options / eligibility / schedule / order all run against the accepted booking-request id; the Wallet
* installment status (D5) is a customer-wide read (no id).
*/
export const bnplKeys = {
all: ['bnpl'] as const,
options: (bookingRequestId: number) => [...bnplKeys.all, 'options', bookingRequestId] as const,
eligibility: (bookingRequestId: number) => [...bnplKeys.all, 'eligibility', bookingRequestId] as const,
schedule: (bookingRequestId: number, providerCode: string, planId: string) =>
[...bnplKeys.all, 'schedule', bookingRequestId, providerCode, planId] as const,
orders: () => [...bnplKeys.all, 'order'] as const,
order: (bookingRequestId: number) => [...bnplKeys.orders(), bookingRequestId] as const,
walletInstallments: () => [...bnplKeys.all, 'wallet-installments'] as const,
};
+264
View File
@@ -0,0 +1,264 @@
import type { BookingRequestStatus } from '@/services/bookingRequests/types';
import type { StatusKind } from '@/components/StatusChip/StatusChip';
/**
* BNPL domain — the installment-checkout alternative to the f9 card flow (b12). Shapes mirror the
* published contract (`dev/contracts/domains/bnpl.md`; camelCase wire, `clientFetch` unwraps the
* `ApiEnvelope<T>`), extended with the D1D5 read surfaces the contract does not yet serve (filed as
* REQ-022/023/024 — see `apis/clientApi.ts`).
*
* The load-bearing product truth every type here encodes
* ([product/business/09](../../../../product/business/09-installments-bnpl.md)):
* - **The installment repayment is OWNED BY THE PROVIDER, not Balinyaar.** The provider pays Balinyaar
* the full order amount up-front and bears 100% of the customer's default risk. In Balinyaar's books a
* settled BNPL order is **identical to a card payment that lands net-of-fee in one inbound settlement**.
* - So the Wallet view (D5) renders **provider-reported** status (`WalletInstallmentPlan`) — never a
* Balinyaar-managed ledger, and Balinyaar never settles a customer installment ("early pay" hands off
* to the provider).
* - **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number; parse with the
* `@/utils` BigInt helpers and display as Toman via `formatIrrToToman`. The client renders whatever the
* contract returns — it never computes a fee, ceiling, monthly amount, or schedule amount itself
* (they are per-contract/per-provider config; the mock plays the server here).
*/
/** `bnpl_transactions.status` (b12 contract enum) — forward-only. */
export type BnplStatus = 'eligible' | 'token_issued' | 'verified' | 'settled' | 'reverted' | 'cancelled' | 'failed';
/** `bnpl_transactions.eligibility_status` (b12) — anything but `eligible` falls back to card. */
export type BnplEligibilityStatus = 'eligible' | 'not_eligible' | 'ceiling_exceeded';
/**
* `provider_code` — selects the provider adapter. The b12 contract enum is
* `snapppay | digipay | tara | torobpay`; `balinyaar` is the in-house plan the wireframe shows
* (اقساط بالین‌یار) which the contract enum does not yet carry (flagged in REQ-022). Provider **display
* names** are i18n keys off this code (`bnpl.provider_{code}`) — never derived from the code text.
*/
export type ProviderCode = 'snapppay' | 'digipay' | 'tara' | 'torobpay' | 'balinyaar';
/** What the provider handoff reports back on return (mock harness); the real provider signals via webhook. */
export type BnplHandoffOutcome = 'success' | 'failure';
/**
* A single installment plan a provider offers (D2). **All amounts are served IRR digit-strings** — the
* client renders them, it never computes a monthly amount, down-payment, or total. `feePercent` /
* `downPaymentPercent` are informational fractions the plan card shows (`کارمزد ۴٪`, `پیش‌پرداخت ۲۰٪`).
*/
export interface BnplPlanOption {
/** Stable id for single-select + the handoff (e.g. `digipay_6m`). */
planId: string;
/** 3/6/12 for monthly plans; `null` for an N-installment plan (اسنپ‌پی ۴ قسط). */
termMonths: number | null;
installmentCount: number;
/** Provider fee as a fraction (0 = بدون سود / interest-free) — display only. */
feePercent: number;
/** Down-payment as a fraction of the total (0..1) — display only. */
downPaymentPercent: number;
/** Per-installment amount (IRR digit-string, served). */
monthlyAmountIrr: string;
/** The پیش‌پرداخت due today (IRR digit-string, served). */
downPaymentIrr: string;
/** What the customer repays in total across down-payment + installments (IRR digit-string, served). */
totalIrr: string;
}
/** A provider and the plans it offers for this order (D1/D2). Rendered from the contract — never hardcoded. */
export interface BnplProvider {
providerCode: ProviderCode;
/** Provider-reported one-line plan headline (informational; the surrounding copy is i18n). */
tagline: string | null;
plans: BnplPlanOption[];
}
/**
* The D1/D2 payload — the payable amount + the provider/plan set. `bnplEligible` is the §1 branch-note
* flag: when `false` the installment options are hidden and only card shows (the mock always returns
* `true` and lets D3 eligibility be the real gate).
*/
export interface BnplOptions {
bookingRequestId: number;
requestStatus: BookingRequestStatus;
/** The payable gross (D1 «مبلغ قابل پرداخت») — IRR digit-string. */
orderAmountIrr: string;
bnplEligible: boolean;
providers: BnplProvider[];
}
/** D3 credit-check inputs. National-ID validation is client-side format only (10 digits); the real check is the provider's. */
export interface CheckEligibilityInput {
bookingRequestId: number;
providerCode: ProviderCode;
/** 10-digit national ID (کد ملی). */
nationalId: string;
/** Mobile, prefilled from the session. */
mobile: string;
/** Gates the submit — no consent, no request. */
consent: boolean;
}
/** D3 result — the provider's verdict + (on approval) the usable credit ceiling. */
export interface BnplEligibilityResult {
eligibilityStatus: BnplEligibilityStatus;
isEligible: boolean;
installmentCount: number;
/** Provider-served plan summary (informational; may be `null`). */
planSummary: string | null;
/** سقف اعتبار — IRR digit-string, or `null` when declined. */
creditCeilingIrr: string | null;
}
/** One row of the D4 repayment table / D5 due list. */
export interface BnplInstallmentRow {
/** 0 = پیش‌پرداخت (down-payment), 1..N = قسط. */
sequence: number;
kind: 'down_payment' | 'installment';
/** ISO date `YYYY-MM-DD` (Shamsi display is a client concern). The down-payment row renders «امروز». */
dueDate: string;
amountIrr: string;
/** Provider-reported per-row status — present in D5 (Wallet), absent in the D4 preview. */
status?: BnplInstallmentStatus;
}
/** The D4 repayment schedule — served, not client-derived (amounts + due dates are provider-owned). */
export interface BnplSchedule {
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
totalIrr: string;
downPaymentIrr: string;
installmentCount: number;
/** `[down_payment, installment×N]`. */
rows: BnplInstallmentRow[];
}
export interface GetBnplScheduleInput {
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
}
/** D4 final action — start the BNPL order + issue the provider token/redirect (contract `initiate`). */
export interface IssueBnplTokenInput {
bookingRequestId: number;
providerCode: ProviderCode;
planId: string;
/** Stable per attempt (b12 `Idempotency-Key` header); a retried initiate reuses the same token. */
idempotencyKey: string;
}
/** `initiate` result (b12) — where to hand the customer off, and the provider references. */
export interface IssueBnplTokenResult {
bnplTransactionId: number;
paymentTransactionId: number;
status: BnplStatus;
externalPaymentToken: string | null;
/** Absolute provider URL on the real path; an app-relative provider-harness path from the mock. */
redirectUrl: string | null;
}
/** The provider-return settle input (down-payment cleared). The real path settles via webhook; on return the client reads the order. */
export interface AcceptBnplScheduleInput {
bookingRequestId: number;
bnplTransactionId: number | null;
outcome: BnplHandoffOutcome;
}
/**
* What the return-from-provider surface reports: the order after the settle attempt, plus which booking
* the settlement confirmed (client-augmented — the b12 contract sets `bookingId` at settle, but the
* customer-readable link is filed as REQ-024). On success the booking confirms exactly as the card path.
*/
export interface BnplSettlementResult {
bnplTransactionId: number;
bookingRequestId: number;
requestStatus: BookingRequestStatus;
status: BnplStatus;
bookingId: number | null;
providerCode: ProviderCode;
}
/** `BnplOrderStatus` (b12 `GET checkout_bnpl/{id}`) — the customer's own order, read for the return poll. */
export interface BnplOrderStatus {
id: number;
paymentTransactionId: number;
bookingId: number | null;
providerCode: ProviderCode;
status: BnplStatus;
eligibilityStatus: BnplEligibilityStatus | null;
orderAmountIrr: string;
settledAmountIrr: string | null;
bnplCommissionIrr: string | null;
currency: string;
installmentCount: number;
/** Nullable — settlement is contract-defined and not instant. */
settledAt: string | null;
createdAt: string;
}
/** Provider-reported per-installment status (D5). */
export type BnplInstallmentStatus = 'paid' | 'due_soon' | 'upcoming' | 'overdue';
/**
* The D5 Wallet view of an active installment plan — **provider-reported status, not a Balinyaar ledger**.
* The contract models none of this (installment_count is informational; the repayment schedule is
* explicitly out of scope), so the whole shape is a client/REQ-024 gap the mock fills.
*/
export interface WalletInstallmentPlan {
bnplTransactionId: number;
bookingId: number | null;
providerCode: ProviderCode;
status: BnplStatus;
/** Human context for the plan (e.g. the service/nurse the order paid for). */
serviceLabel: string;
/** مانده بدهی — IRR digit-string. */
outstandingBalanceIrr: string;
installmentCount: number;
/** قسط بعدی — ISO date, or `null` when nothing is outstanding. */
nextDueDate: string | null;
nextAmountIrr: string | null;
/** Provider-reported due list with per-row status. */
installments: BnplInstallmentRow[];
/** Hand-off link for پرداخت زودهنگام — early-pay is a **provider** action, never a Balinyaar transaction. */
earlyPayUrl: string | null;
createdAt: string;
}
/** Forward-only terminal check for the return poll. */
const TERMINAL_BNPL_STATUSES: readonly BnplStatus[] = ['settled', 'reverted', 'cancelled', 'failed'];
export function isTerminalBnplStatus(status: BnplStatus): boolean {
return TERMINAL_BNPL_STATUSES.includes(status);
}
/** A settled order (or a converted request) — the booking confirmed; nothing left to poll on the return. */
export function isBnplSettlementSuccess(result: BnplSettlementResult): boolean {
return result.status === 'settled' || result.requestStatus === 'converted';
}
/** Maps a provider-reported installment status to the shared StatusChip kind (D5 due list). */
export function installmentStatusKind(status: BnplInstallmentStatus): StatusKind {
switch (status) {
case 'paid':
return 'verified';
case 'due_soon':
return 'pending';
case 'overdue':
return 'rejected';
case 'upcoming':
default:
return 'neutral';
}
}
/**
* The BNPL API seam — the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_BNPL_MOCK`), never scattered `if (mock)` checks.
*/
export interface BnplApi {
getBnplOptions(bookingRequestId: number): Promise<BnplOptions>;
checkEligibility(input: CheckEligibilityInput): Promise<BnplEligibilityResult>;
getBnplSchedule(input: GetBnplScheduleInput): Promise<BnplSchedule>;
issueBnplToken(input: IssueBnplTokenInput): Promise<IssueBnplTokenResult>;
acceptBnplSchedule(input: AcceptBnplScheduleInput): Promise<BnplSettlementResult>;
getBnplOrder(bookingRequestId: number): Promise<BnplOrderStatus>;
getWalletInstallments(): Promise<WalletInstallmentPlan[]>;
}
+3 -3
View File
@@ -17,10 +17,10 @@
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.
* f11 wired the BNPL method screens (D1D5): C6's «پرداخت اقساطی» seam now navigates into the installment
* wizard (`ROUTES.CHECKOUT_BNPL`) instead of rendering a disabled "coming soon" secondary.
*/
export const BNPL_ENABLED = false;
export const BNPL_ENABLED = true;
/** Prices/deadlines can move between visits — keep the checkout summary short-lived. */
export const CHECKOUT_SUMMARY_STALE_TIME = 10 * 1000;