Wallets
A robust PHP library for managing digital wallets and financial transactions with full audit trails, transaction chain integrity, and support for reserved funds.
Features
- Multiple wallets per user - Each user can have multiple wallets for different currencies
- Multiple currencies - Support for any currency type (fiat, crypto, points, etc.)
- Reserved funds - Pre-authorize and block funds for pending transactions
- Transaction chain integrity - Immutable transaction history with UUID linking and checksums
- Atomic operations - All balance updates are atomic and transactional
- Flexible scale - Support for different decimal places (cents, satoshis, whole units)
- Overdraft support - Optional negative balances with configurable minimum values
- Audit trail - Complete transaction history with balance snapshots
- Idempotent operations - UUID-based transaction deduplication
- Extensible - Easily extend wallets and transactions with custom fields
- Transactional outbox - Guaranteed at-least-once event delivery to message brokers
Installation
composer require byjg/wallets
Quick Start
use ByJG\Wallets\Service\WalletService;
use ByJG\Wallets\Service\WalletTypeService;
use ByJG\Wallets\Service\TransactionService;
use ByJG\Wallets\Entity\TransactionEntity;
use ByJG\Wallets\Entity\WalletEntity;
use ByJG\Wallets\Entity\WalletTypeEntity;
use ByJG\Wallets\Repository\WalletRepository;
use ByJG\Wallets\Repository\WalletTypeRepository;
use ByJG\Wallets\Repository\TransactionRepository;
use ByJG\Wallets\DTO\TransactionDTO;
use ByJG\AnyDataset\Db\DatabaseExecutor;
use ByJG\AnyDataset\Db\Factory;
// Initialize database connection
$dbExecutor = DatabaseExecutor::using(Factory::getDbInstance('mysql://user:pass@localhost/dbname'));
// Initialize repositories
$walletTypeRepo = new WalletTypeRepository($dbExecutor, WalletTypeEntity::class);
$transactionRepo = new TransactionRepository($dbExecutor, TransactionEntity::class);
$walletRepo = new WalletRepository($dbExecutor, WalletEntity::class);
// Initialize services
$walletTypeService = new WalletTypeService($walletTypeRepo);
$transactionService = new TransactionService($transactionRepo, $walletRepo);
$walletService = new WalletService($walletRepo, $walletTypeService, $transactionService);
// Create a wallet type
$walletType = new WalletTypeEntity();
$walletType->setWalletTypeId('USD');
$walletType->setName('US Dollar');
$walletTypeService->update($walletType);
// Create a wallet with $100.00 initial balance (10000 cents)
$walletId = $walletService->createWallet('USD', 'user-123', 10000, 2);
// Add funds: $50.00
$transaction = $transactionService->addFunds(
TransactionDTO::create($walletId, 5000)
->setDescription('Deposit from bank account')
);
// Withdraw funds: $30.00
$transaction = $transactionService->withdrawFunds(
TransactionDTO::create($walletId, 3000)
->setDescription('Purchase payment')
);
// Reserve funds for pending withdrawal: $20.00
$reserve = $transactionService->reserveFundsForWithdraw(
TransactionDTO::create($walletId, 2000)
->setDescription('Pre-authorization')
);
// Accept the reservation
$transactionService->acceptFundsById($reserve->getTransactionId());
// Get wallet balance
$wallet = $walletService->getById($walletId);
echo "Available: " . ($wallet->getAvailable() / 100) . " USD\n";
Documentation
- Getting Started - Installation, database setup, and quick start guide
- Wallet Management - Create, retrieve, and manage wallets
- Transaction Operations - Add, withdraw, and query transactions
- Reserved Funds - Pre-authorize and manage pending transactions
- Extending Entities - Add custom fields to wallets and transactions
- Transactional Outbox - Guaranteed event delivery to message brokers
- Database Schema - Complete database schema documentation
Full documentation is available at https://opensource.byjg.com/docs/php/wallets
Core Concepts
Integer Storage with Scale
All monetary amounts are stored as integers (BIGINT) representing the smallest currency unit:
// USD with scale=2 (cents)
$walletId = $walletService->createWallet('USD', 'user-123', 10000, 2);
// 10000 represents $100.00
// Bitcoin with scale=8 (satoshis)
$btcWalletId = $walletService->createWallet('BTC', 'user-123', 100000000, 8);
// 100000000 represents 1.00000000 BTC
// Loyalty points with scale=0 (whole units)
$pointsWalletId = $walletService->createWallet('POINTS', 'user-123', 1000, 0);
// 1000 represents 1000 points
Balance Components
Each wallet maintains three balance components:
- balance - Total funds (reserved + available)
- reserved - Funds held for pending transactions
- available - Funds available for immediate use
$wallet = $walletService->getById($walletId);
echo "Balance: " . $wallet->getBalance(); // 10000 (total)
echo "Reserved: " . $wallet->getReserved(); // 2000 (blocked)
echo "Available: " . $wallet->getAvailable(); // 8000 (usable)
Transaction Types
| Type | Code | Description |
|---|---|---|
| Balance | B | Initial balance or reset |
| Deposit | D | Add funds immediately |
| Withdraw | W | Remove funds immediately |
| Deposit Blocked | DB | Reserve for incoming funds |
| Withdraw Blocked | WB | Reserve funds for withdrawal |
| Reject | R | Reverse a reserved transaction |
Transaction Chain Integrity
Every transaction links to the previous via uuid and previousuuid, creating an immutable audit trail:
$tx1 = $transactionService->addFunds($dto1); // previousuuid = null
$tx2 = $transactionService->addFunds($dto2); // previousuuid = $tx1->uuid
$tx3 = $transactionService->withdrawFunds($dto3); // previousuuid = $tx2->uuid
Each transaction includes a SHA-256 checksum covering every business field and
chained to the previous transaction's checksum, so rewriting one row invalidates
the whole subsequent chain. Optionally, pass an installation secret to
TransactionService to make the checksums impossible to recompute with database
access only. Use verifyChain($walletId) in a reconciliation job to validate the
full chain. See docs/transaction-operations.md.
Use Cases
E-commerce Platform
// Reserve funds when order is placed
$reserve = $transactionService->reserveFundsForWithdraw(
TransactionDTO::create($walletId, 9999)
->setDescription('Order #12345')
->setReferenceSource('ecommerce')
->setReferenceId('order-12345')
);
// Capture payment when order ships
$transactionService->acceptFundsById($reserve->getTransactionId());
// Or cancel and release funds if order is cancelled
// $transactionService->rejectFundsById($reserve->getTransactionId());
Multi-Currency Wallets
// Create multiple wallets for same user
$usdWallet = $walletService->createWallet('USD', 'user-123', 100000, 2);
$eurWallet = $walletService->createWallet('EUR', 'user-123', 50000, 2);
$btcWallet = $walletService->createWallet('BTC', 'user-123', 5000000, 8);
// Transfer between wallets (with exchange rate logic in your app)
$walletService->transferFunds($usdWallet, $eurWallet, 10000);
Gaming/Betting Platform
// Reserve bet amount
$betReserve = $transactionService->reserveFundsForWithdraw(
TransactionDTO::create($walletId, 5000)
->setDescription('Bet on Game #789')
);
// User wins - reject withdrawal and add winnings
$transactionService->rejectFundsById($betReserve->getTransactionId());
$transactionService->addFunds(
TransactionDTO::create($walletId, 10000)
->setDescription('Bet winnings')
);
// User loses - accept withdrawal
// $transactionService->acceptFundsById($betReserve->getTransactionId());
API Reference
WalletService
createWallet(string $walletTypeId, string $userId, int $balance, int $scale = 2, int $minValue = 0, ?string $extra = null): intgetById(int $walletId): array|WalletEntitygetByUserId(string $userId, string $walletType = ""): arraygetByWalletTypeId(string $walletTypeId): arrayoverrideBalance(int $walletId, int $newBalance, int $newScale = 2, int $newMinValue = 0, string $description = "Reset Balance"): ?intpartialBalance(int $walletId, int $balance, string $description = "Partial Balance"): TransactionEntitycloseWallet(int $walletId): ?inttransferFunds(int $walletSource, int $walletTarget, int $amount): array
TransactionService
addFunds(TransactionDTO $dto): TransactionEntitywithdrawFunds(TransactionDTO $dto, bool $capAtZero = false): TransactionEntityreserveFundsForWithdraw(TransactionDTO $dto): TransactionEntityreserveFundsForDeposit(TransactionDTO $dto): TransactionEntityacceptFundsById(int $transactionId, ?TransactionDTO $transactionDto = null): intacceptFundsByUuid(Literal|string $uuid, ?TransactionDTO $transactionDto = null): intrejectFundsById(int $transactionId, ?TransactionDTO $transactionDto = null): intrejectFundsByUuid(Literal|string $uuid, ?TransactionDTO $transactionDto = null): intacceptPartialFundsById(int $transactionId, TransactionDTO $transactionDtoWithdraw, TransactionDTO $transactionDtoRefund): TransactionEntitygetById(int|string $transactionId): ?TransactionEntitygetByDate(int $walletId, string $startDate, string $endDate): arraygetByUuid(Literal|string $uuid): ?TransactionEntitygetRepository()->getByWalletId(int $walletId, int $limit = 20): arraygetRepository()->getByReferenceId(int $walletId, string $referenceSource, string $referenceId, ?string $startDate = null, ?string $endDate = null): arraygetRepository()->getByCode(int $walletId, string $code, ?string $startDate = null, ?string $endDate = null): arraygetRepository()->getByParentId(int $parentId, bool $forUpdate = false): ?TransactionEntitygetReservedTransactions(?int $walletId = null): arrayisTransactionReserved(?int $transactionId = null): boolverifyChain(int $walletId): ChainVerificationResultrecordOutbox(TransactionEntity $transaction): void(no-op unless the outbox is enabled)
Constructor: new TransactionService($transactionRepo, $walletRepo, ?string $checksumSecret = null, ?OutboxRepository $outboxRepository = null)
OutboxService
dispatch(int $limit = 100): OutboxDispatchResult- deliver pending outbox entries to yourOutboxProcessorInterfacepurgeProcessed(?int $olderThanDays = null): intcountPending(): int
Running Tests
# Start MySQL container
docker run --name mysql-container --rm \
-e MYSQL_ROOT_PASSWORD=password \
-p 3306:3306 -d mysql:8.0
# Run migrations
vendor/bin/migrate up mysql://root:password@localhost/test -path=db
# Run tests
vendor/bin/phpunit