Correct Transactions

Learn how to replace a posted transaction under the same transaction ID with a correction.

A ledger never edits a posting. When a posted transaction is wrong — the amount, the accounts, the effective date, or even the tran code — the fix is to void it and post it again. A correction does both in one call, and keeps the transaction's ID.

To correct a transaction, post it again with a correction. Give each correction its own correctionId, and optionally a description and metadata that say why:

mutation CorrectTransaction($accountAliciaId: UUID!, $accountBobbyId: UUID!) {
  postTransaction(
    input: {
      transactionId: "6b5e47b6-60d2-49bf-8210-0e4c3dd3ec68"
      tranCode: "BOOK_TRANSFER"
      params: {
        crAccount: $accountAliciaId
        drAccount: $accountBobbyId
        amount: "1.50"
        currency: "USD"
        effective: "2022-09-08"
      }
      properties: {
        correction: {
          correctionId: "22046f82-dd5f-40b1-b0dc-2cc2e3afff23"
          description: "Wrong amount"
          metadata: { ticket: "OPS-1234" }
        }
      }
    }
  ) {
    transactionId
    version
    correction {
      correctionId
      description
    }
  }
}

What a correction does

The transaction with this IDResult
Does not existIt is posted. Version 1 points to the correction.
ExistsThe transaction is corrected in place, even when the new values are the same.
Is voided, or is itself a voidINVALID_STATE_TRANSITION.

A correction writes two new versions of the transaction, under the same transactionId. Both point to the correction:

  1. A void. It is a copy of the version being corrected, and its entries void that version's entries.
  2. The corrected transaction, with the new tran code, params and entries.

A transaction posted at version 1 and then corrected is at version 3. The versions before the correction stay in history.

On every version, correction is the first correction that wrote that version or a later one, and correction.transaction is the version that correction wrote. So versions 1 to 3 above all resolve to the correction, and to version 3. An updateTransaction after the last correction resolves to that correction until another correction is made; then it resolves to the new one. correction is null on a transaction that was never corrected.

Retries

A correctionId can be used once. A repeat of it fails with ALREADY_EXISTS, whatever its values.

With idempotent: true, a repeat returns the version the correction wrote, and writes nothing, when it asks for the same correction: the same transactionId, description, metadata and voidEffective, and the same transaction. Otherwise it fails with BAD_REQUEST. "The same transaction" means the same tran code, journal, effective date, correlation ID, external ID, description, metadata and groups, and the same entries: account, currency, amount, direction, layer, entry type, description and metadata. Amounts compare by value, so 10 and 10.00 are the same.

The tran code runs again on a repeat, so a compared field must come out the same each time. A field that a tran code computes from the clock or from uuid.New() differs on each run, and its repeats fail with BAD_REQUEST. Pass such values in as params, so a repeat sends the same ones.

The repeat compares with the version the correction wrote, not the current version. So a late retry of a correction does not undo a later correction.

An idempotent: true post without a correction compares with the current version, as it does on any transaction. After a correction, that is the corrected transaction. A late retry of the original post fails with BAD_REQUEST, because the correction changed the transaction. A post of the corrected values returns the current version and writes nothing.

So to make a post and its corrections safe to retry, in any order, post the transaction with a correction too, and set idempotent: true on each post. Each retry then finds its own correctionId and returns the version it wrote.

A correctionId only stops a correction from applying twice. It does not order corrections that were never applied. If correction c1 did not commit, and is sent again after correction c2 committed, c1 is not a repeat: it corrects the transaction c2 left, and becomes the current version. To keep the corrections of one transaction in order, send each one after the one before it has committed, or send them in order in one PostTransactions request.

Read a correction with correction(id:). List the corrections of a transaction with corrections(index: { name: TRANSACTION_ID }, where: { transactionId: { eq: ... } }), or with a custom index on Correction (createIndex(input: { on: Correction, ... })) and index: { name: CUSTOM }.

Entries after a correction

Every entry stays under the transaction's ID, numbered by sequence:

SequenceEntriestransactionVersion
0–1The original entries1
2–3The entries voiding them2
4–5The corrected entries3
  • Transaction.entries returns the entries of that version only. On the current transaction those are the corrected entries; on a history node they are that version's entries.
  • Transaction.entries(all: true) returns every entry under the ID.
  • Entry.transaction resolves to the version the entry belongs to. For the original entries that is version 1, not the corrected transaction. So Entry.transaction.correction of a void or corrected entry is the correction that wrote it, and of an original entry the correction that replaced it.

The original entries are marked voidedBy their void, and the void entries have voidOf set, as voidTransaction does.

Balances and calculations

The void and the corrected entries post together. Calculations evaluate each entry against the version it belongs to, so the void entries land in the same dimensions as the entries they void. A calculation by context.vars.transaction.metadata.memo, for example, moves the amount from the old memo's bucket to the new one.

context.vars.transaction is the current version of the transaction an entry belongs to. If updateTransaction changed the metadata after the post, a calculation attached later and backfilled sees the updated metadata. A correction's void copies the current version, as voidTransaction does.

The void's effective date

By default the void has the effective date of the version it voids. Set voidEffective to date it later, for example in the current period when the original's period is closed. It must not be before the original effective date. A correction that posts a new transaction has no void and ignores it.

properties: {
  correction: { correctionId: "5b0f3c2e-8d1a-4e7b-9c6f-2a4d8e1b3c70", voidEffective: "2022-09-30" }
}

Corrections in a PostTransactions request

The gRPC PostTransactions API posts a list of posts and voids in one request, all or nothing. A correction in a request does what it does alone, in the request's order, and it shares the request's balance writes with the posts around it. These cases differ from the same operations sent one call at a time:

CaseOne call at a timeOne PostTransactions request
Two posts with the same correctionIdThe second is a repeat: ALREADY_EXISTS, or with idempotent: true the version the first wrote.BAD_REQUEST.
A post of a transaction ID, then a correction of itThe correction corrects the posted transaction.The same. The correction builds on the post before it in the request.
A correction, then a post of the same ID without a correctionThe post fails with ALREADY_EXISTS, or with idempotent: true compares with the corrected transaction.BAD_REQUEST. Every later post of an ID in a request must be a correction.
A void of the transaction, then a correction of itThe correction fails with INVALID_STATE_TRANSITION.BAD_REQUEST. A void must come after every post of its transaction.
An exception or asVoidTransaction post, then a correction of the same IDThe correction fails with INVALID_STATE_TRANSITION: the transaction is voided, or is a void.BAD_REQUEST.

A dependsOn of a transaction that the request posts is met from its first post on. Velocity controls check a post and each correction of it as if they were sent one at a time, in the request's order; see Velocity Controls.

Limits

  • A correction cannot be combined with exception or asVoidTransaction.
  • System tran codes, and tran codes that start a workflow, cannot be used in a correction, as either the old or the new tran code. Their workflows expect a posting not to change.
  • A voided transaction cannot be corrected. A corrected transaction can be voided; the void covers the corrected entries only.
  • Two different corrections of one transaction that run at the same time do not both apply: one commits, and the other fails with a retryable error. A retry corrects the transaction the first one left, so the last commit wins.
  • A correction is not checked against the version it replaces. A late correction that was never applied applies on top of later ones (see Retries).
  • A correction reads and writes the Correction table. A client whose policies list tables one by one needs access to it to post or read corrections.
  • A correction writes row versions and entries like any post. Webhook consumers receive transaction.updated for both new versions and entry.inserted for the new entries. The correctionId on the versions tells a correction from an updateTransaction.
Previous
Using VoidTransaction and PostTransaction