Files
baya-monorepo/client/src/services/bnpl/apis/mockApi.ts
T
2026-07-10 13:55:42 +03:30

540 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
};
}