Pular para o conteúdo principal

Transaction Operations

Transaction DTO​

All transaction operations use TransactionDTO to pass data:

use ByJG\Wallets\DTO\TransactionDTO;

// Create with wallet ID and amount
$dto = TransactionDTO::create($walletId, 5000);

// Create empty (for partial operations)
$dto = TransactionDTO::createEmpty();

// Set optional properties
$dto->setDescription('Purchase payment')
->setCode('PMT')
->setReferenceId('order-12345')
->setReferenceSource('ecommerce')
->setUuid($customUuid); // Optional: auto-generated if not set

TransactionDTO Properties​

PropertyTypeDescription
walletIdintTarget wallet ID
amountintTransaction amount in smallest unit
descriptionstringHuman-readable description
codestringTransaction code for categorization (max 10 chars)
referenceIdstringExternal reference ID
referenceSourcestringSource system name
uuidstringIdempotency key (auto-generated when not supplied)
scaleintScale used by setAmountFloat()

setAmountFloat(float $amount, int $scale = 2) converts a decimal amount to the integer amount, and setProperty(string $name, ?string $value) passes values for custom fields of an extended TransactionEntity (see Extending Entities).

Add Funds (Deposit)​

Add funds immediately to a wallet:

$transaction = $transactionService->addFunds(
TransactionDTO::create($walletId, 10000)
->setDescription('Bank deposit')
->setCode('DEP')
->setReferenceId('bank-tx-456')
->setReferenceSource('bank-api')
);

echo "New balance: " . ($transaction->getBalance() / 100);
echo "Transaction ID: " . $transaction->getTransactionId();

Creates a Deposit (D) transaction that:

  • Increases balance by amount
  • Increases available by amount
  • Does not affect reserved

Withdraw Funds​

Remove funds immediately from a wallet:

$transaction = $transactionService->withdrawFunds(
TransactionDTO::create($walletId, 5000)
->setDescription('ATM withdrawal')
->setCode('ATM')
);

Creates a Withdraw (W) transaction that:

  • Decreases balance by amount
  • Decreases available by amount
  • Validates that available - amount >= minValue (minValue is 0 unless the wallet allows an overdraft)
Insufficient Funds

Throws AmountException if the withdraw would take available below minValue.

Pass capAtZero: true to withdraw as much as possible instead of failing: the amount is reduced so available stops at minValue, and the DTO receives the amount actually withdrawn.

// Wallet with available = 1000 and minValue = 0: withdraws 1000, not 1500
$transaction = $transactionService->withdrawFunds(TransactionDTO::create($walletId, 1500), capAtZero: true);

Transaction Types​

Immediate Transactions​

TypeCodeOperationDescription
BalanceBResetSets wallet to a specific balance, ignoring history
DepositDAddAdds funds immediately
WithdrawWSubtractRemoves funds immediately
RejectRReverseReverses a reserved transaction

Reserved Transactions​

TypeCodeOperationDescription
Deposit BlockedDBReserve for depositReserves space for incoming funds
Withdraw BlockedWBReserve for withdrawalBlocks funds for pending withdrawal

See Reserved Funds for details on pending transactions.

Retrieving Transactions​

Get Transaction by ID​

$transaction = $transactionService->getById($transactionId);

echo "Amount: " . ($transaction->getAmount() / 100);
echo "Type: " . $transaction->getTypeId();
echo "Balance after: " . ($transaction->getBalance() / 100);

Get Transactions by Wallet​

// Get the latest transactions of a wallet (default limit: 20)
$transactions = $transactionService->getRepository()->getByWalletId($walletId);

// Get with a different limit
$transactions = $transactionService->getRepository()->getByWalletId(
walletId: $walletId,
limit: 50
);

Get Transactions by Date Range​

$transactions = $transactionService->getByDate(
walletId: $walletId,
startDate: '2024-01-01',
endDate: '2024-01-31'
);

Get Transactions by Reference​

$transactions = $transactionService->getRepository()->getByReferenceId(
walletId: $walletId,
referenceSource: 'ecommerce',
referenceId: 'order-12345'
);
// Optional: startDate / endDate to restrict the period

// Get transactions by code
$transactions = $transactionService->getRepository()->getByCode($walletId, 'PMT');

Get Reserved Transactions​

// Get all pending reserved transactions for a wallet
$reserved = $transactionService->getReservedTransactions($walletId);

foreach ($reserved as $tx) {
echo $tx->getTypeId(); // 'DB' or 'WB'
echo $tx->getAmount();
}

Get Transaction by UUID​

$transaction = $transactionService->getByUuid($uuid);

if ($transaction === null) {
// No transaction with this UUID
}

Accept or Reject Reserved Funds by UUID​

When the caller supplies its own UUIDs (see Idempotency), the whole reserve/accept lifecycle can be driven with identifiers the caller owns - no need to store the internal transaction id:

$uuid = 'F47AC10B-58CC-4372-A567-0E02B2C3D479';

$transactionService->reserveFundsForWithdraw(
TransactionDTO::create($walletId, 5000)->setUuid($uuid)
);

// Later, e.g. when an external callback arrives:
$transactionService->acceptFundsByUuid($uuid);
// or
$transactionService->rejectFundsByUuid($uuid);

There is no need to check for duplicates before creating a transaction: calling addFunds()/withdrawFunds() again with the same UUID and the same data returns the original transaction (see Idempotency).

Transaction Entity Properties​

Core Properties​

PropertyTypeDescription
transactionIdintUnique transaction identifier
walletIdintWallet this transaction belongs to
walletTypeIdstringWallet type (for denormalization)
typeIdstringTransaction type (B/D/W/DB/WB/R)
amountintTransaction amount (always positive)
scaleintDecimal scale at time of transaction
datedatetimeTransaction timestamp

Balance Snapshots​

These represent wallet state after this transaction:

PropertyTypeDescription
balanceintTotal balance after transaction
reservedintReserved amount after transaction
availableintAvailable amount after transaction

Metadata​

PropertyTypeDescription
codestringTransaction code (e.g., 'DEP', 'PMT')
descriptionstringHuman-readable description
transactionParentIdint|nullParent transaction for accept/reject operations
referenceIdstring|nullExternal reference identifier
referenceSourcestring|nullExternal system name

Integrity Fields​

PropertyTypeDescription
uuidbinary(16)Unique transaction identifier for idempotency
previousUuidbinary(16)|nullUUID of previous transaction (chain integrity)
checksumstring(64)SHA-256 hash of transaction data
previousChecksumstring(64)|nullChecksum of the previous transaction (hash chain)
checksumVersionintAlgorithm that produced the checksum (1 = legacy)

Helper Methods​

Float Conversions​

$transaction = $transactionService->getById($transactionId);

// Get values as floats based on scale
$amountFloat = $transaction->getAmountFloat(); // 50.00
$balanceFloat = $transaction->getBalanceFloat(); // 150.75
$reservedFloat = $transaction->getReservedFloat(); // 25.50
$availableFloat = $transaction->getAvailableFloat(); // 125.25

Checksum Validation​

Checksum algorithms are classes implementing ChecksumInterface. Every row records the algorithm that produced it (checksumVersion), and ChecksumFactory resolves the right one:

use ByJG\Wallets\Checksum\ChecksumFactory;

// Calculate a checksum with the current algorithm (v2)
$checksum = ChecksumFactory::current()->calculate($transaction);

// Validate a row with the algorithm that produced it
$algorithm = ChecksumFactory::get($transaction->getChecksumVersion());
$isValid = $algorithm->validate($transaction, $transaction->getChecksum());

if (!$isValid) {
throw new Exception('Transaction data integrity compromised!');
}

The current checksum (version 2) covers every business field, chained to the previous transaction's checksum:

SHA256(walletid|wallettypeid|typeid|amount|scale|balance|reserved|available|
code|description|referenceid|referencesource|transactionparentid|
uuid|previousuuid|previouschecksum|secret)

Because each checksum includes the previous one, tampering with any row invalidates every subsequent checksum: rewriting history requires recomputing the whole chain forward. Rows created before the upgrade (checksumVersion = 1) keep validating with the legacy algorithm (SHA256(amount|balance|reserved|available|uuid|previousuuid)).

Checksum Secret​

Without a secret, an attacker with full database access can recompute the chain. Pass an installation secret to TransactionService to turn the checksum into a keyed hash that cannot be recomputed without it:

$transactionService = new TransactionService(
$transactionRepository,
$walletRepository,
getenv('WALLET_CHECKSUM_SECRET')
);
  • Keep the secret out of the database (environment variable, secret manager).
  • Once transactions are created with a secret, the same secret must always be provided; verification fails otherwise.
  • Legacy (v1) rows predate the secret and keep validating without it.

Transaction Chain Integrity​

Every transaction links to the previous transaction via previousUuid, creating an immutable chain:

$transaction1 = $transactionService->addFunds(...); // previousUuid = null
$transaction2 = $transactionService->addFunds(...); // previousUuid = $transaction1->uuid
$transaction3 = $transactionService->withdrawFunds(...); // previousUuid = $transaction2->uuid

This ensures:

  1. Chronological ordering of transactions
  2. Tamper detection - any modification breaks the chain
  3. Auditability - can verify entire transaction history

Verifying the Chain​

verifyChain() walks the chain from the wallet's last_uuid back to the genesis transaction, validating every checksum (with the algorithm each row was created with) and confirming the wallet balances match the head transaction's snapshot. It detects tampered wallet state, tampered transaction data, deleted rows (broken links), forked/orphan rows, cycles, broken previous-checksum links and checksum version downgrades.

$result = $transactionService->verifyChain($walletId);

if (!$result->isValid()) {
foreach ($result->getErrors() as $error) {
// e.g. "Checksum mismatch on transaction 42 (UUID ...)"
alertOperations($error);
}
}

echo $result->getTransactionsVerified(); // number of transactions walked
echo $result->getLegacyChecksums(); // rows still carrying a pre-v2 checksum

In a healthy wallet getLegacyChecksums() never grows — it only shrinks as old rows are superseded by new activity. An increase means a row was rewritten with a downgraded checksum.

Run it from a scheduled reconciliation job so a corruption is detected as soon as it happens, not when a human audits the ledger.

Observer callbacks are in-process and best-effort: an event can be lost if PHP dies after the commit. For guaranteed delivery to a message broker, enable the transactional outbox.

Idempotency​

Supply your own UUID as an idempotency key to prevent duplicate transactions. A retry with the same UUID and the same data returns the original transaction instead of creating a new movement:

// The idempotency key is generated by the caller, once per business operation
$uuid = 'F47AC10B-58CC-4372-A567-0E02B2C3D479';

// First attempt - creates the transaction
$transaction = $transactionService->addFunds(
TransactionDTO::create($walletId, 5000)
->setUuid($uuid)
->setDescription('Payment')
);

// Retry after a timeout (same UUID, same data) - returns the original transaction
$replay = $transactionService->addFunds(
TransactionDTO::create($walletId, 5000)
->setUuid($uuid)
->setDescription('Payment')
);
// $replay->getTransactionId() === $transaction->getTransactionId()

Reusing a UUID with different data throws a TransactionException (The UUID was already used by a different transaction).

When no UUID is supplied, one is generated automatically and every call creates a new movement.

Error Handling​

Common Exceptions​

use ByJG\Wallets\Exception\AmountException;
use ByJG\Wallets\Exception\TransactionException;
use ByJG\Wallets\Exception\WalletException;

try {
$transaction = $transactionService->withdrawFunds(
TransactionDTO::create($walletId, 100000)
);
} catch (AmountException $e) {
// Insufficient funds or invalid amount
echo "Amount error: " . $e->getMessage();
} catch (WalletException $e) {
// Wallet not found or invalid
echo "Wallet error: " . $e->getMessage();
} catch (TransactionException $e) {
// Transaction operation failed
echo "Transaction error: " . $e->getMessage();
}

Amount Validation​

// Amount must be positive
$transactionService->addFunds(TransactionDTO::create($walletId, -100)); // Throws AmountException

// Must respect minValue
$walletService->createWallet('USD', $userId, 1000, 2, 0);
$transactionService->withdrawFunds(
TransactionDTO::create($walletId, 2000) // Throws AmountException
);

Best Practices​

  1. Always use TransactionDTO

    // Good
    $dto = TransactionDTO::create($walletId, 5000)
    ->setDescription('Purchase')
    ->setCode('PMT');
    $transactionService->addFunds($dto);

    // Bad - don't create TransactionEntity directly
  2. Set meaningful descriptions and codes

    $dto->setDescription('Monthly subscription payment')
    ->setCode('SUB')
    ->setReferenceId('subscription-789')
    ->setReferenceSource('billing-system');
  3. Use reference fields for linking

    // Link to external order
    $dto->setReferenceSource('ecommerce')
    ->setReferenceId('order-12345');

    // Later, find all transactions for an order
    $txs = $transactionService->getRepository()->getByReferenceId($walletId, 'ecommerce', 'order-12345');
  4. Handle idempotency for external operations

    $externalId = 'payment-provider-tx-123';

    // Check if already processed
    $existing = $transactionService->getRepository()->getByReferenceId($walletId, 'payment-provider', $externalId);
    if (!empty($existing)) {
    return $existing[0]; // Already processed
    }

    // Process new transaction
    $dto = TransactionDTO::create($walletId, $amount)
    ->setReferenceSource('payment-provider')
    ->setReferenceId($externalId);
    return $transactionService->addFunds($dto);
  5. Use database transactions for complex operations

    use ByJG\AnyDataset\Db\IsolationLevelEnum;

    $dbExecutor = $transactionService->getRepository()->getExecutor();

    // The service operations join the outer transaction only with the same isolation level
    $dbExecutor->beginTransaction(IsolationLevelEnum::Serializable);
    try {
    $tx1 = $transactionService->withdrawFunds($dto1);
    $tx2 = $transactionService->addFunds($dto2);
    $dbExecutor->commitTransaction();
    } catch (Exception $e) {
    $dbExecutor->rollbackTransaction();
    throw $e;
    }