Value-Added Services — TAE, Pines, Services, Chips
Purpose: the recommended terminales domain model for the four value-added-services (VAS) verticals migrating from the legacy stack — what entities/fields/enums to add so the Appwrite collections capture the right data the first time. This is a plan-phase recommendation; backlog tickets are created separately. Status: as of 2026-07-30 — the model below is built. The Appwrite tables, the DBOS fulfillment workflow, the wallet debit/compensation, PIN custody, the adeudo endpoint and the VAS screens on web and mobile all shipped during sprints 33–34. The one layer still missing is the real aggregator transport. (Connectivity phasing + wallet rule amended per PDR-0003; original model 2026-06-24. 2026-09-26: a VAS sale records a CASH tender on the shift, requires an open shift and is not voidable — § VAS and the shift drawer. Same day, second wave: a platform-funded sale debits only the connection and is crash-safe, a delivered sale counts in the daily rollup, and a failed/reversed sale’s order ends
CANCELLED— § VAS fulfillment funding & sale outcome.)The aggregator is Mantarys. What this document calls “the VB webservice” in the abstract has a name and a contract: see
manual-integracion-mantarys(WSDL, five methods, the 32-entry response-code table, a 13-case certification matrix) and the sequenced implementation plan. Until that transport lands,VbAggregatorAdapteris a deliberate fail-closed 503 stub — so a VAS sale does not silently half-succeed, and the VAS live E2E specs are gated, not failing.Money-safety rule (PO, 2026-07-26): Mantarys
Confirmationcode17is TERMINAL → FAILED — reverse the wallet debit, never poll. This is deliberately fail-safe and overrides the legacy documentation’s softer reading of that code.Companions: the aggregator contract in
mantarys· legacy contracts inapitae-webservice· POS model in ./pos-domain-model.md · reporting in ./reporting.md · per-story status in ../product/user-stories.md (§ VAS) and ../product/mobile-user-stories.md.
Scope & decisions
Section titled “Scope & decisions”The four verticals (mobile airtime / TAE, gift-card PINs, service/bill payment, SIM chips) were previously parked as “legacy TypeORM, decision deferred”. This doc records the decision to bring them into the Appwrite POS model:
- Unified POS flow. TAE/Pines/Services sell through the same
cart → sales_orders → transactionspipeline as physical goods — one POS, one shift/Z-report, one tender/cash model, one reporting model — distinguished by akinddiscriminator, with a fulfillment collection for the external call. - Chips = physical serialized inventory. SIM chips are real stocked goods, modeled with the existing
products/variants+stock_levels/stock_movements, plus serialized units (ICCID/IMEI/NIP/MSISDN). No external webservice call to sell a chip. - Provider catalog = local Appwrite mirror, synced from the Mantarys
pos_prices_productsendpoint. ⚠ Open question: under Phase 1 there is one shared webService user, so that endpoint returns one flat catalog with no tenant dimension — whileproviders/provider_productsrows arestoreTeamId-scoped and the adeudo guard treats that scope as a security boundary. Where the sync job writes is a PO decision (fan-out per store team vs. a shared sentinel); engineering recommends fan-out. Seeopen-questions-and-todos. - The aggregator (Mantarys) stays the upstream money-movement source of truth for TAE/Pines/Services; terminales orchestrates via a NestJS aggregator adapter + DBOS durable workflow (debit wallet → write pending → call provider → reconcile or reverse). terminales does not call apitae-sales.
Per-feature concepts
Section titled “Per-feature concepts”| Vertical | kind |
Recipient ref | Amount | Fulfillment | Notes |
|---|---|---|---|---|---|
| TAE | TAE_AIRTIME |
phone (per-carrier validation) | fixed denomination | VB Request_Transaction |
24h same-number dedup |
| Pines | GIFT_PIN |
email/phone (delivery) | fixed denomination/plan (title, days) | VB call → PIN code custody + delivery + resend | code is sensitive |
| Services | SERVICE_PAYMENT |
account/reference (often barcode) | looked-up (adeudo) or entered | VB call; pre-check via check_service_pending_amount |
CFE may carry a CUR; CFE commission cap |
| Chips | PHYSICAL (serialized) |
— | item price | none (local inventory) | bulk Excel import; activation lifecycle |
Recommended data model
Section titled “Recommended data model”Catalog — extend products, add provider catalog
Section titled “Catalog — extend products, add provider catalog”products additions:
kindenum[PHYSICAL, TAE_AIRTIME, GIFT_PIN, SERVICE_PAYMENT]providerId?(→providers)isVirtual(true for TAE/PIN/SERVICE → skip stock decrement)amountModeenum[FIXED, OPEN, LOOKUP](LOOKUP ⇒ adeudo before sale)referenceTypeenum[PHONE, ACCOUNT, EMAIL, NONE]validationPattern?(per-provider reference/phone format)isSerialized(true for chips)
New providers — local mirror, synced from pos_prices_products:
kind [TAE_CARRIER, BILL_PROVIDER, PIN_BRAND], name, externalProviderCode, logoFileId,
checkAmount (adeudo support), validationRules (JSON), commissionRule (JSON: FIXED|PERCENT + value +
caps, e.g. CFE ≤ 5%), isActive, syncedAt, + base.
New provider_products — denomination/price mirror of pos_prices_products:
providerId, externalSku, title, amount, cost, message, days?, productType, isActive,
syncedAt, + base.
Sales — extend existing collections, add fulfillment + custody
Section titled “Sales — extend existing collections, add fulfillment + custody”line_items additions (VAS lines; variantId becomes optional):
kind, providerId?, providerProductId?, recipientRef? (phone/account/email), denomination?,
serviceReference?. Physical fields (unitPrice, unitCost, tax/discount, qty) unchanged.
transactions additions:
- extend
typewithREFUND/REVERSAL(keepSALE/VOID) - extend
statuswithPENDING_EXTERNAL - add
externalProviderId?,externalFolio?(VBFolio),externalStatusCode?(VBConfirmation) - reuses already-planned reporting fields (
commission,netAmount,provider).
New service_fulfillments — the external-call record / reconciliation, written by the DBOS workflow:
salesOrderId, lineItemId, transactionId, providerId, kind, recipientRef,
requestPayload (JSON), responsePayload (JSON), externalFolio, confirmationCode,
status [PENDING, DELIVERED, FAILED, REVERSED], folioPos (idempotency = ${salesOrderId}${seq}),
retryCount, polledAt?, completedAt?, reversedAt?, + base. Store-team scoped; append-mostly. This is
the terminales replacement for the legacy Mongo Transactions audit.
New pin_vouchers — PIN custody & delivery:
salesOrderId, fulfillmentId, providerId, brand, denomination,
pinCode (sensitive — masked in logs/audit, access-controlled, show-once + controlled reprint),
serial?, expiresAt?, deliveryMethod [ON_SCREEN, EMAIL, SMS], deliveryStatus, recipient,
redeemedAt?, + base.
Chips — physical serialized inventory
Section titled “Chips — physical serialized inventory”Reuse products (kind=PHYSICAL, isSerialized=true) + stock_levels / stock_movements.
New serialized_units (generalized; chips are the first consumer):
productId/variantId, storeId, storeTeamId, merchantId, msisdn, carrier, nip, iccidImei,
status [IN_STOCK, SOLD, ACTIVATED, VOID], registrationDate, applicationDate?, soldOrderId?, + base.
- Unique indexes:
(storeTeamId, iccidImei),(storeTeamId, nip). - Bulk Excel import (legacy columns
TELEFONO / MONTO / COMPAÑIA / NIP / IMEI), with per-row validation. - Selling a chip links a unit to the order, sets
status=SOLD+soldOrderId, and writes a stock movement; activation flipsstatus=ACTIVATED+applicationDate.
Orchestration (NestJS + DBOS)
Section titled “Orchestration (NestJS + DBOS)”- Aggregator adapter parallel to the PSP adapter (
board/backlog/api-appwrite-payments-psp-adapter.md), wrapping the VB methods (Request_Transaction,check_transaction,check_service_pending_amount,pin_resend,Check_Balance). Seeapitae-webservice. - Durable sale workflow: finalize order →
Request_Transaction(idempotent onfolioPos) → on24/17durably pollcheck_transaction→ writeservice_fulfillments(+pin_vouchersfor PIN) → settransactions.status(COMPLETED/FAILED). Replaces the legacy cron leader-lock + in-process poll loop. - Reversal workflow for failures/timeouts.
- Adeudo endpoint: NestJS read calling
check_service_pending_amountwhenamountMode=LOOKUP(cached, not persisted). - Provider-catalog sync job: periodically refresh
providers/provider_productsfrompos_prices_products.
Provider connectivity — three phases (decided 2026-07-11, PDR-0003)
Section titled “Provider connectivity — three phases (decided 2026-07-11, PDR-0003)”| Phase | Connectivity | Credentials | Scope |
|---|---|---|---|
| 1 (MVP) | The client’s existing system — the VB webService directly (not apitae-sales), same SQL Server + provider connections |
One shared webService user managed in the terminales backend (VB_WS_URL/USER/PASSWORD env); terminales transacts on behalf of all merchants. Temporary by design. |
In MVP |
| 2 | Same VB webService | Each merchant connects their own webService user; resolved per call through the resolveVbCredentials(merchantId) seam the Phase-1 adapter ships |
Post-MVP |
| 3 | Direct provider integration (TAE, services, …) | Per-provider certification of the terminales solution | Post-MVP (future) |
The external VB balance is never displayed in any phase — see the wallet rule below.
⚠ Amended 2026-08-03 (PDR-0003 Amendment): a merchant may now connect one or more external platforms
(Mantarys and its instances VentaMóvil / Sureste — same aggregator, different backend URLs — plus a generic
“Otra plataforma”) with their own username/password credential (Phase-2 seam brought forward as a
product feature), and the balance of a connected platform IS displayed, owner-only. See the wallet rule
below and the Wave-3 design module
figma-wave3-external-platforms-prompts.
Cross-cutting constraints
Section titled “Cross-cutting constraints”- VAS is online-only. TAE/Pines/Services need a live external call and cannot be offline-queued —
the offline-first model (
offline-and-idempotency.md) applies to physical/cash sales only. Chips, being local inventory, can sell offline like any physical good. - Reporting: extend
daily_sales_rollupsand the report catalog to break down bykind/provider(TAE by carrier, Pines by brand, Services by biller, commission by provider). See ./reporting.md. - Security: PIN codes masked everywhere; carry over fraud controls (recipient blacklist, velocity/anomaly detection, MFA step-up above an amount threshold).
- Independent ledgers (2026-06-24, amended 2026-07-11 and again 2026-08-03 by
PDR-0003):
- VB provider float (shared Phase-1 user) — the upstream supplier wallet reached through the single
backend-managed credential. terminales reads it (
Check_Balance) internally only (ops metric / low-float platform alert); it is not displayed and not a terminales ledger. - Connected external platform balances (NEW 2026-08-03) — when a merchant connects a platform with
their own credential (Mantarys / VentaMóvil / Sureste / Otra), that platform’s balance IS displayed,
owner-only (same
merchants.ownerIdguard as the wallet), and can fund the VAS portion of a sale as a funding source distinct from and additional to the merchant-wallet debit. Each connection keeps its own balance/usage ledger, still never mirrored intobalance_movements. Auth = username + password (not Keycloak). Insufficient platform balance → blocked at selection (no negatives). Online-only. Data model, credential encryption and the per-platform ledger are still to build (design gaps #46–#49). - Adding balance to a connected platform (NEW 2026-08-04, wave 3.1) — the merchant can top up a
platform balance (or terminales’ own wallet) from the handheld, making the per-platform ledger
bidirectional (credits from top-ups/deposits + debits from VAS). Three funding sources: (1) card on
the handheld (AngelPay bridge, immediate); (2) transfer from the terminales merchant wallet
(
merchant_balances→ platform, immediate); (3) a deposit (bank channels A–C, delayed — an owner/manager validates it; cash channel D immediate) — the deposit form gains atargetfield (merchant wallet or a connected platform). Vendor gap: Mantarys exposes no inbound-credit method today (Check_Balanceonly reads; sales only debit), so the card-charge top-up is forward-looking — terminales records the credit against the per-platform ledger and reconciles the shown balance againstCheck_Balance; the real provider-side crediting depends on a new Mantarys integration (design gaps #50/#51). No negatives; online-only. - terminales merchant wallet — the platform-managed wallet (
merchant_balances/balance_movements), shown to merchant owners only, funded by the deposit channels (PDR-0003 Decision 3) and the default funding source for VAS operations — in Phase 1 terminales pays the provider on the merchant’s behalf, so the sale is blocked before the provider call if funds are insufficient (unless a connected platform balance is chosen instead), with a compensating credit on reverso. Integrity model:TDR-0004(DBOS per-merchant serialization). - The store cash balance (shifts / cash movements / Z-reports) remains the separate, employee-visible cash-control domain — cash paid directly to the merchant (channel D) credits store cash, never the wallet.
- Keep the ledgers independent: never mirror VB movements into
balance_movements— a VAS debit is our own movement, not a copy of VB’s.
- VB provider float (shared Phase-1 user) — the upstream supplier wallet reached through the single
backend-managed credential. terminales reads it (
Suggested future Epic (tickets created separately)
Section titled “Suggested future Epic (tickets created separately)”VAS schema (catalog + sales additions + new collections) · provider-catalog sync · aggregator adapter ·
VAS sale/fulfillment DBOS flow · PIN custody + delivery + resend · chips serialized inventory + Excel import ·
adeudo lookup endpoint · VAS reporting dimensions · fraud/MFA controls · merchant-wallet debit wiring
(PDR-0003 /
TDR-0004 — the ledgers themselves are never reconciled).
Ticketed 2026-07-11 as Wave 3 Tracks C + K.
VAS and the shift drawer — cash tender, open shift, not voidable (R9, 2026-09-26)
Section titled “VAS and the shift drawer — cash tender, open shift, not voidable (R9, 2026-09-26)”Supersedes the 2026-09-03 “Known limitation — VAS bypasses the shared tender (MVP)”. Source: terminales-api #964 (R9 A3, A7; PO rulings in the R9 findings), clients web #1133/#1135 and Android #500.
VAS sales (airtime, service/bill payment, gift-PIN) still do not flow through the shared POS cart or finalizeOrder: a VAS sale is a self-contained checkout that debits the merchant wallet (or a connected platform balance), calls the aggregator and writes its own fulfillment + custody records. What changed is how it meets the cash drawer:
- The customer pays cash, and that cash is recorded on the shift. The customer hands the cashier the «Total a cobrar»; neither the web nor the Android screen has a tender step. Once the cashier is told SUCCESS, the api writes one CASH
order_paymentsrow for the total (deterministic idvcash_<orderId>) and stamps the order’sshiftId/terminalId, in one transaction. The close’s expected cash and the Corte Z (both read the CASH tenders of the shift’s finalized orders) therefore count it exactly once. A replay of the sameclientIdnever adds it twice (and repairs a missing one); a failed or reversed sale records nothing. A platform-funded sale records the cash too — the customer still paid the cashier. - A VAS sale requires an open shift.
POST /vas/saletakes optionalshiftId/terminalIdand resolves the drawer before any charge: a sentshiftIdmust be an OPEN shift of the store; otherwise the store’s open shift onterminalId, then the cashier’s own open shift, then the store’s only open shift. No drawer → 400sale_requires_open_shift, nothing charged or written (same code as a POS sale — see pos-domain-model.md § Shift required to sell). Another terminal’s shift under strict mode → 400sale_shift_terminal_mismatch. - A VAS sale is not voidable or refundable.
POST /pos/orders/:id/voidand/refundrefuse a VAS order (avas-synthetic cart or anyservice_fulfillmentsrow) before any write: 409vas_sale_not_voidable— «Esta recarga ya se entregó y no se puede anular.» (delivered) or «Esta recarga no se completó; no hay cobro que anular.» (failed, reversed or still pending — a pending sale may still deliver). Without this, a same-day void would have handed the customer’s cash back out of the drawer (the CASH tender above) while the airtime stayed delivered. The clients hide «Anular»/«Reembolsar» on a VAS sale and drop it from «Anular venta»; the 409 is the backstop.
Superseded (rollup): the paragraph below said a VAS sale makes no contribution to daily_sales_rollups. As of the same day (terminales-api #970, R9 C7 — see the next section) a delivered VAS sale now adds exactly one contribution to the rollup behind the Inicio KPI tiles. What’s still true: a VAS sale has no unified sale receipt, and its revenue/commission detail (by kind/provider) is still read from service_fulfillments and the commissions report (pos/reports/commissions), not from the rollup — the rollup only carries the aggregate transaction-count/revenue figures the Inicio tiles need. The new CASH tender makes VAS cash visible to the drawer (close, Corte Z cash reconciliation) independently of the rollup. Physical goods and SIM chips are unaffected — they use the normal cart/tender path.
VAS fulfillment funding & sale outcome — single debit, crash-safe, in the rollup, CANCELLED on failure (R9 second wave, 2026-09-26)
Section titled “VAS fulfillment funding & sale outcome — single debit, crash-safe, in the rollup, CANCELLED on failure (R9 second wave, 2026-09-26)”Four more R9 findings, all in terminales-api: #968 (C1 single debit, C3 product cost), #969 (C5, the same PR as the approval-freshness fix — see pos-domain-model.md § Supervisor approval freshness), and #970/#972/#973 + #971 (C7 rollup, C8 CANCELLED).
- A platform-funded sale is charged once (C1, api #968). Before, a VAS sale funded from a connected platform balance also debited the merchant wallet — a double charge. The funding source now travels in the durable fulfillment workflow’s own input: a platform-funded sale debits and reverses only the connection; a wallet-funded sale debits and reverses only the wallet — including on replay and on crash recovery. The sale still refuses an insufficient balance (402) or a cross-tenant connection (403) before writing anything; the response contract is unchanged.
- That debit is crash-safe (C5, api #969). The connection debit, its link to the delivered fulfillment, and its reversal on failure now run as idempotent steps inside the durable fulfillment workflow, keyed per sale. After a crash mid-sale, DBOS recovery replays the workflow and posts exactly one debit and, if the purchase then fails, exactly one reversal — never a duplicate of either.
- A delivered VAS sale counts in the daily rollup (C7, api #970/#972/#973). Behind the Inicio KPI tiles,
daily_sales_rollupsnow gets exactly one contribution per delivered VAS sale: one transaction, its total as revenue, 0 units, cash tender. A failed or reversed sale contributes nothing (see C8 below), and the report’s live-scan fallback applies the same rule. New additive columnsales_orders.rolledUpAtmarks a sale already counted, so a re-run of the backfill or a workflow replay never double-counts. Two follow-ups keep the contribution on the right store: a VAS order whose provider names no store now resolves the team’s realstoresrow (#972, so it lands on the store’s existing rollup row instead of a second one keyed on the store-team id that Inicio never reads) and gets that store’s folio;#973’s backfill repairssales_orders.storeIdon older orders the same way, for teams with exactly one store (ambiguous teams are listed and left alone). - A failed/reversed sale’s order ends
CANCELLED, notFINALIZED(C8, api #971). A VAS sale whose purchase failed or was reversed is a sale that never happened — see pos-domain-model.md § Same-day void for the newsales_orders.statusvalue and how it differs fromVOID. It has no cash tender and makes no rollup contribution;void/refundstill answervas_sale_not_voidable(409, «Esta recarga no se completó; no hay cobro que anular.») exactly as before the reorder needed to keep that message. Clients show it as «No completada» (web #1146, Android #502) — distinct from aVOID’d sale — and hide reprint/share on it (Android). - Backfills (all dry-run by default, idempotent;
-- --applywrites):pnpm backfill:vas-rollup— adds past delivered VAS sales the rollup missed (pre-#970).pnpm backfill:vas-order-storeid— repairssales_orders.storeIdon older VAS orders (pre-#972/#973).pnpm backfill:vas-failed-cancelled— moves older failed/reversed VAS orders fromFINALIZEDtoCANCELLED(pre-#971).