Skip to main content

Changelog - Version 7.0

Status: in development. This document tracks changes landing on the 7.0 branch. Nothing here is released yet, and the contents may still change.

Overview​

Version 7.0 upgrades the library to byjg/micro-orm 7.0 (and byjg/migration 7.0 for development), which rewired the observer system on top of the per-connection DatabaseExecutor observer and removed the global ORMSubject singleton. Starting with this release, the library version is aligned with the byjg/micro-orm major version.

This release also hardens the ledger reliability guarantees: transfers between wallets are now atomic, caller-supplied UUIDs act as idempotency keys, reserved transactions can be accepted/rejected by UUID, transaction rows are immutable at the database level, a reserved transaction can be processed only once (enforced by a unique index), transaction checksums are now chained hashes covering every business field, optionally keyed with an installation secret, and an opt-in transactional outbox guarantees at-least-once event delivery to message brokers.

The public API of the Wallets library is backward compatible with 6.x, except for the removal of the TransactionEntity::calculateChecksum()/validateChecksum() static methods (see Breaking Changes); new methods were added. Observers registered with Repository::addObserver() keep receiving the same payloads as in 6.x: the wallet Update event still carries the updated WalletEntity plus the pre-change wallet as old data, and the transaction Insert event still carries the rehydrated TransactionEntity.

Breaking Changes​

ComponentBefore (6.x)After (7.0)Description
byjg/micro-orm^6.0^7.0ORMSubject removed; observers dispatched per database connection
byjg/migration (dev)^6.0^7.0Query execution moved from the driver to DatabaseExecutor
Observer scopeGlobal (ORMSubject singleton)Per database connectionObservers fire only for writes flowing through the connection of the repository where they were registered
ORMSubject usageORMSubject::getInstance()RemovedAny direct userland usage (e.g. clearObservers() in test suites) must be removed
Ledger immutabilityUPDATE on transaction rows allowedBlocked by a database trigger (migration 00002)Corrections must be made with new transactions, never by editing history
Reserved transaction processingSingle processing enforced by application logic onlyAlso enforced by a unique index on transactionparentid (migration 00002)Direct SQL writes can no longer double-process a reservation
Wallet observer events on accept/rejectTwo Update notifications (balance save + last_uuid save)One Update notificationacceptFundsById()/rejectFundsById() now persist the wallet with a single save
transferFunds() failure behaviorWithdraw could commit even when the deposit failedAll-or-nothingBoth movements run inside a single database transaction
Checksum APITransactionEntity::calculateChecksum() / validateChecksum() static methodsByJG\Wallets\Checksum classes (ChecksumInterface, ChecksumV1, ChecksumV2, ChecksumFactory)The entity statics were removed; resolve the algorithm with ChecksumFactory::get($row->getChecksumVersion()) or ChecksumFactory::current()
Checksum algorithmHash of amount|balance|reserved|available|uuid|previousuuidChained hash of every business field + previous checksum + optional secret (migration 00003)Old rows keep checksumversion = 1 and validate with the legacy algorithm

New Features​

Idempotency keys (caller-supplied UUIDs)​

TransactionDTO::setUuid() is now honored instead of being overwritten: the UUID acts as an idempotency key. Retrying addFunds()/withdrawFunds()/reserveFunds*() with the same UUID and the same data returns the original transaction instead of creating a duplicate movement (enforced by the unique index on uuid). Reusing a UUID with different data throws a TransactionException. When no UUID is supplied, one is generated automatically, as before.

UUID-based operations​

  • TransactionService::getByUuid($uuid): ?TransactionEntity - look up a transaction by its UUID
  • TransactionService::acceptFundsByUuid($uuid, ?TransactionDTO $dto = null): int
  • TransactionService::rejectFundsByUuid($uuid, ?TransactionDTO $dto = null): int

Combined with idempotency keys, the whole reserve/accept lifecycle can be driven with identifiers the caller generated and owns, without storing the internal transaction ids.

Other new methods​

  • TransactionRepository::getAllByWalletId(int $walletId): array - every transaction of a wallet, without pagination, ordered by transactionid (used by verifyChain())
  • TransactionService::getChecksumSecret(): string - the secret given to the constructor (empty string when none)
  • TransactionService::recordOutbox(TransactionEntity $transaction): void - writes the outbox entry for a transaction; a no-op unless the outbox is enabled

Chain verification for reconciliation jobs​

TransactionService::verifyChain(int $walletId): ChainVerificationResult walks the transaction chain from wallet.last_uuid back to the genesis transaction, validating every checksum, confirming the wallet balances equal the head transaction's snapshot, and detecting broken links, cycles and orphan rows. Run it from a scheduled job to detect ledger corruption as soon as it happens.

Chained, versioned checksums with an optional secret (checksum v2)​

The transaction checksum now covers every business field (wallet, type, amount, scale, balances, code, description, references, parent id, UUIDs) and is chained to the previous transaction's checksum (stored in the new previouschecksum column): tampering with any row invalidates every subsequent checksum, so rewriting history requires recomputing the whole chain forward.

Optionally, pass an installation secret to the TransactionService constructor to turn the checksum into a keyed hash that cannot be recomputed by an attacker with database access only:

$transactionService = new TransactionService(
$transactionRepository,
$walletRepository,
getenv('WALLET_CHECKSUM_SECRET')
);

Checksum algorithms are classes in the ByJG\Wallets\Checksum namespace implementing ChecksumInterface. Each row records the algorithm that produced it in the new checksumversion column, so rows created before the upgrade (version 1) keep validating with the legacy algorithm. verifyChain() validates each row with its own algorithm, checks the previous-checksum links, flags checksum version downgrades, and reports the number of legacy rows via ChainVerificationResult::getLegacyChecksums() (a number that must never grow).

Transactional outbox (opt-in)​

When an OutboxRepository is passed as the 4th constructor argument of TransactionService, every created ledger transaction also writes a transaction.created entry to the new outbox table inside the same database transaction: if the money moved, the event exists; if the operation rolled back, no ghost event. The existing in-process observers are unchanged - the outbox is the guaranteed channel next to them.

OutboxService::dispatch() (run from a worker loop or cron) delivers pending entries to a user-implemented OutboxProcessorInterface (typically a message-broker publisher) with at-least-once semantics: a processor exception keeps the entry pending (attempts counter, last error recorded) and it is retried on the next run. purgeProcessed() and countPending() cover housekeeping and monitoring. See docs/outbox.md.

Atomic transfers​

WalletService::transferFunds() now runs the withdrawal and the deposit inside a single SERIALIZABLE database transaction, locking both wallets in a consistent order (preventing deadlocks between opposite concurrent transfers) and validating that both wallets exist before any money moves. Transfers to the same wallet are rejected with a WalletException.

Reliability Fixes​

  • transferFunds() was not atomic: the withdraw and the deposit committed independently, so a failure between them lost money from the source wallet. Both now roll back together.
  • Rollback guards catch Throwable: all transactional operations (updateFunds, acceptFundsById, acceptPartialFundsById, rejectFundsById, overrideBalance) previously caught only Exception; a PHP Error escaped without an explicit rollback. They now catch Throwable, guaranteeing the rollback-and-rethrow contract for every abnormal exit.
  • acceptFundsById()/rejectFundsById() saved the wallet twice (balance change, then last_uuid); consolidated into a single save inside the same transaction.
  • TransactionDTO::setToTransaction() bug: a stray if (!empty($this->get)) statement made the custom-properties loop dead code, silently dropping extended-entity properties in the accept/reject flows. Fixed.
  • Overdraft (minValue) was never enforced: a negative minValue did not allow an overdraft, because the available >= 0 CHECK constraints rejected any withdraw or reserve below zero. Now the wallet's available can go down to its minValue (migration 00005): withdraw, reserve and transfer fail with AmountException below it, and withdrawFunds(..., capAtZero: true) caps the amount at the minValue instead of at zero. With the default minValue of 0 nothing changes.

Database Schema Changes (migrations 00002 to 00005)​

-- Migration 00002
ALTER TABLE `transaction`
ADD CONSTRAINT `idx_transaction_parentid_unique` UNIQUE (`transactionparentid`);

CREATE TRIGGER `trg_transaction_no_update` BEFORE UPDATE ON `transaction`
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Ledger transactions are immutable and cannot be updated';

-- Migration 00003
ALTER TABLE `transaction`
ADD COLUMN `previouschecksum` varchar(64) NULL AFTER `checksum`,
ADD COLUMN `checksumversion` tinyint NOT NULL DEFAULT 1 AFTER `previouschecksum`;
  • The unique index guarantees at the database level that a reserved transaction is accepted or rejected only once, even if the application logic is bypassed.
  • The trigger rejects any UPDATE on the transaction ledger. DELETE is not blocked by the schema; restrict it with database grants in production.
  • previouschecksum chains each checksum to the previous transaction's checksum, and checksumversion records the algorithm that produced the row (existing rows default to the legacy version 1).
-- Migration 00004
CREATE TABLE `outbox` (
`outboxid` int(11) NOT NULL AUTO_INCREMENT,
`transactionid` int(11) NOT NULL,
`uuid` binary(16) NOT NULL,
`event` varchar(40) NOT NULL,
`status` enum('pending','processed') NOT NULL DEFAULT 'pending',
`attempts` int NOT NULL DEFAULT 0,
`lasterror` varchar(500) DEFAULT NULL,
`createdat` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`processedat` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`outboxid`),
KEY `idx_outbox_status` (`status`, `outboxid`),
KEY `idx_outbox_transactionid` (`transactionid`)
);
  • The outbox table stays empty unless the outbox is enabled. There is intentionally no foreign key to transaction (extended entities may use a different ledger table) and no immutability trigger (the dispatcher updates the delivery status).
-- Migration 00005
ALTER TABLE `transaction` DROP CONSTRAINT `transaction_chk_value_nonnegative`;
ALTER TABLE `wallet` DROP CONSTRAINT `wallet_chk_value_nonnegative`;
ALTER TABLE `wallet` ADD CONSTRAINT `wallet_chk_available_minvalue` CHECK (`available` >= `minvalue`);
  • A wallet's available can go down to its minvalue. The ledger row has no minvalue; the wallet row is updated in the same database transaction, so its constraint guards every operation.

Observer scope: global → per connection​

In 6.x an observer registered on any repository was notified globally. In 7.0 the observer is attached to the DatabaseExecutor of the repository where it was registered. Since WalletRepository and TransactionRepository must share the same connection for the atomic wallet/transaction writes, this is transparent for the common setup where both repositories are created from the same DatabaseExecutor. If you observe from a repository on a different connection, register the observer on a repository of the connection that performs the writes.

Internal Changes​

  • TransactionService::updateFunds() no longer uses ORMSubject::getInstance()->notify(). It now builds the transaction insert and wallet update statements upfront, defers their observer notification, executes them inside the SERIALIZABLE transaction, and after commit attaches the entities and flushes the notifications through the micro-orm 7 ObserverBridge. Observers keep receiving entity payloads, after commit, in the same order as before (wallet Update, then transaction Insert). Nothing is notified on rollback.
  • The two statements are executed directly through the repository executor instead of bulkExecute(), within the same transaction, preserving atomicity.

Path to Upgrade from 6.x to 7.0​

Step 1: Update Composer Dependencies​

composer require byjg/wallets:^7.0

This will also update byjg/micro-orm to ^7.0 and byjg/anydataset-db to ^7.0.

Step 2: Remove ORMSubject Usage (If Applicable)​

If your code (typically test suites) calls ORMSubject::getInstance()->clearObservers() or notifies observers manually, remove those calls. Observers die with their executors; there is no global state to clear. To remove a specific observer, use Repository::removeObserver().

Step 3: Review Observer Registration (If Applicable)​

ObserverProcessorInterface implementations require no code change. Just make sure each observer is registered on a repository whose connection performs the writes you want to observe. Note that acceptFundsById()/rejectFundsById() now emit a single wallet Update notification instead of two; adjust observers (or tests) that counted on the second event.

Step 4: Apply the Database Migrations​

Migration 00002 adds the unique index on transactionparentid and the immutability trigger; migration 00003 adds the previouschecksum and checksumversion columns; migration 00004 creates the outbox table (inert unless the outbox is enabled); migration 00005 replaces the available >= 0 constraints with available >= minvalue:

migrate update

Before applying, make sure no existing data violates the new unique index (each transactionparentid value must appear at most once) and that nothing in your application issues UPDATE statements against the transaction table, and that no wallet has available below its minvalue (00005 would fail to add the constraint). Existing rows keep checksumversion = 1 and continue validating with the legacy algorithm; new rows are created with the chained v2 checksum automatically.

Step 5: Update Checksum API Usage (If Applicable)​

If your code called TransactionEntity::calculateChecksum() or TransactionEntity::validateChecksum(), use the ByJG\Wallets\Checksum classes instead:

use ByJG\Wallets\Checksum\ChecksumFactory;

$checksum = ChecksumFactory::current()->calculate($transaction);
$isValid = ChecksumFactory::get($transaction->getChecksumVersion())
->validate($transaction, $transaction->getChecksum());

Optionally configure a checksum secret (see the New Features section) — recommended for production, since without it an attacker with database access can recompute the chain.

Step 6: Test Your Application​

vendor/bin/phpunit

Support​

For questions or issues with the migration:

Requirements​

  • PHP 8.3, 8.4, 8.5 and 8.6 are now supported: "php": ">=8.3 <8.7". The previous <8.6 upper bound excluded PHP 8.6, since <8.6 is exclusive.

ByJG dependencies​

  • byjg/micro-orm is now ^7.0.
  • byjg/migration is now ^7.0.

While 7.0 is unreleased these resolve to 7.0.x-dev from each component's 7.0 branch, via minimum-stability: dev with prefer-stable: true.

Toolchain​

  • PHPUnit updated to ^12.5.

  • Psalm is installed as psalm/phar instead of vimeo/psalm.

    vimeo/psalm lists the PHP versions it supports and no published release includes 8.6, so as a dev dependency it made composer install fail on the 8.6 build job before any test ran. psalm/phar requires only php ^8.2 and bundles its own dependencies, so it installs on every PHP version in the matrix and cannot conflict with the project's. Psalm itself still refuses to run on 8.6, which is why the Psalm job uses 8.5. composer psalm runs it.

  • PHPUnit 13 is deliberately not used. It requires PHP >=8.4.1, breaking the 8.3 floor.

Continuous Integration​

  • The build matrix now includes PHP 8.6.
  • The Psalm job runs on PHP 8.5.

Housekeeping​

  • phpunit.xml.dist renamed to phpunit.xml.