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