providers-specs

TRES Fee Recording

docs/providers-specs/tres-fee-recording.md

TRES Fee Recording

TRES models a transfer as one parent transaction with multiple child subtransactions. Fees should be separate child rows, not netted into the principal transfer amount.

Internal Source Data

Our internal transactions row keeps:

  • amountMinor: principal amount.
  • feeMinor: total fee amount for display/reconciliation.
  • metadata.networkFeeMinor or metadata.networkFee: network/provider fee.
  • metadata.sendFeeMinor, metadata.platformFeeMinor, or metadata.platformFee: Daxchain/platform fee.

The wallet ledger can still debit the total spend (amount + fees) in one ledger_entries row. The TRES export expands that accounting view into separate subtransactions.

TRES Payload Shape

Use buildTresManualTransferPayload() from lib/providers/tres.ts.

For a send or payout:

  1. Create the parent manual transaction with the provider tx hash/id as identifier.
  2. Create one OUTFLOW subtransaction for the principal amount.
  3. Create one OUTFLOW subtransaction for the network fee when non-zero.
  4. Create one OUTFLOW subtransaction for the platform fee when non-zero.

For ERC-20/TRC-20 gas, only set networkFeeAssetId to the native gas asset when the source fee amount is actually denominated in that gas asset. If the source amount is estimated or charged in the token itself, keep the fee asset the same as the transfer asset.

Implementation Approach: Option A (Fire-and-forget hook)

The current implementation calls tres.exportManualTransfer(...) inline from worker/jobs/send.ts (after transactions.status='completed') and worker/jobs/payouts.ts (after payout_rows.status='confirmed'). The call is wrapped in a try/catch and failures are recorded as tres.export_failed audit-log rows. The provider tx hash is the TRES identifier, so retries are idempotent on the TRES side.

Drawbacks

This approach is intentionally minimal. Known trade-offs:

  • No retry / no durable queue. A failed export is logged once and abandoned. Transient TRES outages, auth-token expiry, or 429s cause permanent gaps in the financial export until the row is re-exported manually.
  • No backfill path. There is no tres_export_status column on transactions / payout_rows, so re-exporting after a TRES outage requires an ad-hoc script that filters by audit_log and reconstructs the source rows.
  • No deduplication beyond the TRES identifier. Two successful exports with different identifiers (e.g., chain tx hash arrives late and the fallback send:<id> was used first) would create two TRES parent transactions. Today's jobs run after the tx hash is set, but any future code path that triggers earlier risks a duplicate.
  • Coupling to the user-facing job's lifetime. The export runs in the same async function as the send/payout completion handler. A long TRES request blocks the job from returning, which can starve the in-process job runner. The catch suppresses errors, not latency.
  • No observability surface. Failures land in audit_log only — no admin page showing pending vs. exported state, no metric counter, no alert when the failure rate spikes.
  • Mapping config is environment-only. Per-asset TRES wallet ids, type ids, and platform names are read from TRES_* env vars at call time. Multi-tenant / per-merchant TRES routing is not supported without a schema change.
  • Live mode is intentionally unimplemented. Following the existing provider pattern, LiveTres throws unsupported('TRES'). Switching to a real GraphQL client requires writing the login/refresh flow plus the createManualTransaction + createSubTransaction mutations from the Postman collection.

If any of these become real problems (especially the first two), migrate to Option B: a tres_exports queue table plus a dedicated worker job.

Implementation Approach: Option B (Decoupled queue + worker)

Option B replaces the inline call in worker/jobs/send.ts and worker/jobs/payouts.ts with a write-then-drain pattern: the user-facing job records that an export is needed and a separate worker drains the queue, calling tres.exportManualTransfer(...) with retry and backoff.

Schema

Two viable shapes — pick one, do not maintain both:

B.1 — Per-source export columns (lighter, recommended for a single tenant).

Add migration lib/db/migrations/NNNN_tres_export_status.sql:

ALTER TYPE tres_export_status_enum AS ENUM (
  'pending', 'exported', 'failed'
);

ALTER TABLE transactions
  ADD COLUMN IF NOT EXISTS tres_export_status tres_export_status_enum,
  ADD COLUMN IF NOT EXISTS tres_tx_id text,
  ADD COLUMN IF NOT EXISTS tres_attempts integer NOT NULL DEFAULT 0,
  ADD COLUMN IF NOT EXISTS tres_last_error text,
  ADD COLUMN IF NOT EXISTS tres_exported_at timestamptz;

ALTER TABLE payout_rows
  ADD COLUMN IF NOT EXISTS tres_export_status tres_export_status_enum,
  ADD COLUMN IF NOT EXISTS tres_tx_id text,
  ADD COLUMN IF NOT EXISTS tres_attempts integer NOT NULL DEFAULT 0,
  ADD COLUMN IF NOT EXISTS tres_last_error text,
  ADD COLUMN IF NOT EXISTS tres_exported_at timestamptz;

CREATE INDEX IF NOT EXISTS transactions_tres_pending_idx
  ON transactions (tres_export_status)
  WHERE tres_export_status = 'pending';

CREATE INDEX IF NOT EXISTS payout_rows_tres_pending_idx
  ON payout_rows (tres_export_status)
  WHERE tres_export_status = 'pending';

Append the file to lib/db/migrations/meta/_journal.json and mirror the new columns in lib/db/schema/ (per-domain files, not a single schema.ts).

B.2 — Standalone tres_exports queue (cleaner for multi-tenant or audit).

CREATE TABLE IF NOT EXISTS tres_exports (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  source_type text NOT NULL CHECK (source_type IN ('send', 'payout_row')),
  source_id uuid NOT NULL,
  status tres_export_status_enum NOT NULL DEFAULT 'pending',
  tres_tx_id text,
  attempts integer NOT NULL DEFAULT 0,
  last_error text,
  next_attempt_at timestamptz NOT NULL DEFAULT now(),
  exported_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (source_type, source_id)
);

CREATE INDEX IF NOT EXISTS tres_exports_pending_idx
  ON tres_exports (status, next_attempt_at)
  WHERE status = 'pending';

The unique (source_type, source_id) constraint plus the TRES identifier (provider tx hash) guarantee at-most-once delivery even if the worker is restarted mid-flight.

Trigger sites (replacing today's inline call)

In worker/jobs/send.ts after transactions.status='completed' and in worker/jobs/payouts.ts after payout_rows.status='confirmed':

// B.1 form
await db.update(transactions)
  .set({ tresExportStatus: 'pending' })
  .where(eq(transactions.id, sendId));

// B.2 form
await db.insert(tresExports).values({
  sourceType: 'send',
  sourceId: sendId,
}).onConflictDoNothing();

No payload is built here — the worker does that with fresh row data, so late-arriving fields (e.g., a tx hash that lands after the audit row) are captured correctly.

Worker

New module worker/jobs/tresExport.ts:

  1. Select up to N pending rows with next_attempt_at <= now() ordered by next_attempt_at. Use FOR UPDATE SKIP LOCKED so multiple workers can run safely.
  2. For each row, hydrate the source from transactions / payout_rows, build the payload via buildTresManualTransferPayload(...) + tresMappingForAsset(...), call tres.exportManualTransfer(...).
  3. On success: mark exported, store tres_tx_id, set exported_at = now().
  4. On failure: increment attempts, store last_error, set next_attempt_at = now() + backoff(attempts) (exponential with jitter). After N attempts (default 10), mark failed and stop retrying.
  5. Drain in a loop until the queue is empty or a per-tick budget is hit.

Schedule via the existing in-process job runner (worker/jobs/index.ts) with a fixed cadence (e.g., every 30s) plus an opportunistic enqueue from the send/payout jobs to drain immediately when the system is idle.

Backfill

Re-export after a TRES outage:

-- B.1
UPDATE transactions
   SET tres_export_status = 'pending', tres_attempts = 0,
       tres_last_error = NULL
 WHERE id = ANY($1::uuid[]);

-- B.2
UPDATE tres_exports
   SET status = 'pending', attempts = 0, last_error = NULL,
       next_attempt_at = now()
 WHERE source_type = 'payout_row' AND source_id = ANY($1::uuid[]);

The TRES identifier (provider tx hash) is unchanged across attempts, so TRES treats the second call as an upsert and no duplicate parent transaction is created.

Observability

Once the queue exists, expose two thin admin views:

  • app/admin/tres/page.tsx — counts by status, last error per row, "retry now" action for failed rows.
  • A /api/admin/tres/run debug route that triggers the worker once (gated to admin role) for use in Bruno scenarios.

Trade-offs vs. Option A

Concern Option A Option B
Lines of code ~80 ~300+
Schema migration none one
Survives TRES outage no yes
Backfill is a one-liner SQL no yes
Failure is observable in UI no yes
Decouples job latency from TRES no yes
Multi-tenant routing no yes (B.2)
Admin "retry now" action no yes

Pick Option B when the financial-export integrity bar is high enough that silently dropping a row on a transient TRES failure is unacceptable, or when Finance needs visibility into the export pipeline. Otherwise Option A is enough.