Skip to main content

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

ConstraintValue
Eligible statuscompleted transactions only
Maximum reversals per transaction1 (idempotency by reference_tx_id)
Maximum age365 days
Reason fieldRequired (written to audit trail)
note

Constraint Details & Violations

  • Configuration: The 365-day maximum age is overridable via the MONEY_REVERSAL_MAX_AGE_DAYS environment 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 statusreversal()cancel()Explanation
held400 Bad Request✅ Returns frozen to availableUse cancel() — holds are not completed
confirmed (hold → confirm)✅ Counter-transaction (credit)❌ Not applicableTreated as a regular debit
completed (credit / debit)✅ Counter-transaction❌ Not applicableStandard reversal scenario
canceled400 Bad Request❌ Already canceledCanceled holds cannot be further reversed
reversed409 Conflict❌ Not applicableDouble 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

ScenarioHTTPCode
Transaction not in completed status400INVALID_STATUS
Transaction already reversed409ALREADY_REVERSED
Transaction older than 365 days400REVERSAL_WINDOW_EXPIRED
Missing reason field400validation-error
Transaction not found404not-found
Cross-tenant transaction access403forbidden