Card settlement — the model (AngelPay reconciliation, Option X)
Purpose: the domain truth for how a card sale’s money flows from the terminal to a withdrawable merchant balance — what the system must uphold, independent of how it’s built. Audience: AI agents (and the humans reviewing them) building the
CardSettlementsub-project. Status: as of 2026-09-21 — design-complete, ready to draft; PO-approved 2026-09-20. Source decision log:../../backlog/ideation/api-card-settlement-angelpay-reconciliation.md. Operator flow:../runbooks/card-settlement-reconciliation.md. Related: pos-domain-model.md · reporting.md · runtime-settings.md ·terminales-api/src/core/money.ts(MINOR_SCALE).
Why this exists
Section titled “Why this exists”A card charge on the N86 (the AngelPay SDK) does not land in Axios’s account at sale time — the money lands in AngelPay’s account. AngelPay settles it to the Axios affiliation (9613149) at T+1, automatically, paying net (it deducts its agreed commission before paying Axios); Fri–Sun settle Monday, cutoff 10:59 pm. Axios then pays merchants via liquidaciones (money-out). So at the moment a merchant sees a card sale credited to their wallet, that money is virtual — promised by AngelPay, not yet confirmed to Axios. This model records which card money is virtual vs confirmed, without changing what the merchant can already do with a fresh sale.
The “bank statement” the system imports to reconcile is the AngelPay movements export (an xlsx from the AngelPay portal), not a bank CSV. The siafrac-style bank-transfer / per-merchant-reference deposit reconciliation is a separate later wave (see Deferred, below).
The core rule — Option X (PO, 2026-09-20)
Section titled “The core rule — Option X (PO, 2026-09-20)”Keep the at-sale gross wallet credit; only withdrawals to a bank require settlement. This is the live path today and it does not change:
- A card sale credits the merchant wallet its gross amount at sale time,
AVAILABLE, exactly as it does now. - That balance stays immediately usable for VAS, merchant→merchant transfers, and top-ups — those check the gross
availableBalancesnapshot and are unaffected by settlement. - Only a withdrawal to a bank (a liquidación / money-out) is gated on confirmed money — on
settlement_reports.availableAmount(see the three money figures below).
A per-merchant X/Y runtime toggle (some merchants gross-at-sale, some settle-first) was rejected — it forks ledger semantics. There is one path: gross at sale, settlement gates only the withdrawal.
The AngelPay import is the source of truth for settlement
Section titled “The AngelPay import is the source of truth for settlement”The nightly rollup that computed settlement reports from terminales’ own records (terminales-api/src/wallet/settlements/settlement-rollup.scheduler.ts, crontab 30 6 * * *) is disabled, not deleted — gated behind a new realm setting settlement.source with values angelpay_import | computed_rollup, default angelpay_import. The rollup is kept as a fallback for a future PSP that cannot provide a settlement export. See runtime-settings.md for the resolver; the on-demand fallback and the kill switch are in the reconciliation runbook.
The import is a second writer of availableAmount (the first is applyReportAvailable() in settlement-requests.service.ts); the two must coexist, selected by settlement.source.
The three money figures — kept separate
Section titled “The three money figures — kept separate”A card sale carries three distinct amounts. Conflating them is the classic settlement bug; the model keeps all three:
| Figure | What it is | Source | Used for |
|---|---|---|---|
| Gross | The full sale amount, credited to the wallet at sale time | AngelPay Monto |
The at-sale wallet credit (Option X) — the availableBalance snapshot VAS/transfers/top-ups spend against |
| AngelPay net | Gross − AngelPay’s commission (what AngelPay actually deposits to Axios) | AngelPay Total Depositar |
The Axios↔AngelPay commissions report (what the processor cost us) |
| Merchant net (withdrawable) | Gross of confirmed sales − Axios’s effective-dated commission | Computed at report time | settlement_reports.availableAmount — what the merchant may withdraw |
Note the two commissions are different: AngelPay’s (baked into Total Depositar) and Axios’s (the effective-dated rule, below). Axios may absorb AngelPay’s and/or waive its own.
settlementStatus — a new, orthogonal column
Section titled “settlementStatus — a new, orthogonal column”A new settlementStatus on the card deposit tracks confirmation, independent of the existing status:
- Values:
pending_settlement→settled|rejected. - It does not overload
status.statuscontrols usability and staysAVAILABLEunder Option X (the money is spendable from the moment of sale).settlementStatusanswers a different question — “is this card money virtual or confirmed?” - Target: the
depositstable (terminales-api/src/wallet/deposits/deposits.constants.ts; generated typesappwrite-row-types.generated.ts; schematerminales-api/infra/appwrite/appwrite.config.json). Channel-E card deposits are writtenAVAILABLEtoday indeposits.service.tsregister();salesOrderIdis the link back to the sale.
The import flips an existing card deposit’s settlementStatus — under Option X the deposit already exists from the sale, so the import never creates or credits.
unsettledBalance — how much of the balance is virtual
Section titled “unsettledBalance — how much of the balance is virtual”unsettledBalance = Σ of a merchant’s pending_settlement card deposits, computed from the deposits table (indexed by merchantId), exposed for reporting. It answers “how much of this balance is still virtual right now” — the credit-side view. (It does not answer “did this specific debit spend virtual money” — that per-debit attribution is deferred, below.)
Effective-dated commission (Axios’s own)
Section titled “Effective-dated commission (Axios’s own)”Axios’s commission is a set of effective-dated rules (datetime ranges), global with a per-merchant cascade, and each rule may be $0.00 (Axios can absorb AngelPay’s commission and/or waive its own). It applies only when a settlement report’s withdrawable amount is computed — never in the cart. No commission is shown at the POS (the cart is unchanged). Today’s single rate resolves via RuntimeSettingsService.inspect (merchant → realm → default) in settlement-rollup.scheduler.ts; the effective-dated rules generalise that.
Idempotency and the join
Section titled “Idempotency and the join”- Per-ROW idempotency, not per-file. Operators re-export and overlap date ranges, so the AngelPay
ID Transacciónis the dedup key. It is stored as a settlement-reference column on the deposit — not as the depositidempotencyId(that field is the tenderclientIdthat drives the wallet-credit dedup, a separate concern). - Join key — AngelPay row (
Autorización|Referencia/ID Transacción|Folio) →payment_intents(authCode/integratorReference/folio) →salesOrderId→ theCARD_TERMINALdeposit. A multi-tender sale shares onesalesOrderId, so disambiguate by amount. Terminal serial →terminals.serialNumber→storeTeamId→ merchant.
Money math
Section titled “Money math”All money is integer minor units, ×10000 (terminales-api/src/core/money.ts MINOR_SCALE = 10_000) — see AI_CONTEXT hard-rule #9. Commission math uses one rounding rule (half-up) and the result is compared against AngelPay’s reported figure.
No vendor names in the UI
Section titled “No vendor names in the UI”The operator screens and i18n say “cobros con tarjeta” / “procesador de pagos” — never the brand. “AngelPay” appears in code, docs, and column mappings only, never in merchant- or operator-facing copy.
Edge cases
Section titled “Edge cases”- Rejected / unmatched already-credited sale → flag it in the exceptions queue and leave the wallet credit in place for an operator to decide. No auto-reversal in MVP (see Deferred). Under Option X the money was already spendable; clawing it back is a deliberate later “money-backwards” ticket.
- Overlapping / re-exported rows → deduped by
ID Transacción(per-row idempotency); a row alreadysettledis a no-op. settlement.source = computed_rollup→ the import is bypassed; reports come from the nightly rollup. This is the kill switch.
Deferred — do not forget
Section titled “Deferred — do not forget”These were explicitly deferred by the PO on 2026-09-20 and are recorded here so they are not lost. The runbook repeats them; ../progress/open-questions-and-todos.md is the live tracker.
- Per-debit “used virtual money” flag. Exact per-transaction attribution — stamping a VAS/transfer debit with how much of it was funded by
pending_settlementmoney — is a follow-up. This wave ships only the credit-sidesettlementStatus+unsettledBalance, which answer “which money is virtual,” not “this specific debit spent virtual money.” Revisit when per-debit reporting is needed. - Bank-transfer wallet-deposit reconciliation (the siafrac model: per-merchant reference, staged bank-statement import). This was the original sub-project 4, deferred by “scope A” on 2026-09-20. Its design bullets remain in
backlog/ideation/multi-platform-roles-admin-operator.md. - Auto-reversal of a rejected already-credited card sale. MVP flags the exception only; reversal is a later deliberate money-backwards ticket.