SeptemCore LogoSeptemCore
PrimitivesMoney

Reversals

A reversal creates a counter-transaction linked to a completed original transaction via reference_tx_id. Original is marked reversed: true. Only completed transactions qualify. One reversal per transaction. Maximum age 365 days. Interaction matrix with hold/cancel.

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)

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

On this page