A Rust implementation of a SEPA Instant Credit Transfer (SCT Inst) payment initiation processor, compliant with EPC121-16 (2025 Version 1.0).
It is currently assumed that the client uploads the pain.001.001.09 XML file directly to the processor, and some external system handles the Kafka messages resulting from the parsing of the XML, and sends Kafka messages reflecting the payment validation result back. The application then compiles the pain.002.001.10 report and saves it to the database for the client to fetch.
pain.001.001.09 XML
│
▼
┌──────────────────────────────────────────┐
│ Stage 1: Parse + Validate + Route │
│ │
│ 1. Full parse into memory (~30 MB/100K) │
│ 2. EPC business rule validation │
│ 3. Pluggable content-based routing │
│ 4. Emit valid transactions to Kafka │
│ 5. Build + store pain.002.001.10 │
└───────────────┬──────────────────────────┘
│
┌───────┴────────┐
│ internal │ external
▼ ▼
┌──────────────┐ ┌─────────────────┐
│ Kafka events │ │ Forward raw XML │
└──────┬───────┘ └─────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Stage 2: Processing Workers │
│ 1. Deduplication (Redis) │
│ 2. Balance reservation (Redis) │
│ 3. AML/Sanctions screening (REST) │
│ 4. Emit to clearing topic │
└───────────────┬──────────────────────────┘
│
┌────────┴────────┐
▼ ▼
CSM Adapter Clearing Results
(pacs.008) (from Kafka)
│
▼
┌──────────────────────────────────────────┐
│ Stage 3: Aggregation │
│ Collect clearing verdicts per exec date │
│ Build final pain.002 (ACCP/RJCT) │
│ Timeout: end-of-day + 25s (bank TZ) │
│ Store in PostgreSQL │
└──────────────────────────────────────────┘
- No stream parsing: Full payment initiation held in memory. Rust's efficiency (~300 bytes/transaction) makes 100K transactions cost only ~30 MB.
- Validate before emit: Nothing reaches Kafka until the entire PmtInf block passes validation. No retroactive invalidation needed.
- EPC-first: Business rules from EPC121-16 are the primary validation layer.
- Pluggable routing: Content-based routing via a chain of async plugins.
| Module | Purpose |
|---|---|
model |
Data model + XML parser for pain.001.001.09 |
validation |
EPC business rules engine + structured error reporting |
routing |
Pluggable content-based routing framework |
pain002 |
pain.002.001.10 XML builder (reject/confirm) |
kafka |
Event model for payment transactions + clearing results |
processing |
Stage 2 worker: dedup, balance, AML screening |
screening |
AML/Sanctions REST client trait + mock |
deduplication |
Redis-based MsgId/EndToEndId uniqueness |
persistence |
pain.002 report storage (in-memory store + PostgreSQL repository) |
held |
Future-dated payment holding (in-memory store) |
held_executor |
Background executor for held payments at execution time |
api |
HTTP API layer (axum) |
pipeline |
Stage 1 orchestrator tying everything together |
clearing |
Clearing aggregation storage keyed by (msg_id, execution_date) — InMemory + PostgreSQL |
clearing_aggregator |
Pure business logic: classify clearing results, build final pain.002 |
clearing_consumer |
Kafka consumer for payment.clearing.results topic |
clearing_sweep |
Background timeout sweep — deadline is end-of-day on execution date + buffer (bank timezone) |
cargo build --releasecargo run --release
# With custom configuration
OWN_BIC=NDEAFIHH OWN_NAME="ACME Bank" BIND_ADDR=0.0.0.0:8080 \
cargo run --releasecargo run --release -- path/to/pain001.xmlSubmit a pain.001.001.09 XML document for processing.
Request:
- Content-Type:
application/xml - Body: pain.001.001.09 XML
Responses:
| Status | Meaning | Body |
|---|---|---|
200 OK |
Processed immediately (accepted or rejected) | pain.002.001.10 XML |
202 Accepted |
Future-dated instruction held for later execution | pain.002.001.10 XML (acceptance) |
400 Bad Request |
Malformed XML or parse failure | Error message (text) |
Future-dated responses include an X-Execution-Date header with the scheduled
execution timestamp (RFC 3339).
The pain.002 XML body contains the validation result in both 200 and 202 cases.
Rejection details (reason codes, per-transaction status) are embedded in the XML
per EPC121-16.
Example:
curl -X POST http://localhost:8080/api/v1/payment-initiation \
-H "Content-Type: application/xml" \
-d @tests/fixtures/valid_sct_inst.xmlQuery the status of a payment initiation (primarily for held future-dated payments).
Response (200 OK):
{
"msg_id": "MSG-2025-001",
"status": "Pending",
"execute_at": "2025-04-10T00:00:00+00:00",
"tx_count": 3,
"received_at": "2025-04-02T12:00:00+00:00",
"updated_at": "2025-04-02T12:00:00+00:00"
}Possible status values: Pending, Executing, Executed, Cancelled, Failed.
Returns 404 Not Found if no held payment exists for the given MsgId.
Cancel a future-dated payment initiation before its execution date. Per EPC004-16 §4.2.1, the Originator must be allowed to cancel held instructions.
Responses:
| Status | Meaning |
|---|---|
200 OK |
Successfully cancelled |
404 Not Found |
No payment initiation found for this MsgId |
409 Conflict |
Payment cannot be cancelled (already executing, executed, or failed) |
Retrieve all pain.002 reports for a given original MsgId. Returns reports ordered by creation time (oldest first). Multiple reports can exist when a payment goes through several stages (e.g., initial validation, then clearing confirmation or timeout).
Response (200 OK):
[
{
"report_id": "a1b2c3d4-...",
"report_msg_id": "RPT-...",
"report_type": "ValidationReject",
"processing_source": "Internal",
"report_date": "2025-04-02",
"created_at": "2025-04-02T12:00:00+00:00",
"report_xml": "<?xml version=\"1.0\"?>..."
}
]Possible report_type values: ValidationReject, PositiveConfirmation,
NegativeConfirmation, ExternalResult.
Returns 404 Not Found if no reports exist for the given MsgId.
Liveness probe. Returns 200 OK with body ok.
- Body limit: 50 MB (a 100K-transaction pain.001 is ~30 MB)
- Compression: gzip response compression
- Tracing: HTTP request/response logging via
tower-http
# Run all tests
cargo test
# Run with output
cargo test -- --nocapture
# Run specific test
cargo test test_validate_valid_sct_inst_passes| File | Description |
|---|---|
valid_sct_inst.xml |
3 valid SCT Inst transactions (domestic FI, cross-border DE, NL) |
invalid_mixed_errors.xml |
1 valid + 2 invalid transactions (missing name, bad IBAN, bad BIC) |
group_level_reject.xml |
NbOfTxs mismatch — triggers group-level rejection |
- MsgId: 1-35 chars, valid reference format
- NbOfTxs: must match actual transaction count
- CtrlSum: mandatory, must match sum of instructed amounts, max 2 decimals
- InitgPty: Name max 70 chars, identification required
- PmtMtd: only
TRF - PmtTpInf: mutual exclusion between PmtInf and transaction levels
- SvcLvl/Cd: only
SEPA - LclInstrm/Cd: only
INST - NbOfTxs + CtrlSum at PmtInf level validated
- Debtor Name: mandatory, max 70 chars
- DbtrAcct: IBAN only
- DbtrAgt: BICFI or NOTPROVIDED
- ChrgBr: only
SLEV - Address: structured/hybrid/unstructured rules, max 2 AddressLines
- EndToEndId: 1-35 chars, valid reference format (no leading/trailing
/, no//) - Amount: EUR only, 0.01–999,999,999.99
- ChrgBr: only
SLEV - CdtrAgt: BICFI only
- Creditor Name: mandatory, max 70 chars
- CdtrAcct: IBAN only
- RemittanceInfo: Unstructured (1 occurrence, 140 chars) or Structured (1 occurrence, 140 chars total)
- Structured ref type: only
SCOR - Duplicate EndToEndId within PmtInf detected
All EPC121-16 section 2.2.2 codes implemented:
AC01 AC04 AC06 AG01 AG02 AM02 AM05 BE04 FF01 MD07 MS02 MS03
RC01 RR01 RR02 RR03 RR04 TM01 DNOR CNOR
AB05 AB06 AB07 AB08 AB09 AB10 AG10 AG11 AM23
The following rules from the EPC004-16 (2025 SCT Inst Rulebook v1.0) are not yet implemented and should be addressed in future iterations.
- 10-second timeout (CT-01.11R): If no confirmation from the Beneficiary PSP within 10s of Time Stamp, the Originator PSP must immediately lift the customer reservation but maintain CSM-level settlement certainty until a formal confirmation arrives.
- Belated confirmation handling (CT-01.12R/13R): Must handle positive or negative confirmations that arrive after the 10s timeout and immediately inform the Originator.
- Time Stamp precision (AT-T056): Must include at least milliseconds.
- 3 valid reasons: duplicate sending, technical error, fraud.
- Time windows: 10 Banking Business Days (duplicate/technical), 13 months (fraud).
- Only 1 recall per transaction; must use same routing path as the original.
- No recall API or DS-05/DS-06 message generation exists yet.
- For other reasons (wrong IBAN, wrong amount, originator request).
- Must warn originator that recovery is not guaranteed.
- Only 1 RFRO per transaction; same routing path as the original.
- When required by applicable law, must perform VOP check per the EPC VOP Scheme Rulebook before processing.
- No scheme-level maximum amount anymore (removed per amended SEPA Regulation).
- PSP may apply its own per-transaction or aggregate amount limits — only
transaction count (
MAX_TRANSACTIONS) is enforced today.
- AT-T014 default: Originator reference must default to
"Not provided"when absent, not null. - Remittance data (§2.7): Must be forwarded in full and without alteration.
- Address format deadline: After 22 November 2026 at 03:30 CET, only structured/hybrid address formats are allowed (no more unstructured).
- Optional DS-07 status inquiry when no confirmation within 9 seconds.
- The Originator PSP cannot consider the transaction failed until it receives a formal confirmation message.
- Must process SCT Inst instructions 24 hours a day on all calendar days, including business continuity arrangements.
- Recommended (not mandatory) to validate ISO 11649 format at point of capture.
| Component | Purpose | Required |
|---|---|---|
| Apache Kafka | Event backbone | Yes |
| Redis | Deduplication + balance reservation | Yes |
| PostgreSQL | pain.002 report storage | Yes |
| AML REST service | Sanctions screening | Yes |
| SFTP | External payment forwarding | For routing plugins |
| Variable | Default | Description |
|---|---|---|
OWN_BIC |
NDEAFIHH |
This PSP's BIC code |
OWN_NAME |
ACME Bank |
This PSP's name |
MAX_TRANSACTIONS |
100000 |
Max transactions per payment initiation |
BIND_ADDR |
0.0.0.0:8080 |
HTTP server listen address |
KAFKA_BROKERS |
localhost:9092 |
Kafka bootstrap servers for Stage 3 consumer |
BANK_TIMEZONE |
Europe/Helsinki |
IANA timezone for computing clearing deadlines |
CLEARING_EOD_BUFFER_SECS |
25 |
Seconds after midnight before timeout sweep fires |
HELD_POLL_INTERVAL_SECS |
10 |
How often to check for held payments ready to run |
RUST_LOG |
- | Log level filter (e.g. sct_inst_processor=debug) |