frontend phase 11
This commit is contained in:
@@ -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`)),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 D1–D5 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(),
|
||||
};
|
||||
}
|
||||
@@ -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 D1–D5 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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 D1–D5 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[]>;
|
||||
}
|
||||
@@ -17,10 +17,10 @@
|
||||
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.
|
||||
* f11 wired the BNPL method screens (D1–D5): 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;
|
||||
|
||||
Reference in New Issue
Block a user