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 amount field type is unconfirmed. TransactionsViewResponse.amount and FeesViewResponse.amount are 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 with parseFloat; if decimal-implied integers (e.g., "1000" = $10.00), use parseInt. See Transactions & Fees.


What's next