blnkfinance/blnk

A committed/voided transaction permanently reports inflight: true

已关闭

#293 创建于 2026年5月18日

 (1 条评论) (0 个反应) (0 位负责人)Go (129 个派生)auto 404
buggood first issue

仓库指标

星标
 (475 个星标)
PR 合并指标
 (PR 指标待抓取)

描述

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.

贡献者指南