540 lines
21 KiB
TypeScript
540 lines
21 KiB
TypeScript
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(),
|
||
};
|
||
}
|