Ownership context
customers
- Grain
- One customer
- Key
customer_id
Settlement reconciliation SQL, DuckDB and PostgreSQL
Why do so many EUR purchases from the 2024-11-12 close still carry a fee-mismatch exception?
A status report calls a purchase done once the customer pays. Operations can only close the day when the money that arrives later matches the expected amount, currency, fee and deadline. These four metrics define that check. Refunds and transfers stay in the data but sit outside it.
exception_queueDistinct eligible payments with at least one independent exception flag.paymentmart_exception_queuesegment_isolationRecorded processing fee minus the effective merchant-term fee.payment, then date and currencyint_settlement_reconciliationclose_summaryGross value missing past its effective SLA or actually settled after that SLA.close date and currencymart_daily_closeclose_summaryPurchases with evidence, matching currency, and gross identity within 0.01 divided by eligible purchases.close date and currencymart_daily_closeThe merchant contract says what a payment should cost. The settlement record says what it did cost. Effective dates pick the contract that applied on the purchase day, and a left join keeps a missing settlement visible instead of dropping the payment.
Ownership context
customer_idCurrency boundary
account_idEvent spine
transaction_idOperating segment
merchant_idExpected fee and SLA
merchant_id + valid_fromRecorded money evidence
settlement_idReview context
flag_idcustomersaccountsone to manyAn account belongs to one customer.
accountstransactionsone to manyEvery transaction retains its account currency.
transactionsmerchantsmany to zero or oneMerchant is required for the flagship purchase population.
merchantsmerchant_termsone to many over timeValidity dates select exactly one effective term.
transactionssettlementsone to zero or oneA left join keeps missing evidence visible.
transactionsfraud_flagsone to zero or oneReview context can coexist with settlement flags.
One ordinary day and three incidents, each with its own cause and reaching merchants in several categories. The walkthrough follows the fee mismatch incident.
No incident. The close still carries the everyday mix of exceptions any day has.
37 payments on this GBP close classify as late, most of them from the incident, spread across categories and led by Entertainment.
47 payments on this EUR close classify as fee mismatch, most of them from the incident, spread across categories and led by Retail.
Followed below46 payments on this CAD close classify as missing, most of them from the incident, spread across categories and led by Retail.
Three queries, each answering the question the last one raised. The chart stays beside its explanation while you read.
Baseline daily close
At the 2024-11-15 observation cut, the selected close was 310/326 matched. Coverage recovers, yet the exceptions stay in the final snapshot.
close_summaryModel mart_daily_close-- analytics_context.as_of_date = :investigation_as_of_date
SELECT
close_date, currency, eligible_count, matched_count,
coverage_rate, overdue_minor_units, fee_delta_minor_units
FROM mart_daily_close
WHERE close_date = :scenario_date
AND currency = :currency
ORDER BY close_date, currency;Same purchases, read again at later dates as settlements arrive.
| Purchase close | Read on | Currency | Eligible | Matched | Coverage | Overdue value | Fee delta |
|---|---|---|---|---|---|---|---|
| 12 Nov 2024 | 12 Nov 2024 | EUR | 326 | 0 | 0.00% | €0.00 | €0.00 |
| 12 Nov 2024 | 15 Nov 2024 | EUR | 326 | 310 | 95.09% | €0.00 | €20.10 |
| 12 Nov 2024 | 18 Nov 2024 | EUR | 326 | 311 | 95.40% | €621.26 | €20.10 |
| 12 Nov 2024 | 10 Jan 2025 | EUR | 326 | 318 | 97.55% | €621.26 | €20.10 |
Segment isolation
Retail carries 13 of the 47 fee mismatches, then Electronics (12), Entertainment (8), Healthcare (5). The fee mismatches reach 8 of 8 categories, so this is one cause reaching many merchants, and no category is at fault. 7 of 8 categories also carry everyday exceptions of other kinds.
segment_isolationModel mart_category_healthSELECT
merchant_category, currency, eligible_count,
exception_count, exception_rate, primary_reason,
overdue_minor_units, fee_delta_minor_units
FROM mart_category_health
WHERE close_date = :scenario_date
AND currency = :currency
ORDER BY exception_count DESC, eligible_count DESC, merchant_category;Bar length is the share of that category's payments with an exception.
Exception classification
47 fee mismatches lead the close. The rest is the everyday mix any close carries: missing (2), currency mismatch (2), amount mismatch (4), late (8), disputed (2). Each payment keeps every flag that fired; the primary label only decides where it sits in the queue.
exception_queueModel mart_exception_queueSELECT
payment_id, primary_reason, exception_reasons, currency,
gross_minor_units, expected_settlement_date, actual_settlement_date
FROM mart_exception_queue
WHERE CAST(transaction_date AS DATE) = :scenario_date
AND currency = :currency
ORDER BY priority_rank, gross_minor_units DESC, payment_id;A payment can sit in several rows. The queue order above only sorts; it never drops a flag.
One payment, end to end
536062The recorded fee was EUR 9.56. The contract in force that day sets 110 bps, an expected fee of EUR 7.01. Amount and currency still match. Fee mismatch is the only flag.
payment_traceModel mart_payment_traceThe finding turns into one action with an owner. The same SQL runs on DuckDB and PostgreSQL, and quality checks run before anything is published.
47 EUR payments on the 2024-11-12 close carry fee-mismatch exceptions, across 8 categories. Most belong to merchants repriced on 1 Oct whose new rate never reached the processor's fee table, and the rest are everyday cross-border fee pass-throughs. Amounts and currency still agree.
Update the processor's fee table, recompute the fee on every repriced merchant's payments since the repricing, and export the filtered evidence for operations.
int_expected_settlementsEligible purchaseint_settlement_reconciliationEligible purchasemart_daily_closeClose date and currencymart_exception_queuePaymentmart_merchant_healthMerchant, date, and currencymart_payment_tracePaymentmart_category_healthCategory, date, and currency12 of 12quality checks pass on this snapshot
accepted_currenciesanomaly_features_completebenford_digit_coveragedaily_close_uniquedaily_currency_graineffective_term_joineligible_purchase_uniquematched_monetary_identitymerchant_nullabilityquality_screens_populatedreconciliation_uniqueresolved_date_consistencyexception_queueModel mart_exception_queueEXPLAIN ANALYZE
SELECT
payment_id, primary_reason, exception_reasons, currency,
gross_minor_units, expected_settlement_date, actual_settlement_date
FROM mart_exception_queue
WHERE CAST(transaction_date AS DATE) = DATE '2024-11-12'
AND currency = 'EUR'
ORDER BY priority_rank, gross_minor_units DESC, payment_id;Reproduce
python data/generate_data.pypython scripts/generate_artifacts.pypython scripts/check_sql_parity.pycd site && npm ci && npm run buildLimits
The analysis above ran once. The same query registry also powers a live workbench and a read-only MCP server, so you can check any payment in the four scripted closes.
Workbench
536062 in the workbenchFilter the queue, open a payment and read the rule that flagged it. Review notes are session-only and never change the snapshot.
Trace this payment The free Streamlit Community Cloud app may need to wake after inactivity; that pause is normal for the public demo tier.Ask Claude
https://settlement-gap-mcp.onrender.com/mcpThree read-only tools: list_queries, run_query, trace_payment. None of them accepts SQL.
claude mcp add --transport http settlement-gap https://settlement-gap-mcp.onrender.com/mcpcd mcp_server && uv run settlement-gap-mcp| Tool | What it returns |
|---|---|
list_queries | Lists the registered queries and the parameters each accepts. |
run_query | Runs one registered query with validated parameters, at most 200 rows. |
trace_payment | Explains one payment: terms, settlement, and every rule with whether it fired. |
Tested live with openai/gpt-oss-120b: through these tools it answered 12 of 15 questions correctly. Writing its own SQL against the raw tables, it got 1 of 31. How it was measured
The free server sleeps when idle, so the first call can take 30 to 60 seconds.
Why was payment 536062 flagged?
trace_payment({"payment_id": 536062})Returned 1 row.{
"scenario": "stale_electronics_eur_fee",
"as_of_date": "2025-01-10",
"primary_reason": "fee_mismatch",
"exception_reasons": ["fee_mismatch"],
"days_overdue": 0,
"settlement_status": "settled",
"fee_delta_minor_units": 255
}Payment 536062 is a EUR 637.36 Electronics purchase from the 2024-11-12 close. The settlement arrived on 2024-11-13, but the recorded fee was EUR 9.56 against EUR 7.01 under the 110 bps term, a EUR 2.55 difference, so fee_mismatch fired. No other rule applies.
trace_payment call against this snapshot, with the answer written from the fields it returned. CI makes the same call on every push and fails if the result changes.AnalyticsEngine.query