From 9d28c253b75f47531719c4eb4baa976f320e81c1 Mon Sep 17 00:00:00 2001 From: sa-salami <332581866+sa-salami@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:15:56 +0100 Subject: [PATCH] feat(server): reconcile virtual account deposits --- server/.env.example | 9 + server/src/jobs/index.ts | 14 ++ .../jobs/virtualAccountReconciliation.test.ts | 96 +++++++++ .../src/jobs/virtualAccountReconciliation.ts | 193 ++++++++++++++++++ server/src/services/virtualAccount.test.ts | 79 +++++++ server/src/services/virtualAccount.ts | 89 ++++++++ 6 files changed, 480 insertions(+) create mode 100644 server/src/jobs/virtualAccountReconciliation.test.ts create mode 100644 server/src/jobs/virtualAccountReconciliation.ts diff --git a/server/.env.example b/server/.env.example index 8bc1131..f80f0b9 100644 --- a/server/.env.example +++ b/server/.env.example @@ -176,3 +176,12 @@ OTEL_SERVICE_NAME=airflex-server # [OPTIONAL] Set to "false" to disable telemetry instrumentation (e.g. in test runs). Defaults to true. OTEL_ENABLED=true + +# --------------------------------------------------------------------------- +# Paystack virtual-account deposit reconciliation (issue #492) +# --------------------------------------------------------------------------- +# [OPTIONAL] Reconciliation frequency in milliseconds (minimum 60000). Defaults to one hour. +VIRTUAL_ACCOUNT_RECONCILIATION_INTERVAL_MS=3600000 + +# [OPTIONAL] Per-deposit amount difference tolerated in Naira. Defaults to 0. +VIRTUAL_ACCOUNT_RECONCILIATION_TOLERANCE_NAIRA=0 diff --git a/server/src/jobs/index.ts b/server/src/jobs/index.ts index de65233..0a3202e 100644 --- a/server/src/jobs/index.ts +++ b/server/src/jobs/index.ts @@ -17,6 +17,10 @@ import { generateDataExportProcessor, purgeExpiredDataExports } from "./processo import { purgeExpiredAuditLogs } from "./auditLogRetention"; import { schedule, ONE_DAY_MS } from "./cron"; import { checkKycDocumentExpiry } from "./kycExpiry"; +import { + getVirtualAccountReconciliationIntervalMs, + reconcileVirtualAccountDeposits, +} from "./virtualAccountReconciliation"; /** Initialise the queue service and register all processors. */ export function initJobQueue(): void { @@ -37,6 +41,16 @@ export function initJobQueue(): void { schedule({ name: "audit-log-retention", intervalMs: ONE_DAY_MS, run: () => purgeExpiredAuditLogs() }); schedule({ name: "data-export-cleanup", intervalMs: ONE_DAY_MS, run: () => purgeExpiredDataExports() }); schedule({ name: "kyc-document-expiry", intervalMs: ONE_DAY_MS, run: checkKycDocumentExpiry }); + + // The Paystack customer transaction API is only available when credentials + // are configured; reconciliation itself is read-only and safe to rerun. + if (process.env["PAYSTACK_SECRET_KEY"]) { + schedule({ + name: "virtual-account-reconciliation", + intervalMs: getVirtualAccountReconciliationIntervalMs(), + run: reconcileVirtualAccountDeposits, + }); + } } // Re-export queue primitives so route/service files only import from here diff --git a/server/src/jobs/virtualAccountReconciliation.test.ts b/server/src/jobs/virtualAccountReconciliation.test.ts new file mode 100644 index 0000000..da97d50 --- /dev/null +++ b/server/src/jobs/virtualAccountReconciliation.test.ts @@ -0,0 +1,96 @@ +import pool from "../db"; +import logger from "../utils/logger"; +import { listDedicatedAccountTransactions } from "../services/virtualAccount"; +import { + compareDepositReferences, + getVirtualAccountReconciliationIntervalMs, + reconcileVirtualAccountDeposits, +} from "./virtualAccountReconciliation"; + +jest.mock("../db", () => ({ __esModule: true, default: { query: jest.fn() } })); +jest.mock("../utils/logger", () => ({ + __esModule: true, + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), fatal: jest.fn(), debug: jest.fn() }, +})); +jest.mock("../services/virtualAccount", () => ({ + listDedicatedAccountTransactions: jest.fn(), + maskAccountNumber: jest.fn(() => "******1234"), +})); + +const mockPool = pool as unknown as { query: jest.Mock }; +const mockListTransactions = listDedicatedAccountTransactions as jest.Mock; + +describe("virtual account reconciliation", () => { + const originalInterval = process.env["VIRTUAL_ACCOUNT_RECONCILIATION_INTERVAL_MS"]; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env["VIRTUAL_ACCOUNT_RECONCILIATION_TOLERANCE_NAIRA"]; + process.env["VIRTUAL_ACCOUNT_RECONCILIATION_INTERVAL_MS"] = "1800000"; + }); + + afterAll(() => { + if (originalInterval === undefined) { + delete process.env["VIRTUAL_ACCOUNT_RECONCILIATION_INTERVAL_MS"]; + } else { + process.env["VIRTUAL_ACCOUNT_RECONCILIATION_INTERVAL_MS"] = originalInterval; + } + }); + + it("compares references and tolerates configured minor-unit differences", () => { + const mismatches = compareDepositReferences( + [ + { reference: "same", amount: 10000, currency: "NGN", channel: "dedicated_nuban", status: "success" }, + { reference: "provider-only", amount: 5000, currency: "NGN", channel: "dedicated_nuban", status: "success" }, + ], + [ + { external_reference: "same", amount_kobo: "10050" }, + { external_reference: "local-only", amount_kobo: "2500" }, + ], + 100n + ); + + expect(mismatches).toEqual([ + { reference: "provider-only", kind: "provider_only", providerAmountKobo: "5000" }, + { reference: "local-only", kind: "local_only", localAmountKobo: "2500" }, + ]); + }); + + it("logs mismatches without changing ledger data and reports run totals", async () => { + mockPool.query + .mockResolvedValueOnce({ + rows: [{ + user_id: "user-1", + paystack_customer_code: "CUS_1", + virtual_account_number: "0123451234", + }], + }) + .mockResolvedValueOnce({ + rows: [{ external_reference: "deposit-local", amount_kobo: "20000" }], + }); + mockListTransactions.mockResolvedValue([ + { reference: "deposit-local", amount: 21000, currency: "NGN", channel: "dedicated_nuban", status: "success" }, + { reference: "deposit-provider", amount: 5000, currency: "NGN", channel: "dedicated_nuban", status: "success" }, + ]); + + await expect(reconcileVirtualAccountDeposits()).resolves.toEqual({ + accountsScanned: 1, + accountsReconciled: 1, + mismatches: 2, + errors: 0, + }); + expect(mockPool.query).toHaveBeenCalledTimes(2); + expect(mockPool.query.mock.calls.every(([query]) => String(query).trimStart().startsWith("SELECT"))).toBe(true); + expect(logger.warn).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ accountsScanned: 1, accountsReconciled: 1, mismatches: 2, errors: 0 }), + "[virtual-account-reconciliation] Finished" + ); + }); + + it("uses a configurable reconciliation interval", () => { + expect(getVirtualAccountReconciliationIntervalMs()).toBe(1_800_000); + process.env["VIRTUAL_ACCOUNT_RECONCILIATION_INTERVAL_MS"] = "1"; + expect(getVirtualAccountReconciliationIntervalMs()).toBe(60_000); + }); +}); diff --git a/server/src/jobs/virtualAccountReconciliation.ts b/server/src/jobs/virtualAccountReconciliation.ts new file mode 100644 index 0000000..fe6a75f --- /dev/null +++ b/server/src/jobs/virtualAccountReconciliation.ts @@ -0,0 +1,193 @@ +import pool from "../db"; +import logger from "../utils/logger"; +import { + listDedicatedAccountTransactions, + maskAccountNumber, + type PaystackDvaTransaction, +} from "../services/virtualAccount"; + +const DEFAULT_INTERVAL_MS = 60 * 60 * 1_000; +const MIN_INTERVAL_MS = 60_000; +const KOBO_PER_NAIRA = 100n; + +export interface VirtualAccountReconciliationResult { + accountsScanned: number; + accountsReconciled: number; + mismatches: number; + errors: number; +} + +export interface LocalDepositReference { + external_reference: string; + amount_kobo: string; +} + +export interface ReconciliationMismatch { + reference: string; + kind: "provider_only" | "local_only" | "amount_mismatch"; + providerAmountKobo?: string; + localAmountKobo?: string; +} + +function positiveInteger(value: string | undefined, fallback: number): number { + if (!value?.trim()) return fallback; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +export function getVirtualAccountReconciliationIntervalMs(): number { + return Math.max( + MIN_INTERVAL_MS, + positiveInteger(process.env["VIRTUAL_ACCOUNT_RECONCILIATION_INTERVAL_MS"], DEFAULT_INTERVAL_MS) + ); +} + +function getToleranceKobo(): bigint { + const raw = process.env["VIRTUAL_ACCOUNT_RECONCILIATION_TOLERANCE_NAIRA"]; + if (!raw?.trim()) return 0n; + const naira = Number(raw); + const kobo = naira * Number(KOBO_PER_NAIRA); + return Number.isFinite(kobo) && Number.isSafeInteger(kobo) && kobo >= 0 + ? BigInt(Math.round(kobo)) + : 0n; +} + +/** Compare provider payments with local deposit references without mutating either ledger. */ +export function compareDepositReferences( + providerTransactions: PaystackDvaTransaction[], + localDeposits: LocalDepositReference[], + toleranceKobo: bigint +): ReconciliationMismatch[] { + const providerByReference = new Map( + providerTransactions.map((transaction) => [transaction.reference, BigInt(transaction.amount)]) + ); + const localByReference = new Map( + localDeposits.map((deposit) => [deposit.external_reference, BigInt(deposit.amount_kobo)]) + ); + const mismatches: ReconciliationMismatch[] = []; + + for (const [reference, providerAmount] of providerByReference) { + const localAmount = localByReference.get(reference); + if (localAmount === undefined) { + mismatches.push({ reference, kind: "provider_only", providerAmountKobo: String(providerAmount) }); + } else if (providerAmount - localAmount > toleranceKobo || localAmount - providerAmount > toleranceKobo) { + mismatches.push({ + reference, + kind: "amount_mismatch", + providerAmountKobo: String(providerAmount), + localAmountKobo: String(localAmount), + }); + } + } + + for (const [reference, localAmount] of localByReference) { + if (!providerByReference.has(reference)) { + mismatches.push({ reference, kind: "local_only", localAmountKobo: String(localAmount) }); + } + } + + return mismatches; +} + +/** + * Reconciles successful Paystack DVA deposit references against local ledger + * references. This is read-only: reruns can repeat alerts, but cannot duplicate + * credits or otherwise change wallet balances. + */ +export async function reconcileVirtualAccountDeposits(): Promise { + const startedAt = Date.now(); + const toleranceKobo = getToleranceKobo(); + const result: VirtualAccountReconciliationResult = { + accountsScanned: 0, + accountsReconciled: 0, + mismatches: 0, + errors: 0, + }; + + logger.info( + { toleranceKobo: String(toleranceKobo), intervalMs: getVirtualAccountReconciliationIntervalMs() }, + "[virtual-account-reconciliation] Started" + ); + + const { rows: accounts } = await pool.query<{ + user_id: string; + paystack_customer_code: string; + virtual_account_number: string; + }>( + `SELECT id AS user_id, paystack_customer_code, virtual_account_number + FROM users + WHERE paystack_customer_code IS NOT NULL + AND virtual_account_number IS NOT NULL + ORDER BY id` + ); + result.accountsScanned = accounts.length; + + for (const account of accounts) { + try { + const providerTransactions = await listDedicatedAccountTransactions( + account.paystack_customer_code + ); + if (providerTransactions === null) { + result.mismatches += 1; + logger.warn( + { + userId: account.user_id, + accountNumber: maskAccountNumber(account.virtual_account_number), + kind: "provider_account_inactive", + }, + "[virtual-account-reconciliation] Provider account is inactive" + ); + continue; + } + + const { rows: localDeposits } = await pool.query( + `SELECT t.external_reference, + (t.amount * 100)::bigint::text AS amount_kobo + FROM transactions t + JOIN payment_events pe + ON pe.provider = 'paystack' + AND pe.reference = t.external_reference + WHERE t.user_id = $1 + AND t.direction = 'credit' + AND t.type = 'deposit' + AND t.external_reference IS NOT NULL + AND COALESCE( + pe.payload #>> '{data,authorization,channel}', + pe.payload #>> '{data,channel}' + ) = 'dedicated_nuban'`, + [account.user_id] + ); + + const mismatches = compareDepositReferences( + providerTransactions, + localDeposits, + toleranceKobo + ); + result.accountsReconciled += 1; + result.mismatches += mismatches.length; + + for (const mismatch of mismatches) { + logger.warn( + { + userId: account.user_id, + accountNumber: maskAccountNumber(account.virtual_account_number), + ...mismatch, + }, + "[virtual-account-reconciliation] Deposit mismatch" + ); + } + } catch (err) { + result.errors += 1; + logger.error( + { userId: account.user_id, err: (err as Error).message }, + "[virtual-account-reconciliation] Account check failed" + ); + } + } + + logger.info( + { ...result, durationMs: Date.now() - startedAt }, + "[virtual-account-reconciliation] Finished" + ); + return result; +} diff --git a/server/src/services/virtualAccount.test.ts b/server/src/services/virtualAccount.test.ts index 9c3e7b6..1211902 100644 --- a/server/src/services/virtualAccount.test.ts +++ b/server/src/services/virtualAccount.test.ts @@ -8,6 +8,7 @@ import { createDedicatedVirtualAccount, ensurePaystackCustomer, + listDedicatedAccountTransactions, PaystackTimeoutError, PAYSTACK_TIMEOUT_MS, } from "./virtualAccount"; @@ -149,4 +150,82 @@ describe("virtualAccount Paystack timeout", () => { await expect(assertion).resolves.toBe(networkErr); }); + + it("lists only successful NGN dedicated-account payments for the customer", async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ status: true, data: { id: 42, dedicated_account: { active: true } } }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + status: true, + data: [ + { reference: "dva-1", amount: 12500, currency: "NGN", channel: "dedicated_nuban", status: "success" }, + { reference: "card-1", amount: 9000, currency: "NGN", channel: "card", status: "success" }, + { reference: "dva-usd", amount: 500, currency: "USD", channel: "dedicated_nuban", status: "success" }, + ], + }), + }); + (global as unknown as { fetch: jest.Mock }).fetch = fetchMock; + + await expect(listDedicatedAccountTransactions("CUS_42")).resolves.toEqual([ + { reference: "dva-1", amount: 12500, currency: "NGN", channel: "dedicated_nuban", status: "success" }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1]![0])).toContain("customer=42"); + }); + + it("does not list transactions for an inactive Paystack virtual account", async () => { + const fetchMock = jest.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ status: true, data: { id: 42, dedicated_account: { active: false } } }), + }); + (global as unknown as { fetch: jest.Mock }).fetch = fetchMock; + + await expect(listDedicatedAccountTransactions("CUS_42")).resolves.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("follows Paystack transaction pages for customers with long histories", async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + reference: `dva-${index}`, + amount: 1000, + currency: "NGN", + channel: "dedicated_nuban", + status: "success", + })); + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ status: true, data: { id: 42, dedicated_account: { active: true } } }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ status: true, data: firstPage, meta: { pageCount: 2 } }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + status: true, + data: [{ reference: "dva-last", amount: 1500, currency: "NGN", channel: "dedicated_nuban", status: "success" }], + meta: { pageCount: 2 }, + }), + }); + (global as unknown as { fetch: jest.Mock }).fetch = fetchMock; + + const transactions = await listDedicatedAccountTransactions("CUS_42"); + expect(transactions).toHaveLength(101); + expect(transactions?.at(-1)?.reference).toBe("dva-last"); + expect(String(fetchMock.mock.calls[2]![0])).toContain("page=2"); + }); }); diff --git a/server/src/services/virtualAccount.ts b/server/src/services/virtualAccount.ts index b4a37cb..ab1217c 100644 --- a/server/src/services/virtualAccount.ts +++ b/server/src/services/virtualAccount.ts @@ -109,9 +109,98 @@ interface PaystackCustomerResponse { data: { customer_code: string; email: string; + id?: number; + dedicated_account?: { active?: boolean | number } | null; }; } +export interface PaystackDvaTransaction { + reference: string; + amount: number; + currency: string; + channel: string; + status: string; +} + +interface PaystackTransactionListResponse { + status: boolean; + data: PaystackDvaTransaction[]; + meta?: { pageCount?: number }; +} + +/** + * Lists successful NGN deposits for an active dedicated virtual account. + * Paystack exposes customer transactions rather than a current DVA balance; + * callers can reconcile these payment references against the local deposit + * ledger without changing wallet balances. + */ +export async function listDedicatedAccountTransactions( + customerCode: string +): Promise { + const paystackKey = process.env["PAYSTACK_SECRET_KEY"]; + if (!paystackKey) throw new Error("PAYSTACK_SECRET_KEY is not set"); + + const headers = { Authorization: `Bearer ${paystackKey}` }; + const customerResponse = await paystackFetch( + "fetch customer for reconciliation", + `https://api.paystack.co/customer/${encodeURIComponent(customerCode)}`, + { method: "GET", headers } + ); + if (!customerResponse.ok) { + throw new Error(`Paystack customer lookup failed [${customerResponse.status}]`); + } + + const customer = (await customerResponse.json()) as PaystackCustomerResponse; + const customerId = customer.data?.id; + const accountActive = customer.data?.dedicated_account?.active; + if (accountActive !== true && accountActive !== 1) return null; + if (!Number.isSafeInteger(customerId) || !customerId || customerId < 1) { + throw new Error("Paystack customer response is missing a valid customer ID"); + } + + const pageSize = 100; + const maxPages = 1_000; + const transactions: PaystackDvaTransaction[] = []; + for (let page = 1; page <= maxPages; page++) { + const url = new URL("https://api.paystack.co/transaction"); + url.searchParams.set("customer", String(customerId)); + url.searchParams.set("status", "success"); + url.searchParams.set("perPage", String(pageSize)); + url.searchParams.set("page", String(page)); + + const response = await paystackFetch("list customer transactions", url.toString(), { + method: "GET", + headers, + }); + if (!response.ok) { + throw new Error(`Paystack transaction lookup failed [${response.status}]`); + } + + const result = (await response.json()) as PaystackTransactionListResponse; + if (!result.status || !Array.isArray(result.data)) { + throw new Error("Paystack returned an invalid customer transaction response"); + } + transactions.push( + ...result.data.filter( + (transaction) => + transaction.status === "success" && + transaction.channel === "dedicated_nuban" && + transaction.currency === "NGN" && + typeof transaction.reference === "string" && + Number.isSafeInteger(transaction.amount) && + transaction.amount >= 0 + ) + ); + + const pageCount = result.meta?.pageCount; + if (pageCount !== undefined ? page >= pageCount : result.data.length < pageSize) { + return transactions; + } + } + + throw new Error(`Paystack transaction history exceeded the ${maxPages}-page safety limit`); +} + interface PaystackDVAResponse { status: boolean; data: {