Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

receive-rails

A runnable simulation of Wise Platform Receive: how inbound cross-border and domestic money actually moves into a partner's multi-currency account, and how you would own the compliance risk and the data behind it.

Wise Platform is Wise-for-enterprises. Banks, neobanks, and fintechs (Monzo, N26, Bank Mandiri, and the like) let their own customers receive funds, internationally and domestically, into multi-currency accounts under the partner's own brand, on Wise rails and licensing. "Receive" is the pay-in side of that: money lands, gets screened, gets converted and settled, and has to reconcile to the cent.

This repo models that inbound flow as working code. It ships configurable pay-in corridors, an explicit inbound-payment lifecycle from initiated to reconciled, a compliance screening trust boundary that routes payments to auto-clear or hold-for-review, a reconciliation engine that surfaces breaks, and a real SQLite + SQL analytics layer a Receive PM runs to decide which corridor to launch next.

It runs with zero setup, no API key, and only the Python standard library. The numbers are deterministic, so what you see is what a reviewer sees.


The headline number

Processed 1,200 inbound payments across 10 corridors and 5 markets. 1,099 entered reconciliation scope (1,086 settled and reconciled, 13 failed to settle and were flagged as missing-settlement breaks). Of those, 1,046 reconciled clean (95.2%) and 53 were flagged as breaks (4.8%). 85 payments (8.4%) were held for compliance review, of which 2 were false positives (0.2% of legitimate flow), and 16 uncleared holds were returned to the sender. Screening recall on the labeled set is 1.000 overall, but that number is two mechanisms: hard rules cover 73.7% of labeled-suspicious payments (the catastrophic classes), and the heuristic scorer earns 100% recall on the 26 gray-zone suspicious payments that trip no hard rule. Precision is 0.980.

"Reached settlement" would be loose here: 13 of the 1,099 never settled (the rail delivered nothing) and the missing credit was flagged as a break, so the framing is reconciliation scope, not settlement. The summed settled value is a mixed-currency figure (corridors settle in GBP, EUR, SGD, USD, and IDR), and it is IDR-dominated, so it is not a headline number. The report carries it lower down in destination-equivalent minor units with that currency caveat rather than dressing a single inflated total up front. The headline that matters is the payment count and the reconciliation clean rate.

Every figure above is read back from the SQLite database the pipeline wrote, and it is deterministic, so make report prints the same numbers on your machine.

Four commands, no pip install, no API key:

make demo        # run a handful of inbound payments end to end and narrate each
make report      # headline money-movement numbers + correctness scorecard
make analytics   # the SQL analytics layer a Receive PM lives in
make test        # settlement math, lifecycle invariants, screening, reconciliation

No Make? Run the same things directly:

python -m receive_rails.demo
python -m receive_rails.report
python -m receive_rails.analytics.runner
python -m unittest discover -s tests

Requires Python 3.9+. sqlite3 is part of the standard library, so there is no database to install.


How inbound money moves

The corridor model

A corridor is a config. Launching a corridor means adding one of these, nothing more:

{
  "corridor_id": "USD_SGD_SWIFT_SG",
  "market": "SG",
  "src_currency": "USD",
  "dst_currency": "SGD",
  "rail": "swift",                 // "swift" (international) | "local" (domestic scheme)
  "scheme": "SWIFT",               // FPS, SEPA_INST, FAST, ACH, SWIFT
  "fee_fixed_minor": 300,          // SGD 3.00
  "fee_bps": 45,                   // 0.45%
  "fx_margin_bps": 55,             // 0.55% applied to the mid-market rate
  "screening_profile": "enhanced", // standard | enhanced
  "amount_hold_threshold_minor": 1500000,  // SGD 15,000 -> always hold above this
  "active": true
}

The catalog spans both kinds of rail that matter for Receive: international SWIFT corridors (USD into SGD, for example) and domestic local scheme corridors (GBP into GBP over FPS, EUR into EUR over SEPA Instant). Same-currency local corridors carry no FX margin; cross-currency SWIFT corridors carry both an FX margin and fees. The distinction is not cosmetic. Local rails settle fast and cheap; SWIFT is slower with a heavier tail, and the analytics layer measures exactly that.

The inbound-payment lifecycle

Every inbound payment moves through an explicit, enforced state machine. Nothing skips screening, and nothing reaches settled without a cleared screening state.

initiated ──> screened ──> fx_applied ──> settled ──> reconciled
                  │
                  ├──> held_for_review   (model said hold, or a hard rule fired)
                  ├──> returned          (held and not cleared, or account closed)
                  └──> failed            (rail rejection, invalid account)
State What it means
initiated Inbound credit received from the partner/sender. Nothing applied yet.
screened Compliance screening ran. Carries an auto-clear or hold decision. No payment skips this.
held_for_review Off-path. Entered when screening says hold, or any high-stakes hard rule fires. Never settles without manual clearance.
fx_applied Mid-market rate snapshot taken, FX margin and fees applied, expected net computed. Reachable only from screened + auto-clear.
settled Funds credited to the partner's multi-currency account in the destination currency. Settlement is idempotent.
reconciled Expected vs settled matched, or a break flagged and recorded. The normal terminal state.
returned Off-path terminal. Funds sent back.
failed Off-path terminal. Settlement could not complete.

Settlement math

Money is stored as integer minor units (cents), never floats, so settlement is exact and reproducible. The conversion applies the FX margin to the mid-market rate first, then applies fees, in a defined order, with documented rounding. Same-currency corridors skip the FX step entirely.

The exactness is not asserted by hand-waving. The test suite recomputes expected_net_dst from the settlement function for every settled payment and checks it to the minor unit, and proves net never exceeds the converted gross. See make test.


Owning compliance risk: the screening trust boundary

Every inbound payment is screened before it can settle. Screening routes a payment to one of two places, with a hard override on top.

Tier Trigger Outcome
Auto-clear Backend risk score below the threshold AND no hard rule fired Proceeds straight to fx_applied. Optimizes for not blocking legitimate partner funds.
Hold-for-review (model-driven) Backend risk score at or above the threshold (tighter for enhanced corridors) Routed to held_for_review. This is the tunable precision/recall dial.
High-stakes override (always holds) Sanctions-list name match, restricted-jurisdiction sender, or amount at/above the corridor threshold Holds regardless of model confidence, even at score 0.02. hard_rule_fired records which rule triggered, so every hold is auditable.

The design stance, stated plainly because it drives every threshold:

  • A false positive blocks a partner's legitimate funds. That is partner pain, ops cost, and churn risk.
  • A false negative is a regulatory incident.

So the hard rules are deliberately recall-maximizing on the catastrophic cases (sanctions, restricted jurisdictions, over-threshold amounts). The model score only governs the gray zone in between. The model never overrides a hard rule. The scorer never reads the labeled test set, so the numbers in the report are honest, not fitted.

Because recall blends those two mechanisms, the report splits them: a hard-rule coverage figure (the share of labeled-suspicious payments a hard rule caught) and a model recall on gray-zone suspicious figure (of the labeled-suspicious payments that trip no hard rule, the share the scorer held on its own). Overall recall stays reported, but labeled so it cannot be read as a pure model number. The gray-zone class is real: the dataset plants labeled-suspicious payments with a risky sender name and an elevated origin country but an ordinary amount, so only the scorer's corroborating signals (not the override layer) can catch them.


Reconciliation

Settling money is not the same as accounting for it. The reconciliation engine matches expected net against settled net for every payment and classifies each one:

Status Meaning
matched Expected equals settled. Clean.
amount_mismatch Settled, but the amount differs from expected.
missing_settlement Expected to settle, but no settlement landed.
unexpected_credit A settlement with no matching expectation.

It aggregates breaks per corridor and totals the unmatched value, so "reconciled clean" means a specific, measurable thing: every settled payment is either matched or flagged with a break type and a delta. No payment is silently unaccounted for. The test suite enforces that completeness invariant.


The SQL analytics layer

This is the part a Receive PM actually lives in. Everything (payments, corridors, partners, screening results, reconciliation results) persists to SQLite via one canonical schema, and the analytics/ folder holds real, non-trivial .sql queries (joins, window functions for percentiles, conditional aggregation for rates) that answer the questions you would ask before and after launching a corridor. No SELECT *.

Query The PM question it answers
corridor_volume_success_by_market.sql Where is the money actually moving, and how cleanly?
settlement_time_p50_p95_by_rail.sql How slow is SWIFT vs local scheme, and what is the tail?
screening_hold_and_fp_rate_by_corridor.sql Which corridors are we over-blocking legitimate partner funds on?
reconciliation_break_rate_and_unmatched_value.sql Where are funds unaccounted for, and how much?
next_corridor_to_launch.sql Which corridor do we invest in next? (volume opportunity weighed against friction)

make analytics runs each one against the rebuilt database and prints a labeled table. Settlement latency is a per-payment draw, not a fixed per-rail constant, so the percentiles measure a real distribution. Local schemes clear in a tight band around two minutes; SWIFT runs ~30 minutes at the median but carries a long correspondent-bank tail, so p95 lands hours out. This is the actual output of one query from a fresh run, and it is the friction the "next corridor" view prices in:

[2] Settlement time p50 and p95 by rail
    Q: How slow is SWIFT vs local scheme, and how ugly is the tail?
    (settlement_time_p50_p95_by_rail.sql, 2 rows)

rail   settled_count  p50_seconds  p95_seconds  p50_minutes  p95_minutes
-----  -------------  -----------  -----------  -----------  -----------
local            506          125          178          2.1            3
swift            580         1869        13241         31.1        220.7

The SWIFT p95 sitting at 3.7 hours against a 31-minute p50 is the point: the tail, not the median, is what a partner feels. The "which corridor to launch next" view turns all of this into a single ordered decision, weighting normalized volume against that settlement tail, hold/false-positive rate, and break rate. That is the artifact, not a dashboard screenshot.


Correctness and eval

make report prints the headline money-movement numbers and a correctness scorecard. make test proves the system is right, not just runnable:

  • Settlement math reproduces expected_net_dst exactly, to the minor unit, for every settled payment.
  • Reconciliation completeness: every settled payment is matched or flagged. Zero silently unaccounted for.
  • Break detection: each injected break type (amount mismatch, missing settlement, unexpected credit) is caught with the right status and delta.
  • Screening precision and recall computed against ground-truth labels the scorer never sees, reported as a hard-rule coverage figure and a separate gray-zone model recall figure so the two detection mechanisms are not conflated.
  • Hard-rule guarantee: sanctions / restricted-jurisdiction / over-threshold payments hold 100% of the time, regardless of model score.
  • Lifecycle invariants: nothing reaches settled without a cleared screening state; settlement is idempotent (re-running does not double-credit).
  • Determinism: headline numbers are identical across two consecutive fresh runs, because the dataset is seeded and never touches the wall clock.

Optional: AI-assisted screening

The default screening backend is a deterministic heuristic scorer. It is fully self-contained, needs no key, and works the gray zone from payment features (sender-name risk tokens, sender-country risk tier, amount ratio vs the corridor norm, partner MCC band, and a round-amount heuristic). Recall on the catastrophic classes is the hard rules' job; this scorer sets precision and earns the gray-zone true positives. It is the default for a reason: the system's correctness does not depend on a model call.

For the gray zone, there is an optional Claude backend. Turn it on with environment variables; it changes nothing about the hard rules or the lifecycle:

pip install "anthropic>=0.69"
RECEIVE_RAILS_SCREEN_BACKEND=claude ANTHROPIC_API_KEY=sk-... make demo

It uses the Messages API with structured JSON output for a risk score plus reasons, and falls back to the stub backend on any error, so the demo never breaks. Models: claude-sonnet-4-6 by default; override with CLAUDE_MODEL (for example claude-opus-4-8 or claude-haiku-4-5-20251001). The anthropic import is lazy, so a keyless clone never touches the SDK.

AI is a small accent here, not the point. The center of gravity is corridor mechanics, the inbound lifecycle, reconciliation, and the SQL.


What I would build next

Honest scope boundaries, in priority order:

  • Real sanctions-list integration (the in-repo list is a small fixture).
  • Partner-facing hold notifications and a clearance workflow for held payments.
  • Multi-leg settlement and corridor liquidity modeling.
  • Per-partner SLA reporting on settlement time and hold rate.

Project layout

receive-rails/
  README.md                  this file
  Makefile                   demo | report | analytics | test | install
  requirements.txt           core: none (stdlib). optional: anthropic, claude backend only
  demo.py                    convenience shim -> python -m receive_rails.demo
  receive_rails/
    money.py                 integer-minor-unit settlement math (FX margin, fees, rounding)
    models.py                frozen dataclasses for the domain
    lifecycle.py             the inbound-payment state machine + invariants
    corridors.py             the seed corridor catalog + mid-market rates
    partners.py              the seed partner roster (Receive customer archetypes)
    screening/               hard rules, stub scorer, optional Claude backend, decision logic
    reconciliation.py        expected vs settled matching, break detection
    dataset.py               deterministic synthetic payment generator (seeded)
    engine.py                orchestrates the inbound pipeline; writes to SQLite
    db.py                    SQLite persistence (stdlib sqlite3)
    schema.sql               canonical DDL shared by the writer and the SQL authors
    analytics/               the real SQL a Receive PM runs + a stdlib table runner
    report.py                headline numbers + correctness scorecard
    demo.py                  the narrated zero-setup walkthrough
  tests/                     settlement, lifecycle, screening, reconciliation, analytics, e2e

Start with make demo to watch payments move, then make analytics to see the decisions the data supports.


Built by Anjali Vashisth. Questions: kitanjali.an@gmail.com

About

A Wise Platform Receive style corridor simulation: inbound cross-border pay-in lifecycle, FX/settlement, reconciliation, a compliance screening trust boundary, and the SQL analytics a Receive PM runs. Runs offline, no API key.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages