Payments Engine
2025-11-11
Related GitHub Repository: https://github.com/Daktic/payments-engine
Overview
The payments-engine is a small, self-contained Rust application that ingests a stream of client transactions from a CSV file and produces a normalized account ledger as CSV to stdout. It supports the typical flows used in simple payment platforms:
- deposit
- withdrawal
- dispute
- resolve
- chargeback
The engine maintains per-client account state with available, held, and total balances and a locked status. Disputes and chargebacks move funds between available and held and can lock the account.
This repository is intentionally minimal: it uses the csv crate for robust, RFC 4180–compliant parsing, and still avoids external decimal/big-number crates by using a lightweight fixed-point Amount type.
How to use
-
Prerequisites: Rust stable toolchain installed.
-
Build and run:
$ cargo run -- <path-to-transactions.csv>
# Example
$ cargo run -- example_datasets/good_data.csv
- Output: A CSV is written to stdout with columns: client,available,held,total,locked
- Save the result to a file:
$ cargo run -- input.csv > output.csv
Input CSV format (header required):
| type | client | tx | amount |
|---|---|---|---|
| deposit | 1 | 1 | 100.5 |
| withdrawal | 1 | 2 | 50 |
| dispute | 1 | 1 | |
| resolve | 1 | 1 | |
| chargeback | 1 | 1 |
Run tests:
$ cargo test
Structure
The project is organized to separate parsing, domain logic, and output concerns. The engine module segments different aspects of the transaction workflow (account state, transaction types, and orchestration), while the Amount type lives in src/lib.rs rather than a separate amount module to keep it central and reusable by both the binary and internal modules.
Project file tree:
.
├── Cargo.toml
├── README.md
├── example_datasets/
│ └── bad_data.csv
├── src/
│ ├── main.rs # CLI entry point
│ ├── lib.rs # Amount type (fixed-point currency)
│ ├── parser.rs # Streaming CSV parser
│ ├── output.rs # CSV writer for final accounts
│ └── engine/ # Core domain logic segmented by responsibility
│ ├── account.rs # Account model and balance rules
│ ├── engine.rs # Engine orchestrating transaction application
│ └── transaction.rs# TxKind enum and TransactionEvent
└── output.csv # Example output artifact
Key files:
- src/main.rs: CLI entry point; reads a CSV file path from argv, streams parsed events into the engine, and prints accounts to stdout.
- src/lib.rs: Defines the fixed-point Amount type used to represent currency safely with four decimal places (used across modules; no separate amount module).
- src/parser.rs: Streaming CSV parser that converts lines into TransactionEvent values.
- src/output.rs: Writes final account states as CSV.
- src/engine/: Core domain logic segmented by workflow stage
- engine.rs: The Engine orchestrating transaction application and keeping in-memory indices.
- account.rs: Account data model and balance mutation rules.
- transaction.rs: Transaction types and event struct.
- example_datasets/: Sample inputs.
Dependencies
- Runtime:
- csv = "1.4" (RFC 4180–compliant CSV parser)
- Rust:
- Edition: 2024
- No external decimal/big-number crates; currency uses an internal fixed-point Amount type.
Cargo.toml snippet:
[dependencies]
csv = "1.4.0"
Limitations
- Transaction consistency and duplicates:
- The engine ignores exact duplicate events: if a new event arrives with the same tx id and the same transaction kind as an already-seen event, it is skipped.
- Reusing a tx id with a different kind is not prevented; the newer event overwrites the stored record for that tx id, which can affect downstream dispute/resolve/chargeback lookups and lead to incorrect amounts.
- The specifications define the transaction IDs as globally unique, but this is not enforced by the engine.
- Amount domain:
- Only non-negative amounts; no fees or negative adjustments supported.
- Fixed scale of 4 decimal places; values outside i64 range after scaling are rejected.
- Accounts and storage:
- In-memory only; no persistence or concurrency controls.
- Engine will create a default account when a non-deposit event is seen for an unknown client.
- Error handling:
- Many invalid rows are skipped with warnings; processing continues best-effort.
Design decisions
- Fixed-point Amount instead of floats or external decimal crates to keep dependencies minimal while avoiding typical floating-point issues.
- Streaming parser over std::io::Read to allow large files and flexible input sources (files, stdin, network streams).
- Minimal, explicit domain model (Account, Engine, TxKind, TransactionEvent) to keep logic readable and testable.
- Side-effect strategy:
- Engine records all deposit/withdrawal events by tx id for later dispute flows.
- Disputes move funds from available to held without locking the account, allowing continued account activity while disputes are pending.
- Chargebacks remove held funds and immediately lock the account permanently, preventing any future transactions.
- Resolves release held funds back to available; once locked by a chargeback, the account cannot be unlocked.
- Dispute applicability: only deposit transactions are eligible for dispute. Attempts to dispute non-deposit transaction types (withdrawal, resolve, chargeback) are rejected and produce no state changes; a dedicated unit test in the engine verifies this behavior.
- Rationale: in typical payment flows, disputes are raised against incoming funds credited to an account (deposits). Withdrawals are client-initiated outflows and are not reversible via dispute in this simplified model, while resolve and chargeback are administrative actions within the dispute lifecycle rather than original financial transfers. Enforcing this prevents invalid creation/movement of held funds and keeps state transitions coherent.
- Simplicity over completeness: The implementation focuses on clarity and robustness for typical cases rather than covering every edge (e.g., full CSV spec, idempotency, multi-client dispute validation), though effort has been made to minimize the number of edge cases.
Information about the Structs and files
Structs
-
Amount (src/lib.rs)
-
Fixed-point integer wrapper that stores values as i64 scaled by 10,000 (four decimal places).
-
Key methods:
from_f64,to_f64; implements Add/Sub, AddAssign/SubAssign, Display. -
Constraints: non-negative, finite values only; rejects NaN/inf and too-large numbers.
-
Account (src/engine/account.rs)
-
Fields:
available: Amount,held: Amount,locked: bool(total is computed as available + held). -
Methods:
-
new, total, status
-
deposit(amount): increases available; fails if locked. -
withdraw(amount): decreases available; fails if locked or insufficient available. -
dispute(amount): moves amount from available to held; fails if insufficient available or account is locked. -
resolve(amount): moves amount from held back to available; fails if account is locked. -
chargeback(amount): removes funds from held and permanently locks the account; fails if insufficient held funds. -
TxKind (src/engine/transaction.rs)
-
Enum of supported transaction types: Deposit, Withdrawal, Dispute, Resolve, Chargeback.
-
Helpers: Display, from_str.
-
TransactionEvent (src/engine/transaction.rs)
-
Struct with fields:
kind,client: u16,tx: u32,amount: Option<Amount> -
Used by the parser and the engine to carry an event through the pipeline.
-
Engine (src/engine/engine.rs)
-
Fields:
accounts: HashMap<u16, Account>,transactions: HashMap<u32, TransactionEvent> -
Public API:
new(),accounts() -> &HashMap<u16, Account>,apply(event) -
Behavior:
-
Deposit/Withdrawal: apply to the client account, persist the event under its tx id.
-
Dispute/Resolve/Chargeback: look up the original tx by id to get the amount, then mutate the client account accordingly.
-
If an event arrives for a missing client, a default account is created and a warning is emitted.
Files
-
src/parser.rs (Parser)
-
Streaming reader over any Read; trims fields and supports quoted amounts and types.
-
Skips the header; yields
Result<TransactionEvent, String>for each data row. -
src/output.rs (Output)
-
Writes a header, then one row per client in ascending client id order with available, held, total, and locked columns.
AI usage
I utilized an AI to discuss the requirements, break down different architectural designs and limitations to best suit the task needs. The result of that process was distilled into a set of instructions used to build the initial structure and design of the project, which was used as a guide throughout development.
Ai was then able to generate a fairly comprehensive initial structure and codebase for this task, however, reading over the code revealed some alterations in design decisions such as avoiding keeping excess data. In general, generated code does not imprint as much context for the developer, so writing the code, utilizing autocomplete and reading from the suggested generation was used to better retain the context and information. Tests were a mix of generation, autocomplete, and manual intervention as changes were made in the codebase. The README was entirely generated using an agent, then refined for clarity and correctness as edge cases were addressed.
Additional notes
- The project was first attempted with no external dependencies as they felt unneeded. The csv crate was later added to apply a more robust ingestion pipeline.
- The requirement that new accounts be created for all transactions was implemented, but feels unnecessary as the account will be initialized with a default value, and deposits will always create an account.
- The existing Amount implementation could be replaced with a more robust fixed-point arithmetic library, but the project was designed to be as simple as possible.
- Time/space considerations: after reviewing runtime characteristics, the engine’s in-memory HashMap allocations (for accounts and transactions) grow without bound during processing because there is no eviction/expiry policy. In a real-world deployment, historical transaction storage could be used to reconcile balances over time; however, this would not capture disputes, resolves, and chargebacks by itself. Since we intentionally do not persist those events as first-class records (to minimize stored data), account status cannot be fully reconstructed from historical deposits/withdrawals alone unless dispute-related events are also recorded.