Skip to content

vgrid recon

Config-driven reconciliation engine that matches records across 2 or 3 data sources (processor, ledger, bank). Reads a .recon.toml config defining roles, column mappings, transforms, and pair strategies; loads CSVs; aggregates by match key + currency; matches per configured pair; classifies into buckets; and emits JSON.

Run a reconciliation from a TOML config file.

Terminal window
vgrid recon run <config.recon.toml> [options]
OptionDescription
--jsonOutput JSON to stdout (in addition to human summary on stderr)
--output <path>Write JSON output to file
Terminal window
# Run with human summary to stderr
vgrid recon run stripe-qbo.recon.toml
# JSON to stdout
vgrid recon run stripe-qbo.recon.toml --json
# Save JSON to file
vgrid recon run stripe-qbo.recon.toml --output result.json
# Both: save to file + print to stdout
vgrid recon run stripe-qbo.recon.toml --json --output result.json

Always printed to stderr:

2-way recon: 4 groups — 2 matched, 0 amount mismatches, 0 timing mismatches, 2 unmatched

Check a config file for correctness without running the reconciliation.

Terminal window
vgrid recon validate <config.recon.toml>
Terminal window
vgrid recon validate stripe-qbo.recon.toml
# valid: 2-way recon 'Stripe ↔ QBO' with 2 role(s), 1 pair(s)

Configs use .recon.toml extension. CSV file paths are resolved relative to the config file’s directory.

name = "Stripe ↔ QBO Payout Recon"
way = 2
[roles.processor]
kind = "processor"
file = "stripe.csv"
[roles.processor.columns]
record_id = "source_id"
match_key = "group_id"
amount = "amount_minor"
date = "effective_date"
currency = "currency"
kind = "type"
[roles.processor.filter]
column = "type"
values = ["payout"]
[roles.processor.transform]
multiply = -1
when_column = "type"
when_values = ["payout"]
[roles.ledger]
kind = "ledger"
file = "qbo.csv"
[roles.ledger.columns]
record_id = "source_id"
match_key = "source_id"
amount = "amount_minor"
date = "effective_date"
currency = "currency"
kind = "type"
[roles.ledger.filter]
column = "type"
values = ["deposit"]
[pairs.processor_ledger]
left = "processor"
right = "ledger"
strategy = "fuzzy_amount_date"
[tolerance]
amount_cents = 0
date_window_days = 2
name = "Stripe ↔ QBO ↔ Mercury"
way = 3
# ... processor and ledger roles same as above ...
[roles.bank]
kind = "bank"
file = "mercury.csv"
[roles.bank.columns]
record_id = "source_id"
match_key = "source_id"
amount = "amount_minor"
date = "effective_date"
currency = "currency"
kind = "type"
[roles.bank.filter]
column = "type"
values = ["deposit"]
[pairs.processor_ledger]
left = "processor"
right = "ledger"
strategy = "fuzzy_amount_date"
[pairs.processor_bank]
left = "processor"
right = "bank"
strategy = "fuzzy_amount_date"
[tolerance]
amount_cents = 0
date_window_days = 2
FieldRequiredDescription
nameyesHuman-readable name for the recon
wayyes2 (one pair) or 3 (two pairs)
roles.<name>yesAt least 2 roles required
roles.<name>.kindyesprocessor, ledger, or bank
roles.<name>.fileyesPath to CSV file (relative to config)
roles.<name>.columnsyesColumn mapping (see below)
roles.<name>.filternoRow filter: keep only rows where column is in values
roles.<name>.transformnoAmount transform: multiply with optional when_column/when_values
pairs.<name>.leftyesRole name for left side
pairs.<name>.rightyesRole name for right side
pairs.<name>.strategynoexact_key (default) or fuzzy_amount_date
tolerance.amount_centsnoMax allowed amount difference in cents (default: 0)
tolerance.date_window_daysnoMax allowed date offset in days (default: 0)

Every role must map these 6 columns to CSV header names:

ColumnPurpose
record_idUnique row identifier (e.g. source_id)
match_keyAggregation/matching key (e.g. payout_id, deposit_id)
amountInteger cents column (e.g. amount_minor)
dateDate column, YYYY-MM-DD format (e.g. effective_date)
currencyCurrency code (e.g. USD). Part of the aggregation key — never matches across currencies
kindRow type (e.g. payout, deposit). Used by filters

exact_key (default): Matches aggregates by (match_key, currency). Both sides must use the same key space (e.g. payout ID).

fuzzy_amount_date: Matches aggregates by amount and date within tolerance. Same currency required. Greedy best-match: smallest |amount_delta| + |date_offset| wins. Use when left and right have different key spaces.

Keep only rows where the named column’s value is in the list:

[roles.processor.filter]
column = "type"
values = ["payout"]

Modify amounts conditionally. Currently supports multiply:

[roles.processor.transform]
multiply = -1 # Negate the amount
when_column = "type" # Only when this column...
when_values = ["payout"] # ...has one of these values

If when_column/when_values are omitted, the transform applies to all rows.


Records are grouped by (match_key, currency) per role. Within each group:

  • Amounts are summed (integer cents, i64)
  • The earliest date is used as the group date
  • All record_id values are preserved

This means multiple charges/fees/refunds with the same payout ID roll up into one aggregate before matching.


BucketMeaning
matched_two_wayMatched within tolerance
amount_mismatchMatched by key but amount delta exceeds amount_cents
timing_mismatchMatched by key but date offset exceeds date_window_days
processor_ledger_onlyLeft-side aggregate with no match on right
ledger_onlyRight-side aggregate with no match on left

3-way recon runs two pairs (e.g. processor↔ledger, processor↔bank) and merges results:

BucketMeaning
matched_three_wayMatched in both pairs
processor_ledger_onlyMatched processor↔ledger but no bank match
processor_bank_onlyMatched processor↔bank but no ledger match
ledger_onlyLedger aggregate with no match
bank_onlyBank aggregate with no match
amount_mismatchKey matched but amount out of tolerance (either pair)
timing_mismatchKey matched but date out of window (either pair)

{
"meta": {
"config_name": "Demo: Missing Deposits",
"way": 2,
"engine_version": "0.9.0",
"run_at": "2026-02-17T17:53:20+00:00"
},
"summary": {
"total_groups": 4,
"matched": 2,
"amount_mismatches": 0,
"timing_mismatches": 0,
"left_only": 2,
"right_only": 0,
"bucket_counts": {
"matched_two_way": 2,
"processor_ledger_only": 2
}
},
"groups": [
{
"bucket": "matched_two_way",
"match_key": "po_501",
"currency": "USD",
"aggregates": {
"processor": {
"role": "processor",
"match_key": "po_501",
"currency": "USD",
"date": "2026-01-12",
"total_cents": 24275,
"record_count": 1,
"record_ids": ["po_501"]
},
"ledger": {
"role": "ledger",
"match_key": "dep_501",
"currency": "USD",
"date": "2026-01-13",
"total_cents": 24275,
"record_count": 1,
"record_ids": ["dep_501"]
}
},
"deltas": {
"delta_cents": 0,
"date_offset_days": -1
}
},
{
"bucket": "processor_ledger_only",
"match_key": "po_503",
"currency": "USD",
"aggregates": {
"processor": {
"role": "processor",
"match_key": "po_503",
"currency": "USD",
"date": "2026-01-15",
"total_cents": 48550,
"record_count": 1,
"record_ids": ["po_503"]
}
},
"deltas": {}
}
]
}

CodeMeaning
0All groups matched within tolerance
1Mismatches found (amount, timing, or unmatched)
2Runtime error (IO, CSV parse, etc.)
60Invalid config