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
+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(),
};
}