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.
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 — status41with phaseVOIDEDagain, notOK_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) returnsOK_DUPLICATEwith phasePOSTED— 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:
- Capture full, refund the difference — two durable records, no unprotected window. Usually the right choice.
- 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.
Related
Holds and two-phase transfers · Idempotency · Errors and rejections · Wallets