blnkfinance/blnk

A committed/voided transaction permanently reports inflight: true

Open

#293 opened on May 18, 2026

View on GitHub
 (0 comments) (0 reactions) (0 assignees)Go (129 forks)auto 404
buggood first issue

Repository metrics

Stars
 (475 stars)
PR merge metrics
 (PR metrics pending)

Description

Bug: a committed/voided transaction permanently reports inflight: true

Type: bug Component: inflight_transaction.gofinalizeCommitment, finalizeVoidTransaction Version checked: main @ e0f8676 (2026-05-18)

Summary

When an inflight transaction is committed (or voided), the resulting terminal transaction has status: APPLIED (or VOID) but still reports inflight: true. The wrong value is returned by the commit/void call and by every subsequent GET /transactions/{id} and GET /transactions/reference/{ref} — it is not transient.

A transaction cannot be both APPLIED and inflight. Any consumer that classifies a transaction by the inflight flag (rather than status) will misjudge a settled transaction as still in flight.

Root cause

inflight is not a column on blnk.transactions — it is stored inside the meta_data JSONB. An inflight transaction's meta_data therefore contains {"inflight": true, ...}.

finalizeCommitment (inflight_transaction.go:378) builds the committed child by mutating the inflight transaction in place:

func (l *Blnk) finalizeCommitment(ctx context.Context, transaction *model.Transaction, withQueue bool) (*model.Transaction, error) {
    transaction.Status = StatusCommit
    transaction.ParentTransaction = transaction.TransactionID
    transaction.CreatedAt = time.Now()
    ...
    transaction.TransactionID = model.GenerateUUIDWithSuffix("txn")
    transaction.Reference = model.GenerateUUIDWithSuffix("ref")
    transaction.Hash = transaction.HashTxn()
    ...
}

It overrides status / id / reference / hash / parent — but never touches transaction.MetaData, which still holds inflight: true. The committed child is then INSERTed with that metadata verbatim. finalizeVoidTransaction (inflight_transaction.go:566) has the same omission.

On read, restoreTransactionFlagsFromMetadata (transaction.go:1653) re-derives the struct field from metadata:

func restoreTransactionFlagsFromMetadata(transaction *model.Transaction) {
    if v, ok := transaction.MetaData["inflight"].(bool); ok {
        transaction.Inflight = v          // <-- a committed txn becomes Inflight=true
    }
    ...
}

So a terminal transaction's meta_data carries inflight: true, and every read faithfully restores Inflight = true from it. transformTransaction strips the inflight key out of the response meta_data but leaves result.Inflight = true.

Steps to reproduce

# 1. Create an inflight transaction.
INF=$(curl -s -X POST http://localhost:5001/transactions \
  -H 'X-Blnk-Key: <key>' -H 'Content-Type: application/json' \
  -d '{"amount":100,"precision":100,"currency":"USD","reference":"repro-'"$(date +%s)"'",
       "source":"<balance_id>","destination":"@World","description":"x",
       "inflight":true,"skip_queue":true,"allow_overdraft":true}')
INFID=$(echo "$INF" | jq -r .transaction_id)

# 2. Commit it — inspect the response.
COMMIT=$(curl -s -X PUT "http://localhost:5001/transactions/inflight/$INFID" \
  -H 'X-Blnk-Key: <key>' -H 'Content-Type: application/json' \
  -d '{"status":"commit"}')
echo "$COMMIT" | jq '{status, inflight}'
#   -> { "status": "APPLIED", "inflight": true }      ❌ BUG

# 3. GET it back later — still wrong.
CHILDID=$(echo "$COMMIT" | jq -r .transaction_id)
curl -s "http://localhost:5001/transactions/$CHILDID" -H 'X-Blnk-Key: <key>' | jq '{status, inflight}'
#   -> { "status": "APPLIED", "inflight": true }      ❌ BUG

Confirmed against the database

The committed child's stored meta_data (queried directly from Postgres) carries the inherited flag — there is no inflight column, and the value survives a full Redis FLUSHALL, so it is genuinely persisted, not cached:

txn_<parent> | INFLIGHT | {"inflight": true, "allow_overdraft": true}
txn_<child>  | APPLIED  | {"inflight": true, "allow_overdraft": true}   <-- stale

Expected

A committed transaction should report inflight: false (status APPLIED); a voided one inflight: false (status VOID). The inflight flag must reflect the terminal state, not be inherited from the parent.

Suggested fix

In finalizeCommitment and finalizeVoidTransaction, after the field overrides, clear the inherited flag from metadata so the persisted child and every subsequent read are correct:

if transaction.MetaData != nil {
    delete(transaction.MetaData, "inflight")
}

Clearing it in meta_data is the right layer because restoreTransactionFlagsFromMetadata derives the Inflight struct field from metadata — the metadata is the source of truth, and it is what is wrong. (Setting transaction.Inflight = false alone would be overwritten on the next read.)

Note

This is the same class of bug as #291 (prepareRefundTransaction): a terminal/derived transaction is built by copying a source transaction and overriding a partial set of fields, leaving one field stale. The commit/void path and the refund path share this pattern.

Contributor guide