Docs/Use casesv0.0.1-preview

Card payments

Authorization, capture, void, and declined retries — the full card lifecycle in the operations the engine enforces, with every outcome durable.

On this page
  1. Account topology
  2. Authorize → capture
  3. Void
  4. The declined retry
  5. Partial capture
  6. Abandoned authorizations
  7. Related

A card payment is a lifecycle, not a row: authorize (hold the funds), then capture (post them) or void (release them) — and when the processor declines, that decision belongs on the books too. TulaDB’s two-phase operations map to this one-to-one. The snippets use the Python SDK, where a declined single-op call raises LedgerError carrying the status.

Account topology

Two accounts per currency are enough to start: the cardholder funding account (debit side) and the merchant receivable (credit side). Both are ordinary double-entry accounts; the lifecycle state lives on the transfer, not in a status column you maintain.

# Ids are numbers: account ids are 128-bit integers, ledger_id is a
# 32-bit integer. Only txn_id is a string.
L = c.create_ledger(LedgerInstance(ledger_id=0, currency=840)).ledger_id
CARDHOLDER, MERCHANT, ACQUIRER = 1001, 2001, 1

c.create_account(AccountCreate(id=CARDHOLDER, ledger_id=L, currency=840,
                               limit_low=-10**12, limit_high=10**12))
c.create_account(AccountCreate(id=MERCHANT, ledger_id=L, currency=840,
                               limit_low=-10**12, limit_high=10**12))

Authorize → capture

# Authorization: hold the funds. The reserve debits the cardholder's
# pending balance; nothing is posted yet.
r = c.reserve(TransferCreate(source_id=ACQUIRER, ledger_id=L, currency=840,
        debit_account_id=CARDHOLDER, credit_account_id=MERCHANT,
        amount=2_500, txn_id="auth-91"))
# → status 0, phase PENDING; cardholder debits_pending = 2500

# Capture: settle the hold by its idempotency tuple — the settling service
# never needs to have seen the reserve's response.
rc = c.commit_by_key(ACQUIRER, L, 840, "auth-91")
# → status 0, phase POSTED; merchant credits_posted = 2500

The tuple (source_id, ledger_id, currency, txn_id) is the link between authorization and capture — there is no separate correlation id to store, lose, or mismatch.

Void

c.reserve(TransferCreate(..., amount=900, txn_id="auth-92"))
rv = c.rollback_by_key(ACQUIRER, L, 840, "auth-92")
# → status 0, phase VOIDED; the hold is released, the merchant is untouched,
#   and the voided record stays queryable forever

A capture attempted after the void is refused with ERR_WRONG_PHASE (raised as a LedgerError with that status) — exactly one terminal outcome wins, durably. Under a concurrent capture-vs-void race, one side wins and the other gets ERR_WRONG_PHASE; there is no double-application window to reason about. That status is classed retryable (it is also the answer to a duplicate still in flight), so retrying is safe, but it never turns a finished void into a capture. To learn which side won, read the record: get_transaction_by_key(ACQUIRER, L, 840, "auth-92") shows phase POSTED or VOIDED.

The declined retry

When the issuer or your risk system declines, record it as an explicit reject — a durable outcome, not a dropped error:

rj = c.reject(RejectCreate(source_id=ACQUIRER, ledger_id=L, currency=840,
        txn_id="auth-93", amount=700, reason_code=4207))
# → status 41 (ERR_UPSTREAM_REJECTED — engine-fixed), phase VOIDED,
#   your reason code recorded verbatim

Now the retry semantics hold together end to end:

  • Redelivering auth-93 (the declined payment) replays the original rejection — status 41 with phase VOIDED again, not OK_DUPLICATE. A retry of a declined identity always answers the original decline, so a client that only checks for success can never read a decline as posted. The decline is remembered; it is never re-decided.
  • Redelivering auth-91 (the captured payment) returns OK_DUPLICATE with phase POSTED — the original outcome, applied exactly once.

Support can answer “what happened to this payment?” with one by-ref lookup, and “every decline under rule 4207 this week” is a search for result_code=41 over the week, filtered on reject_reason_code == 4207 (search has no reason-code filter), or SQL over the analytics export — not a log grep.

Partial capture

Capturing less than the authorized amount is not yet a single engine operation — commit settles the full reserved amount. Two honest interim patterns:

  1. Capture full, refund the difference — two durable records, no unprotected window. Usually the right choice.
  2. Void, then transfer the captured amount — one posted record, but the hold is briefly released before the capture posts.

A native partial commit (capture ≤ reserved, release the remainder) is on the engine roadmap.

Abandoned authorizations

Give the ledger a reservation TTL and an authorization that is never captured or voided expires on its own — released exactly once, to a distinct EXPIRED terminal state, without a cleanup job of yours in the loop.

Holds and two-phase transfers · Idempotency · Errors and rejections · Wallets