Files
wownerogue/docs/FINANCIAL_EVENT_EXPORT.md
T
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

166 lines
8.7 KiB
Markdown

# Financial event export
Migrations `041_financial_event_outbox.sql` and `042_immutable_financial_event_snapshots.sql` create
`financial_event_outbox`, a durable outbox for confirmed customer payments and final refund states.
Database triggers append an immutable source snapshot inside the payment or refund transaction, so a
slow or unreachable accounting receiver can never hold a money transaction open.
`src/services/financialEventExporter.js` performs delivery asynchronously and never reconstructs an
event from later-mutated payment rows.
## Event sources
| Event kind | Trigger source | Fires when |
| --- | --- | --- |
| `payment.confirmed` | `payments` | `status` becomes `confirmed` and `received_amount > 0` |
| `payment.refund` | `payment_refunds` | `status` reaches `recorded` (bookkeeping reversal) or `completed` (wallet refund) |
Refunds in `requested`, `processing`, or `needs_review` are never accounting events. Confirmed
payments with `received_amount = 0` lack economic evidence and are never turned into revenue.
Uniqueness on `(event_kind, source_type, source_id)` and on `event_id` means one source produces at
most one event; the foreign key to `payments` is `ON DELETE RESTRICT`.
## Source snapshot
`payload_snapshot` uses schema `financial-event-source/v1`. A CHECK constraint restricts it to
exactly this key set: `schema`, `event_id`, `event_kind`, `source_type`, `source_id`,
`aggregate_id`, `occurred_at`, `atomic_amount`, `payment_type`, `product_id`, `provider_id`,
`receipt_count`, `refund_status`. The constraint also pins `event_id`, `event_kind`, `source_type`,
`source_id`, and `aggregate_id` to the matching columns.
The snapshot carries no player ID, wallet address, invoice destination, transaction hash, provider
invoice ID, session token, IP address, email address, or authentication material.
## Delivery document
On the first delivery attempt the worker builds `delivery_payload` from the snapshot plus the
deployment's chain profile (`src/chain/chainProfile.js`) and writes it once. Schema is
`financial-event/v1`:
- `id`: the immutable event ID, also used as the idempotency key.
- `occurred_at`: ISO-8601 timestamp derived from the snapshot.
- `producer` / `product`: `wowngeon`.
- `activity`: `customer-payment`, `customer-refund` (completed wallet refund), or
`customer-payment-reversal` (recorded bookkeeping reversal).
- `account_refs` and each leg's `account_ref`: `FINANCIAL_EVENT_ACCOUNT_REF`.
- `provenance`: one entry of kind `wowngeon-payment` or `wowngeon-refund` referencing
`payment-<id>` or `payment-refund-<id>`.
- `legs`: one leg with the chain profile's `asset` symbol, decimal `quantity`, `direction` `in` for
payments and `out` for refunds, and a `network` identifier.
- `attributes`: `valuation_required`, `atomic_amount`, `atomic_decimals`, `source_type`,
`source_id`, `aggregate_id`, plus `refund_status` for refunds or
`payment_type` / `product_id` / `provider_id` / `receipt_count` for payments.
The leg's `usd_value` is `"0"` with empty `price_source` and `price_confidence`, and `memo` plus
`attributes.valuation_required` state that the receiving system must apply USD valuation before
confirming the entry. The exporter deliberately performs no pricing.
The `network` identifier is `wownero:mainnet` when `CRYPTO_TYPE` resolves to the WOW profile, and
`monero:<MONERO_NETWORK>` otherwise. An unrecognised `CRYPTO_TYPE` resolves to the XMR-shaped
default profile (12 decimals, 120000 ms mean block time).
Once written, the delivery document is immutable and every retry reuses it byte for byte.
## Delivery contract
Delivery is **at least once**. Each attempt is a `POST` with a bearer `authorization` header,
`content-type: application/json`, `idempotency-key` set to the immutable event ID, manual redirect
handling, and a 10-second timeout. The receiving system must durably deduplicate that key and
durably record the event before answering 2xx. A timeout or lost acknowledgement otherwise produces
a repeat request. The application marks a row `delivered` only after a 2xx response.
## Row lifecycle
`pending` → `in_flight` → `delivered`, with `dead_letter` and `ignored` as the other terminal
states.
- A run first moves rows whose `attempts` already reached `FINANCIAL_EVENT_MAX_ATTEMPTS` to
`dead_letter`.
- Claiming selects the oldest due row with `FOR UPDATE SKIP LOCKED`, increments `attempts`, and
takes a five-minute lease. An expired lease makes the row claimable again, so a crashed worker
never strands an event.
- A failed attempt returns the row to `pending` with backoff of 30 seconds doubled per attempt and
capped at one hour, recording the sanitised error in `last_error`.
- An attempt that exhausts `FINANCIAL_EVENT_MAX_ATTEMPTS` moves the row to `dead_letter` for
operator review.
- Each run processes at most `FINANCIAL_EVENT_BATCH_SIZE` rows.
### Immutability
A `BEFORE UPDATE` trigger rejects any change to `event_kind`, `aggregate_id`, `source_type`,
`source_id`, `event_id`, or `payload_snapshot`. `delivery_payload` has exactly one legal transition,
`NULL` to its final value, and only while the row is `in_flight`. A row cannot reach `delivered`
without a delivery document.
## Configuration
| Variable | Default | Notes |
| --- | --- | --- |
| `FINANCIAL_EVENT_SINK_URL` | unset | Enables export together with the token |
| `FINANCIAL_EVENT_SINK_TOKEN` | unset | Bearer secret, at least 32 characters |
| `FINANCIAL_EVENT_ACCOUNT_REF` | `wowngeon:receipts` | Non-PII account identifier |
| `FINANCIAL_EVENT_POLL_MS` | `60000` | Clamped to 5000-3600000 |
| `FINANCIAL_EVENT_BATCH_SIZE` | `20` | Clamped to 1-100 |
| `FINANCIAL_EVENT_MAX_ATTEMPTS` | `8` | Clamped to 1-100 |
```dotenv
FINANCIAL_EVENT_SINK_URL=https://ledger.operator.example/v1/events
FINANCIAL_EVENT_SINK_TOKEN=<strong protected bearer token>
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:wow-receipts
FINANCIAL_EVENT_POLL_MS=60000
FINANCIAL_EVENT_BATCH_SIZE=20
FINANCIAL_EVENT_MAX_ATTEMPTS=8
```
The full environment template is `src/.env.example`; the mainnet and stagenet profiles are
`src/.env.mainnet.example` and `src/.env.stagenet.example`.
Startup validation (`src/config/environmentValidator.js`) and the exporter constructor both enforce:
- URL and token are set together or not at all.
- The URL parses, uses `http:` or `https:`, and embeds no credentials. `https:` is required when
`NODE_ENV=production`.
- The token is at least 32 characters, contains at least 8 distinct characters, and matches no
placeholder pattern such as `change_me`, `example`, `password`, or `secret`.
- The account reference matches `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$`.
- Sink configuration is rejected on any non-mainnet `MONERO_NETWORK`.
Set the URL and token only in the protected mainnet runtime environment. Never place the token in
Git, Ansible inventory, an invocation, a receipt, a log, or a public health check.
## Non-mainnet networks
When `MONERO_NETWORK` is not `mainnet`, export is disallowed and each run moves every `pending`,
`in_flight`, or `dead_letter` row to `ignored` with reason `non_mainnet_network`. This keeps
no-value test activity out of real books. Migration 042 separately marks legacy rows whose
historical amount is unusable as `ignored` with reason `malformed_legacy_snapshot`, so they
terminate instead of retrying forever.
## Health reporting
The public `/health` response includes a `financialEvents` object: the delivery contract string,
`enabled`, `exportAllowed`, `running`, the `backlog`, `deadLetters`, and `ignoredRows` gauges, and
the cumulative `delivered`, `deferred`, `deadLettered`, and `ignored` counters. It exposes no sink
URL, token, account reference, event ID, error text, payload, source identity, or timestamp.
Accounting export is informational and does not make gameplay readiness fail, so production
monitoring must alert on backlog age, dead letters, and the exporter's process logs and metrics in
order to detect stale delivery.
The poll loop runs even with no sink configured, so the backlog gauge stays truthful and non-mainnet
deployments keep suppressing newly appended rows.
## Operations
Before enabling mainnet export, prove in a disposable environment that the sink:
1. authenticates the protected bearer token over TLS;
2. deduplicates repeated requests by `Idempotency-Key`;
3. durably records the event before returning 2xx;
4. rejects malformed events without echoing secrets; and
5. supports alerting and reconciliation for a deliberately generated dead letter.
Keep the outbox in database backups. Do not delete or rewrite a dead-letter row to make monitoring
green: reconcile it against the receiving ledger, preserve the evidence, and use a separately
reviewed repair procedure. Running without a sink is permitted, but confirmed mainnet events then
accumulate as pending rows and must be reconciled by another route.