Skip to content

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. §1–§3 — what Vecnet is and how it's shaped (mission, architecture, the two-rail model).
  2. §4 — the single most important section: how we port Tonder's AWS/NoSQL finances engine to Supabase/Postgres, and why the port simplifies it.
  3. §5–§7 — stack, domain model, and the concrete Supabase schema.
  4. §8 — the finances flows, each rewritten for Supabase.
  5. §9 — the two surfaces you build: the merchant frontend and the Vecnet admin backend (operated by Vecnet personnel).
  6. §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 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 Panel

Two load-bearing principles (carry these through every design decision):

  1. Acquirer-as-plugin. Tonder and Menta are two rails behind one abstraction. The finances engine treats each rail as an ACQUIRER entity. Adding a third rail later must not require schema or core-flow changes — only a new acquirer record + fee rules + a notification adapter.
  2. 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 ACQUIRERRailCapture modelNotifies Vecnet via
tonderOnline / CNPHosted Checkout + Tonder Lite SDK v2.0 → Tonder APITonder Backend → Transaction Notification → Listener
mentaIn-person / CPTerminal POS → Menta APIMenta Backend → Transaction Notification → Listener
(future)anypluginnew 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-item ADD, and running balances must be applied in order. A version counter provides optimistic concurrency. maxReceiveCount = 160 because losing a balance update permanently corrupts the ledger.
  • An IdempotencyTable (120s TTL) plus a process_id-index dedup 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 primitiveReason it existsVecnet (Supabase) replacement
DynamoDB single-table (PK/SK)NoSQL key accessPostgres relational tables with proper PKs, FKs, indexes
MongoDB query mirror + aggregation pipelinesRich querying DynamoDB can't doGone. Postgres is the query engine. Aggregations are SQL.
AccountUpdateQueue FIFO + ADD operator + version counterDynamoDB can't update journal + entries + balances atomicallyA 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 TTLGuard against SQS redeliveryA 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 businesspgmq (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 cronsScheduled jobspg_cron (Supabase extension) invoking SQL functions / Edge Functions
Step Functions (bulk reprocess)Long-running orchestrationA 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 storageSupabase Storage bucket
Lambda + Middy + InversifyJS + RxJS ObservablesAWS runtime + hexagonal wiringSupabase 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 serializationAvoid lost updatesRow-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 uses toMoney()/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:

LayerVecnet locationResponsibility
Domainsrc/service/ (pure TS) and supabase/functions/_sharedBusiness logic: fee calc, journal generation, settlement math. No framework deps.
ApplicationEdge Functions (supabase/functions/*) + Postgres functionsEntry points: HTTP handlers, queue consumers, cron jobs.
Portssrc/repository/I*.tsInterfaces between domain and infra. I prefix retained.
Infrastructuresrc/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 typeDEBITCREDIT
ASSET+
EXPENSE+
LIABILITY+
REVENUE+
EQUITY+

REVERSAL inverts the sign regardless of type.

6.2 Entity types (entity_type)

ValueVecnet meaningKey prefix
BUSINESSVecnet client / merchant (e.g. the government entity / operator)ENT#{entity_id}
ACQUIRERA rail: tonder, menta, …ACQ#{acquirer}
PLATFORMVecnet itselfENT#V1

6.3 Account codes (account_code)

Each code implies a canonical account_type and entity_type. Ported set:

CodeTypeEntityPurpose
ACQUIRER_RECEIVABLEASSETACQUIRERFunds receivable from a rail after capture
BUSINESS_PAYABLELIABILITYBUSINESSNet owed to the merchant after fees
PLATFORM_CLEARINGLIABILITYPLATFORMTemporary platform-fee clearing
WITHDRAWAL_FUNDSASSETACQUIRERFunds held at a rail to settle withdrawals
BUSINESS_WITHDRAWAL_PAYABLELIABILITYBUSINESSWithdrawal amount owed to merchant
PROCESSING_FEES_REVENUEREVENUEPLATFORMRevenue from payin fees
WITHDRAWAL_FEES_REVENUEREVENUEPLATFORMRevenue from withdrawal fees
ACQUIRER_FEES_EXPENSEEXPENSEPLATFORMPayin fees charged by the rail
WITHDRAWAL_ACQUIRER_FEES_EXPENSEEXPENSEPLATFORMWithdrawal fees charged by the rail
WITHDRAWAL_ACQUIRER_FEES_PAYABLELIABILITYACQUIREROwed to a rail for withdrawal fees
VAT_PAYABLELIABILITYPLATFORMIVA collected, to remit
VAT_RECEIVABLEASSETPLATFORMIVA paid to rails, reclaimable
RESERVE_RECEIVABLEASSETACQUIRERRolling reserve a rail holds for the platform
RESERVE_PAYABLELIABILITYBUSINESSMerchant balance held as rolling reserve
BANKASSETPLATFORMBank settlement account
BUSINESS_SETTLEMENT_PENDINGLIABILITYBUSINESSFunds pending settlement disbursement
PRIOR_PERIOD_ADJUSTMENTEQUITYPLATFORMContra for historical loads / prior-period fixes

PLATFORM_CASH from Tonder is reserved/unused — omit it; use BANK for 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_typeadjustment_type mapping (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. The ACCOUNT_NUMBER#…/ALIAS companion item is just the unique(account_number) index. balance is 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_at vs generated_at semantics 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. Use created_at for business queries, generated_at for 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 journals ledger 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, including declined, failed, pending, and expired, which never become journals. So Vecnet persists every rail notification (all outcomes) as a transactions row. journals = source of truth for money; transactions = source of truth for attempts/analytics. Both are joined on correlation_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_id so retries count once (the Tonder "every metric is deduplicated" rule, §9.1).
  • PII: customer_ref is 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_log row — 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:

  1. Dedup / order: advisory lock on entity_id; if a journal already exists for (process_id, category), return ALREADY_PROCESSED.
  2. Normalize: map the rail payload → internal payin or withdrawal shape. (Tonder's PAYOUT→WITHDRAWAL, WAA_DEPOSIT→TOPUP normalizations: keep the concept, no EventBridge naming.)
  3. Validate required fields → MISSING_INFO if absent.
  4. Cutoff: event older than 48h → ALREADY_PROCESSED (realtime only).
  5. Module check: FEES_CALCULATION enabled for this entity+acquirer? If not → MODULE_DISABLED.
  6. Enrich: fill currency_code if missing (a SQL lookup now, not a Mongo call).
  7. 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 transactions row (§7.9) for every event regardless of outcomesuccess, declined, failed, pending, expired. Journals are created only for the successful financial subset; the attempt row is what powers acceptance/conversion/risk metrics. A declined or expired notification produces a transactions row 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/$exists null handling becomes Postgres (col is null or col @> array[:val]). Specificity ranking (business_id/risk_level/card_brand match=0, null=1) is the _rank expression. Missing either IN or OUT rule → error E0013.

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; if Wonnet = fee + iva, else net = 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:

  1. Resolves the accounts for the category (the PK/SK tables below become account_code + entity lookups).
  2. Generates the journal(s) — IN/OUT pair for transactions, single for adjustment/settlement/routing/RR.
  3. Generates DEBIT/CREDIT ledger entries per the category generator.
  4. Validates sum(debits) = sum(credits) — and rejects if imbalanced (stricter than Tonder's warn-only).
  5. Locks each affected account (SELECT … FOR UPDATE), computes balance_before/balance_after/seq, updates balance and version.
  6. For PAYMENT, sets rr_amount and expected_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 journals by process_id = settlement_id and category. E0017 if already approved; E0018 if confirm-before-approve.
  • Approve: select pending journals (settlement_id is null within [from,to], optional acquirer filter); optional ROUTING and ROLLING_RESERVE_RELEASE sub-flows; post SETTLEMENT_APPROVE OUT (business net) and SETTLEMENT_APPROVE IN per acquirer (each acquirer gets its own settlement_id); tag the journals with settlement_id.
  • Confirm: post SETTLEMENT_CONFIRM OUT only.

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_snapshots row.

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

CycleCadenceWindow source
T+0 (batch)Mon & Thu (default 08:00 UTC)the batch on-cycle dates computed by day-of-week
T+1dailyjournals dated 1 day prior
T+2dailyjournals 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)

  1. Resolve config — payout fee, language, excluded rails, recipients (from settlement_merchant_config, below).
  2. Aggregate the cycle window from journals, always filtering journal_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, not DISPUTE_REVIEW), DISPUTE_WON, REFUND, VOID, WITHDRAWAL, ROLLING_RESERVE_RELEASE.
  3. IVA — most comes from iva_amount on the journals. The payout fee (tarifa_liquidacion) is config-driven and has no journal record, so compute its IVA separately as payout_fee × 0.16 and add it in.
  4. 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.
  5. Trigger the settlement — hand the totals to the settlement orchestration (§8.11), which records the settlement, computes force_decrease, and drives the §8.4 approveconfirm accounting. This replaces Tonder's "build settlement_json payload + invoke SETTLEMENT_LAMBDA_ARN." All amounts in the disbursement are positive/absolute (Tonder rule — preserve it). Tag the cycle's journals with the resulting settlement_id.
  6. 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 in settlement_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 paramVecnet 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_IDstructured 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):

  1. Record a settlement — receives settlement info, computes force_decrease, persists to the settlements system-of-record. (Tonder: settlementHandler, Lambda-invoked.)
  2. Bridge to finances — receives the settlement payload (without internal calc fields) and synchronously triggers the §8.4 accounting. (Tonder: changeStatusFinancesHandler, HTTP POST.)
  3. 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
Conditionforce_decreaseeffective rolling_reserve_release
diff > 0 (intended > finances)truediff (release only the surplus)
diff == 0falseoriginal 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 resulting force_decrease feeds the force_decrease_rolling_reserve_release flag in the §8.4 settlement request; the effective amount feeds rolling_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

PayloadCarries rolling_reserve_release_finances, settlement_type, s3?Used for
internal settlement (record)yes — all three requiredrecording + force_decrease
change-status to financesno — those three are internal-onlytriggering §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 a normalizeDecimalFields traverse helper, because the BSON type breaks JSON clients. Postgres numeric serializes 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 guards constructor.name against 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, PK settlement_id, DeletionPolicy: Retain, created_at as Unix ms, its created_at GSI 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:

CapabilityBacked byNotes
Balance overview (per account/currency)accounts (RLS)Realtime subscription for live balance
Transaction historyjournals + ledger_entries (RLS)filter by category/date/rail/correlation_id
Daily ledger statementsbalance_snapshots + Storagedownload XLSX
Settlement statementsjournals where settlement_id setread-only
Rolling reserve viewjournals (rr_amount, expected_reserve_release_date)upcoming releases
Payment status (live)Webhook Manager → Realtimethe 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):

  1. Acceptance rate = successful ÷ (success + declined + failed) × 100. Exclude expired, pending, and duplicates from the denominator.
  2. APM conversion rate = successful APM ÷ all APM intents × 100. Include expired and pending (it measures conversion, not authorization).
  3. Card acceptance rate and APM conversion rate are never blended into one number — they measure different things.
  4. Dedup everywhere: each payment_intent_id is counted once; retries behind one intent collapse.
  5. FTD = first-ever successful payment for a customer_ref, confirmed by looking back across all history (not just the selected range).
  6. Every metric respects the global date-range filter (Today / 7D / 30D / 90D / custom).

Home — Primary KPIs

MetricFormula
Gross volumeΣ all transaction amounts, any outcome
Success volumeΣ amounts where status = success
Successful paymentscount 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

KPIFormula
Overall acceptance raterule #1
FTD acceptance ratesuccessful ÷ total payments, restricted to first-time customers (rule #1 denominator)
Trusted acceptance ratesame, restricted to returning customers
APM conversion raterule #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)

KPIFormula
Total depositsΣ successful payment amounts
FTDscount of new first-time customers (rule #5)
Active depositorscount of distinct customer_ref with ≥1 success in range
Avg deposit amounttotal 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)

KPIFormula
Guardian block rateblocked ÷ total card transactions screened × 100 (healthy <5%)
3DS abandonment rate3DS challenges abandoned ÷ total 3DS transactions × 100
Intl card acceptancesuccessful 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 tonder rail is card/CNP-rich (full card_brand/BIN/3DS/Guardian dimensions); the menta POS rail is card-present and won't populate every fraud field. Card-network and 3DS metrics are mostly the tonder rail; 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):

ModuleSurfaceBacked by
MerchantsAdmin Panelmerchants (+ provisions accounts/config/checkout/fee rules)
Rails / AcquirersAdmin Panelrails
Fee Rules (+ simulator)Admin Panelfee_rules
Configurations (flags)Admin Panelfinances_config
Settlements & cyclesSystem Control Panelsettlement flows + settlements/settlement_runs/settlement_merchant_config
Rolling ReserveSystem Control PanelRR flow + journals
WebhooksAdmin Panel + SCPwebhooks / webhook_deliveries
Checkout brandingAdmin Panelcheckout_config
Reconciliation / ingestion healthSystem Control Paneltransaction_events, pgmq, journals
TreasurySystem Control Panelaccounts + settlements + transfers
Operators / RBAC / auditAdmin Paneloperators / audit_log
All-entity transaction & balance browseAdmin Paneljournals / 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-checkout is 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)

  1. Portal opens the checkout with { entity_id, amount, currency, reference, correlation_id } — mint the correlation_id here if absent (§2 principle).
  2. Payer enters card (Skyflow elements) or picks an APM (SPEI / OXXO / Mercado Pago).
  3. Lite SDK sends the payment requestTonder API → Tonder Backend.
  4. response returns to the checkout: approved / declined / 3DS challenge / APM pending-with-voucher.
  5. 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_color override 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 into transaction_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 to webhook_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); numeric storage; 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)

PhaseDeliverableDone when
M1 — FoundationSupabase project, enums, accounts/journals/ledger_entries + merchants/rails registries + RLS, account CRUD, posting function with double-entry validationA merchant + rail exist; a PAYMENT posts and balances move atomically; imbalanced posting is rejected
M2 — Feesfee_rules + matching SQL + all fee formulas; finances_config + module flagsCorrect fees for PAYMENT/REFUND/DISPUTE/VOID/WITHDRAWAL/TOPUP, matching Tonder fixtures
M3 — IngestionListener adapters (Tonder + Menta), transaction_events/pgmq, orchestrator worker, idempotency, correlation IDs, transactions attempt store (all outcomes), webhooks/webhook_deliveries + Webhook ManagerA rail notification → posted journals + a transactions row + outbound webhook delivered; idempotent under redelivery
M4 — Settlements + Transfersapprove/confirm, journal tagging, settlements system-of-record + calculateForceDecrease, settlement query API, internal transfers, prior-period loadSettlement records persist with correct force_decrease; produces correct OUT + per-acquirer IN journals; transfers audited
M5 — Scheduledpg_cron rolling reserve + daily ledger; XLSX → Storage; snapshotsReserves 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 + emailA scheduled cycle settles each active merchant, files the statement, and emails FinOps — automated from day 1
M6 — Merchant frontendDashboard pages (Home / Acceptance & Conversion / Deposit Analytics / Fraud & Risk) with the §9.1.1 metric catalog from transactions + journals; Realtime; RLS scopingMerchant sees only their entity, live; acceptance/APM/dedup rules match Tonder fixtures
M6b — Hosted CheckoutPayer-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 platformAdmin 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 + hardeningcross-rail recon, DLQ handling, bulk reprocess, observabilityCorrelation gaps surfaced; bulk historical load works

13. Open items / still needed

  • vecnet-dashboard repo 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_config branding). Two things still needed: access to the yuyo99/vecnet-pay-checkout repo (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

TonderVecnetNote
DynamoDB tables (AccountsTable, JournalsTable, …)Postgres tables§7
MongoDB (financesJournals, financesFeeRules, …)— (folded into Postgres)no query mirror
DynamoDB PK/SKcolumns + unique constraints§7.1
process_id-index dedup + IdempotencyTable (120s)unique(process_id, category) + ON CONFLICT§7.8
AccountUpdateQueue (FIFO) + ADD + version + maxReceiveCount=160one DB transaction + SELECT … FOR UPDATE§4.3, §8.3
TransactionEventsQueue (EventBridge→SQS)pgmq / transaction_events + advisory locks§7.7
SettlementJournalsQueueUPDATE … WHERE id = ANY(:ids)§8.4
CloudWatch cronspg_cron§8.5, §8.7
Step Functions (bulk)bulk_jobs + worker§12 M8
S3 reportsSupabase Storage§8.7
Lambda + Middy + Inversify + RxJSEdge Functions + Postgres functions; async/await; keep hexagonal§5.3
Platform entity T1V1§3
tonder/acquirer fee side labelplatform/acquirer§8.2
Acquirers (kushki,unlimit,stp,…)rails (tonder,menta,…)§3
validateBalance warnsposting fn rejects imbalance§8.3
Ledger TTL 15 days + Firehose archivepermanent in Postgres (optional partition/archive)§7.3
DetailType/EventBridge taxonomyinternal normalized transaction_event§8.1
Legacy flat fee-rule formatunified 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 payloaddirect §8.4 approve/confirm; positive/absolute amounts§8.10
SSM MERCHANT_CONFIG_SETTLEMENT_BATCHsettlement_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### codeskeep 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_iddedup 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.0Vecnet 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 trackingoperators (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.

Vecnet — Build Spec v0.2 · Obsidian Terminal