Reversals
A reversal is the mechanism for undoing a completed financial
transaction. It does not delete the original — it creates a new
counter-transaction with the opposite balance effect and links it to
the original via reference_tx_id. Both records are permanent.
How Reversals Work
Original: credit $50 to wallet-1
→ available: 1000 → 1500 [tx: completed]
Reversal of original:
→ available: 1500 → 1000 [tx: reversal, reference_tx_id = originalTxId]
→ original marked: reversed: true
Audit: money.transaction.reversed written to Audit Service
The double-entry ledger remains balanced after a reversal — the reversal entry mirrors the original entry in the opposite direction.
SDK
const reversalTx = await kernel.money().reversal({
transactionId: '01j9patx1000000000000000',
idempotencyKey: crypto.randomUUID(),
reason: 'Duplicate charge by module billing',
});
// reversalTx:
// {
// transactionId: '01j9prev1000000000000000',
// type: 'reversal',
// status: 'completed',
// amount: 5000,
// walletId: '01j9paw1t000000000000000',
// referenceTransactionId: '01j9patx1000000000000000',
// reason: 'Duplicate charge by module billing',
// balanceAfter: {
// available: 15000,
// pending: 0,
// frozen: 0
// },
// createdAt: '2026-04-15T11:00:00.000Z'
// }
REST
POST https://api.septemcore.com/v1/wallets/01j9paw1t000000000000000/reversal
Authorization: Bearer <access_token>
Content-Type: application/json
Idempotency-Key: c3d4e5f6-a7b8-9012-cdef-123456789012
{
"transactionId": "01j9patx1000000000000000",
"reason": "Duplicate charge by module billing"
}
Response 201 Created:
{
"transactionId": "01j9prev1000000000000000",
"type": "reversal",
"status": "completed",
"amount": 5000,
"walletId": "01j9paw1t000000000000000",
"referenceTransactionId":"01j9patx1000000000000000",
"reason": "Duplicate charge by module billing",
"balanceAfter": {
"available": 15000,
"pending": 0,
"frozen": 0
},
"createdAt": "2026-04-15T11:00:00.000Z"
}
The original transaction record is updated:
{
"transactionId": "01j9patx1000000000000000",
"type": "credit",
"status": "completed",
"reversed": true,
"reversalId": "01j9prev1000000000000000"
}
Reversal Constraints
| Constraint | Value |
|---|---|
| Eligible status | completed transactions only |
| Maximum reversals per transaction | 1 (idempotency by reference_tx_id) |
| Maximum age | 365 days |
| Reason field | Required (written to audit trail) |
Constraint Details & Violations
- Configuration: The 365-day maximum age is overridable via the
MONEY_REVERSAL_MAX_AGE_DAYSenvironment variable. - Error Handling: Violating any of these constraints will result in a failed reversal attempt. See the Error Reference below for exact HTTP status codes and error constants.
Industry reference: Stripe enforces a 180-day reversal window. The platform uses 365 days. Transactions older than 1 year require Platform Owner manual intervention via CLI.
End-to-End Example: Hold → Confirm → Reversal
t=0: hold(wallet, 5000)
available: 10000 → 5000
frozen: 0 → 5000
tx: held
t=1: confirm(holdTxId)
frozen: 5000 → 0
tx: confirmed (= debit)
available: 5000 (unchanged)
t=2: reversal(confirmTxId)
available: 5000 → 10000
frozen: 0 (unchanged — already 0 after confirm)
tx: reversal (credit back into available)
Reversal of a confirmed hold = credit back into available. The
frozen field is not involved because it returned to 0 at confirm
time.
Reversal + Hold Interaction Matrix
Reversal does not apply to held transactions. A hold has its own
cancellation mechanism (cancel()). Reversal applies only to
completed (finalized) transactions.
| Transaction status | reversal() | cancel() | Explanation |
|---|---|---|---|
held | ❌ 400 Bad Request | ✅ Returns frozen to available | Use cancel() — holds are not completed |
confirmed (hold → confirm) | ✅ Counter-transaction (credit) | ❌ Not applicable | Treated as a regular debit |
completed (credit / debit) | ✅ Counter-transaction | ❌ Not applicable | Standard reversal scenario |
canceled | ❌ 400 Bad Request | ❌ Already canceled | Canceled holds cannot be further reversed |
reversed | ❌ 409 Conflict | ❌ Not applicable | Double reversal is forbidden |
Audit Trail
Every reversal writes an immutable audit record:
Event: money.transaction.reversed
EntityType: transaction
EntityId: 01j9prev1000000000000000 (reversal tx)
Who: userId who called reversal()
Before: { reversed: false }
After: { reversed: true, reversalId: '01j9prev1000000000000000' }
Error Reference
| Scenario | HTTP | Code |
|---|---|---|
Transaction not in completed status | 400 | INVALID_STATUS |
| Transaction already reversed | 409 | ALREADY_REVERSED |
| Transaction older than 365 days | 400 | REVERSAL_WINDOW_EXPIRED |
Missing reason field | 400 | validation-error |
| Transaction not found | 404 | not-found |
| Cross-tenant transaction access | 403 | forbidden |