timefrontiers/php-wallet

Exact-money SQL wallet journal with idempotent audit-ledger projection

Maintainers

Package info

github.com/timefrontiers/php-wallet

pkg:composer/timefrontiers/php-wallet

Transparency log

Statistics

Installs: 11

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.1 2026-08-14 06:34 UTC

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; and
  • ext-pdo plus ext-pdo_mysql when 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, UncertainOperationException is 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:

  • guardedsubmitTransfer(), submitConvertedTransfer();
  • unaffectedtransfer(), convertedTransfer(), batchTransfer(), externalCredit(), reverse(), createWallet(), setStatus(); and
  • deliberately openreconcilePayout() and lookup(), 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.

License

MIT