Appearance
Vecnet — Build Spec for Dropout Capital
Audience: Dropout Capital engineering team building Vecnet greenfield. Status: v0.1 — finances module + system architecture. Repo mockups and exact rail API contracts to follow. Owner: Yuyo (Tonder / Vecnet). Source of truth: This document supersedes verbal context. Where it conflicts with the architecture PDF or the
vecnet_citizen_flow.mmd, this document wins.
0. How to use this document
This is the master spec. It gives you everything needed to start building Vecnet, with the finances module as the technical centerpiece.
Read in this order:
- §1–§3 — what Vecnet is and how it's shaped (mission, architecture, the two-rail model).
- §4 — the single most important section: how we port Tonder's AWS/NoSQL finances engine to Supabase/Postgres, and why the port simplifies it.
- §5–§7 — stack, domain model, and the concrete Supabase schema.
- §8 — the finances flows, each rewritten for Supabase.
- §9 — the two surfaces you build: the merchant frontend and the Vecnet admin backend (operated by Vecnet personnel).
- §10–§14 — rails integration, security, build phases, and the full Tonder→Vecnet mapping appendix.
Hard constraint up front: Vecnet does not use MongoDB or DynamoDB. The entire data layer is Supabase (Postgres). Tonder's finances engine is the blueprint; the storage and eventing primitives are replaced.
1. Mission & context
Vecnet is an orchestration, reconciliation, and settlement layer that sits on top of two independent payment rails and presents a unified financial picture to the merchant and to Vecnet operations.
In the reference deployment (government collections / GovTech):
- A citizen (
Ciudadano) pays either:- online through the government portal (
Portal de Gobierno→ Web/App), or - in person at a POS terminal (
Terminal POS) in the collection office (Oficina de Recaudación).
- online through the government portal (
- Online payments are processed through the Tonder rail (card-not-present).
- In-person payments are processed through the Menta rail (card-present / POS).
- Vecnet consolidates both rails: it listens to their transaction notifications, records them in its own books, runs fees / settlements / rolling reserve / daily ledger, and exposes dashboards and admin tooling.
The same engine generalizes beyond GovTech — the merchant ("BUSINESS" entity) can be any Vecnet client, and the rails ("ACQUIRER" entities) can be extended. See §3 (acquirer-as-plugin).
Vecnet owns its finances module. It is not a thin client of Tonder's books. Vecnet keeps its own double-entry ledger and computes its own fees, settlements, reserves, and reports — modeled on the Tonder engine documented here, rebuilt on Supabase.
2. Architecture at a glance
The authoritative diagram is the uploaded vecnet_citizen_flow.mmd and the architecture PDF. Summary of the flow:
Ciudadano ─┬─ Web/App (Portal de Gobierno) ─checkout→ Vecnet Hosted Checkout ─payment request→ Tonder API → Tonder Backend → Tonder DB
│ (embeds Tonder Lite SDK v2.0) │
│ └─ Tonder Backend ─Transaction Notif→ Vecnet Listener
│
└─ Terminal POS (Oficina de Recaudación) ─→ Menta API → Menta Backend → Menta DB
└─ Menta Backend ─Transaction Notif→ Vecnet Listener
Vecnet Listener → Vecnet DB (Postgres) + Webhook Manager → Transaction Notification → Web/App
Vecnet Backend also runs: Finanzas (finances module) + Liquidaciones (settlements)
Vecnet Frontend exposes: Hosted Checkout, Dashboard, Admin Panel, System Control PanelTwo load-bearing principles (carry these through every design decision):
- Acquirer-as-plugin. Tonder and Menta are two rails behind one abstraction. The finances engine treats each rail as an
ACQUIRERentity. Adding a third rail later must not require schema or core-flow changes — only a new acquirer record + fee rules + a notification adapter. - One correlation ID per transaction. Every payment — CNP or CP — carries a single correlation ID from the rail notification, through the Listener, into the journal, the ledger entries, and the settlement record. This is the thread that makes reconciliation tractable. Design every table and event around it.
3. The two rails and how finances sees them
In Tonder's own books, an "acquirer" is a downstream processor it receives funds from (kushki, unlimit, stp, …). In Vecnet's books, the acquirers are the rails Vecnet settles against: tonder and menta. That is the clean mapping and it is exactly what "acquirer-as-plugin" means here.
Vecnet ACQUIRER | Rail | Capture model | Notifies Vecnet via |
|---|---|---|---|
tonder | Online / CNP | Hosted Checkout + Tonder Lite SDK v2.0 → Tonder API | Tonder Backend → Transaction Notification → Listener |
menta | In-person / CP | Terminal POS → Menta API | Menta Backend → Transaction Notification → Listener |
| (future) | any | plugin | new notification adapter |
Vecnet itself is the PLATFORM entity (the equivalent of Tonder's T1). Use V1 as the platform entity id throughout Vecnet.
4. The big shift — AWS/NoSQL → Supabase/Postgres
If you read one section, read this one. Most of Tonder's finances complexity exists to work around DynamoDB's lack of multi-item ACID transactions and MongoDB's role as a query mirror. Postgres removes the need for almost all of that machinery. Do not port the workarounds — port the intent.
4.1 What Tonder does, and why
Tonder's engine is event-driven on AWS:
- DynamoDB holds accounts, journals, ledger entries, fee rules, config, snapshots, idempotency.
- MongoDB mirrors journals/fee-rules/config for rich queries and aggregation pipelines.
- EventBridge → SQS FIFO (
TransactionEventsQueue,MessageGroupId = businessId) delivers transaction events in per-business order. AccountUpdateQueue(FIFO,MessageGroupId = account.id) serializes balance updates because DynamoDB can only do atomic single-itemADD, and running balances must be applied in order. Aversioncounter provides optimistic concurrency.maxReceiveCount = 160because losing a balance update permanently corrupts the ledger.- An
IdempotencyTable(120s TTL) plus aprocess_id-indexdedup guard plus a 2-day cutoff prevent double-processing of redelivered SQS messages. - CloudWatch crons drive rolling-reserve release (02:00 UTC) and daily ledger (10:00 UTC).
- Step Functions drive bulk reprocess.
4.2 What Vecnet does instead
| Tonder primitive | Reason it exists | Vecnet (Supabase) replacement |
|---|---|---|
DynamoDB single-table (PK/SK) | NoSQL key access | Postgres relational tables with proper PKs, FKs, indexes |
| MongoDB query mirror + aggregation pipelines | Rich querying DynamoDB can't do | Gone. Postgres is the query engine. Aggregations are SQL. |
AccountUpdateQueue FIFO + ADD operator + version counter | DynamoDB can't update journal + entries + balances atomically | A single Postgres transaction. Insert journals, insert ledger entries, and UPDATE accounts SET balance = balance + :delta — all-or-nothing, row-locked. |
IdempotencyTable + process_id-index dedup + 120s TTL | Guard against SQS redelivery | A UNIQUE (process_id, category) constraint + INSERT … ON CONFLICT DO NOTHING. Idempotency is a property of the schema. |
TransactionEventsQueue (EventBridge→SQS FIFO) | Ordered async ingestion per business | pgmq (Postgres Message Queue, available on Supabase) for the inbox, or a transaction_events table consumed with SELECT … FOR UPDATE SKIP LOCKED. Per-entity ordering via pg_advisory_xact_lock(hashtext(entity_id)). |
| CloudWatch crons | Scheduled jobs | pg_cron (Supabase extension) invoking SQL functions / Edge Functions |
| Step Functions (bulk reprocess) | Long-running orchestration | A worker driven off a bulk_jobs table + pgmq; or an Edge Function loop. No state machine needed at Vecnet's scale initially. |
| S3 (XLSX reports) | File storage | Supabase Storage bucket |
| Lambda + Middy + InversifyJS + RxJS Observables | AWS runtime + hexagonal wiring | Supabase Edge Functions (Deno/TypeScript) for ingestion/reports + Postgres functions for the ledger core. Keep hexagonal layering (§5.3) but drop RxJS — use plain async/await. |
| Account balance via FIFO serialization | Avoid lost updates | Row-level locking (SELECT … FOR UPDATE) inside the transaction; balance_before/balance_after/seq computed from the locked row. |
4.3 The core simplification, stated plainly
Tonder: calculate fees → generate journals → persist journals (transactWrite) → enqueue entries to FIFO → accountUpdater applies entries one-by-one with ADD + version.
Vecnet: one transaction:
sql
BEGIN;
-- idempotent: skip if already processed
INSERT INTO journals (...) VALUES (...) ON CONFLICT (process_id, category) DO NOTHING;
-- if no row inserted, the event was already processed → COMMIT and return.
INSERT INTO ledger_entries (...) VALUES (...); -- the DEBIT/CREDIT pair(s)
-- apply balances atomically, locking each account row
UPDATE accounts SET balance = balance + :delta, version = version + 1, modified_at = now()
WHERE id = :account_id; -- repeated per affected account
-- (balance_before / balance_after / seq derived from the locked rows)
COMMIT;Double-entry balance (sum(debits) = sum(credits)) is enforced, not merely warned about, via a deferred constraint / a check in the posting function. (Tonder only logs a warning — Vecnet should do better since Postgres lets us.)
5. Tech stack & conventions
5.1 Platform
- Database / backend: Supabase — Postgres 15+, Row Level Security, Auth, Realtime, Edge Functions (Deno), Storage,
pg_cron,pgmq,pg_net. - Frontend: React + TypeScript (Next.js App Router recommended). Two apps or one app with role-gated routes — see §9.
- Design system: Obsidian Terminal (dark-first; Syne / DM Sans / JetBrains Mono; Signal Teal / Forge Amber / Ember Coral / Deep Violet). All dashboards, admin, and documents use it.
- Money: never use floats for money in app code. Store amounts as
numeric(20,4)in Postgres; in TS use a decimal library (e.g.decimal.js) at boundaries. (Tonder usestoMoney()/toRate()Decimal helpers — keep the discipline.) - IDs: UUID v7 for journals, ledger entries, processes, settlements (time-sortable). Postgres can generate these via an extension or app-side.
- Language: code/comments in English; user-facing copy bilingual ES/EN.
5.2 Repos / environments
- Mirror Tonder's stage model:
dev,stage,pdn(prod). Supabase project per stage (or branching). - Conventional commits (
feat,fix,refactor,docs,test,chore); pre-commit lint + format (ESLint + Prettier). - Migrations are versioned SQL under
supabase/migrations. RLS policies live in migrations, never applied by hand.
5.3 Layering (hexagonal, adapted)
Keep Tonder's hexagonal discipline, drop the AWS/RxJS specifics:
| Layer | Vecnet location | Responsibility |
|---|---|---|
| Domain | src/service/ (pure TS) and supabase/functions/_shared | Business logic: fee calc, journal generation, settlement math. No framework deps. |
| Application | Edge Functions (supabase/functions/*) + Postgres functions | Entry points: HTTP handlers, queue consumers, cron jobs. |
| Ports | src/repository/I*.ts | Interfaces between domain and infra. I prefix retained. |
| Infrastructure | src/gateway/ | Adapters: Supabase client, Storage, pgmq, rail webhook clients. |
Naming conventions from Tonder carry over: I-prefixed interfaces, PascalCase classes/services, camelCase methods. ESLint limits (no any, explicit return types, function length caps) carry over.
6. Domain model (the double-entry foundation)
This is ported faithfully from Tonder's glossary — it is rail- and storage-agnostic and is the heart of correctness.
6.1 Account types (account_type)
Standard double-entry. Sign rules:
| Account type | DEBIT | CREDIT |
|---|---|---|
ASSET | + | − |
EXPENSE | + | − |
LIABILITY | − | + |
REVENUE | − | + |
EQUITY | − | + |
REVERSAL inverts the sign regardless of type.
6.2 Entity types (entity_type)
| Value | Vecnet meaning | Key prefix |
|---|---|---|
BUSINESS | Vecnet client / merchant (e.g. the government entity / operator) | ENT#{entity_id} |
ACQUIRER | A rail: tonder, menta, … | ACQ#{acquirer} |
PLATFORM | Vecnet itself | ENT#V1 |
6.3 Account codes (account_code)
Each code implies a canonical account_type and entity_type. Ported set:
| Code | Type | Entity | Purpose |
|---|---|---|---|
ACQUIRER_RECEIVABLE | ASSET | ACQUIRER | Funds receivable from a rail after capture |
BUSINESS_PAYABLE | LIABILITY | BUSINESS | Net owed to the merchant after fees |
PLATFORM_CLEARING | LIABILITY | PLATFORM | Temporary platform-fee clearing |
WITHDRAWAL_FUNDS | ASSET | ACQUIRER | Funds held at a rail to settle withdrawals |
BUSINESS_WITHDRAWAL_PAYABLE | LIABILITY | BUSINESS | Withdrawal amount owed to merchant |
PROCESSING_FEES_REVENUE | REVENUE | PLATFORM | Revenue from payin fees |
WITHDRAWAL_FEES_REVENUE | REVENUE | PLATFORM | Revenue from withdrawal fees |
ACQUIRER_FEES_EXPENSE | EXPENSE | PLATFORM | Payin fees charged by the rail |
WITHDRAWAL_ACQUIRER_FEES_EXPENSE | EXPENSE | PLATFORM | Withdrawal fees charged by the rail |
WITHDRAWAL_ACQUIRER_FEES_PAYABLE | LIABILITY | ACQUIRER | Owed to a rail for withdrawal fees |
VAT_PAYABLE | LIABILITY | PLATFORM | IVA collected, to remit |
VAT_RECEIVABLE | ASSET | PLATFORM | IVA paid to rails, reclaimable |
RESERVE_RECEIVABLE | ASSET | ACQUIRER | Rolling reserve a rail holds for the platform |
RESERVE_PAYABLE | LIABILITY | BUSINESS | Merchant balance held as rolling reserve |
BANK | ASSET | PLATFORM | Bank settlement account |
BUSINESS_SETTLEMENT_PENDING | LIABILITY | BUSINESS | Funds pending settlement disbursement |
PRIOR_PERIOD_ADJUSTMENT | EQUITY | PLATFORM | Contra for historical loads / prior-period fixes |
PLATFORM_CASHfrom Tonder is reserved/unused — omit it; useBANKfor platform cash.
6.4 Journals
Every transaction produces journals. journal_type ∈ {IN (rail side), OUT (business side)}.
journal_category: PAYMENT, VOID, REFUND, DISPUTE_WON, DISPUTE_IN_REVIEW, WITHDRAWAL, TOPUP (each → IN+OUT pair); SETTLEMENT_APPROVE, SETTLEMENT_CONFIRM, ROUTING, ROLLING_RESERVE_RELEASE, ADJUSTMENT (each → single journal).
6.5 Other enums (port verbatim)
entry_type:DEBIT,CREDIT.transaction_type:PAYMENT,PAYOUT,REFUND,DISPUTE,WITHDRAWAL,VOID.transfer_type→adjustment_typemapping (for internal transfers — §8.6).adjustment_type: full taxonomy (reversals, corrections, fees, taxes/FX/reserves/risk, general accounting, manual). See appendix §14.fee_rule_status:active/inactive.transaction_status:Success,PAID_FULL,Won,Needs Response,In Review,Liquidado,Lost.- Module flags (§8.8):
FEES_CALCULATION,LEDGER_ENTRIES,DAILY_BALANCES,JOURNALS_GENERATION.
Rail/detail-type note: Tonder's DetailType/EventBridge taxonomy is AWS-specific. Vecnet's Listener normalizes each rail notification into a clean internal transaction_event (see §8.1). Keep the semantic categories; drop the EventBridge naming.
7. Supabase data model
Reference DDL — a starting point, not final migrations. Adjust types/indexes during implementation, but keep the keys, constraints, and the idempotency/double-entry guarantees.
7.1 accounts
sql
create table accounts (
id uuid primary key default uuid_generate_v7(),
account_number text not null unique, -- 10-digit + Luhn check digit
account_code account_code_enum not null,
account_type account_type_enum not null,
entity_type entity_type_enum not null, -- BUSINESS | ACQUIRER | PLATFORM
entity_id text not null, -- merchant id | 'V1'
acquirer text, -- rail name when entity_type = ACQUIRER
currency_code text not null,
name text not null,
status text not null default 'active',
balance numeric(20,4) not null default 0, -- updated ONLY by the posting fn
version bigint not null default 0,
metadata jsonb,
created_at timestamptz not null default now(),
modified_at timestamptz not null default now(),
last_transaction_at timestamptz,
deleted_at timestamptz,
-- one account per (entity, acquirer, currency, code)
unique (entity_type, entity_id, coalesce(acquirer,''), currency_code, account_code)
);
create index on accounts (entity_id);
create index on accounts (acquirer);
create index on accounts (account_code, currency_code);The DynamoDB
PK = ENT#{entity_id}#CUR#{currency}/SK = ACCT#{code}pattern becomes the columns above + the unique constraint. TheACCOUNT_NUMBER#…/ALIAScompanion item is just theunique(account_number)index.balanceis never updated by CRUD — only by the posting function (§8.3).
7.2 journals
sql
create table journals (
id uuid primary key default uuid_generate_v7(),
journal_type journal_type_enum not null, -- IN | OUT
category journal_category_enum not null,
process_id text not null, -- transaction / settlement / correlation id
correlation_id text not null, -- the one-ID-per-transaction thread
entity_id text not null,
currency_code text not null,
gross_amount numeric(20,4) not null,
fee_amount numeric(20,4) not null default 0,
iva_amount numeric(20,4) not null default 0,
net_amount numeric(20,4) not null,
rr_amount numeric(20,4) not null default 0, -- PAYMENT only
acquirer text, -- 'tonder' | 'menta'
provider text,
process_date timestamptz not null, -- ORIGINAL event time (preserve in bulk)
created_at timestamptz not null, -- = process_date when valid, else now()
generated_at timestamptz not null default now(),-- when WE created the record
modified_at timestamptz not null default now(),
related_journal_id uuid references journals(id), -- IN<->OUT link
settlement_id text, -- set during settlement
expected_reserve_release_date timestamptz, -- PAYMENT only
description text,
fee_rule_id uuid,
metadata jsonb,
-- IDEMPOTENCY: this is what replaces the whole Dynamo idempotency stack
unique (process_id, category)
);
create index on journals (entity_id, created_at);
create index on journals (correlation_id);
create index on journals (settlement_id);
create index on journals (expected_reserve_release_date)
where category = 'PAYMENT' and (settlement_id is null or settlement_id = '');
created_atvsgenerated_atsemantics carry over exactly:created_at= "when did the transaction happen" (the rail's event time),generated_at= "when did Vecnet ingest it." They diverge in bulk reprocess. Usecreated_atfor business queries,generated_atfor ops/ingest queries.
7.3 ledger_entries
sql
create table ledger_entries (
id uuid primary key default uuid_generate_v7(),
account_id uuid not null references accounts(id),
journal_id uuid not null references journals(id),
entry_type entry_type_enum not null, -- DEBIT | CREDIT
amount numeric(20,4) not null,
process_id text not null,
correlation_id text not null,
entity_id text not null,
currency_code text not null,
balance_account_before numeric(20,4) not null,
balance_account_after numeric(20,4) not null,
seq bigint not null, -- per-account sequence
process_date timestamptz not null,
created_at timestamptz not null default now()
);
create index on ledger_entries (account_id, seq);
create index on ledger_entries (journal_id);
create index on ledger_entries (correlation_id);Tonder gives entries a 15-day DynamoDB TTL and archives to Firehose; Mongo keeps history. Vecnet keeps entries in Postgres permanently (they're the audit trail). If volume demands it later, partition by month or archive cold partitions to Storage via
pg_cron. No TTL needed for correctness.
7.4 fee_rules
sql
create table fee_rules (
id uuid primary key default uuid_generate_v7(),
fee_rule_key text not null unique, -- dedup key
rule_type text not null, -- PAYIN | WITHDRAWAL
status fee_rule_status_enum not null default 'active',
description text,
-- match criteria (NULL = matches all)
business_id text[], -- null/empty => generic
acquirer text[],
currency_code text,
country_code text[],
payment_method_id integer[],
card_brand text[],
risk_level text[],
method text, -- withdrawal method
transaction_type text, -- WITHDRAWAL | TOPUP
-- fee fields nested for IN and OUT sides (unified format)
fee_in jsonb, -- {intra_rate, intra_fee, inter_rate, inter_fee, minimum_fee, iva_rate, hold_reserve_percentage, hold_reserve_period, chargeback_fee, refund_fee}
fee_out jsonb,
settlement_policy jsonb, -- {type, fixed_days_delay:{days}, settlement_hour, settlement_periods[]}
created_at timestamptz not null default now(),
modified_at timestamptz not null default now()
);
create index on fee_rules (rule_type, status, currency_code);
create index on fee_rules using gin (acquirer);Tonder supports a unified format (IN/OUT nested) and a legacy flat format. Vecnet ships unified only (
fee_in/fee_out). Don't carry the legacy split.
7.5 finances_config
sql
create table finances_config (
id uuid primary key default uuid_generate_v7(),
entity_id text not null, -- merchant id | 'global'
entity_type text not null, -- BUSINESS | GLOBAL
description text,
acquirers jsonb not null, -- { "_default": {...}, "tonder": {...}, "menta": {...} }
created_at timestamptz not null default now(),
modified_at timestamptz not null default now(),
unique (entity_id)
);Each acquirer entry: { fees_calculation: text[], ledger_entries: bool, journals_generation: bool, daily_balances: bool }. Lookup order: business-specific → global → disabled. Acquirer key fallback: specific acquirer → _default → disabled.
7.6 balance_snapshots
sql
create table balance_snapshots (
id uuid primary key default uuid_generate_v7(),
account_id uuid not null references accounts(id),
entity_id text not null,
account_code text not null,
currency_code text not null,
period text not null default 'DAILY',
date_local date not null,
timezone text not null,
opening numeric(20,4) not null,
closing numeric(20,4) not null,
totals jsonb not null, -- IDailyLedgerTotals equivalent
report_path text, -- Supabase Storage URL
created_at timestamptz not null default now(),
unique (account_id, period, date_local)
);
create index on balance_snapshots (account_id, date_local desc);7.7 Ingestion inbox
Use pgmq for the transaction inbox (FIFO-ish, visibility timeouts, DLQ semantics) or a plain table:
sql
create table transaction_events (
id uuid primary key default uuid_generate_v7(),
correlation_id text not null,
entity_id text not null,
acquirer text not null, -- tonder | menta
raw_payload jsonb not null, -- normalized rail notification
status text not null default 'pending', -- pending | processing | processed | failed
attempts int not null default 0,
created_at timestamptz not null default now(),
processed_at timestamptz,
unique (correlation_id, acquirer) -- dedup at ingest
);Worker pulls with ... where status='pending' for update skip locked limit N, takes a pg_advisory_xact_lock(hashtext(entity_id)) to preserve per-entity ordering, runs the orchestrator transaction, marks processed.
7.8 Idempotency, recap
There is no idempotency table. Idempotency is three constraints:
transaction_events:unique(correlation_id, acquirer)— dedup at the door.journals:unique(process_id, category)+ON CONFLICT DO NOTHING— dedup at posting.- The orchestrator transaction is atomic, so a redelivery either inserts nothing or rolls back cleanly.
Keep Tonder's 2-day cutoff as a guard in the orchestrator (skip realtime processing of events older than 48h; bulk reprocess bypasses it).
7.9 transactions (attempt-level analytics store)
Why this table exists. The
journalsledger only records successful, captured financial events. But most dashboard metrics (§9.1) — acceptance rate, decline reasons, status distribution, 3DS, BIN/issuer performance, Guardian block rate — need every attempt, includingdeclined,failed,pending, andexpired, which never become journals. So Vecnet persists every rail notification (all outcomes) as atransactionsrow.journals= source of truth for money;transactions= source of truth for attempts/analytics. Both are joined oncorrelation_id.
sql
create table transactions (
id uuid primary key default uuid_generate_v7(),
correlation_id text not null,
entity_id text not null,
acquirer text not null, -- tonder | menta
payment_intent_id text, -- dedup key: one intent counted once across retries
status text not null, -- success | declined | failed | pending | expired
transaction_type text not null, -- PAYMENT | REFUND | DISPUTE | WITHDRAWAL | PAYOUT | VOID
is_apm boolean not null default false,-- APM vs card (acceptance vs conversion — never blended)
payment_method text, -- card | spei | oxxopay | mercadopago | ...
amount numeric(20,4) not null,
currency_code text not null,
card_brand text, -- Visa | Mastercard | Amex
bin text, -- card BIN (suspicious-BIN analysis)
issuing_bank text, -- top/worst issuing banks
issuing_country text, -- intl acceptance / geographic risk
is_international boolean,
customer_ref text, -- HASHED customer email/id (FTD, active payers) — never raw PII
is_first_success boolean, -- first-ever success for this customer (FTD flag)
decline_code text, -- top decline reasons
threeds_outcome text, -- frictionless | challenge | abandoned | success | na
guardian_decision text, -- allowed | blocked | na
attempt_count int not null default 1, -- retries collapsed under one intent
occurred_at timestamptz not null, -- rail event time
created_at timestamptz not null default now(),
unique (correlation_id, acquirer)
);
create index on transactions (entity_id, occurred_at);
create index on transactions (entity_id, status);
create index on transactions (entity_id, customer_ref);
create index on transactions (bin);- Dedup is structural: group by
payment_intent_idso retries count once (the Tonder "every metric is deduplicated" rule, §9.1). - PII:
customer_refis a hash/token, never a raw email — matters doubly in the GovTech deployment. - The orchestrator (§8.1) upserts this row for every event; it creates journals only for the successful financial subset.
7.10 Operator & configuration tables
These back the Vecnet admin operator platform (§9.2). Two of them — merchants and rails — close gaps the spec referenced everywhere (entity_id, acquirer) but never defined; they are foundational and land in M1.
sql
-- The BUSINESS-entity registry. Ties together accounts, fee rules, configs, RLS claim.
create table merchants (
id uuid primary key default uuid_generate_v7(),
entity_id text not null unique, -- the entity_id used across journals/accounts/RLS
legal_name text not null,
display_name text,
type text not null default 'business', -- business | government | operator
country_code text not null default 'MX',
default_currency text not null default 'MXN',
timezone text not null default 'America/Mexico_City',
status text not null default 'onboarding', -- onboarding | active | suspended | closed
rails_enabled text[] not null default '{}', -- acquirers this merchant may use
risk_level text, -- feeds fee-rule matching (§8.2)
features jsonb not null default '{}', -- per-merchant feature toggles
metadata jsonb,
created_at timestamptz not null default now(),
modified_at timestamptz not null default now()
);
-- The acquirer-as-plugin registry. One row per rail. Adding a rail = a row + a Listener adapter (§10).
create table rails (
id uuid primary key default uuid_generate_v7(),
acquirer text not null unique, -- tonder | menta | ...
display_name text not null,
capture_model text not null, -- cnp (tonder) | cp (menta)
status text not null default 'active',-- active | disabled
capabilities text[] not null default '{}', -- payment, refund, dispute, withdrawal, 3ds, apm, ...
api_base_url text, -- non-secret endpoint
credentials_ref text, -- Vault reference — NEVER the secret itself
webhook_secret_ref text, -- Vault reference for inbound signature verification
config jsonb, -- rail-specific knobs
created_at timestamptz not null default now(),
modified_at timestamptz not null default now()
);
-- Outbound webhook endpoints (the Webhook Manager, §10, delivers against these).
create table webhooks (
id uuid primary key default uuid_generate_v7(),
entity_id text not null, -- merchant, or 'V1' for platform
url text not null,
events text[] not null, -- payment.succeeded | payment.failed | settlement.confirmed | ...
secret_ref text not null, -- Vault ref for HMAC signing
status text not null default 'active',
created_at timestamptz not null default now(),
modified_at timestamptz not null default now()
);
create table webhook_deliveries (
id uuid primary key default uuid_generate_v7(),
webhook_id uuid not null references webhooks(id),
correlation_id text,
event text not null,
payload jsonb not null,
status text not null default 'pending', -- pending | delivered | failed
attempts int not null default 0,
response_code int,
next_retry_at timestamptz,
created_at timestamptz not null default now(),
delivered_at timestamptz
);
create index on webhook_deliveries (status, next_retry_at);
-- Operator RBAC + an immutable audit of every config mutation.
create table operators (
id uuid primary key default uuid_generate_v7(),
user_id uuid not null, -- Supabase Auth user
email text not null unique,
role text not null, -- vecnet_admin | finops | support | read_only
status text not null default 'active',
created_at timestamptz not null default now()
);
create table audit_log (
id uuid primary key default uuid_generate_v7(),
operator_id uuid references operators(id),
action text not null, -- created | updated | deleted | triggered
resource_type text not null, -- merchant | rail | fee_rule | webhook | settlement | config | ...
resource_id text,
before jsonb,
after jsonb,
created_at timestamptz not null default now()
);
create index on audit_log (resource_type, resource_id, created_at desc);Every config mutation in the operator platform writes an
audit_logrow — non-negotiable for a surface that edits money rules. Secrets (rail credentials, webhook signing keys) live in Supabase Vault; the tables hold only references.
8. Finances flows (ported to Supabase)
Each flow keeps Tonder's semantics and replaces the plumbing.
8.1 Transaction processing (orchestrator)
Trigger: the Listener receives a rail notification (Tonder Backend or Menta Backend), normalizes it into a transaction_events row (or pgmq message) with a correlation_id. A worker consumes it.
Per event, inside one transaction:
- Dedup / order: advisory lock on
entity_id; if a journal already exists for(process_id, category), returnALREADY_PROCESSED. - Normalize: map the rail payload → internal payin or withdrawal shape. (Tonder's
PAYOUT→WITHDRAWAL,WAA_DEPOSIT→TOPUPnormalizations: keep the concept, no EventBridge naming.) - Validate required fields →
MISSING_INFOif absent. - Cutoff: event older than 48h →
ALREADY_PROCESSED(realtime only). - Module check:
FEES_CALCULATIONenabled for this entity+acquirer? If not →MODULE_DISABLED. - Enrich: fill
currency_codeif missing (a SQL lookup now, not a Mongo call). - Fees (§8.2) → Accounting (§8.3).
Result codes carry over: PROCESSED, ALREADY_PROCESSED, MISSING_INFO, MODULE_DISABLED, NOT_SUPPORTED_DETAIL_TYPE.
Settlement / routing / reserve-release events skip fees and go straight to accounting (Tonder's processBySettlement).
Persist every attempt. Before (or alongside) the steps above, the orchestrator upserts a
transactionsrow (§7.9) for every event regardless of outcome —success,declined,failed,pending,expired. Journals are created only for the successful financial subset; the attempt row is what powers acceptance/conversion/risk metrics. Adeclinedorexpirednotification produces atransactionsrow and no journal.
8.2 Fee calculation
A SQL-driven port of FeesCalculatorService. For each fee_type in (IN,OUT), find the single most specific active rule:
sql
select *,
(case when business_id @> array[:business_id] then 0 else 1 end
+ case when risk_level @> array[:risk_level] then 0 else 1 end
+ case when card_brand @> array[:card_brand] then 0 else 1 end) as _rank
from fee_rules
where rule_type = :rule_type
and status = 'active'
and currency_code = :currency_code
and (acquirer is null or acquirer @> array[:acquirer])
and (business_id is null or business_id @> array[:business_id])
and (payment_method_id is null or payment_method_id @> array[:pmid])
-- ... country_code, risk_level, card_brand with the same null-or-match pattern
order by _rank asc
limit 1;Tonder's Mongo
$or/$existsnull handling becomes Postgres(col is null or col @> array[:val]). Specificity ranking (business_id/risk_level/card_brandmatch=0, null=1) is the_rankexpression. Missing either IN or OUT rule → errorE0013.
Formulas (port verbatim; tonder side renamed to platform in the response since Vecnet is the platform):
- PAYMENT / APMS:
fee = max(amount×rate/100 + fixed_fee, minimum_fee);iva = fee×iva_rate/100;rr = amount×hold_reserve_percentage/100;net = amount − fee − iva − rr. International vs domestic selects inter/intra rates; APMS is always domestic. - REFUND:
fee = refund_fee;net = amount + fee + iva(merchant absorbs fees on top). - DISPUTE:
fee = chargeback_fee; ifWon→net = fee + iva, elsenet = amount + fee + iva. - VOID: all zero;
net = amount. - WITHDRAWAL:
fee = max(amount×rate/100 + fixed_fee, minimum_fee);net = amount(fees posted as separate entries). - TOPUP: hardcoded default rule (0 rates/fees,
fixed_days_delay), no rule lookup.
8.3 Accounting (the posting function)
This replaces AccountingService.accountingPreparer() and the entire AccountUpdateQueue/accountUpdater machinery. Implement as a Postgres function post_journals(...) (or a transaction inside the worker) that:
- Resolves the accounts for the category (the PK/SK tables below become
account_code+ entity lookups). - Generates the journal(s) — IN/OUT pair for transactions, single for adjustment/settlement/routing/RR.
- Generates DEBIT/CREDIT ledger entries per the category generator.
- Validates
sum(debits) = sum(credits)— and rejects if imbalanced (stricter than Tonder's warn-only). - Locks each affected account (
SELECT … FOR UPDATE), computesbalance_before/balance_after/seq, updatesbalanceandversion. - For
PAYMENT, setsrr_amountandexpected_reserve_release_date = process_date + hold_reserve_period days at 00:00 UTC.
All in one transaction → atomic, idempotent (via the journals unique constraint), ordered (via row locks). No queue, no version-race, no maxReceiveCount=160.
Account sets per category (ported — accounts identified by account_code + entity, not Dynamo PK/SK):
- PAYMENT / REFUND / DISPUTE:
ACQUIRER_RECEIVABLE,RESERVE_RECEIVABLE,BUSINESS_PAYABLE,RESERVE_PAYABLE,PLATFORM_CLEARING,PROCESSING_FEES_REVENUE,VAT_PAYABLE,ACQUIRER_FEES_EXPENSE,VAT_RECEIVABLE. - VOID:
ACQUIRER_RECEIVABLE,BUSINESS_PAYABLE,PLATFORM_CLEARING. - WITHDRAWAL / TOPUP:
WITHDRAWAL_FUNDS,BUSINESS_WITHDRAWAL_PAYABLE,BUSINESS_PAYABLE,PLATFORM_CLEARING,WITHDRAWAL_FEES_REVENUE,VAT_PAYABLE,WITHDRAWAL_ACQUIRER_FEES_EXPENSE,VAT_RECEIVABLE,WITHDRAWAL_ACQUIRER_FEES_PAYABLE. - SETTLEMENT_APPROVE/CONFIRM, ROUTING, ROLLING_RESERVE_RELEASE, ADJUSTMENT: see Tonder preparers — same account selections, single journal.
8.4 Settlements
Two phases via POST /settlements with type: approve | confirm.
- State validation: query
journalsbyprocess_id = settlement_idand category.E0017if already approved;E0018if confirm-before-approve. - Approve: select pending journals (
settlement_id is nullwithin[from,to], optional acquirer filter); optionalROUTINGandROLLING_RESERVE_RELEASEsub-flows; postSETTLEMENT_APPROVE OUT(business net) andSETTLEMENT_APPROVE INper acquirer (each acquirer gets its own settlement_id); tag the journals withsettlement_id. - Confirm: post
SETTLEMENT_CONFIRM OUTonly.
Async pattern simplifies: Tonder invokes a worker Lambda and tags journals via a FIFO queue with 20-concurrent Dynamo updates. In Vecnet, the approve runs as a transaction (or a pgmq-driven worker if a period is huge); tagging journals is a single UPDATE journals SET settlement_id=…, modified_at=now() WHERE id = ANY(:ids).
Net math (port verbatim): positive categories PAYMENT, TOPUP, ROLLING_RESERVE_RELEASE; negative REFUND, DISPUTE_*, WITHDRAWAL, ROUTING. Business net = gross − routing − rolling_reserve − fee + iva_correction. Use the decimal library.
8.5 Rolling reserve release
Trigger: pg_cron daily (pick a UTC hour; Tonder uses 02:00). For each business, resolve the release-day window in the business timezone (America/Mexico_City default), find PAYMENT journals with expected_reserve_release_date in window, settlement_id null, rr_amount > 0, and post a ROLLING_RESERVE_RELEASE journal per source journal (linked via related_journal_id).
Postgres timezone math (
(date_local::timestamp at time zone tz) at time zone 'UTC') replaces the Luxon window calc. The "release uses NOW as process_date" rule carries over — the release is a new accounting event in current time. No separate processor Lambda; the cron job calls the same posting function.
8.6 Internal transfers
POST /internal-transfers — move funds from_account_number → to_account_number, generating one ADJUSTMENT journal + ledger entries. Directional model: source decreases, destination increases, regardless of account nature; the posting function maps to correct DEBIT/CREDIT.
Validations: both accounts exist, different accounts, same currency, amount ≥ 0.01.
transfer_type → adjustment_type mapping (port verbatim): PAYMENT_CORRECTION→MANUAL_ADJUSTMENT, FEE_ADJUSTMENT→FEE_CORRECTION, RESERVE_MOVEMENT→RESERVE_ADJUSTMENT, SERVICE_CREDIT→SERVICE_COMPENSATION, BALANCE_RECONCILIATION→BALANCE_RECONCILIATION, TAX_ADJUSTMENT→TAX_CORRECTION, PRIOR_PERIOD_CORRECTION→PRIOR_PERIOD_CORRECTION.
PRIOR_PERIOD_CORRECTION is the only way to inject balance with no operational source — source must be the platform PRIOR_PERIOD_ADJUSTMENT (EQUITY, V1) account, one per currency, created at platform setup. (Use this to load opening balances when onboarding a merchant from a legacy system.)
8.7 Daily ledger
Trigger: pg_cron daily (Tonder uses 10:00 UTC). For each account with DAILY_BALANCES enabled, resolve the previous day in the business timezone, compute opening (= prior snapshot closing, else 0) and closing (= opening + net movements over period journals, unsettled only), totals by category, and:
- generate an XLSX (Summary + Transactions sheets, embedded logo) via an Edge Function (Deno + a SheetJS/ExcelJS-equivalent),
- upload to Supabase Storage (
reports/daily-ledger/{entity_id}/{date}/…xlsx), - write a
balance_snapshotsrow.
Ordering rule carries over: sort report journals by min seq of their entries so the balance chain reads correctly.
8.8 Configurations (module flags)
finances_config per entity controls fees_calculation (which categories trigger fees), ledger_entries, journals_generation, daily_balances — per acquirer with _default fallback. Disabling journals_generation disables all financial processing for that acquirer (journals are the foundation). Lookup order and the processing-flow decision tree port directly.
8.9 Accounts CRUD
Standard CRUD over accounts. POST supports batch creation (Tonder's additional_accounts) in one transaction. Account-number generation: <prefix><4-digit issuer hash><4 random><Luhn> — BUSINESS 1, ACQUIRER 2, PLATFORM 9. balance is not updatable via the API — only via the posting function. Soft-delete via deleted_at.
8.10 Settlement reports & FinOps disbursement automation
This is the FinOps-facing automation layer on top of §8.4. It generates a per-merchant settlement statement for each cycle, triggers the actual settlement, and delivers the statement — so Vecnet FinOps has automated settlements from day 1 with no manual spreadsheet work.
Ported from Tonder's separate usrv-batch-transaction-report service (Python, AWS Batch/Fargate). Tonder runs a Daily Transaction Report (daily) and a Settlement Batch Report (Mon/Thu 08:00 UTC = the T+0 cycle), with shared logic across T+0 / T+1 / T+2 cycles. Vecnet collapses this into one parametrized cycle engine (Edge Function or worker) driven by pg_cron, with the cycle type as a parameter.
Cycle model
| Cycle | Cadence | Window source |
|---|---|---|
| T+0 (batch) | Mon & Thu (default 08:00 UTC) | the batch on-cycle dates computed by day-of-week |
| T+1 | daily | journals dated 1 day prior |
| T+2 | daily | journals dated 2 days prior |
The cadence per merchant follows their settlement_policy (fixed_days_delay.days → 0/1/2) from the matched fee rule (§7.4/§8.2); a pg_cron entry exists per cadence. The same date-calculation logic that Tonder's date_calculation_service does by day-of-week is reimplemented in SQL/TS once and parametrized by cycle.
Per-merchant run (inside the cycle, for each active merchant)
- Resolve config — payout fee, language, excluded rails, recipients (from
settlement_merchant_config, below). - Aggregate the cycle window from
journals, always filteringjournal_type = 'OUT'— this is the single most important rule; OUT is the business side of the double entry, and forgetting it doubles every amount. Sum by category:PAYMENT,DISPUTE_IN_REVIEW(active disputes — note:DISPUTE_IN_REVIEW, notDISPUTE_REVIEW),DISPUTE_WON,REFUND,VOID,WITHDRAWAL,ROLLING_RESERVE_RELEASE.
- IVA — most comes from
iva_amounton the journals. The payout fee (tarifa_liquidacion) is config-driven and has no journal record, so compute its IVA separately aspayout_fee × 0.16and add it in. - Build the statement (XLSX) — reuse the daily-ledger XLSX generator (§8.7); bilingual ES/EN labels (Tonder's
excel_translations). Per-category totals (gross / fee / iva / net), rolling-reserve release, payout fee + its IVA, and the final net to disburse. - Trigger the settlement — hand the totals to the settlement orchestration (§8.11), which records the settlement, computes
force_decrease, and drives the §8.4approve→confirmaccounting. This replaces Tonder's "buildsettlement_jsonpayload + invokeSETTLEMENT_LAMBDA_ARN." All amounts in the disbursement are positive/absolute (Tonder rule — preserve it). Tag the cycle's journals with the resultingsettlement_id. - Deliver — upload the statement to Supabase Storage (
reports/settlement/{cycle}/{entity_id}/{date}/…xlsx) and email FinOps + the merchant (recipients from config). Record the run insettlement_runs.
Rolling reserve — Vecnet simplification
Tonder's batch runs two rolling-reserve queries: an OLD one against the legacy mv_payment_transactions (90/180-day lookback, rolling_reserve_amount) and a NEW one against usrv-finances-journals (current cycle, gross_amount), and always uses the OLD result for calculations while the NEW is logging-only — pure migration baggage.
Vecnet is greenfield: there is no legacy materialized view. Use the journals directly — the ROLLING_RESERVE_RELEASE journals produced by §8.5 are the single source. Drop the OLD/NEW dual-query entirely.
Run modes
Two modes (Tonder's TESTING_CONFIG): scheduled (default_config, the pg_cron path, all active merchants) and manual (manual_execution / specific_merchants, an on-demand admin trigger for a subset). Active-merchant selection respects an explicit allowlist when present.
Supporting tables
sql
create table settlement_merchant_config (
id uuid primary key default uuid_generate_v7(),
entity_id text not null unique,
language text not null default 'es', -- es | en (statement language)
payout_fee numeric(20,4) not null default 0, -- tarifa_liquidacion (no journal; IVA computed separately)
excluded_acquirers text[] default '{}', -- rails to exclude from this merchant's cycle
cadence text not null default 'T+1', -- T+0 | T+1 | T+2
recipients text[] not null default '{}', -- statement email recipients
active boolean not null default true,
created_at timestamptz not null default now(),
modified_at timestamptz not null default now()
);
create table settlement_runs (
id uuid primary key default uuid_generate_v7(),
entity_id text not null,
cycle text not null, -- T+0 | T+1 | T+2
window_from timestamptz not null,
window_to timestamptz not null,
totals jsonb not null, -- per-category gross/fee/iva/net + RR + payout fee
net_to_disburse numeric(20,4) not null,
settlement_id text, -- links to the §8.4 settlement
statement_path text, -- Supabase Storage URL
status text not null default 'pending', -- pending | settled | emailed | failed
run_mode text not null default 'scheduled', -- scheduled | manual
error text,
created_at timestamptz not null default now(),
unique (entity_id, cycle, window_from) -- idempotent per cycle window
);The
unique (entity_id, cycle, window_from)constraint makes a cycle run idempotent — a re-run of the same window won't double-disburse. (Same philosophy as §7.8.)
Config mapping (Tonder SSM → Vecnet)
| Tonder SSM param | Vecnet home |
|---|---|
MERCHANT_CONFIG_SETTLEMENT_BATCH (id, language, payout_fee, exclude_acq) | settlement_merchant_config |
TESTING_CONFIG (default_config / manual_execution / specific_merchants) | run-mode parameter to the cycle engine |
EMAIL_CONFIG (SMTP + recipients) | email provider creds in Vault; recipients in settlement_merchant_config |
SETTLEMENT_LAMBDA_ARN | — (call §8.4 settlements flow directly) |
MerchantLogCapture via AWS_BATCH_JOB_ID | structured run logs in settlement_runs + Storage |
FinOps surfacing
Expose in the System Control Panel (§9.2): trigger/re-run a cycle, view each merchant's run + statement + disbursement status, and catch failures (a merchant whose settlement_runs.status = 'failed'). This is the day-1 automated-settlement loop for the FinOps team.
8.11 Settlement orchestration, treasury & system of record
Tonder splits settlement across three services: the ledger (usrv-finances, §8), the report/cycle engine (usrv-batch-transaction-report, §8.10), and usrv-settlement — the orchestration + system-of-record + treasury bridge ported here. In Vecnet they live in one Supabase project, but the responsibilities stay distinct.
usrv-settlement does three things (its three handlers):
- Record a settlement — receives settlement info, computes
force_decrease, persists to thesettlementssystem-of-record. (Tonder:settlementHandler, Lambda-invoked.) - Bridge to finances — receives the settlement payload (without internal calc fields) and synchronously triggers the §8.4 accounting. (Tonder:
changeStatusFinancesHandler, HTTP POST.) - Query settlements — filtered, paginated read API over the records. (Tonder:
getSettlementsHandler, HTTP GET/v1/settlements.)
Position in the chain: cycle engine (§8.10) → settlement orchestration (§8.11: record + force_decrease) → finances accounting (§8.4).
The treasury control: calculateForceDecrease (port exactly — prod-bug-sensitive)
Reconciles the rolling-reserve amount the settlement intends to release against what the finances ledger reports as releasable:
diff = rolling_reserve_release − rolling_reserve_release_finances # ALWAYS this order| Condition | force_decrease | effective rolling_reserve_release |
|---|---|---|
diff > 0 (intended > finances) | true | diff (release only the surplus) |
diff == 0 | false | original rolling_reserve_release |
diff < 0 (finances > intended) | — | reject — error ES007 |
Never invert the subtraction. It is always
release − finances. Inverting it caused a production incident (Tonder HU-003). The resultingforce_decreasefeeds theforce_decrease_rolling_reserve_releaseflag in the §8.4 settlement request; the effective amount feedsrolling_reserve_release.
This is the treasury guardrail: it refuses to disburse when the ledger believes more reserve is releasable than the settlement intends — a discrepancy that must be investigated, never auto-resolved.
settlement_type / cadence
settlement_type ∈ {batch (= T+0), t1 (= T+1), t2 (= T+2)}, aligned with the §8.10 cycles. Tonder guards via isSupportedSettlementType() / SUPPORTED_SETTLEMENT_TYPES (only batch enabled today; t1/t2 to be added). Vecnet validates settlement_type against an allowlist and rejects unsupported types early.
Two payloads — keep them separate
| Payload | Carries rolling_reserve_release_finances, settlement_type, s3? | Used for |
|---|---|---|
| internal settlement (record) | yes — all three required | recording + force_decrease |
| change-status to finances | no — those three are internal-only | triggering §8.4 accounting |
The s3/statement reference and the internal calc fields never leak into the finances accounting call.
Query API + Decimal128 simplification
GET /settlements — dynamic filters + pagination. In Vecnet: a Postgres query over settlements with WHERE filters, ORDER BY, LIMIT/OFFSET (or keyset), and count(*) OVER () for the total.
Tonder must normalize Mongo
Decimal128({"$numberDecimal":"1000"}) to plain numbers before every HTTP response, shipping anormalizeDecimalFieldstraverse helper, because the BSON type breaks JSON clients. Postgresnumericserializes cleanly — that whole bug class and its helper vanish (added to the §14 mapping). Tonder also paginates with a Mongo$facet(metadata count + data) and guardsconstructor.nameagainst esbuild mangling — both gone.
settlements table (system of record)
sql
create table settlements (
id uuid primary key default uuid_generate_v7(),
settlement_id text not null unique, -- business settlement id
entity_id text not null,
settlement_type text not null, -- batch | t1 | t2
currency_code text not null,
window_from timestamptz not null,
window_to timestamptz not null,
acquirers text[] not null, -- rails settled
gross_amount numeric(20,4) not null,
fee_amount numeric(20,4) not null,
iva_amount numeric(20,4) not null,
net_amount numeric(20,4) not null,
rolling_reserve_release numeric(20,4) not null default 0, -- intended
rolling_reserve_release_finances numeric(20,4) not null default 0, -- ledger figure
force_decrease boolean not null default false, -- output of calculateForceDecrease
status text not null default 'recorded', -- recorded | approved | confirmed | failed
statement_path text, -- Supabase Storage (was the `s3` field)
metadata jsonb,
created_at timestamptz not null default now(), -- epoch ms in Tonder; timestamptz here
modified_at timestamptz not null default now()
);
create index on settlements (entity_id, created_at desc);
create index on settlements (settlement_type, status);Tonder's
SettlementTable(DynamoDB, PKsettlement_id,DeletionPolicy: Retain,created_atas Unix ms, itscreated_atGSI since removed) → this table. Preserve Retain semantics: never hard-delete a settlement record.
Treasury ops framing
Settlement orchestration is where treasury decisions live, because it governs money movement: the force_decrease control above, plus the §8.4 accounting it triggers moves funds through the treasury accounts already defined in §6.3 — BUSINESS_SETTLEMENT_PENDING → BANK on confirm, RESERVE_* on reserve release, WITHDRAWAL_FUNDS for rail-payout funding. Treasury reconciliation (actual bank/rail movements vs ledger) belongs in the System Control Panel (§9.2). Deeper treasury (bank-account funding, liquidity management, multi-currency FX) is out of v0.1 scope — flag if there's a dedicated service for it.
9. Surfaces
Three surfaces over the same Postgres core, all following the Obsidian Terminal design system and all mapping to the Frontend boxes in the architecture diagram: the payer-facing Hosted Checkout (§9.4), and two role-gated operator surfaces — the merchant Dashboard (§9.1) and the Vecnet admin (§9.2) — with RLS enforcing the boundary between the operator surfaces.
9.1 Merchant frontend (front) — Dashboard
Who: the Vecnet client / merchant (the government entity or operator). Read-mostly, hard-scoped by RLS to their own entity_id.
Capabilities:
| Capability | Backed by | Notes |
|---|---|---|
| Balance overview (per account/currency) | accounts (RLS) | Realtime subscription for live balance |
| Transaction history | journals + ledger_entries (RLS) | filter by category/date/rail/correlation_id |
| Daily ledger statements | balance_snapshots + Storage | download XLSX |
| Settlement statements | journals where settlement_id set | read-only |
| Rolling reserve view | journals (rr_amount, expected_reserve_release_date) | upcoming releases |
| Payment status (live) | Webhook Manager → Realtime | the citizen-facing notification loop |
The merchant cannot create fee rules, run settlements, post transfers, or see other entities.
9.1.1 Dashboard metric catalog (ported from Tonder's metric definitions)
The merchant Dashboard organizes into four pages, matching Tonder's metric definitions. Build the same formulas. (The vecnet-dashboard repo is private and I couldn't read it — this page/metric structure is the working model until you share the repo; align component/route names to it then.)
Two data sources, never confused (§7.9):
- Money metrics (settled volume, refunds, disputes, deposits, fees, net) →
journals/ledger_entries. - Acceptance / conversion / risk metrics (acceptance rate, decline reasons, status mix, 3DS, BIN/issuer, Guardian) →
transactions(the only place declines/failures/pending/expired live).
Global metric rules (get these exactly right — they're the usual source of wrong dashboards):
- Acceptance rate =
successful ÷ (success + declined + failed) × 100. Excludeexpired,pending, and duplicates from the denominator. - APM conversion rate =
successful APM ÷ all APM intents × 100. Includeexpiredandpending(it measures conversion, not authorization). - Card acceptance rate and APM conversion rate are never blended into one number — they measure different things.
- Dedup everywhere: each
payment_intent_idis counted once; retries behind one intent collapse. - FTD = first-ever successful payment for a
customer_ref, confirmed by looking back across all history (not just the selected range). - Every metric respects the global date-range filter (Today / 7D / 30D / 90D / custom).
Home — Primary KPIs
| Metric | Formula |
|---|---|
| Gross volume | Σ all transaction amounts, any outcome |
| Success volume | Σ amounts where status = success |
| Successful payments | count of unique successful payments |
| Acceptance rate (count & volume) | rule #1 above, computed by count and by amount |
Home — Secondary KPIs: Refunds (count + Σ; cards only), Disputes (count + Σ chargebacks), FTDs (rule #5), Avg ticket (success volume ÷ successful payments).
Home — Charts: Payment volume over time (toggle $ / count / acceptance); Payment-method donut (SPEI, cards, OxxoPay, Mercado Pago, …); Status distribution (success/pending/declined/failed); Top issuing banks by acceptance; Daily-health 30-day heatmap (green ≥80%, amber 50–79%, red <50%); Top decline reasons (code + count + severity).
Analytics — Acceptance & Conversion
| KPI | Formula |
|---|---|
| Overall acceptance rate | rule #1 |
| FTD acceptance rate | successful ÷ total payments, restricted to first-time customers (rule #1 denominator) |
| Trusted acceptance rate | same, restricted to returning customers |
| APM conversion rate | rule #2 |
Charts: acceptance over time (Overall / FTD / Trusted lines); acceptance by method (APM bars show conversion); card-network performance table (acceptance, avg ticket, volume share, count per Visa/MC/Amex); top & worst issuing banks (with auth rate); FTD-vs-Trusted comparison cards.
Analytics — Deposit Analytics (operator/iGaming framing; relabel "deposit/depositor" → "payment/payer" for GovTech)
| KPI | Formula |
|---|---|
| Total deposits | Σ successful payment amounts |
| FTDs | count of new first-time customers (rule #5) |
| Active depositors | count of distinct customer_ref with ≥1 success in range |
| Avg deposit amount | total deposits ÷ successful deposits |
Charts: deposit volume + count; FTD funnel (initiated → authorized → FTD completed, incl. pending/expired since it's conversion); deposit-amount distribution; deposits vs withdrawals (net-flow line); top depositors; payment-method mix over time.
Fraud & Risk (Tonder marks formulas "pending to confirm" — treat as provisional)
| KPI | Formula |
|---|---|
| Guardian block rate | blocked ÷ total card transactions screened × 100 (healthy <5%) |
| 3DS abandonment rate | 3DS challenges abandoned ÷ total 3DS transactions × 100 |
| Intl card acceptance | successful international card payments ÷ total international card payments × 100 |
3D Secure sub-metrics: success rate (successful 3DS ÷ total 3DS); challenge rate (challenged ÷ total 3DS); frictionless = 100% − challenge rate; abandonments (count). Charts: block decisions over time (allowed / issuer declines / Guardian blocks); suspicious BINs (decline rate, unique emails, velocity, alert tier); geographic risk (count, block rate, acceptance, risk level by country); suspicious email velocity.
Rail note: Vecnet's
tonderrail is card/CNP-rich (full card_brand/BIN/3DS/Guardian dimensions); thementaPOS rail is card-present and won't populate every fraud field. Card-network and 3DS metrics are mostly thetonderrail; status/acceptance/volume span both. The Vecnet admin (§9.2) sees these same metrics across all entities.
9.2 Vecnet Admin — operator platform (Admin Panel + System Control Panel)
The back-office where Vecnet personnel configure and operate the whole platform: merchants, the Tonder and Menta rails, fee rules, settlements, rolling reserve, webhooks, and more. Full access across all entities, gated by operator RBAC. Two app areas: the Admin Panel (configuration CRUD) and the System Control Panel (runtime ops, health, reconciliation).
Operator roles (RBAC): vecnet_admin (everything), finops (settlements, treasury, fee rules, cycles), support (read + limited merchant ops), read_only. Every mutation is written to audit_log (§7.10).
The platform is organized into modules. Each module CRUDs config the engine reads at runtime — config is data, not code.
1. Merchants — entity lifecycle & feature config. Onboard a merchant = provision, in one transaction: a merchants row + its accounts (§8.9, the BUSINESS account set per currency) + finances_config (§8.8) + settlement_merchant_config (§8.10) + checkout_config (§9.4) + initial fee_rules + the Supabase Auth entity_id claim for RLS (§9.3). Then manage feature toggles (merchants.features), rails_enabled, risk_level (feeds fee matching), and lifecycle status (onboarding → active → suspended → closed). Backed by merchants.
2. Rails / Acquirers — Tonder, Menta, and future plugins. The acquirer-as-plugin registry (rails). Configure each rail's capture_model (Tonder = CNP, Menta = CP), capabilities (payment / refund / dispute / withdrawal / 3ds / apm), api_base_url, enable/disable, and Vault references for credentials + inbound webhook secrets. Adding a third rail is a rails row + a Listener adapter (§10) — no core change. Backed by rails.
3. Fee Rules. CRUD fee_rules (§7.4) in unified IN/OUT form, with a matching simulator that runs the §8.2 specificity ranking against sample inputs so an operator can confirm which rule wins before activating. Backed by fee_rules.
4. Configurations — module flags. Per merchant/acquirer toggles (fees_calculation, ledger_entries, journals_generation, daily_balances) with the _default fallback (§8.8). Backed by finances_config.
5. Settlements & cycles. Run approve/confirm per acquirer (§8.4); view the settlements system-of-record and settlement_runs (§8.10/§8.11) with statement + email-delivery status; manage settlement_merchant_config (payout fee, cadence T+0/T+1/T+2, recipients, excluded rails); trigger / re-run a cycle (scheduled vs manual). Backed by settlement flows + settlement_merchant_config / settlement_runs / settlements.
6. Rolling Reserve. View upcoming releases (hold % and period come from the matched fee rule), inspect the pg_cron job status (§8.5), do a manual release, and review force_decrease reconciliation (§8.11) before disbursing. Backed by RR flow + journals.
7. Webhooks. Manage outbound endpoints per merchant (webhooks): URL, subscribed events, HMAC secret (Vault). Inspect the delivery log with attempts/response codes and replay failed deliveries (webhook_deliveries). This is the config + observability layer for the Webhook Manager (§10). Backed by webhooks / webhook_deliveries.
8. Checkout branding. Per-merchant checkout_config (§9.4): logo, primary/button/accent colors, enabled methods, locale, success redirect, origin allowlist. Backed by checkout_config.
9. Reconciliation & ingestion health.transaction_events / pgmq / DLQ depth and retries, last rail notification per acquirer, and correlation gaps (a transactions/notification with no matching journal) — the cross-rail reconciliation view. Backed by inbox tables + journals joined on correlation_id.
10. Treasury.force_decrease review, balances of the treasury accounts (BANK, WITHDRAWAL_FUNDS, RESERVE_*, BUSINESS_SETTLEMENT_PENDING), internal transfers / adjustments (§8.6), and bank/rail-movement reconciliation. Backed by accounts + settlements + internal-transfers flow.
11. Operators, RBAC & audit. Manage operators and roles; browse the immutable audit_log (who changed which fee rule / merchant / rail / webhook, with before/after). Backed by operators / audit_log.
Module → surface → backing (quick matrix):
| Module | Surface | Backed by |
|---|---|---|
| Merchants | Admin Panel | merchants (+ provisions accounts/config/checkout/fee rules) |
| Rails / Acquirers | Admin Panel | rails |
| Fee Rules (+ simulator) | Admin Panel | fee_rules |
| Configurations (flags) | Admin Panel | finances_config |
| Settlements & cycles | System Control Panel | settlement flows + settlements/settlement_runs/settlement_merchant_config |
| Rolling Reserve | System Control Panel | RR flow + journals |
| Webhooks | Admin Panel + SCP | webhooks / webhook_deliveries |
| Checkout branding | Admin Panel | checkout_config |
| Reconciliation / ingestion health | System Control Panel | transaction_events, pgmq, journals |
| Treasury | System Control Panel | accounts + settlements + transfers |
| Operators / RBAC / audit | Admin Panel | operators / audit_log |
| All-entity transaction & balance browse | Admin Panel | journals / accounts / transactions |
9.3 Auth & roles
Supabase Auth. JWT carries role and, for merchants, an entity_id claim. Roles: merchant (scoped to its entity_id), and the operator roles vecnet_admin / finops / support / read_only (§9.2, backed by operators). RLS policies:
sql
-- merchants: only their own entity
create policy merchant_accounts on accounts for select
using ( auth.jwt()->>'role' = 'merchant'
and entity_id = auth.jwt()->>'entity_id' );
-- vecnet admins / service role: full access
create policy admin_accounts on accounts for all
using ( auth.jwt()->>'role' = 'vecnet_admin' );Apply analogous policies to journals, ledger_entries, balance_snapshots. Writes to finances tables happen only through the posting function / admin role — never directly from the merchant client.
9.4 Hosted Checkout (payer-facing surface)
The citizen/payer-facing payment page — the third surface, launched from the government portal or a merchant site (Web/App → checkout → Hosted Checkout in the architecture diagram). It is the CNP entry point to the Tonder rail. In-person POS via Menta does not go through Hosted Checkout — that's card-present at the terminal.
Repo
github.com/yuyo99/vecnet-pay-checkoutis private — I couldn't read it. The spec below is grounded in the architecture diagram, the prior drop-in/light-SDK session, and your stated intent (custom logo + button colors, "super aesthetic," Tonder Lite SDK v2.0). Align component/structure to the repo once it's shared (§13).
Tech & PCI
- Embeds Tonder Lite SDK v2.0. Card fields are collected through Skyflow Elements and tokenized — the PAN never touches Vecnet's frontend or backend, so Vecnet stays out of PCI scope (Skyflow's Level-1 cert carries it). Cross-ref §11.
- The light SDK is the refactored drop-in from the prior session: Vecnet consumes it to build the checkout rather than reimplementing card capture.
Flow (maps to vecnet_citizen_flow.mmd)
- Portal opens the checkout with
{ entity_id, amount, currency, reference, correlation_id }— mint thecorrelation_idhere if absent (§2 principle). - Payer enters card (Skyflow elements) or picks an APM (SPEI / OXXO / Mercado Pago).
- Lite SDK sends the
payment request→ Tonder API → Tonder Backend. responsereturns to the checkout: approved / declined / 3DS challenge / APM pending-with-voucher.- Tonder Backend → Transaction Notification → Listener (§10) →
transactions(§7.9) + journals (if successful) → Webhook Manager → portal/merchant + the merchant Dashboard via Realtime.
States the UI must handle: approved; declined (offer retry); 3DS challenge (iframe/redirect); APM pending (show SPEI CLABE / OXXO voucher with reference + expiry); expired; failed/network. Every one of these becomes a transactions row — that is what feeds the acceptance / decline / 3DS metrics in §9.1.1.
Theming — config-driven, per merchant (the "custom logo + button color, super aesthetic" part)
A checkout_config row per entity drives branding — no per-merchant code:
sql
create table checkout_config (
id uuid primary key default uuid_generate_v7(),
entity_id text not null unique,
logo_url text, -- merchant / government logo
primary_color text not null default '#14b8a6', -- button / accent (Obsidian Terminal token by default)
button_text_color text,
accent_color text,
background text, -- dark-first per Obsidian Terminal
locale_default text not null default 'es', -- es | en
methods_enabled text[] not null default '{card,spei,oxxopay,mercadopago}',
success_redirect_url text,
origin_allowlist text[] not null default '{}', -- which sites may launch this checkout
metadata jsonb,
created_at timestamptz not null default now(),
modified_at timestamptz not null default now()
);- Defaults to the Obsidian Terminal tokens (dark-first; Syne / DM Sans / JetBrains Mono);
logo_url/primary_color/accent_coloroverride per merchant. - Mobile-first, minimal fields, fast paint — the checkout is the highest-stakes conversion surface, so latency and field count are first-order. The §9.1.1 acceptance/conversion metrics are how you'll know if the aesthetics actually convert.
- Editable by Vecnet personnel (and optionally the merchant) via the Admin Panel (§9.2).
Security specifics: no PAN in checkout_config or anywhere in Vecnet; the SDK + Skyflow hold card data; the return is signed; the correlation_id threads into the transactions/journal rows; the launching origin is allowlisted per merchant (origin_allowlist).
10. Rails integration (Listener + Webhook Manager)
- Listener (Edge Function, one adapter per rail): receives Transaction Notifications from Tonder Backend and Menta Backend, verifies signature, normalizes to the internal event shape, attaches/derives the
correlation_id, inserts intotransaction_events(dedup on(correlation_id, acquirer)). - Hosted Checkout embeds Tonder Lite SDK v2.0 — Vecnet stays out of PCI scope (no PAN touches Vecnet; card data is tokenized via the rail/Skyflow). Full spec in §9.4; PCI in §11.
- Webhook Manager (Edge Function or worker): after a journal posts, deliver to the outbound endpoints registered in
webhooks(§7.10) — sign with the per-endpoint HMAC secret (Vault), retry failed deliveries with backoff, and log every attempt towebhook_deliveries. Also drives the merchant Dashboard via Supabase Realtime. Operators configure endpoints and replay failures in the admin (§9.2 module 7). - Correlation ID is mandatory on every notification. If a rail can't supply one, the Listener mints a deterministic one and records the mapping — but push the rails to send it.
Exact Tonder API (payment request/response + webhook payload) and Menta API (POS notification payload) contracts are still needed — flag as a blocker for the Listener adapters (§13).
11. Security & compliance
- PCI: Vecnet never handles raw PAN. Card capture is delegated to the rail (Tonder Lite SDK v2.0 + Skyflow tokenization). Vecnet stores tokens/references only. Do not add any field that could hold a PAN.
- RLS everywhere on finances tables; merchants scoped to their entity; writes via posting function / admin role only.
- Secrets: rail API keys, signing secrets in Supabase Vault / env — never in the repo, never in the client bundle.
- Money integrity: double-entry balance enforced (reject imbalanced postings);
numericstorage; decimal library at boundaries; idempotency via constraints. - Gov data: treat citizen/payer PII as sensitive; minimize what Vecnet stores; encrypt at rest (Supabase default) and scope access.
- Audit: journals + ledger entries are the immutable audit trail; adjustments go through internal-transfers with
description+reference, never by mutating balances.
12. Build phases (suggested sequencing for Dropout Capital)
| Phase | Deliverable | Done when |
|---|---|---|
| M1 — Foundation | Supabase project, enums, accounts/journals/ledger_entries + merchants/rails registries + RLS, account CRUD, posting function with double-entry validation | A merchant + rail exist; a PAYMENT posts and balances move atomically; imbalanced posting is rejected |
| M2 — Fees | fee_rules + matching SQL + all fee formulas; finances_config + module flags | Correct fees for PAYMENT/REFUND/DISPUTE/VOID/WITHDRAWAL/TOPUP, matching Tonder fixtures |
| M3 — Ingestion | Listener adapters (Tonder + Menta), transaction_events/pgmq, orchestrator worker, idempotency, correlation IDs, transactions attempt store (all outcomes), webhooks/webhook_deliveries + Webhook Manager | A rail notification → posted journals + a transactions row + outbound webhook delivered; idempotent under redelivery |
| M4 — Settlements + Transfers | approve/confirm, journal tagging, settlements system-of-record + calculateForceDecrease, settlement query API, internal transfers, prior-period load | Settlement records persist with correct force_decrease; produces correct OUT + per-acquirer IN journals; transfers audited |
| M5 — Scheduled | pg_cron rolling reserve + daily ledger; XLSX → Storage; snapshots | Reserves release on schedule; daily statements generated per timezone |
| M5b — Settlement reports (FinOps) | Cycle engine (T+0/T+1/T+2), settlement_merchant_config + settlement_runs, per-merchant statement → settlement trigger → Storage + email | A scheduled cycle settles each active merchant, files the statement, and emails FinOps — automated from day 1 |
| M6 — Merchant frontend | Dashboard pages (Home / Acceptance & Conversion / Deposit Analytics / Fraud & Risk) with the §9.1.1 metric catalog from transactions + journals; Realtime; RLS scoping | Merchant sees only their entity, live; acceptance/APM/dedup rules match Tonder fixtures |
| M6b — Hosted Checkout | Payer-facing checkout embedding Tonder Lite SDK v2.0 + Skyflow elements; checkout_config branding; APM + 3DS states; correlation-id threading (gated on the Lite SDK v2.0 contract, §13) | A branded checkout takes a card/APM payment end-to-end, no PAN in Vecnet, attempt lands in transactions |
| M7 — Vecnet admin operator platform | Admin Panel + System Control Panel: all 11 modules (merchants/onboarding, rails, fee rules + simulator, configs, settlements & cycles, rolling reserve, webhooks, checkout branding, reconciliation, treasury, operators/RBAC/audit) | Vecnet personnel configure merchants/rails/fees/webhooks and run the full ops loop; every mutation is audited |
| M8 — Reconciliation + hardening | cross-rail recon, DLQ handling, bulk reprocess, observability | Correlation gaps surfaced; bulk historical load works |
13. Open items / still needed
vecnet-dashboardrepo access — the repo (github.com/vecnetpay/vecnet-dashboard) is private; I couldn't read it. Make it public, add read access, or share the file tree/README so §9.1.1's dashboard pages, routes, and components map to the actual mockup. The metric formulas are already captured (from the Tonder metric-definitions page).- Fraud & Risk formulas are marked "pending to confirm" in Tonder's definitions — confirm before building Guardian/3DS/intl metrics.
- Repo mockups from Tonder (folder structure, CLAUDE.md, slash commands) — to align Vecnet's repo conventions. (Yuyo to send.)
- Tonder API contract for Lite SDK v2.0: payment request/response + webhook payload schema.
- Menta API contract: POS transaction notification payload + signature scheme.
- Frontend framework confirmation (Next.js assumed) and where the merchant Dashboard vs admin surfaces live (one app, role-gated, vs two apps).
- Currencies/countries Vecnet launches with (MXN-only first?).
- Hosted Checkout is now specced in §9.4 (light-SDK refactor of the Tonder drop-in, Skyflow elements,
checkout_configbranding). Two things still needed: access to theyuyo99/vecnet-pay-checkoutrepo (private — couldn't read; make public or share structure) to align components, and the Tonder Lite SDK v2.0 contract (also below) which gates the build.
14. Appendix — Tonder → Vecnet construct mapping
| Tonder | Vecnet | Note |
|---|---|---|
DynamoDB tables (AccountsTable, JournalsTable, …) | Postgres tables | §7 |
MongoDB (financesJournals, financesFeeRules, …) | — (folded into Postgres) | no query mirror |
DynamoDB PK/SK | columns + unique constraints | §7.1 |
process_id-index dedup + IdempotencyTable (120s) | unique(process_id, category) + ON CONFLICT | §7.8 |
AccountUpdateQueue (FIFO) + ADD + version + maxReceiveCount=160 | one DB transaction + SELECT … FOR UPDATE | §4.3, §8.3 |
TransactionEventsQueue (EventBridge→SQS) | pgmq / transaction_events + advisory locks | §7.7 |
SettlementJournalsQueue | UPDATE … WHERE id = ANY(:ids) | §8.4 |
| CloudWatch crons | pg_cron | §8.5, §8.7 |
| Step Functions (bulk) | bulk_jobs + worker | §12 M8 |
| S3 reports | Supabase Storage | §8.7 |
| Lambda + Middy + Inversify + RxJS | Edge Functions + Postgres functions; async/await; keep hexagonal | §5.3 |
Platform entity T1 | V1 | §3 |
tonder/acquirer fee side label | platform/acquirer | §8.2 |
Acquirers (kushki,unlimit,stp,…) | rails (tonder,menta,…) | §3 |
validateBalance warns | posting fn rejects imbalance | §8.3 |
| Ledger TTL 15 days + Firehose archive | permanent in Postgres (optional partition/archive) | §7.3 |
DetailType/EventBridge taxonomy | internal normalized transaction_event | §8.1 |
| Legacy flat fee-rule format | unified only | §7.4 |
usrv-batch-transaction-report (AWS Batch/Fargate, Python) | Edge Function/worker + pg_cron cycle engine | §8.10 |
MongoDB usrv-finances-journals (filter type:"OUT") | journals WHERE journal_type='OUT' | §8.10 |
mv_payment_transactions (legacy RR view, OLD query) | — (greenfield; use ROLLING_RESERVE_RELEASE journals) | §8.10 |
SETTLEMENT_LAMBDA_ARN invoke + settlement_json payload | direct §8.4 approve/confirm; positive/absolute amounts | §8.10 |
SSM MERCHANT_CONFIG_SETTLEMENT_BATCH | settlement_merchant_config table | §8.10 |
SSM TESTING_CONFIG (default/manual/specific_merchants) | run-mode parameter (scheduled/manual) | §8.10 |
MerchantLogCapture (AWS_BATCH_JOB_ID) | settlement_runs + Storage logs | §8.10 |
| EventBridge cron (Mon/Thu 08:00 UTC batch) | pg_cron per cadence (T+0/T+1/T+2) | §8.10 |
usrv-settlement (3 handlers, DynamoDB system-of-record) | settlements table + orchestration in Supabase | §8.11 |
SettlementTable (PK settlement_id, DeletionPolicy: Retain) | settlements table (never hard-delete) | §8.11 |
calculateForceDecrease (release − finances) | same logic + prod-bug warning preserved | §8.11 |
settlement_type allowlist (isSupportedSettlementType) | batch/t1/t2 validation | §8.11 |
Decimal128 / normalizeDecimalFields / $facet pagination | — (Postgres numeric + LIMIT/OFFSET + count() OVER()) | §8.11 |
changeStatusFinancesHandler (sync Lambda invoke) | direct call into §8.4 | §8.11 |
BatchGateway.submitJob (AWS Batch) | trigger cycle run (pg_cron/Edge Function) | §8.10 |
S3Gateway.getSignedUrl (presigned URL) | Supabase Storage signed URLs | §8.11 |
TonderError / ES### codes | keep code taxonomy (ES007 etc.) | §8.11 |
| Tonder Dashboard metric definitions (Notion) | §9.1.1 metric catalog, same formulas | §9.1.1 |
| Full attempt stream (declined/failed/pending/expired) | transactions table | §7.9 |
"every metric deduplicated" / payment_intent_id | dedup by payment_intent_id | §9.1.1 |
| Cards acceptance vs APM conversion (never blended) | separate denominators (rules #1 vs #2) | §9.1.1 |
| Tonder drop-in / Hosted Checkout + Lite SDK v2.0 | Vecnet Hosted Checkout consuming light SDK; Skyflow elements; checkout_config branding | §9.4 |
business_business (Tonder merchant collection) | merchants registry | §7.10 |
| acquirer config (scattered: SSM, code) | rails registry (acquirer-as-plugin) | §7.10 |
| Webhook delivery (ad hoc) | webhooks / webhook_deliveries + Webhook Manager | §7.10, §10 |
| operator access / change tracking | operators (RBAC) + audit_log (every config mutation) | §7.10 |
Adjustment type taxonomy (port verbatim)
Reversals: PAYMENT_REVERSAL, WITHDRAWAL_REVERSAL, REFUND_REVERSAL. Flow corrections: REFUND_CORRECTION, DISPUTE_CORRECTION. Fees/costs/income: FEE_CORRECTION, PROVIDER_CORRECTION, SERVICE_COMPENSATION, PROMOTIONAL_CREDIT. Taxes/FX/reserves/risk: TAX_CORRECTION, FX_RATE_CORRECTION, RESERVE_ADJUSTMENT, RISK_ADJUSTMENT. General: BALANCE_RECONCILIATION, ACCOUNTING_ERROR_CORRECTION, TECHNICAL_CORRECTION, WRITE_OFF, PRIOR_PERIOD_CORRECTION. Catch-all: MANUAL_ADJUSTMENT, OTHER.
Error codes (port verbatim)
E001 500 unexpected · E002 401 unauthorized · E003 404 not found · E004 400 bad request · E005 409 exists · E006–E0010 CRUD errors · E0011 action not allowed · E0012 business not found · E0013 fee rules not found · E0014 no settlement policy · E0015 detail type unsupported · E0016 journal type unsupported · E0017 settlement already processed · E0018 no approved settlement pending confirm.
End of v0.1. Next revision folds in repo mockups and the Tonder/Menta API contracts.