frontend phase 11
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user