Daily Reconciliations
This recipe implements a paginating daily reconciliation job that pulls all three financial datasets (card transactions, card fees, wallet transactions) for a given date range, correlates fees to transactions via authRefNum, and outputs a unified ledger in JSON and CSV formats. It's designed to be idempotent — safe to re-run for the same date range.
Overview
flowchart TD
A["Define date window\n(yesterday 00:00 → today 00:00 UTC)"]
B["Paginate GET /cards/transactions"]
C["Paginate GET /cards/fees"]
D["GET /wallets/transactions"]
E["Join fees to transactions\nvia authRefNum"]
F["Combine all three\ninto unified ledger rows"]
G["Output to JSON + CSV"]
H["Verify: sum of net movements\nvs. balance delta"]
A --> B & C & D
B & C --> E
E --> F
D --> F
F --> G
G --> H
Client setup
// Uses the shared client from recipe-zero-to-funded-card
const { api } = require('./client');
const fs = require('fs');Step 1: Paginated list fetcher
A generic paginator that handles the limit / offset pagination pattern used by card transactions and fees:
async function fetchAll(path, params = {}) {
const results = [];
const limit = 200;
let offset = 0;
let total = Infinity;
while (results.length < total) {
const query = new URLSearchParams({
limit,
offset,
...params,
}).toString();
const page = await api('GET', `${path}?${query}`);
// First page: capture total
if (total === Infinity) {
total = page.total ?? page.items?.length ?? 0;
}
const items = page.items ?? [];
results.push(...items);
offset += items.length;
// Guard: stop if API returns fewer items than expected
if (items.length < limit) break;
}
return results;
}Step 2: Define the reconciliation window
function dailyWindow(offsetDays = 1) {
// Default: yesterday's full day in UTC
const end = new Date();
end.setUTCHours(0, 0, 0, 0);
const start = new Date(end);
start.setUTCDate(start.getUTCDate() - offsetDays);
return {
after: Math.floor(start.getTime() / 1000).toString(),
before: Math.floor(end.getTime() / 1000).toString(),
label: start.toISOString().slice(0, 10), // e.g. "2024-06-13"
};
}Step 3: Fetch all three datasets
async function fetchReconciliationData(window, cardId = null) {
const filters = { after: window.after, before: window.before };
if (cardId) {
filters.cardId = cardId;
}
console.log(`Fetching data for window: ${window.label}`);
const [transactions, fees, walletTxns] = await Promise.all([
fetchAll('/cards/transactions', filters),
fetchAll('/cards/fees', filters),
// Wallet transactions: no pagination confirmed — fetch directly
api('GET', `/wallets/transactions?after=${window.after}&before=${window.before}`)
.then(r => r.items ?? [])
.catch(() => []), // Gracefully handle if endpoint unavailable
]);
console.log(` Transactions: ${transactions.length}`);
console.log(` Fees: ${fees.length}`);
console.log(` Wallet txns: ${walletTxns.length}`);
return { transactions, fees, walletTxns };
}Step 4: Correlate fees to transactions
function correlate(transactions, fees) {
// Build a lookup: authRefNum → [fees]
const feesByRef = {};
for (const fee of fees) {
const ref = fee.authRefNum ?? '__no_ref__';
(feesByRef[ref] ??= []).push(fee);
}
const standaloneFeesRefs = new Set(
fees
.filter(f => !transactions.some(t => t.authRefNum === f.authRefNum))
.map(f => f.authRefNum)
);
// Attach fees to each transaction
const enrichedTransactions = transactions.map(txn => ({
...txn,
associatedFees: feesByRef[txn.authRefNum] ?? [],
}));
// Standalone fees (no matching transaction — e.g. monthly maintenance)
const standaloneFees = fees.filter(f => standaloneFeesRefs.has(f.authRefNum));
return { enrichedTransactions, standaloneFees };
}Step 5: Build unified ledger rows
function buildLedger(enrichedTransactions, standaloneFees, walletTxns, window) {
const rows = [];
// Card transactions (with associated fees nested)
for (const txn of enrichedTransactions) {
rows.push({
date: txn.dateCreated,
type: 'CARD_TRANSACTION',
transId: txn.transId,
authRefNum: txn.authRefNum,
status: txn.transStatus,
amount: txn.amount, // string — verify format in sandbox
currency: txn.merchantCurrency,
cardId: txn.cardId,
merchant: txn.merchantName,
description: txn.description,
fees: txn.associatedFees.map(f => ({
feeId: f.feeId,
feeType: f.feeType,
feeStatus: f.transStatus,
amount: f.amount,
})),
});
}
// Standalone fees (no parent transaction)
for (const fee of standaloneFees) {
rows.push({
date: fee.dateCreated,
type: 'STANDALONE_FEE',
feeId: fee.feeId,
authRefNum: fee.authRefNum,
status: fee.transStatus,
amount: fee.amount,
cardId: fee.cardId,
description: fee.description,
fees: [],
});
}
// Wallet movements
for (const wtxn of walletTxns) {
rows.push({
date: wtxn.dateCreated ?? wtxn.date,
type: 'WALLET_MOVEMENT',
transId: wtxn.transId ?? wtxn.id,
status: wtxn.transStatus ?? wtxn.status,
amount: wtxn.amount,
walletId: wtxn.walletId,
description: wtxn.description,
fees: [],
});
}
// Sort chronologically
rows.sort((a, b) => (a.date ?? '').localeCompare(b.date ?? ''));
return rows;
}Step 6: Output JSON and CSV
function exportLedger(rows, window) {
const baseName = `reconciliation_${window.label}`;
// JSON
fs.writeFileSync(
`${baseName}.json`,
JSON.stringify({ window: window.label, rows }, null, 2)
);
// CSV
const headers = [
'date','type','transId','authRefNum','feeId','status',
'amount','currency','cardId','walletId','merchant','description',
'feeCount','totalFeeAmount',
];
const csvRows = rows.map(r => [
r.date ?? '',
r.type,
r.transId ?? '',
r.authRefNum ?? '',
r.feeId ?? '',
r.status ?? '',
r.amount ?? '',
r.currency ?? '',
r.cardId ?? '',
r.walletId ?? '',
r.merchant ?? '',
(r.description ?? '').replace(/,/g, ';'), // escape commas
r.fees?.length ?? 0,
r.fees?.reduce((sum, f) => sum + Number(f.amount ?? 0), 0) ?? 0,
].join(','));
fs.writeFileSync(
`${baseName}.csv`,
[headers.join(','), ...csvRows].join('\n')
);
console.log(`✓ Exported: ${baseName}.json and ${baseName}.csv`);
return baseName;
}Step 7: Assemble the full job
async function runDailyReconciliation({ offsetDays = 1, cardId = null } = {}) {
const window = dailyWindow(offsetDays);
const { transactions, fees, walletTxns } =
await fetchReconciliationData(window, cardId);
const { enrichedTransactions, standaloneFees } =
correlate(transactions, fees);
const rows = buildLedger(enrichedTransactions, standaloneFees, walletTxns, window);
const baseName = exportLedger(rows, window);
// Summary
const totalTxns = enrichedTransactions.length;
const totalFees = fees.length;
const totalWal = walletTxns.length;
console.log(`\nReconciliation summary for ${window.label}:`);
console.log(` Card transactions: ${totalTxns}`);
console.log(` Fee records: ${totalFees}`);
console.log(` Wallet movements: ${totalWal}`);
console.log(` Ledger rows output: ${rows.length}`);
return { rows, baseName };
}
runDailyReconciliation();Idempotency
This job is idempotent by design: re-running it for the same offsetDays value fetches the same data and overwrites the same output file. Downstream consumers of the JSON/CSV should deduplicate on transId/feeId rather than trusting that the file is always a net-new delta.
The
amountfield type is unconfirmed.TransactionsViewResponse.amountandFeesViewResponse.amountare typed as strings in the API specification with no confirmed format. The script above passes them through as strings. Verify the format against your sandbox data before applying arithmetic — if decimal strings (e.g.,"10.00"), parse withparseFloat; if decimal-implied integers (e.g.,"1000"= $10.00), useparseInt. See Transactions & Fees.
