Wallet & Exchanges Module Guide¶
Last reviewed: 2026-06-23
This guide is a how-to/reference for maintainers of the time-credit Wallet and the structured Exchanges workflow in Project NEXUS. It describes the exchange lifecycle, the ledger invariants that keep credits conserved, the idempotency guard on transfers, money-column precision, tenant scoping, feature gates, failure modes, and the regression tests that protect this surface.
Time credits ("hours") are the platform's internal unit of account. They are not money, but the ledger is treated with the same rigour as money: every movement is atomic and value-conserving.
Audience & supported workflows¶
Use this guide when changing wallet balances, the transfer path, or any step of the exchange state machine.
Supported workflows:
- Direct transfer — a member sends credits to another member from their wallet.
- Structured exchange — a request against a listing that moves through accept → (optional broker approval) → work → dual confirmation → completion, minting the transfer only at completion.
- Group exchange — a multi-participant exchange with equal / custom / weighted hour splits, settled in one atomic completion.
- Exchange rating — a 1–5 star satisfaction rating left by either party after completion.
Tenant & feature-gate rules¶
- Tenant scoping is mandatory. Every query is scoped by
App\Core\TenantContext::getId()(directly, or via theHasTenantScopetrait on Eloquent models).users.balance,transactions, andexchange_requestsall carrytenant_id. Balance debits/credits inExchangeWorkflowService::createTransaction()andGroupExchangeService::complete()filter on bothidandtenant_id. - Module gate: wallet (
module: wallet). - Feature gate: the exchange workflow is gated by
exchange_workflow, resolved at runtime throughBrokerControlConfigService::isExchangeWorkflowEnabled(). When disabled, exchange endpoints returnFEATURE_DISABLED(HTTP 400) and the "needs attention" surfaces return0— seeExchangesControllerandExchangeService::countNeedingAttention(). The dashboard "exchanges need your attention" count is the single source of truth shared by the React and accessible frontends. - The transfer endpoint additionally requires onboarding completion (
onboarding-requiredmiddleware) and is rate-limited.
Key code & data locations¶
Routes are defined in routes/api.php. Do not copy the endpoint table here — read the route file or the OpenAPI/docs/API.md reference for the live list. Primary entry points:
| Concern | Route prefix | Controller |
|---|---|---|
| Wallet balance / transactions / transfer | /v2/wallet/* |
App\Http\Controllers\Api\WalletController |
| Wallet extras (statement, donations, community fund, ratings) | /v2/wallet/*, /v2/exchanges/{id}/rate |
App\Http\Controllers\Api\WalletFeaturesController |
| Exchange lifecycle | /v2/exchanges/* |
App\Http\Controllers\Api\ExchangesController |
| Group exchanges | /v2/group-exchanges/* |
App\Http\Controllers\Api\GroupExchangeController |
| Broker approvals | /v2/admin/broker/exchanges/* |
App\Http\Controllers\Api\AdminBrokerController |
Services:
app/Services/WalletService.php— balance aggregation, transaction history, thetransfer()credit-movement path and its idempotency guard.app/Services/ExchangeService.php— lightweight exchange listing/create/accept/decline/complete plus the "needs attention" dashboard signal.app/Services/ExchangeWorkflowService.php— the full state machine, broker approval, dual-party confirmation, and the credit-minting transaction at completion.app/Services/ExchangeRatingService.php— post-completion 1–5 star ratings (one per rater per exchange).app/Services/GroupExchangeService.php— multi-participant exchanges and split calculation.app/Listeners/UpdateWalletBalance.php— post-transaction side effects only (XP + badge checks), wired toTransactionCompletedinapp/Providers/EventServiceProvider.php.
Models / tables:
transactions— the wallet ledger (App\Models\Transaction).exchange_requests— exchange state (App\Models\ExchangeRequest).exchange_history— append-only audit trail of status transitions (App\Models\ExchangeHistory).exchange_ratings,group_exchanges,group_exchange_participants,users.balance.
Exchange lifecycle / state machine¶
ExchangeWorkflowService owns the authoritative state machine. Allowed transitions are declared in its TRANSITIONS constant and enforced on every status change inside updateStatus(), which locks the row (lockForUpdate) and rejects any transition not in the allow-list. The exchange_requests.status column is an enum whose values are a superset of the workflow states — it additionally carries a legacy scheduled value that the TRANSITIONS allow-list does not use (some ExchangeService filters still reference it).
┌─ pending_broker ─┐
request → pending_provider ├→ accepted → in_progress → pending_confirmation → completed
└──────────────────┘ │
(provider declines / either party cancels → cancelled) └→ disputed → completed
(or cancelled)
State-by-state:
- Request →
pending_provider.createRequest()records the request against a listing. Self-requests are rejected. The provider (listing owner) is notified.proposed_hoursis clamped to[0.25, 24]. - Provider accepts / declines. Accept moves to
accepted, or topending_brokerif broker approval is required (see below). Decline moves tocancelled. Both guard on the caller being the provider and the current status beingpending_provider. - Optional broker approval. When required, a broker approves (→
accepted) or rejects (→cancelled) frompending_brokeronly. - Work.
startProgress()(→in_progress) andmarkReadyForConfirmation()(→pending_confirmation) are provider-only — this closes a direct-call IDOR where a requester could otherwise drive the workflow. - Dual-party confirmation. Both parties confirm hours via
confirmCompletion(). Confirmed hours are clamped to the configured variance band aroundproposed_hours(max_hour_variance_percent, default 25%). The exchange row is locked for the whole confirmation to prevent concurrent-confirmation races. - Completion or dispute (
processConfirmations()): - Hours agree (difference
< 0.01) → complete at that figure. - Hours differ but within
0.25h tolerance → complete at the average. - Hours differ by more than the tolerance →
disputed; both parties are emailed in their own locale. A broker/admin resolves the dispute, which completes the exchange (disputed → completed). - Completion settles the ledger.
completeExchange()→createTransaction()performs the credit movement (see invariants) and linkstransaction_idback onto the exchange. Notifications are sent only after the financial transaction commits. - Rating. After
completed, either party may submit one 1–5 star rating viaExchangeRatingService::submitRating(); a second attempt by the same rater is rejected.
Terminal states (completed, cancelled, expired) accept no further transitions.
Ledger invariants¶
These invariants hold for credit movements, with the row-level shape differing by path:
- Double-entry / conservation. For a direct transfer (
WalletService::transfer()) and a structured exchange completion (ExchangeWorkflowService::createTransaction()), a movement ofnhours debits the sender by exactlyn, credits the receiver by exactlyn, and writes onetransactionsrow; net system balance is unchanged. System-originated rows (community fund, admin grant, starting balance) are the deliberate exception and carry a distincttransaction_type. Group exchange settles differently:GroupExchangeService::complete()writes a credittransactionsrow only for provider participants (withsender_id = organizer_id) and debits non-provider participants via a guarded conditionaldecrementwith no separate ledger row, so per-row debit==credit does not hold for the group path — the whole settlement is balanced across participants and runs atomically in oneDB::transaction. - Atomicity. The balance updates and the ledger insert run inside a single
DB::transaction(...). On any failure the whole movement rolls back — no partial debit, no orphan ledger row. In exchange completion the financial transaction is isolated from notifications: notifications run outside the DB transaction so a notification failure can never roll back a credit transfer. - Row locking & deadlock avoidance.
WalletService::transfer()locks both user rows withlockForUpdate()in ascending id order (min id, then max id) so two members transferring to each other simultaneously cannot deadlock. Exchange and group completion likewise lock the rows they mutate. - No negative balances. Every debit path re-reads the sender's balance under lock and aborts if
balance < amount. The group-exchange debit uses a conditionalwhere('balance', '>=', $hours)->decrement(...)and throws if zero rows are affected. - Precision is preserved. Splits are rounded to 2 decimal places, never truncated to integer (truncating once caused 3×3.33 h to credit 9 h while debiting 10 h).
Money column precision¶
| Column | Type | Notes |
|---|---|---|
users.balance |
decimal(10,2) |
Member wallet balance. |
transactions.amount |
decimal(10,2) |
Ledger amount. |
exchange_requests.proposed_hours |
decimal(5,2) |
|
exchange_requests.requester_confirmed_hours / provider_confirmed_hours / final_hours |
decimal(5,2) |
|
org_wallets.balance |
decimal(10,2) |
Separate organisation wallet subsystem. |
All credit amounts are decimal(10,2) — fractional credits are first-class. WalletService::transfer() rejects amounts with more than 2 decimal places and caps a single transfer at 1000 hours.
Note: the
nexus_testdatabase may typebalance/amountas integers, which is why several money tests use whole-hour amounts. Production and the schema dump (database/schema/mysql-schema.sql) usedecimal(10,2).
Idempotency (duplicate-submit guard on transfers)¶
WalletService::transfer() carries an explicit anti-double-submit guard (re-implemented from the federation "H6" pattern). The row lock alone prevents over-spend below zero but not duplicate intent: a double-click or network retry of a well-funded amount would otherwise create two real, legitimate-looking debits.
How it works:
- A fingerprint is computed for the request:
- If a client
Idempotency-Keyis supplied, the fingerprint issha1('key:' . key)with a 24-hour window.WalletController::transfer()accepts the key from theIdempotency-KeyHTTP header or anidempotency_keybody field. - Otherwise a content fingerprint (
receiver | amount | description) is used with a 120-second window, so an accidental double-click is still caught without a client key. - The fingerprint is claimed atomically with
Cache::add(...)(tenant + sender scoped). - On a duplicate within the window: if the original transaction already committed, the service replays it — it returns the same transaction and does not debit again. If the twin is still in flight, the duplicate is rejected (
Duplicate transfer ignored) rather than risk a double debit. - On failure of the underlying transfer, the claim is released so a legitimate retry of a failed transfer is not blocked.
- The guard fails open on any cache error — cache flakiness must never block a legitimate transfer.
Note this guard is specific to WalletService::transfer() (the direct member-to-member path). Exchange completion and group completion rely instead on locked, status-predicated updates to make double-completion a no-op.
Tenant scoping & cross-tenant transactions¶
All native wallet/exchange queries filter by the active tenant. Inbound federation transactions (from external partners) live in federation_transactions and are surfaced read-only in wallet history with synthetic negative ids and a source: 'federation' marker; they are already credited by the federation webhook handler and are scoped by receiver_tenant_id.
TransactionCompleted side effects¶
WalletService::transfer() dispatches App\Events\TransactionCompleted after the transfer commits. Per EventServiceProvider, this fans out to:
UpdateWalletBalance— XP awards + badge checks only (the balance itself is already updated in the transfer; despite the class name this listener does not move money). It is queued, runstries = 1, and is idempotent: a per-transaction cache claim suppresses duplicate deliveries, backed by a unique index onuser_xp_log.NotifyTransactionCompleted— recipient notification.PushTransactionToFederatedPartner— federation push.
Exchange and group completions write their transactions rows directly and do not all flow through WalletService::transfer(), so do not assume TransactionCompleted fires for every ledger row.
Failure modes & recovery¶
| Failure | How it is handled |
|---|---|
| Insufficient balance (transfer) | Re-checked under row lock; throws Insufficient balance. No ledger row, no balance change. |
| Insufficient balance (exchange completion) | createTransaction() throws the typed INSUFFICIENT_BALANCE; the surrounding DB::transaction rolls back so no credits move and the counterparty's confirmation is preserved. The confirm endpoint returns a 4xx rather than a generic 500. |
| Insufficient balance (group exchange) | Conditional debit affects 0 rows → throws; the whole completion (all participants) rolls back. |
| Concurrent transfer / self-deadlock | Both user rows locked in ascending-id order. |
| Concurrent / double completion | The exchange row is locked and status-predicated; a second completer becomes a no-op. Group completion claims the row with a whereNotIn(status, ['completed','cancelled']) update first; a losing racer credits no one. |
| Double-submit transfer | Idempotency guard replays the original (one debit) — see above. |
| Invalid state transition | updateStatus() rejects transitions not in TRANSITIONS. |
| Self / banned / inactive recipient | Rejected before any movement. |
| Notification / email failure | Caught and logged; never rolls back a committed financial transaction (notifications run after commit). |
Recovery: ledger movements are atomic, so a failed operation leaves no partial state — retry the operation. For a stuck exchange, inspect exchange_history for the last valid transition and the status enum to see what transition is allowed next.
Security & privacy invariants¶
- Never move credits outside a
DB::transactionwith the relevant rows locked. - Every
UPDATE/DELETEontransactions,exchange_requests, andusers.balancemust includetenant_id. - Provider-only workflow steps (
start,markReadyForConfirmation) must stay provider-gated to prevent IDOR. - Transaction history honours
deleted_for_sender/deleted_for_receiversoft-hide flags per side. - Cancellation by a non-party requires broker/admin privileges (
cancelExchange()throwsUNAUTHORIZEDotherwise).
Test commands & key regression tests¶
Run the backend suites (run heavy suites one at a time):
Targeted runs:
vendor/bin/phpunit tests/Laravel/Feature/Services/WalletServiceDoubleSubmitTest.php
vendor/bin/phpunit tests/Laravel/Integration/ExchangeWorkflowTest.php
Important regression tests:
| Test | What it locks down |
|---|---|
tests/Laravel/Feature/Services/WalletServiceDoubleSubmitTest.php |
One debit per double-submit (with key and content fingerprint); distinct keys are not collapsed; a failed transfer releases its claim so a funded retry succeeds. |
tests/Laravel/Feature/Services/WalletServiceFractionalTest.php |
Fractional (decimal(10,2)) credit handling. |
tests/Laravel/Feature/Services/WalletServiceEdgeCasesTest.php, WalletServiceTest.php |
Insufficient balance, self-transfer, recipient resolution, caps. |
tests/Laravel/Integration/ExchangeWorkflowTest.php |
Full lifecycle incl. confirmation, completion, and dispute path. |
tests/Laravel/Unit/Services/ExchangeWorkflowServiceTest.php |
State-machine transitions and broker-approval branching. |
tests/Laravel/Feature/Services/GroupExchangeServiceTest.php, tests/Laravel/Feature/Controllers/GroupExchangeControllerTest.php |
Atomic multi-participant settlement, split precision, double-complete no-op. |
tests/Laravel/Unit/Services/ExchangeRatingServiceTest.php |
One rating per rater, range validation, participant check. |
tests/Laravel/Unit/Listeners/UpdateWalletBalanceTest.php |
XP/badge side effects are idempotent on re-delivery. |
tests/Laravel/Feature/Federation/FederationV2InternalTransferTest.php |
Money conservation + atomicity on the federation transfer path (origin of the idempotency pattern). |
Related references¶
- ARCHITECTURE.md — runtime boundaries.
- MODULES.md — module map and guide checklist.
routes/api.php— authoritative endpoint list (do not duplicate here).- Federation: FEDERATION_API_MANUAL.md for cross-platform transfers.