timefrontiers / php-wallet
Exact-money SQL wallet journal with idempotent audit-ledger projection
Requires
- php: >=8.5
- ext-json: *
- ext-mysqli: *
- timefrontiers/php-sql-database: ^1.1
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.13
Suggests
- ext-pdo: Required only when SQLDatabase::pdo() is selected
- ext-pdo_mysql: Required only for the PDO-MySQL driver-parity path
This package is auto-updated.
Last update: 2026-08-14 12:16:46 UTC
README
timefrontiers/php-wallet 1.1 is an exact-money, SQL-authoritative wallet
journal for PHP 8.5 and newer. It supports atomic same-currency transfers and
explicit cross-currency transfers using an exact rate supplied by the caller.
The package never discovers a market rate and no financial API accepts a
float.
Requirements
- PHP 8.5 or newer on a 64-bit platform;
ext-json;ext-mysqli(required by SQL Database 1.1's dual-driver facade);- MySQL 8.0.16+ or MariaDB 10.5+ using InnoDB;
timefrontiers/php-sql-database:^1.1; andext-pdoplusext-pdo_mysqlwhen the PDO-MySQL path is selected.
Installation
composer require timefrontiers/php-wallet:^1.1
Install the schema into an empty database, either from the command line:
mysql -h 127.0.0.1 -u wallet -p wallet_db < schema/install.sql
or programmatically, which is the supported path for application bootstrap, provisioning scripts, and CI:
use TimeFrontiers\Wallet\Infrastructure\Sql\SchemaInstaller; $installer = new SchemaInstaller($database); $created = $installer->install(); // true on first install, false if already current
SchemaInstaller interprets the DELIMITER directives that schema/install.sql
needs for the mysql client, so the same authoritative file serves both paths.
It refuses to install over existing wallet tables that do not carry the expected
schema marker, throwing PersistenceException with safe code
incompatible_schema rather than guessing whether that data can be discarded or
converted.
Version 1.1 is deliberately a clean installation: there is no v1.0 backup, migration, float compatibility facade, or legacy file import.
After connecting, fail startup early on an incompatible schema:
use TimeFrontiers\SQLDatabase; use TimeFrontiers\Wallet\Application\Service\WalletService; use TimeFrontiers\Wallet\Domain\Value\CurrencyRegistry; $database = SQLDatabase::pdo( driver: 'mysql', host: '127.0.0.1', port: 3306, database: 'wallet', user: $databaseUser, password: $databasePassword, ); $currencies = CurrencyRegistry::fromArray([ 'USD' => 2, 'NGN' => 2, ]); $wallets = new WalletService($database, $currencies); $wallets->assertSchemaVersion();
The hardened service accepts TimeFrontiers\SQLDatabase, not a raw PDO
connection. Use the normal SQL Database facade for MySQLi or
SQLDatabase::pdo() for PDO-MySQL.
Exact money
Currencies have an immutable exponent and amounts are signed PHP integers in minor units:
use TimeFrontiers\Wallet\Domain\Value\Currency; use TimeFrontiers\Wallet\Domain\Value\Money; $usd = new Currency('USD', 2); $tenDollars = Money::fromMinor($usd, 1000); $alsoTenDollars = Money::fromDecimal($usd, '10.00');
Decimal input is a plain string. Scientific notation, commas, non-zero excess
precision, infinity, NaN, and values outside the signed 64-bit domain are
rejected. Arithmetic checks currency/exponent equality and overflow.
Wallet creation
Creation is explicit and idempotent. Wallet addresses have the registered
219 prefix; a constructor never creates persistent state.
use TimeFrontiers\Wallet\Application\Command\CreateWalletCommand; use TimeFrontiers\Wallet\Domain\Value\IdempotencyKey; $wallet = $wallets->createWallet(new CreateWalletCommand( idempotencyKey: new IdempotencyKey('account-42-usd-wallet-v1'), ownerReference: 'account-42', currency: $usd, ));
The unique owner/currency constraint is the concurrency authority. Repeating the same command returns the original wallet.
Same-currency transfer
use TimeFrontiers\Wallet\Application\Command\TransferCommand; $result = $wallets->transfer(new TransferCommand( idempotencyKey: new IdempotencyKey('payout-2026-000123'), sourceAddress: $sourceAddress, destinationAddress: $destinationAddress, amount: Money::fromMinor($usd, 2500), narration: 'Payout to partner wallet', externalReference: 'payout-000123', ));
The source and destination must both be active and use the command currency and exponent. The database transaction locks wallets by ascending private ID, guards cached balances and versions, inserts immutable matched entries, and enqueues projection and outbox work before commit.
Cross-currency transfer
The caller supplies an exact rate whose direction is always:
1 source major unit = rate destination major units
use TimeFrontiers\Wallet\Application\Command\ConvertedTransferCommand; use TimeFrontiers\Wallet\Domain\Value\ExchangeRate; $rate = ExchangeRate::fromDecimal( sourceCurrency: 'USD', destinationCurrency: 'NGN', destinationPerSource: '1550.25', quoteReference: 'quote-8472', quotedAt: $quotedAtUtc, expiresAt: $expiresAtUtc, ); $converted = $wallets->convertedTransfer(new ConvertedTransferCommand( idempotencyKey: new IdempotencyKey('conversion-2026-000044'), sourceAddress: $usdAddress, destinationAddress: $ngnAddress, debitAmount: Money::fromMinor($usd, 10000), rate: $rate, narration: 'USD to NGN settlement', ));
An exact ratio is also accepted with ExchangeRate::fromRatio(). The converter
uses the source and destination currency exponents and half-up rounding:
destination_minor = HALF_UP(
source_minor × rate_numerator × 10^destination_exponent
/ (rate_denominator × 10^source_exponent)
)
The implementation cross-cancels factors and uses overflow-safe integer division. It persists the normalized ratio, original canonical rate, rate direction, quote timestamps/reference, both exact amounts, rounding mode, rounding occurrence, remainder, and divisor. It never generates an inverse rate. Replay returns the persisted conversion instead of recalculating it.
Other financial operations
batchTransfer()atomically debits one source and credits distinct destinations in the same currency. The default limit is 100 and hard limit is 500 items.externalCredit()records an authorized outside source/reference and credits one wallet. Provider verification and spent-state ownership remain with the host.reverse()creates new inverse entries for a committed two-leg transfer. Converted reversal is full-only and uses the original exact amounts; no new rate is requested or calculated.lookup()returns a committed semantic result by caller idempotency key.setStatus()supports active/frozen/closed lifecycle transitions. Closed wallets cannot be reopened and a non-zero wallet cannot be closed.
Authorization is a host responsibility. Knowledge of an address is not proof that an actor may debit, credit, convert, freeze, close, or reverse it.
Idempotency and uncertain commits
Every persistent financial command has a caller-owned key covering the entire operation namespace. A canonical SHA-256 fingerprint binds the key to all semantic input, including normalized rate data and ordered batch items.
- Same key and fingerprint returns the original committed result.
- Same key with different semantic input throws
IdempotencyConflictException. - An ambiguous database commit is looked up using the original key.
- If a matching commit cannot be proven,
UncertainOperationExceptionis returned. Do not generate a replacement key; reconcile the original key.
SQL truth and file projection
SQL owns balances, operations, entries, idempotency, projection work, outbox work, and reconciliation cases. The file ledger is a post-commit audit projection and is never read to authorize value movement.
use TimeFrontiers\Wallet\Infrastructure\Ledger\ArrayHmacKeyResolver; use TimeFrontiers\Wallet\Infrastructure\Ledger\FileLedger; use TimeFrontiers\Wallet\Infrastructure\Worker\LedgerProjectionWorker; $keys = new ArrayHmacKeyResolver('2026-01', [ '2026-01' => $ledgerHmacKey, ]); $ledger = new FileLedger($privateLedgerDirectory, $keys); $worker = new LedgerProjectionWorker($database, $ledger, 'projector-01'); $worker->run(maximumJobs: 100);
Ledger files are canonical JSON Lines chained by the SQL entry hashes. Each record and head manifest is HMAC-SHA-256 authenticated with a versioned runtime key. The key is not stored with the ledger. Projection replay detects an already-appended identical entry; a conflicting hash or invalid chain is quarantined for reconciliation.
Archives roll deterministically by UTC YYYY-MM. Every new archive starts
with an authenticated non-financial header carrying the prior sequence, head
hash, and balance. FileLedger::files() explicitly enumerates all periods;
financial history queries should normally use SQL.
Store the ledger directory outside the web root with access limited to the worker account. Retain old HMAC keys while any record or manifest uses their version.
Outbox and reconciliation
OutboxWorker provides leased, at-least-once delivery through an injected
EventPublisher. Consumers must therefore be idempotent by operation/event
identity.
ReconciliationService::reconcileCachedBalances() compares every cached
wallet balance with the signed immutable entry sum and creates a durable,
deduplicated open reconciliation case for discrepancies. It reports problems;
it does not rewrite financial history.
reconcileFileLedger() compares authenticated file sequence/head state with
SQL, while reconcileConversionSnapshots() checks both converted entry legs
against the operation's exact persisted rate and rounding snapshot.
To recover files, construct a FileLedger in a separate empty directory and
call LedgerRebuildService::rebuild(). The service streams committed SQL
entries in bounded keyset batches, appends them through the normal verifier,
and verifies every rebuilt wallet. It never changes balance, operation, entry,
or live projection state; activation remains an explicit operational step.
Payout gateway boundary
This package is standalone. It owns wallet balances, entries, and operations — nothing else. It has no knowledge of billing, invoices, subscriptions, commissions, or any particular host application, and it never calls a payment provider.
PayoutWalletGateway is the narrow contract a host implements against when it
wants to use a wallet as a value-delivery rail. It exposes same-currency
submission, explicitly authorized converted submission, and reconcilePayout()
lookup that never initiates value movement. WalletService implements it.
The dispatch guard
The gateway is the unattended path: a host worker calls it after its own commit, with no human in the loop. It is therefore shut by default, so that installing or wiring this package can never by itself begin moving value.
// Default: submitTransfer() and submitConvertedTransfer() throw // PayoutDispatchDisabledException. $wallets = new WalletService($database, $currencies); // An operator opens the path deliberately. $wallets = new WalletService($database, $currencies, payoutDispatchEnabled: true);
payoutDispatchEnabled() reports the current state. The guard covers only the
two gateway submit methods:
- guarded —
submitTransfer(),submitConvertedTransfer(); - unaffected —
transfer(),convertedTransfer(),batchTransfer(),externalCredit(),reverse(),createWallet(),setStatus(); and - deliberately open —
reconcilePayout()andlookup(), which never move value, so an uncertain payout can always be resolved while dispatch is shut.
A refused dispatch claims no idempotency key and writes no entries. Opening the guard changes nothing about how a transfer behaves, including replay.
The rules for any such host are the same:
- commit your own obligation and outbox record before calling this package;
- supply your stable payout key as the wallet idempotency key, and your own reference as external metadata only;
- after an ambiguous response, call
reconcilePayout()with the same key — never submit a replacement; and - keep your own accounting as the source of truth. A wallet balance is the result of delivery, not the record of what was owed.
Crediting a wallet delivers internal wallet value. A bank or mobile-money disbursement is a different rail and must not be simulated by crediting a wallet.
See docs/operations.md for worker and incident handling.
Verification
composer validate --strict
composer dump-autoload --strict-psr --optimize
composer test
composer analyse
composer style
composer audit
Database contract and concurrency tests require an explicitly disposable,
pre-installed schema configured with WALLET_TEST_DB_*. Unit, conversion, and
filesystem suites do not require a database.