Integrating a payment gateway is the part everyone budgets for. Reconciliation is the part that shows up three months later, when the finance team asks why the dashboard says SAR 480,000 and the bank statement says SAR 472,318.
Both numbers are correct. The gap is merchant discount rate, VAT on that rate, two captures that never settled, one refund that settled in a later batch than its sale, and a terminal transaction nobody recorded. Until something reconciles those two ledgers automatically, somebody in your finance department is doing it in a spreadsheet at month-end — and the errors they make are invisible until an audit finds them.
This tutorial builds that something.
This is the sequel, not the starting point. If you have not built the checkout yet, start with Integrating Saudi Payment Gateways with TypeScript, which covers authorization, 3-D Secure and webhooks. This one picks up after the money has moved.
What You'll Build
A reconciliation engine that takes two inputs — your internal payment ledger and the settlement files your acquirer and PSPs deliver — and produces a report with three parts:
- Matched pairs, each tagged with the confidence tier that produced the match.
- Breaks, classified by cause, so each one routes to whoever can actually fix it.
- Totals that tie your gross revenue to the net cash that landed in the bank, with the fees explaining the difference.
The design principle throughout: an ambiguous match is a break. An engine that guesses is worse than no engine, because it produces a clean report that is quietly wrong.
Prerequisites
- Node.js 20 or newer, and TypeScript 5 with
strictenabled - A working payment integration producing a ledger of captured payments
- Sample settlement files from your acquirer and PSPs — the real ones, not the documentation examples
- Vitest for the test suite
Install what the examples need:
npm install -D typescript vitestThe tsconfig.json used to verify every snippet below:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}Step 1: Money as Integers, Parsed From Strings
Settlement files arrive as CSV. Amounts arrive as decimal strings. The single most common bug in this entire domain is parseFloat(row.amount) * 100.
Try it: 8.29 * 100 evaluates to 828.9999999999999 in IEEE 754. Round it and you are fine; truncate it and you have silently lost a halala on every affected row. Across 40,000 transactions a month, that is a break report that never balances and nobody can explain.
Parse the string as a string.
/** A signed integer number of halalas. 1 SAR = 100 halalas. */
export type Halalas = number & { readonly __brand: unique symbol };
export function halalas(value: number): Halalas {
if (!Number.isSafeInteger(value)) {
throw new RangeError(`Halalas must be a safe integer, received: ${value}`);
}
return value as Halalas;
}
export function addHalalas(...values: Halalas[]): Halalas {
return halalas(values.reduce<number>((sum, v) => sum + v, 0));
}
/** Accepts "1234.56", "1,234.5", "-80", "0.07". Rejects anything else. */
const SAR_DECIMAL = /^(-)?(\d{1,3}(?:,\d{3})*|\d+)(?:\.(\d{1,2}))?$/;
export function parseSarToHalalas(raw: string): Halalas {
const trimmed = raw.trim();
const match = SAR_DECIMAL.exec(trimmed);
if (match === null) {
throw new TypeError(`Unparseable SAR amount: ${JSON.stringify(raw)}`);
}
const [, sign, whole = '0', fraction = ''] = match;
const units = Number.parseInt(whole.replace(/,/g, ''), 10);
const cents = Number.parseInt(fraction.padEnd(2, '0'), 10);
const magnitude = units * 100 + cents;
return halalas(sign === '-' ? -magnitude : magnitude);
}The branded type is doing real work. Halalas is a number at runtime with zero overhead, but TypeScript will not let a raw number — a SAR float, a percentage, an array index — flow into a field that expects halalas without going through halalas(), which validates.
Note what the parser refuses. A third decimal place throws instead of rounding. In a settlement file, 1.005 is not a rounding opportunity; it means you are parsing a column you think is SAR and it is something else. Failing loudly at ingest is far cheaper than discovering it in the break report.
Fee VAT deserves its own function, because of the refund case:
/**
* VAT on the acquirer fee, rounded half-up on the absolute value so that a
* refund's fee VAT mirrors the sale's exactly instead of drifting by 1 halala.
*/
export function vatOnFee(fee: Halalas, ratePercent: number): Halalas {
const sign = fee < 0 ? -1 : 1;
const raw = (Math.abs(fee) * ratePercent) / 100;
return halalas(sign * Math.round(raw));
}Rounding the signed value directly would use Math.round's half-up-toward-positive-infinity behaviour, which is asymmetric: a fee of 11.5 halalas rounds to 12, and its reversal of -11.5 rounds to -11. One halala, permanently stranded, on every reversal that hits that boundary. Rounding the magnitude and reapplying the sign makes reversals exact.
VAT applies to the fee, not to the transaction. The 15% is charged on the merchant discount rate your acquirer keeps, and it is a separate line in the settlement file. If you model it as VAT on the sale amount, every single row will mismatch.
Step 2: A Calendar Where the Weekend Is Friday and Saturday
Every date library's default business-day helper assumes Saturday and Sunday. In Saudi Arabia the weekend is Friday and Saturday, and Sunday is a full working day.
Get this wrong and your settlement SLA is off by two days in the direction that matters: you will page someone about an overdue settlement every Sunday morning, and stay silent on the Thursday captures that genuinely did not arrive.
/** Saudi Arabia's weekend is Friday and Saturday, not Saturday and Sunday. */
const FRIDAY = 5;
const SATURDAY = 6;
export type IsoDate = string; // YYYY-MM-DD
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function toUtc(date: IsoDate): Date {
if (!ISO_DATE.test(date)) {
throw new TypeError(`Expected YYYY-MM-DD, received: ${JSON.stringify(date)}`);
}
const parsed = new Date(`${date}T00:00:00.000Z`);
// Date rolls impossible days over silently: '2026-02-30' becomes March 2nd
// rather than NaN. Only a round-trip comparison catches that.
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {
throw new TypeError(`Not a real calendar date: ${date}`);
}
return parsed;
}
export function isBusinessDay(date: IsoDate, holidays: ReadonlySet<IsoDate>): boolean {
const day = toUtc(date).getUTCDay();
return day !== FRIDAY && day !== SATURDAY && !holidays.has(date);
}
/** Walks forward `count` business days, skipping Fri/Sat and Eid closures. */
export function addBusinessDays(
start: IsoDate,
count: number,
holidays: ReadonlySet<IsoDate> = new Set(),
): IsoDate {
if (!Number.isInteger(count) || count < 0) {
throw new RangeError(`count must be a non-negative integer, received: ${count}`);
}
const cursor = toUtc(start);
let remaining = count;
while (remaining > 0) {
cursor.setUTCDate(cursor.getUTCDate() + 1);
if (isBusinessDay(cursor.toISOString().slice(0, 10), holidays)) remaining -= 1;
}
return cursor.toISOString().slice(0, 10);
}The round-trip check in toUtc is not defensive padding — it caught a real bug while this code was being written. new Date('2026-02-30T00:00:00.000Z') does not return an invalid date. It returns March 2nd. A malformed date in a settlement file would have shifted an entire SLA window without raising anything.
Holidays are injected rather than hardcoded, because Eid al-Fitr and Eid al-Adha move against the Gregorian calendar every year and banking closure days are announced, not computed. Load them from configuration and update them annually.
Everything here works in UTC on date-only strings. Do not reach for the local timezone: a server running in UTC and a settlement file stamped in Riyadh time will disagree about which day a late-evening capture belongs to, and you will spend a day chasing a break that does not exist.
Step 3: Model Both Sides Honestly
Two record types, and the discipline is that neither pretends to know anything the other side hasn't told it.
export type Provider = 'mada' | 'moyasar' | 'tabby';
export type EntryKind = 'sale' | 'refund' | 'chargeback' | 'adjustment';
/** What our own system believes happened. */
export interface LedgerEntry {
readonly paymentId: string;
readonly orderId: string;
readonly provider: Provider;
/** The PSP's own id, when we stored it. Null for terminal-only sales. */
readonly providerRef: string | null;
/** Retrieval Reference Number — the only id a mada acquirer file guarantees. */
readonly rrn: string | null;
readonly kind: Extract<EntryKind, 'sale' | 'refund'>;
/** Signed: sales positive, refunds negative. */
readonly grossHalalas: Halalas;
readonly capturedOn: IsoDate;
}
/** What the money actually did, according to the settlement file. */
export interface SettlementRow {
readonly fileId: string;
readonly rowId: string;
readonly provider: Provider;
readonly providerRef: string | null;
readonly rrn: string | null;
readonly kind: EntryKind;
readonly grossHalalas: Halalas;
/** What the acquirer kept. Positive on sales, zero on most refunds. */
readonly feeHalalas: Halalas;
readonly feeVatHalalas: Halalas;
/** Must equal gross - fee - feeVat. Verified on ingest. */
readonly netHalalas: Halalas;
readonly settledOn: IsoDate;
}LedgerEntry.kind is narrowed with Extract to just sale and refund. Your system can initiate those two. It cannot initiate a chargeback or a scheme adjustment — those only ever arrive from outside, so only SettlementRow can carry them. Encoding that in the type means an impossible ledger entry will not compile.
The sign convention is the load-bearing decision: sales positive, refunds and chargebacks negative, everywhere, after normalization. Get this right at the edge and every downstream sum is a plain addition.
SLA and tolerance are per-provider policy, not constants:
export interface ProviderPolicy {
/** Business days from capture to money-in-bank before we call it overdue. */
readonly settlementSlaBusinessDays: number;
/** Tolerance for rounding drift between our fee model and theirs. */
readonly feeToleranceHalalas: number;
}
export const DEFAULT_POLICIES: Readonly<Record<Provider, ProviderPolicy>> = {
// Card rails settle fast; anything past this is a real operational break.
mada: { settlementSlaBusinessDays: 2, feeToleranceHalalas: 2 },
moyasar: { settlementSlaBusinessDays: 3, feeToleranceHalalas: 2 },
// BNPL pays the merchant on its own cycle, unrelated to customer instalments.
tabby: { settlementSlaBusinessDays: 7, feeToleranceHalalas: 5 },
};Treat these numbers as placeholders and replace them with your own contract. Settlement timetables and merchant discount rates are negotiated per merchant. The values above are structurally right — cards settle in a couple of business days, BNPL takes longer — but the exact figures belong to your acquirer agreement, and a rate you assumed rather than read is a rate you will reconcile against forever.
The BNPL comment is the point most integrations get wrong. When a customer buys through Tabby in four instalments, the merchant is not paid in four instalments. Tabby pays the merchant the order total minus commission, once, on its own settlement cycle, and then carries the customer credit risk itself. If you model merchant settlement against the customer's instalment schedule you will build a reconciliation engine that reports three phantom breaks for every BNPL order you take.
Step 4: Normalize at the Edge, Verify on Ingest
Acquirer files publish refunds as a positive amount with a type column saying REFUND. Sum that column naively and refunds inflate your revenue instead of reducing it.
Flip the sign once, at the boundary:
export interface RawMadaRow {
readonly RRN: string;
readonly AUTH_CODE: string;
readonly TXN_TYPE: string;
readonly TXN_AMOUNT: string;
readonly MDR_AMOUNT: string;
readonly MDR_VAT: string;
readonly NET_AMOUNT: string;
readonly SETTLEMENT_DATE: string;
}
export function normaliseMadaRow(fileId: string, index: number, raw: RawMadaRow): SettlementRow {
const isCredit = raw.TXN_TYPE === 'REFUND' || raw.TXN_TYPE === 'CHARGEBACK';
const sign = isCredit ? -1 : 1;
return {
fileId,
rowId: `${fileId}:${index}`,
provider: 'mada',
providerRef: null,
rrn: raw.RRN,
kind: raw.TXN_TYPE === 'CHARGEBACK' ? 'chargeback' : isCredit ? 'refund' : 'sale',
grossHalalas: halalas(sign * parseSarToHalalas(raw.TXN_AMOUNT)),
feeHalalas: halalas(sign * parseSarToHalalas(raw.MDR_AMOUNT)),
feeVatHalalas: halalas(sign * parseSarToHalalas(raw.MDR_VAT)),
netHalalas: halalas(sign * parseSarToHalalas(raw.NET_AMOUNT)),
settledOn: raw.SETTLEMENT_DATE,
};
}Write one of these per provider. The rest of the engine then works on SettlementRow and never learns that mada, Moyasar and Tabby disagree about column names, date formats and sign conventions. Adding a fourth provider means adding a normalizer, not touching the matcher.
Now the invariant that catches file-level corruption before it reaches your matcher:
export class FeeInvariantError extends Error {
constructor(readonly row: SettlementRow, readonly expected: Halalas) {
super(
`Row ${row.rowId}: net ${row.netHalalas} != gross ${row.grossHalalas} ` +
`- fee ${row.feeHalalas} - vat ${row.feeVatHalalas} (expected ${expected})`,
);
this.name = 'FeeInvariantError';
}
}
export function assertFeeInvariant(row: SettlementRow): void {
const expected = addHalalas(
row.grossHalalas,
halalas(-row.feeHalalas),
halalas(-row.feeVatHalalas),
);
if (expected !== row.netHalalas) throw new FeeInvariantError(row, expected);
}Every settlement row asserts its own internal arithmetic. If net does not equal gross - fee - feeVat, you have mapped a column wrong, or the file has a category of deduction you do not know about yet. Either way, that row must not silently enter the report.
Ingestion has one more requirement that surprises people: settlement files get re-issued. A corrected file arrives with the same transactions and one fixed amount. So idempotency has to be per-row, not per-file.
export interface IngestResult {
readonly accepted: readonly SettlementRow[];
readonly duplicates: readonly string[];
readonly rejected: readonly { readonly rowId: string; readonly reason: string }[];
}
export function ingest(
rows: readonly SettlementRow[],
alreadyIngested: ReadonlySet<string> = new Set(),
): IngestResult {
const seen = new Set(alreadyIngested);
const accepted: SettlementRow[] = [];
const duplicates: string[] = [];
const rejected: { rowId: string; reason: string }[] = [];
for (const row of rows) {
if (seen.has(row.rowId)) {
duplicates.push(row.rowId);
continue;
}
try {
assertFeeInvariant(row);
seen.add(row.rowId);
accepted.push(row);
} catch (error) {
rejected.push({
rowId: row.rowId,
reason: error instanceof Error ? error.message : String(error),
});
}
}
return { accepted, duplicates, rejected };
}Note that a bad row is quarantined with a reason, not thrown past the whole batch. One malformed row out of 12,000 should not stop you reconciling the other 11,999 — but it must appear somewhere a human will read.
In production, persist rowId with a unique constraint in your database and let the constraint be the real idempotency guarantee. The in-memory set above is the same logic, made testable.
Step 5: Tiered Matching That Refuses to Guess
Three tiers, tried in order, each less certain than the last.
Tier 1 — the PSP's own reference. If you stored moy_... or tby_... at capture time and the settlement row carries the same id, that is a definitive match.
Tier 2 — RRN plus amount. A mada acquirer file frequently has no PSP reference at all; what it guarantees is the Retrieval Reference Number. RRN alone is not quite enough, so it is paired with an exact amount.
Tier 3 — amount plus date window. For terminal transactions with no usable id. Only accepted when exactly one candidate survives the filter.
A claim is one ledger entry together with the rows it has taken:
interface Claim {
readonly entry: LedgerEntry;
readonly rows: SettlementRow[];
tier: 1 | 2 | 3;
}export function matchEntries(
ledger: readonly LedgerEntry[],
rows: readonly SettlementRow[],
policies: Readonly<Record<Provider, ProviderPolicy>>,
holidays: ReadonlySet<IsoDate>,
): { claims: Claim[]; unclaimed: SettlementRow[] } {
const available = new Map(rows.map((row) => [row.rowId, row]));
const claims: Claim[] = [];
const take = (candidates: SettlementRow[]): SettlementRow[] => {
for (const row of candidates) available.delete(row.rowId);
return candidates;
};
const remaining = (predicate: (row: SettlementRow) => boolean): SettlementRow[] =>
[...available.values()].filter(predicate);
for (const entry of ledger) {
const sameProvider = (row: SettlementRow): boolean => row.provider === entry.provider;
if (entry.providerRef !== null) {
const byRef = remaining((r) => sameProvider(r) && r.providerRef === entry.providerRef);
if (byRef.length > 0) {
claims.push({ entry, rows: take(byRef), tier: 1 });
continue;
}
}
if (entry.rrn !== null) {
const byRrn = remaining(
(r) => sameProvider(r) && r.rrn === entry.rrn && r.grossHalalas === entry.grossHalalas,
);
if (byRrn.length > 0) {
claims.push({ entry, rows: take(byRrn), tier: 2 });
continue;
}
}
const policy = policies[entry.provider];
const deadline = addBusinessDays(entry.capturedOn, policy.settlementSlaBusinessDays, holidays);
const byWindow = remaining(
(r) =>
sameProvider(r) &&
r.grossHalalas === entry.grossHalalas &&
r.settledOn >= entry.capturedOn &&
r.settledOn <= deadline,
);
// Exactly one, or we refuse — two identical amounts on the same day are a
// genuinely ambiguous pair and a human has to look at them.
if (byWindow.length === 1) {
claims.push({ entry, rows: take(byWindow), tier: 3 });
} else {
claims.push({ entry, rows: [], tier: 3 });
}
}
return { claims, unclaimed: [...available.values()] };
}Three properties are worth naming.
Rows are claimed, not just read. take() removes matched rows from available, so no settlement row can satisfy two ledger entries. Without this, a duplicate charge reconciles perfectly against two separate orders and disappears.
Provider is always part of the predicate. Two providers can easily produce the same amount on the same day. Cross-matching them yields a report that balances and is meaningless.
Tier 3 refuses ties. byWindow.length === 1 is deliberate. If two SAR 1,150.00 sales settled the same day and neither has an id, the honest output is two breaks, not a coin flip. Since ISO date strings sort lexicographically, the window comparison is a plain string comparison — no date parsing in the hot loop.
Step 6: Classify Breaks by Who Fixes Them
A break report that says "47 exceptions" is not actionable. A break report that separates the bank is late from we billed the wrong amount routes each exception to the person who can close it.
export type BreakCode =
| 'MISSING_IN_SETTLEMENT'
| 'OVERDUE_IN_SETTLEMENT'
| 'MISSING_IN_LEDGER'
| 'AMOUNT_MISMATCH'
| 'DUPLICATE_SETTLEMENT'
| 'FEE_INVARIANT_BROKEN';| Code | What it means | Who owns it |
|---|---|---|
MISSING_IN_SETTLEMENT | Captured, not yet settled, still inside SLA | Nobody — this is normal, do not alert |
OVERDUE_IN_SETTLEMENT | Past the SLA deadline and still not paid | Operations, then the acquirer |
MISSING_IN_LEDGER | Money arrived with no order behind it | Engineering — usually a dropped webhook |
AMOUNT_MISMATCH | Settled amount disagrees beyond tolerance | Finance — wrong fee model or partial capture |
DUPLICATE_SETTLEMENT | Two settlement rows, one payment | Acquirer dispute |
FEE_INVARIANT_BROKEN | The file's own arithmetic does not hold | Engineering — a column is mapped wrong |
The distinction between the first two rows is what makes the report survivable. A capture from this morning has not settled yet and never should have. If you emit an alert for it, your team will be looking at hundreds of non-events daily and will stop reading the report inside a week.
Two small pieces the engine needs first — a typed summing helper and its input shape:
function sumBy(rows: readonly SettlementRow[], pick: (r: SettlementRow) => Halalas): Halalas {
return addHalalas(...rows.map(pick));
}
export interface ReconcileInput {
readonly asOf: IsoDate;
readonly ledger: readonly LedgerEntry[];
readonly settlement: readonly SettlementRow[];
readonly policies?: Readonly<Record<Provider, ProviderPolicy>>;
readonly holidays?: ReadonlySet<IsoDate>;
}export function reconcile(input: ReconcileInput): ReconciliationReport {
const policies = input.policies ?? DEFAULT_POLICIES;
const holidays = input.holidays ?? new Set<IsoDate>();
const { claims, unclaimed } = matchEntries(input.ledger, input.settlement, policies, holidays);
const breaks: Break[] = [];
const matched: MatchedPair[] = [];
let grossSettled = halalas(0);
let netSettled = halalas(0);
let fees = halalas(0);
let unsettled = halalas(0);
for (const claim of claims) {
const { entry, rows } = claim;
const policy = policies[entry.provider];
if (rows.length === 0) {
unsettled = addHalalas(unsettled, entry.grossHalalas);
const deadline = addBusinessDays(entry.capturedOn, policy.settlementSlaBusinessDays, holidays);
const overdue = input.asOf > deadline;
breaks.push({
code: overdue ? 'OVERDUE_IN_SETTLEMENT' : 'MISSING_IN_SETTLEMENT',
provider: entry.provider,
paymentId: entry.paymentId,
rowIds: [],
expectedHalalas: entry.grossHalalas,
actualHalalas: null,
detail: overdue
? `Captured ${entry.capturedOn}, due by ${deadline}, still unsettled on ${input.asOf}.`
: `Captured ${entry.capturedOn}, within SLA until ${deadline}.`,
});
continue;
}
const gross = sumBy(rows, (r) => r.grossHalalas);
const net = sumBy(rows, (r) => r.netHalalas);
const fee = addHalalas(
sumBy(rows, (r) => r.feeHalalas),
sumBy(rows, (r) => r.feeVatHalalas),
);
if (rows.length > 1) {
breaks.push({
code: 'DUPLICATE_SETTLEMENT',
provider: entry.provider,
paymentId: entry.paymentId,
rowIds: rows.map((r) => r.rowId),
expectedHalalas: entry.grossHalalas,
actualHalalas: gross,
detail: `${rows.length} settlement rows point at one payment.`,
});
} else if (Math.abs(gross - entry.grossHalalas) > policy.feeToleranceHalalas) {
breaks.push({
code: 'AMOUNT_MISMATCH',
provider: entry.provider,
paymentId: entry.paymentId,
rowIds: rows.map((r) => r.rowId),
expectedHalalas: entry.grossHalalas,
actualHalalas: gross,
detail: `Ledger and settlement disagree by ${gross - entry.grossHalalas} halalas.`,
});
}
grossSettled = addHalalas(grossSettled, gross);
netSettled = addHalalas(netSettled, net);
fees = addHalalas(fees, fee);
matched.push({
paymentId: entry.paymentId,
tier: claim.tier,
rowIds: rows.map((r) => r.rowId),
grossHalalas: gross,
netHalalas: net,
feeHalalas: fee,
});
}
for (const row of unclaimed) {
breaks.push({
code: 'MISSING_IN_LEDGER',
provider: row.provider,
paymentId: null,
rowIds: [row.rowId],
expectedHalalas: null,
actualHalalas: row.grossHalalas,
detail: `Settled ${row.settledOn} as ${row.kind}, no matching ledger entry.`,
});
}
return {
asOf: input.asOf,
matched,
breaks,
totals: {
grossSettledHalalas: grossSettled,
netSettledHalalas: netSettled,
feesHalalas: fees,
unsettledHalalas: unsettled,
},
};
}asOf is an explicit input rather than a call to new Date(). That is what makes the whole engine deterministic: the same ledger and the same files always produce the same report, so you can re-run last Tuesday's reconciliation and get last Tuesday's answer. Reading the clock inside a reconciliation engine makes it untestable and makes reprocessing lie.
The tolerance comparison is Math.abs(gross - entry.grossHalalas) > policy.feeToleranceHalalas, so a one- or two-halala rounding difference between your fee model and the acquirer's passes silently, while a genuine amount discrepancy is caught. Set this to a few halalas, never to a percentage.
Step 7: The Refund That Settles in a Later Batch
This is the case that breaks naive implementations, so it is worth walking through end to end.
A customer buys for SAR 1,150.00 on the 13th. It settles on the 17th: gross 115000, MDR 1150, VAT on MDR 173, net 113677. On the 19th they are refunded in full, and that refund settles on the 20th — a different file, a different batch, a different reporting period.
Two things have to be true for the report to be right.
The refund must net against the sale, across batches. Row-by-row matching that treats each file in isolation reports the sale as an unexplained credit and the refund as unmatched money leaving. Reconciliation is a position over time, not a per-file diff.
The MDR is not returned. When you refund a customer, the acquirer generally does not give back the discount rate it earned on the original sale. So the correct end state is: gross nets to zero, net cash position is negative 1,323 halalas, and that 1,323 is fee expense you have absorbed.
That is the assertion in the test suite:
it('nets a refund that settles in a later batch than its sale', () => {
const report = reconcile({
asOf: '2026-08-25',
ledger: [
entry({ paymentId: 'pay_1', providerRef: 'moy_a', provider: 'moyasar' }),
entry({
paymentId: 'pay_2',
providerRef: 'moy_a_r',
provider: 'moyasar',
kind: 'refund',
grossHalalas: halalas(-115000),
capturedOn: '2026-08-19',
}),
],
settlement: [
row({ providerRef: 'moy_a', provider: 'moyasar' }),
row({
rowId: 'MADA-20260820:0',
providerRef: 'moy_a_r',
provider: 'moyasar',
kind: 'refund',
grossHalalas: halalas(-115000),
feeHalalas: halalas(0),
feeVatHalalas: halalas(0),
netHalalas: halalas(-115000),
settledOn: '2026-08-20',
}),
],
});
expect(report.breaks).toHaveLength(0);
expect(report.totals.grossSettledHalalas).toBe(0);
// The sale's MDR is not returned when the customer is refunded.
expect(report.totals.netSettledHalalas).toBe(-1323);
expect(report.totals.feesHalalas).toBe(1323);
});If your engine reports zero net on this scenario, it is silently absorbing fee expense that should be visible on the income statement.
Step 8: Wire the Output Into Accounting
The report is a data structure. It becomes useful when it drives ledger entries.
The pattern is a clearing account. At capture you debit a payments-clearing account and credit revenue. At settlement you debit cash, credit clearing, and debit the fee expense with its VAT recoverable. The balance of the clearing account at any moment should equal totals.unsettledHalalas — money you have earned that has not yet reached the bank.
That single equality is the strongest control in the whole system. When the clearing balance and the engine's unsettled total diverge, something is wrong in one of them, and you find out in a day instead of at year-end audit.
Emit the report on a schedule after each provider's file lands, route the break codes to different destinations — overdue settlements to operations, missing-in-ledger to engineering — and store each run. The stored history is what lets you show an auditor that a break was detected on the 17th and closed on the 19th.
The fee totals also matter beyond accounting: totals.feesHalalas divided by totals.grossSettledHalalas, tracked per provider per month, is your real blended cost of payment acceptance. Most merchants quote the rate in their contract. Very few know the number they are actually paying, and the difference between the two is a negotiating position.
Testing Your Implementation
Reconciliation is the rare domain where exhaustive unit testing is genuinely cheap: pure functions, integer inputs, deterministic output. The suite backing this tutorial is 30 tests across money, calendar, ingest and reconcile, and runs in under 10 milliseconds.
npx tsc --noEmit && npx vitest run ✓ src/recon.test.ts (30 tests) 6ms
Test Files 1 passed (1)
Tests 30 passed (30)
The cases worth writing first, because they are the ones that fail in production:
it('treats Friday and Saturday as the weekend', () => {
expect(isBusinessDay('2026-08-14', new Set())).toBe(false); // Friday
expect(isBusinessDay('2026-08-15', new Set())).toBe(false); // Saturday
expect(isBusinessDay('2026-08-16', new Set())).toBe(true); // Sunday is a work day
});
it('skips the weekend when computing a T+2 deadline', () => {
// Thursday + 2 business days lands on Monday, not Saturday.
expect(addBusinessDays('2026-08-13', 2)).toBe('2026-08-17');
});
it('refuses to guess between two identical amounts', () => {
const report = reconcile({
asOf: '2026-08-18',
ledger: [entry({ rrn: null })],
settlement: [row({ rrn: null }), row({ rrn: null, rowId: 'MADA-20260817:1' })],
});
expect(report.breaks.map((b) => b.code).sort()).toEqual([
'MISSING_IN_LEDGER',
'MISSING_IN_LEDGER',
'OVERDUE_IN_SETTLEMENT',
]);
});
it('does not cross-match between providers', () => {
const report = reconcile({
asOf: '2026-08-18',
ledger: [entry({ provider: 'tabby', rrn: null, providerRef: null })],
settlement: [row({ rrn: null, providerRef: null })],
});
expect(report.breaks.map((b) => b.code).sort()).toEqual([
'MISSING_IN_LEDGER',
'MISSING_IN_SETTLEMENT',
]);
});That third test encodes the design principle as an executable assertion. Three breaks from an ambiguous pair is the correct output, and writing it down as a test stops a future contributor from "improving" the matcher into guessing.
Beyond unit tests, run a shadow period before you trust the engine: reconcile for a month in parallel with whatever the finance team does by hand, and compare. Every disagreement teaches you something — usually a deduction category or an adjustment row nobody mentioned.
Troubleshooting
Every row breaks the fee invariant. Your column mapping is wrong, or the file reports net before an additional deduction. Print one raw row next to its normalized form and do the arithmetic by hand.
Everything mismatches by a consistent small amount. You are applying VAT to the transaction instead of to the fee, or your fee percentage is wrong. Divide the discrepancy by the gross to recover the rate the acquirer is actually charging.
Overdue alerts every Sunday. A date library's default business-day helper is treating Sunday as a weekend. Saudi weekends are Friday and Saturday.
Tier 3 matches everything, tier 1 matches nothing. You are not persisting the provider reference at capture. Store it in the same transaction that records the payment — it is the difference between certain matching and inference.
BNPL orders always show three phantom breaks. You are reconciling against the customer's instalment schedule. The merchant is paid once, in full, minus commission.
The report is right but nobody reads it. You are alerting on MISSING_IN_SETTLEMENT. Only OVERDUE_IN_SETTLEMENT deserves a notification.
Amounts drift by one halala on reversals. You are rounding a signed value. Round the magnitude and reapply the sign.
Next Steps
- Add a normalizer per additional provider — the matcher does not change
- Persist every run so break lifetime, not just break count, becomes measurable
- Track blended cost of acceptance per provider per month from
totals.feesHalalas - Feed the report into ZATCA Phase 2 e-invoicing, where settled amounts must agree with reported ones
- Compare the approach with the GOSI contribution reconciliation engine — same tiered-matching shape, different domain
Conclusion
Reconciliation looks like a reporting problem and is actually a modelling problem. Once money is an integer, the calendar knows the weekend is Friday and Saturday, providers are normalized at the edge, and the matcher refuses to guess, the report writes itself — and it is right, which is the only property that matters when an auditor is reading it.
The three decisions that carry the most weight: parse decimal strings without floating point, make asOf an input rather than a clock read, and treat an ambiguous match as a break. Everything else is bookkeeping.
The hardest part is rarely the algorithm. It is discovering, file by file, the deduction categories and adjustment rows nobody documented. Budget for the shadow period.
Reconciling by hand at month-end? If your finance team is matching settlement files to orders in a spreadsheet, we can tell you in one session what an engine like this would take to build against your actual providers and file formats — and where the breaks in your current process are hiding. Get in touch.