What this project is about
A transfer endpoint needs a stronger contract than inserting a row. The source must not be debited without the destination being credited, insufficient funds must leave no partial changes, and an ordinary retry must not create a second transfer. Ledger API demonstrates these concerns in a small dependency-free PHP 8.2 application backed by SQLite.
The narrow scope makes the entire request path reviewable: authenticate, validate, perform a transaction and return a structured result. The project also exposes account-entry history and a public liveness endpoint that the separate Integration Monitor can check. It is a local financial-software example, not a deployed banking service or a full double-entry accounting platform.
How it works
- Authenticated transfer request
- Atomic debit + credit
- Ledger entry or rollback
Protect the transfer as one database operation
The service begins a transaction before changing balances. Its debit statement only succeeds when the source account has sufficient funds; the destination credit and transfer record follow. If the destination is missing or another operation fails, the catch path rolls back the transaction. This ties the audit record to the same success boundary as the two balance updates.
Give requests a stable identity
Every transfer requires a caller-supplied idempotency key. The service checks for an existing record before performing a new transfer, while a database uniqueness constraint prevents duplicate stored keys. A sequential retry returns the original result and identifies it as a duplicate. This version does not bind the key to a payload hash, so it cannot distinguish an identical retry from accidental reuse with different transfer details.
Keep the HTTP contract small and explicit
Amounts are positive integer minor units: 1,250 represents £12.50. Parameterised SQL separates values from query structure. API-key authentication uses a constant-time comparison, and errors expose stable codes alongside readable messages. New transfers return HTTP 201 and recognised retries return HTTP 200. Account history derives debit or credit direction from the account's role in each stored transfer.
The important design choices
Minimise setup requirements
Use PHP's PDO interface and SQLite with no application-framework dependency.
The trade-offReviewers can run and inspect the system quickly, but its database concurrency model is not evidence for a high-volume, multi-worker deployment. A production database needs its own locking and isolation tests.
Expose predictable errors
Separate machine-readable error codes from human-readable explanations and reject invalid transfer requests before updating balances.
The trade-offThis supports reliable callers, but a complete public API would also need documented compatibility rules, request limits, credential rotation and richer operational logging.
Rollback after a missing destination
A valid source account has enough money for a £10 transfer, but the submitted destination account does not exist.
- Validation accepts the positive amount and distinct account identifiers, and the service starts a database transaction.
- The conditional debit succeeds, but the destination update affects no account and raises a domain error.
- The error path rolls back the debit, leaving the source balance unchanged and avoiding a completed transfer record.
What this showsThe caller receives an account-not-found error without a partial money movement. The repository has a regression test for this behavior.
Illustrative example, not a production incident.
How it was tested—and what it shows
The checks
The in-memory SQLite test suite checks a successful transfer, a repeated idempotency key, insufficient funds and rollback when the destination account does not exist. Assertions inspect account balances and stored transfer counts rather than only checking a returned success flag. The defect note explains the retry problem and identifies concurrent uniqueness races and payload-aware keys as further hardening; it is an explanatory project artifact, not verified evidence of a customer incident.
What to take from the results
The implementation provides an understandable example of atomic updates and safe sequential retries. Its limits are also concrete: idempotency keys are global and key-only, and the duplicate-key race is not converted into a winning-record response. Authentication is a shared API key. These distinctions matter when comparing this smaller project with ClearLedger's tenant-scoped, payload-aware payment model.
What to keep in mind
- An API prototype does not cover the operational, legal and security requirements of a financial institution.
- The local SQLite design is not a claim of production payment processing.
Technical notes & source
Built with
PHP 8.2 · SQLite · REST · regression tests
Terms used in this project
- Idempotency
- Repeating the same request does not repeat its effect.