Mohamed AjabSoftware / security / dataLet’s talk
Back to the project mapLA / 14 / CASE STUDY
Working API prototype4 min read

Transaction Ledger API

Move money between two accounts without half-finishing a transfer.

A small PHP API handles transfers between accounts. It checks requests, prevents a repeated request from moving money twice, and saves the debit and credit together.

Read the case study
01 / CONTEXT

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.

02 / IMPLEMENTATION

How it works

The workflow, at a glance
  1. Authenticated transfer request
  2. Atomic debit + credit
  3. 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.

03 / REASONING

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.

04 / WALKTHROUGH

Rollback after a missing destination

ILLUSTRATIVE EXAMPLE

A valid source account has enough money for a £10 transfer, but the submitted destination account does not exist.

  1. Validation accepts the positive amount and distinct account identifiers, and the service starts a database transaction.
  2. The conditional debit succeeds, but the destination update affects no account and raises a domain error.
  3. 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.

05 / EVIDENCE

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.

06 / BOUNDARIES

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.

Where the work could go next

Possible next steps—not features or experiments already completed.

  1. Store a request-body hash with each idempotency key and reject conflicting reuse, with a regression test for changed amounts.
  2. Add concurrent retry tests against the intended production database and handle unique-key races by fetching the committed winner.
07 / REFERENCE

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.