openvibers/openvibe.billing Commits

Billing on PostgreSQL (ADR-035, roadmap WS-X2): openvibe-sdk/db, async throughout, every money movement serializable.

OP OpenVibers committed 28 Sept 2026 · 5fc1af30ac · 61 files, +1788 −1372

migrations/0001_initial.sql is the SQLite boot schema converted (19 tables, the append-only triggers as PL/pgSQL, the
settings seed; settings."freeze" is quoted, PostgreSQL reserves the word) plus SQLite's json_extract as a text
function. ledger.js money(db, fn) runs a SERIALIZABLE transaction retried on 40001/40P01; every operation, the v1 API,
the console, the Live importer and provider processing use it, and post() joins the caller's. A stored receipt is
processed with its row locked (FOR UPDATE), so racing deliveries settle once. Sums are cast to bigint, JSON numbers
cast where compared or summed, a JSON true matches 'true'.

Per-person limits count in Valkey when VALKEY_URL is set (a readiness check reports it); the refund budget awaits
its payer lookup. scripts/freeze.js exports its command (the cutover test runs it in-process), the race test runs
four processings of one receipt at once in-process, scripts/migrate-to-postgres.js is the one-time move (and
refuses to empty a PostgreSQL journal that already has transactions). CI runs every file on PGlite and on
PostgreSQL + PgBouncer + Valkey; the unit orders after them. SDK v0.21.2, contracts v0.76.0, shared v1.28.0.
npm test 19/19, npm run test:pg 19/19.

Changes

ddiff --git a/.env.example b/.env.example
iindex 428e00e..e169d17 100644
---- a/.env.example
++++ b/.env.example
@@@ -11,7 +11,15 @@ NODE_ENV=development
BASE_URL=http://localhost:4600
TRUST_PROXY=1
--# SQLite ledger (ADR-007). The systemd unit sets /var/lib/openvibe-billing/billing.db.
++# PostgreSQL (ADR-035): DATABASE_URL serves through PgBouncer, DATABASE_DIRECT_URL (the owner) runs migrations/ at
++# boot. OpenVibe.Host `roles/data/add-service.sh billing` writes both. Without DATABASE_URL, development runs on an
++# embedded PGlite database in data/pglite (BILLING_PGLITE_DIR); production refuses to start.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++# Valkey: the per-person limit counters, kept across restarts (optional; without it they count in the process).
++VALKEY_URL=
++# The SQLite ledger the one-time move reads (scripts/migrate-to-postgres.js). The systemd unit sets
++# /var/lib/openvibe-billing/billing.db.
BILLING_DB_PATH=./data/billing.db
# Who keeps the money books: live (default — OpenVibe.Live is the ledger and receives the PowerChat
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 1efee20..5f0f8f2 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -4,11 +4,14 @@ on:
branches: [main]
pull_request:
jobs:
-- # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
++ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q). ADR-035: PostgreSQL
++ # 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk scripts/test-services.sh); npm test runs every
++ # file on PGlite, test:pg runs them all through PgBouncer with the limit counters in Valkey.
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
contracts-service: billing
++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
ddiff --git a/README.md b/README.md
iindex a79062f..299e096 100644
---- a/README.md
++++ b/README.md
@@@ -31,7 +31,8 @@ webhooks land here and nowhere else.
- the journal (`accounts`, `transactions`, `ledger_entries`, `account_balances`), provider receipts
(`provider_events`, `external_receipts`), intents, subscriptions and entitlements, cashouts, the
-- freeze, reconciliation runs, import runs and the staff audit log, in Billing's own SQLite
++ freeze, reconciliation runs, import runs and the staff audit log, in Billing's own PostgreSQL database
++ (`ov_billing` on the host's data role, ADR-035; schema in [migrations/](migrations/))
- provider webhooks (PowerChat, Stripe, PayPal, CCBill, NOWPayments): they land here and nowhere else
- the `billing.*` events and the public billing policy page (`/policy`, every number from the rates)
@@@ -47,7 +48,9 @@ webhooks land here and nowhere else.
- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
- OpenVibe.Events (the outbox relay, only when `EVENTS_URL` is set)
- the payment providers whose secrets are configured (none in production today)
--- `openvibe-contracts` v0.49.0, `openvibe-sdk` v0.12.0 (service tokens, per-person limits), `openvibe-shared` v1.25.0, pinned by release tarball
++- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.21.2 (service tokens, per-person limits, `openvibe-sdk/db`), `openvibe-shared` v1.28.0, pinned by release tarball
++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
++ `openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
## Capabilities
@@@ -85,8 +88,9 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
`ledger_entries(txn_id, account_id, amount)` with signed integer minor units. Every transaction sums
to zero **per currency** (`vibes-bits`, `usd-cents`); triggers refuse UPDATE/DELETE on entries and
transactions; corrections are reversing transactions. `account_balances` is a cache updated in the
-- same SQLite transaction and verified by reconciliation. Funds checks run inside the transaction
-- that moves the money.
++ same transaction and verified by reconciliation. Funds checks run inside the transaction that moves
++ the money, and every money transaction is SERIALIZABLE (`ledger.js` `money()`, retried on a
++ serialization failure), so two movements that each passed a check cannot both commit.
- **Two currencies.** Money arrives in cents and becomes bits through the `fx_conversion` account pair
at the recorded value rate (`bits_per_usd`, 100 today); reconciliation checks that each transaction's
cents side mirrors its bits side. Anything a payment exceeds the bits' value by is `platform_revenue`.
@@@ -94,7 +98,7 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
70 % streamer share, 10 % site-route fee, minimum cashout, escrow days. Each transaction records the
rates it used.
- **Receipts**: webhooks are verified, stored in `provider_events` (UNIQUE provider + event id) before
-- anything else, then processed with the effect and the "processed" mark in one SQLite transaction.
++ anything else, then processed with the effect and the "processed" mark in one transaction, with the receipt's row locked.
Every settling transaction has a unique `receipt_ref` (`<provider>:<payment id>`), so a payment
settles once whatever event, retry or API call carries it.
- **Freeze** (ADR-012 rule 11): `settings.freeze`; while on, every mutating endpoint answers
@@@ -236,7 +240,7 @@ reprocess — and every refused attempt (non-staff sign-in, CSRF failure, a refu
in the append-only `staff_audit` table (actor subject and username, action, target, reason, outcome,
a small detail such as the payout reference or refusal code, request id, and an HMAC of the client IP
keyed by `BILLING_SESSION_SECRET`). Done actions are also written to the outbox as
--`billing.staff.action` (visibility `internal`, actor = the staff subject), in the same SQLite
++`billing.staff.action` (visibility `internal`, actor = the staff subject), in the same
transaction as their effect. Adjustments are not in the console; they stay on the API
(`POST /api/v1/admin/adjustments`).
@@@ -322,13 +326,16 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
Production deploys with `sudo ovhost deploy billing` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.billing`, install on a lockfile change, restart, wait for `/api/ready`).
--The unit is `openvibe-billing.service` on `127.0.0.1:4600`, the env file `/etc/openvibe/billing.env`. State lives in
--`/var/lib/openvibe-billing`; nginx serves `billing.openvibe.network` from
++The unit is `openvibe-billing.service` on `127.0.0.1:4600`, the env file `/etc/openvibe/billing.env`. The ledger is
++`ov_billing` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh billing` writes its settings);
++the release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
++`runSqliteMigration`, with a `--pglite` rehearsal mode; it refuses to empty a PostgreSQL journal that already has
++transactions), run while the service is stopped and frozen; the old `/var/lib/openvibe-billing/billing.db` stays
++read-only for 7 days as the rollback. nginx serves `billing.openvibe.network` from
[deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf) (console, `/policy`,
`/webhooks/*`, `/api/health`). Freeze the economy before any risky change (below).
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
--restart; afterwards `sudo ovhost rollback billing --to <sha>`. Nothing blocks a rollback: the schema
--code only adds tables and columns.
++restart; afterwards `sudo ovhost rollback billing --to <sha>`. Migrations only add tables and columns.
`BILLING_LIMITS=off` turns the per-person limits off without a deploy.
## Cutover runbook (Live → Billing)
ddiff --git a/STATUS.json b/STATUS.json
iindex 5e39426..5837337 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,8 +8,8 @@
"authoritative": false,
"deployed": true,
"deployment": "Deployed in shadow with `sudo ovhost deploy billing`: /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), /api/health, /api/ready (openvibe-shared/ready shape), /release.json and /webhooks/*. No provider secret is configured, so every provider is off and /webhooks/* answers 404 billing.provider_disabled.",
-- "contracts": "openvibe-contracts v0.49.0",
-- "notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation (GET /api/v1/admin/reconciliations; 16 runs by 2026-09-24 05:06 UTC, ledger-vs-receipt checks), EXTERNAL PowerChat receipts (billing.receipt.external under BILLING_AUTHORITY=billing; recorded only while Live is the authority), overflow guards and IMMEDIATE-transaction settlement. Production holds the shadow import of Live (322 transactions, 996 ledger entries), 0 subscriptions and 0 entitlements; the console has never been used (staff_audit 0). BILLING_AUTHORITY is not set in live.env, so Live stays the money authority. The cutover (README runbook, docs/live-cutover.md) waits on the owner putting the PowerChat secret in billing.env and re-pointing the PowerChat webhook to https://billing.openvibe.network/webhooks/powerchat; a duplicate-webhook proof in production follows it. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
++ "contracts": "openvibe-contracts v0.76.0",
++ "notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation (GET /api/v1/admin/reconciliations; 16 runs by 2026-09-24 05:06 UTC, ledger-vs-receipt checks), EXTERNAL PowerChat receipts (billing.receipt.external under BILLING_AUTHORITY=billing; recorded only while Live is the authority), overflow guards and serializable settlement (PostgreSQL, ADR-035). Production holds the shadow import of Live (322 transactions, 996 ledger entries), 0 subscriptions and 0 entitlements; the console has never been used (staff_audit 0). BILLING_AUTHORITY is not set in live.env, so Live stays the money authority. The cutover (README runbook, docs/live-cutover.md) waits on the owner putting the PowerChat secret in billing.env and re-pointing the PowerChat webhook to https://billing.openvibe.network/webhooks/powerchat; a duplicate-webhook proof in production follows it. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
"updated": "2026-09-28",
"features": [
"append-only double-entry journal in bits and cents, balanced per currency",
ddiff --git a/deploy/systemd/openvibe-billing.service b/deploy/systemd/openvibe-billing.service
iindex 1d0d281..302f03c 100644
---- a/deploy/systemd/openvibe-billing.service
++++ b/deploy/systemd/openvibe-billing.service
@@@ -1,9 +1,10 @@
[Unit]
Description=OpenVibe.Billing — the isolated money ledger (journal, provider receipts, subscriptions, payouts)
# Network signs the service tokens Billing verifies (its key is fetched at boot and retried);
--# Events receives the outbox when EVENTS_URL is set. Billing starts without either.
--After=network.target
--Wants=openvibe-network.service
++# Events receives the outbox when EVENTS_URL is set. Billing starts without either. The ledger is ov_billing on the
++# host's data role (ADR-035): PostgreSQL through PgBouncer, and Valkey for the per-person limit counters.
++After=network.target postgresql.service pgbouncer.service valkey-server.service
++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
Type=simple
@@@ -18,8 +19,8 @@ Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/env node /opt/openvibe.billing/server/index.js
Restart=always
RestartSec=5
--# The ledger database lives in /var/lib/openvibe-billing (ADR-007: one database per service);
--# the code tree stays read-only.
++# /var/lib/openvibe-billing keeps the SQLite ledger the service moved from (read-only after the switch: the rollback
++# for 7 days; scripts/migrate-to-postgres.js reads it); the code tree stays read-only.
StateDirectory=openvibe-billing
StateDirectoryMode=0700
Environment=BILLING_DB_PATH=/var/lib/openvibe-billing/billing.db
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..26ec90f
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,296 @@
++-- phase: expand
++-- OpenVibe.Billing on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
++-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
++-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.
++
++-- SQLite's json_extract on the text columns (metadata, result, event): the value at a '$.a.b' path, as text (callers
++-- cast numbers; a JSON true is 'true').
++CREATE FUNCTION json_extract(t text, path text) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT jsonb_extract_path_text(t::jsonb, VARIADIC string_to_array(substr(path, 3), '.')) $$;
++
++CREATE TABLE accounts (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending', 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion')),
++ owner_subject text COLLATE "C",
++ currency text COLLATE "C" NOT NULL CHECK (currency IN ('vibes-bits', 'usd-cents')),
++ created_at text COLLATE "C" NOT NULL,
++ UNIQUE (kind, owner_subject, currency)
++);
++
++CREATE TABLE provider_events (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ provider text COLLATE "C" NOT NULL,
++ provider_event_id text COLLATE "C" NOT NULL,
++ type text COLLATE "C",
++ payload_hash text COLLATE "C" NOT NULL,
++ payload text COLLATE "C" NOT NULL,
++ received_at text COLLATE "C" NOT NULL,
++ processed_at text COLLATE "C",
++ result text COLLATE "C",
++ attempts bigint NOT NULL DEFAULT 0,
++ last_error text COLLATE "C",
++ UNIQUE (provider, provider_event_id)
++);
++
++CREATE TABLE transactions (
++ id text COLLATE "C" PRIMARY KEY,
++ type text COLLATE "C" NOT NULL CHECK (type IN ('purchase', 'donation', 'subscription', 'subscription_share', 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import')),
++ status text COLLATE "C" NOT NULL DEFAULT 'settled' CHECK (status IN ('settled', 'imported')),
++ idempotency_key text COLLATE "C" NOT NULL UNIQUE,
++ reverses_txn text COLLATE "C" REFERENCES transactions (id),
++ test bigint NOT NULL DEFAULT 0 CHECK (test IN (0, 1)),
++ actor text COLLATE "C" NOT NULL DEFAULT '{}',
++ metadata text COLLATE "C" NOT NULL DEFAULT '{}',
++ from_subject text COLLATE "C",
++ to_subject text COLLATE "C",
++ provider text COLLATE "C",
++ receipt_ref text COLLATE "C",
++ source_event_id bigint REFERENCES provider_events (id),
++ created_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE ledger_entries (
++ txn_id text COLLATE "C" NOT NULL REFERENCES transactions (id),
++ account_id bigint NOT NULL REFERENCES accounts (id),
++ amount bigint NOT NULL CHECK (amount <> 0),
++ PRIMARY KEY (txn_id, account_id)
++);
++
++CREATE TABLE account_balances (
++ account_id bigint PRIMARY KEY REFERENCES accounts (id),
++ balance bigint NOT NULL DEFAULT 0,
++ updated_at text COLLATE "C"
++);
++
++CREATE TABLE external_receipts (
++ receipt_ref text COLLATE "C" PRIMARY KEY,
++ provider text COLLATE "C" NOT NULL,
++ provider_event bigint NOT NULL REFERENCES provider_events (id),
++ receiving_account text COLLATE "C",
++ streamer_subject text COLLATE "C",
++ amount_cents bigint NOT NULL,
++ test bigint NOT NULL DEFAULT 0 CHECK (test IN (0, 1)),
++ status text COLLATE "C" NOT NULL CHECK (status IN ('announced', 'not_announced')),
++ reason text COLLATE "C",
++ event_id text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE provider_accounts (
++ provider text COLLATE "C" NOT NULL,
++ username text COLLATE "C" NOT NULL,
++ account_id text COLLATE "C",
++ subject text COLLATE "C" NOT NULL,
++ source text COLLATE "C" NOT NULL,
++ live_user_id bigint,
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (provider, username)
++);
++
++CREATE TABLE payment_intents (
++ id text COLLATE "C" PRIMARY KEY,
++ provider text COLLATE "C" NOT NULL,
++ provider_ref text COLLATE "C",
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('purchase', 'subscription')),
++ subject text COLLATE "C" NOT NULL,
++ streamer_subject text COLLATE "C",
++ amount_cents bigint NOT NULL,
++ fee_cents bigint NOT NULL DEFAULT 0,
++ bits bigint NOT NULL DEFAULT 0,
++ route text COLLATE "C",
++ auto_renew bigint NOT NULL DEFAULT 0,
++ status text COLLATE "C" NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'settled', 'failed', 'expired', 'canceled', 'refunded')),
++ settled_txn text COLLATE "C" REFERENCES transactions (id),
++ legacy_order_id bigint UNIQUE,
++ metadata text COLLATE "C" NOT NULL DEFAULT '{}',
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE cashouts (
++ id text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ amount_bits bigint NOT NULL CHECK (amount_bits > 0),
++ value_cents bigint NOT NULL,
++ status text COLLATE "C" NOT NULL CHECK (status IN ('requested', 'paid', 'denied')),
++ payout_method text COLLATE "C" NOT NULL DEFAULT '{}',
++ escrow_until text COLLATE "C" NOT NULL,
++ request_txn text COLLATE "C" NOT NULL REFERENCES transactions (id),
++ settle_txn text COLLATE "C" REFERENCES transactions (id),
++ payout_provider text COLLATE "C",
++ payout_reference text COLLATE "C",
++ decided_by text COLLATE "C",
++ reason text COLLATE "C",
++ legacy_live_txn bigint UNIQUE,
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE subscriptions (
++ id text COLLATE "C" PRIMARY KEY,
++ subscriber text COLLATE "C" NOT NULL,
++ streamer text COLLATE "C" NOT NULL,
++ tier bigint NOT NULL DEFAULT 1,
++ provider text COLLATE "C" NOT NULL,
++ provider_ref text COLLATE "C",
++ route text COLLATE "C",
++ status text COLLATE "C" NOT NULL CHECK (status IN ('active', 'canceled', 'expired')),
++ auto_renew bigint NOT NULL DEFAULT 0,
++ cancel_at_period_end bigint NOT NULL DEFAULT 0,
++ price_cents bigint NOT NULL DEFAULT 0,
++ current_period_end text COLLATE "C",
++ legacy_live_id bigint UNIQUE,
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL,
++ UNIQUE (subscriber, streamer)
++);
++
++CREATE TABLE entitlements (
++ id text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL,
++ scope text COLLATE "C" NOT NULL,
++ subscription_id text COLLATE "C" REFERENCES subscriptions (id),
++ starts_at text COLLATE "C" NOT NULL,
++ ends_at text COLLATE "C" NOT NULL,
++ source_txn text COLLATE "C" REFERENCES transactions (id),
++ revoked_at text COLLATE "C",
++ revoked_reason text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE idempotency_keys (
++ key text COLLATE "C" PRIMARY KEY,
++ request_hash text COLLATE "C" NOT NULL,
++ method text COLLATE "C" NOT NULL,
++ path text COLLATE "C" NOT NULL,
++ status bigint NOT NULL,
++ response text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE settings (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY CHECK (id = 1),
++ "freeze" bigint NOT NULL DEFAULT 0,
++ freeze_reason text COLLATE "C",
++ frozen_at text COLLATE "C",
++ frozen_by text COLLATE "C"
++);
++
++CREATE TABLE reconciliation_runs (
++ id text COLLATE "C" PRIMARY KEY,
++ started_at text COLLATE "C" NOT NULL,
++ finished_at text COLLATE "C" NOT NULL,
++ ok bigint NOT NULL,
++ report text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE import_runs (
++ id text COLLATE "C" PRIMARY KEY,
++ source text COLLATE "C" NOT NULL,
++ dry_run bigint NOT NULL,
++ started_at text COLLATE "C" NOT NULL,
++ finished_at text COLLATE "C",
++ report text COLLATE "C"
++);
++
++CREATE TABLE import_holds (
++ live_user_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ owner text COLLATE "C" NOT NULL,
++ reason text COLLATE "C" NOT NULL,
++ credit_bits bigint NOT NULL DEFAULT 0,
++ payable_bits bigint NOT NULL DEFAULT 0,
++ resolved_subject text COLLATE "C",
++ first_seen_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE outbox (
++ seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text COLLATE "C" NOT NULL UNIQUE,
++ event text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ sent_at text COLLATE "C",
++ attempts bigint NOT NULL DEFAULT 0,
++ last_error text COLLATE "C"
++);
++
++CREATE TABLE staff_audit (
++ seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ id text COLLATE "C" NOT NULL UNIQUE,
++ at text COLLATE "C" NOT NULL,
++ actor_subject text COLLATE "C",
++ actor_username text COLLATE "C",
++ action text COLLATE "C" NOT NULL,
++ target_type text COLLATE "C",
++ target_id text COLLATE "C",
++ reason text COLLATE "C",
++ outcome text COLLATE "C" NOT NULL CHECK (outcome IN ('done', 'refused')),
++ detail text COLLATE "C" NOT NULL DEFAULT '{}',
++ request_id text COLLATE "C",
++ ip_hash text COLLATE "C"
++);
++
++CREATE TABLE staff_sessions (
++ id_hash text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ username text COLLATE "C",
++ role text COLLATE "C" NOT NULL,
++ csrf text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ expires_at text COLLATE "C" NOT NULL,
++ revoked_at text COLLATE "C",
++ ip_hash text COLLATE "C"
++);
++
++CREATE UNIQUE INDEX accounts_identity ON accounts (kind, COALESCE(owner_subject, ''), currency);
++CREATE UNIQUE INDEX transactions_receipt ON transactions (receipt_ref) WHERE receipt_ref IS NOT NULL;
++CREATE INDEX transactions_from ON transactions (from_subject);
++CREATE INDEX transactions_to ON transactions (to_subject);
++CREATE INDEX transactions_reverses ON transactions (reverses_txn);
++CREATE INDEX transactions_event ON transactions (source_event_id);
++CREATE INDEX ledger_entries_account ON ledger_entries (account_id);
++CREATE INDEX provider_events_pending ON provider_events (processed_at);
++CREATE INDEX external_receipts_streamer ON external_receipts (streamer_subject);
++CREATE INDEX provider_accounts_account ON provider_accounts (provider, account_id);
++CREATE UNIQUE INDEX payment_intents_ref ON payment_intents (provider, provider_ref) WHERE provider_ref IS NOT NULL;
++CREATE INDEX payment_intents_subject ON payment_intents (subject);
++CREATE INDEX cashouts_status ON cashouts (status);
++CREATE INDEX cashouts_subject ON cashouts (subject);
++CREATE INDEX subscriptions_provider_ref ON subscriptions (provider, provider_ref);
++CREATE INDEX subscriptions_streamer ON subscriptions (streamer, status);
++CREATE INDEX entitlements_lookup ON entitlements (subject, kind, scope);
++CREATE INDEX entitlements_source ON entitlements (source_txn);
++CREATE INDEX outbox_unsent ON outbox (sent_at, seq);
++CREATE INDEX staff_audit_target ON staff_audit (target_type, target_id);
++CREATE INDEX staff_sessions_expiry ON staff_sessions (expires_at);
++
++CREATE FUNCTION ledger_entries_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'ledger_entries are append-only'; RETURN NEW; END $$;
++CREATE TRIGGER ledger_entries_no_update BEFORE UPDATE ON ledger_entries FOR EACH ROW EXECUTE FUNCTION ledger_entries_no_update();
++
++CREATE FUNCTION ledger_entries_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'ledger_entries are append-only'; RETURN OLD; END $$;
++CREATE TRIGGER ledger_entries_no_delete BEFORE DELETE ON ledger_entries FOR EACH ROW EXECUTE FUNCTION ledger_entries_no_delete();
++
++CREATE FUNCTION transactions_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'transactions are append-only; post a reversing transaction'; RETURN NEW; END $$;
++CREATE TRIGGER transactions_no_update BEFORE UPDATE ON transactions FOR EACH ROW EXECUTE FUNCTION transactions_no_update();
++
++CREATE FUNCTION transactions_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'transactions are append-only; post a reversing transaction'; RETURN OLD; END $$;
++CREATE TRIGGER transactions_no_delete BEFORE DELETE ON transactions FOR EACH ROW EXECUTE FUNCTION transactions_no_delete();
++
++CREATE FUNCTION provider_events_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'provider receipts are immutable'; RETURN NEW; END $$;
++CREATE TRIGGER provider_events_immutable BEFORE UPDATE OF provider, provider_event_id, payload, payload_hash, received_at ON provider_events FOR EACH ROW EXECUTE FUNCTION provider_events_immutable();
++
++CREATE FUNCTION provider_events_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'provider receipts are immutable'; RETURN OLD; END $$;
++CREATE TRIGGER provider_events_no_delete BEFORE DELETE ON provider_events FOR EACH ROW EXECUTE FUNCTION provider_events_no_delete();
++
++CREATE FUNCTION staff_audit_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'staff_audit is append-only'; RETURN NEW; END $$;
++CREATE TRIGGER staff_audit_no_update BEFORE UPDATE ON staff_audit FOR EACH ROW EXECUTE FUNCTION staff_audit_no_update();
++
++CREATE FUNCTION staff_audit_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'staff_audit is append-only'; RETURN OLD; END $$;
++CREATE TRIGGER staff_audit_no_delete BEFORE DELETE ON staff_audit FOR EACH ROW EXECUTE FUNCTION staff_audit_no_delete();
++
++-- Rows the SQLite boot seeded
++INSERT INTO settings (id, "freeze", freeze_reason, frozen_at, frozen_by) VALUES
++ (1, 0, NULL, NULL, NULL);
++SELECT setval(pg_get_serial_sequence('settings', 'id'), (SELECT MAX(id) FROM settings));
ddiff --git a/package-lock.json b/package-lock.json
iindex 2e52846..15cce06 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,14 +12,32 @@
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
},
"engines": {
"node": ">=22.0.0"
}
},
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.8",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
++ "node_modules/@iovalkey/commands": {
++ "version": "0.1.0",
++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
++ "license": "MIT"
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -221,6 +239,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
++ "node_modules/cluster-key-slot": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@@ -290,6 +317,15 @@
"node": ">=4.0.0"
}
},
++ "node_modules/denque": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10"
++ }
++ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@@ -699,6 +735,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
++ "node_modules/iovalkey": {
++ "version": "0.4.0",
++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
++ "license": "MIT",
++ "dependencies": {
++ "@iovalkey/commands": "^0.1.0",
++ "cluster-key-slot": "^1.1.0",
++ "debug": "^4.3.4",
++ "denque": "^2.1.0",
++ "lodash.defaults": "^4.2.0",
++ "lodash.isarguments": "^3.1.0",
++ "redis-errors": "^1.2.0",
++ "redis-parser": "^3.0.0",
++ "standard-as-callback": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18.12.0"
++ }
++ },
++ "node_modules/iovalkey/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/iovalkey/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@@ -763,12 +842,24 @@
"safe-buffer": "^5.0.1"
}
},
++ "node_modules/lodash.defaults": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
++ "license": "MIT"
++ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
++ "node_modules/lodash.isarguments": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
++ "license": "MIT"
++ },
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@@ -968,9 +1059,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "version": "0.76.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -984,18 +1075,38 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.12.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
++ "version": "0.21.2",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
++ "integrity": "sha512-k04b9r2FCYVIhb8KLNrr5y43eb1l3UKzYNKl9m/vqMRAjZGh4p3pI2I/8UFU9bosKD8KAS121OO4rH8NUtx4sg==",
"license": "MIT",
"engines": {
"node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "better-sqlite3": ">=11",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "better-sqlite3": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
}
},
"node_modules/openvibe-shared": {
-- "version": "1.25.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
-- "integrity": "sha512-oqZ+PvljrWi8IEbtg6bDYai17ZV2sALFx/ezMptniELSr10TgFUiGUQt+b1IWwiHv0T2Y1U2iSrkirJxMNgjSw==",
++ "version": "1.28.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
++ "integrity": "sha512-WLd11lDG75veVi7mFwngquK2nqIQV86Z14Ad/xE2dYvyMT5mhKJxxxlKUoskpdyLeap0ucMvzzS8Tg7vPGwYoQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
@@@ -1016,6 +1127,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
++ "node_modules/pg": {
++ "version": "8.23.0",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-connection-string": "^2.14.0",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.0",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
++ },
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.0"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
++ },
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.0",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "node_modules/pg-protocol": {
++ "version": "1.16.0",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@@ -1139,6 +1378,27 @@
"node": ">= 6"
}
},
++ "node_modules/redis-errors": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/redis-parser": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
++ "license": "MIT",
++ "dependencies": {
++ "redis-errors": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@@ -1354,6 +1614,21 @@
"simple-concat": "^1.0.0"
}
},
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/standard-as-callback": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
++ "license": "MIT"
++ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@@ -1481,6 +1756,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 299b7cc..f491af1 100644
---- a/package.json
++++ b/package.json
@@@ -2,7 +2,7 @@
"name": "openvibe-billing",
"version": "0.1.0",
"private": true,
-- "description": "OpenVibe.Billing — the isolated money ledger of the OpenVibe network: a balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts (ADR-012).",
++ "description": "OpenVibe.Billing \u2014 the isolated money ledger of the OpenVibe network: a balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts (ADR-012).",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
@@@ -10,20 +10,31 @@
"test": "node test/run.js",
"reconcile": "node scripts/reconcile.js",
"freeze": "node scripts/freeze.js",
-- "import-live": "node scripts/import-live.js"
++ "import-live": "node scripts/import-live.js",
++ "test:pg": "BILLING_TEST_STORE=pg node test/run.js"
},
-- "keywords": ["openvibe", "billing", "ledger", "double-entry"],
++ "keywords": [
++ "openvibe",
++ "billing",
++ "ledger",
++ "double-entry"
++ ],
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"dependencies": {
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
++ "pg": "^8.23.0"
},
"engines": {
"node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
}
}
ddiff --git a/scripts/freeze.js b/scripts/freeze.js
iindex 6b071de..036a2f4 100644
---- a/scripts/freeze.js
++++ b/scripts/freeze.js
@@@ -10,18 +10,28 @@
* node scripts/freeze.js off the running service processes the held webhooks in
* arrival order on its next retry tick (≤ BILLING_WEBHOOK_RETRY_MS)
*/
--const { loadConfig } = require('../server/config');
--const { openDb } = require('../server/db');
++const os = require('os');
const admin = require('../server/ops/admin');
--const [cmd = 'status', reason] = process.argv.slice(2);
--if (!['status', 'on', 'off'].includes(cmd)) { console.error('usage: freeze.js status | on "<reason>" | off'); process.exit(2); }
--const config = loadConfig();
--const db = openDb(config.dbPath);
--const os = require('os');
--const state = cmd === 'status'
-- ? admin.freezeState(db)
-- : admin.setFreeze({ db, now: () => Date.now() }, { on: cmd === 'on', reason: reason || null, actor: { principal: 'operator:cli', host: os.hostname(), user: os.userInfo().username } });
--const pending = db.prepare('SELECT COUNT(*) AS n FROM provider_events WHERE processed_at IS NULL').get().n;
--console.log(JSON.stringify({ ...state, held_webhooks: pending }));
--db.close();
++/** The command on a database handle (the script opens the service's own; tests pass theirs). Returns what it prints. */
++async function freeze([cmd = 'status', reason] = [], { db }) {
++ if (!['status', 'on', 'off'].includes(cmd)) throw Object.assign(new Error('usage: freeze.js status | on "<reason>" | off'), { usage: true });
++ const state = cmd === 'status'
++ ? await admin.freezeState(db)
++ : await admin.setFreeze({ db, now: () => Date.now() }, { on: cmd === 'on', reason: reason || null, actor: { principal: 'operator:cli', host: os.hostname(), user: os.userInfo().username } });
++ const pending = (await db.prepare('SELECT COUNT(*) AS n FROM provider_events WHERE processed_at IS NULL').get()).n;
++ return { ...state, held_webhooks: pending };
++}
++
++if (require.main === module) {
++ (async () => {
++ const { loadConfig } = require('../server/config');
++ const { openDb } = require('../server/db');
++ const args = process.argv.slice(2);
++ if (!['status', 'on', 'off'].includes(args[0] || 'status')) { console.error('usage: freeze.js status | on "<reason>" | off'); process.exit(2); }
++ const db = await openDb(loadConfig());
++ try { console.log(JSON.stringify(await freeze(args, { db }))); } finally { await db.close(); }
++ })().catch((err) => { console.error(err); process.exit(1); });
++}
++
++module.exports = { freeze };
ddiff --git a/scripts/import-live.js b/scripts/import-live.js
iindex 92adab7..06093b3 100644
---- a/scripts/import-live.js
++++ b/scripts/import-live.js
@@@ -34,7 +34,7 @@ async function main() {
const config = loadConfig();
if (path.resolve(livePath) === path.resolve(config.dbPath)) throw new Error('--live-db points at the Billing database');
const live = new Database(livePath, { readonly: true, fileMustExist: true });
-- const db = openDb(config.dbPath);
++ const db = await openDb(config);
const ctx = { db, config, rates: createRates(config.rates), now: () => Date.now(), log: console };
const identity = createIdentity(config);
if (flag('accounts-only')) {
@@@ -50,7 +50,7 @@ async function main() {
return;
}
const report = await importLive(ctx, { live, resolveLiveUsers: identity.resolveLiveUsers, dryRun: flag('dry-run') });
-- const rec = flag('dry-run') ? null : reconcile(ctx, { trigger: 'import' });
++ const rec = flag('dry-run') ? null : await reconcile(ctx, { trigger: 'import' });
if (flag('json')) console.log(JSON.stringify({ import: report, reconciliation: rec }, null, 2));
else {
console.log(`import ${report.run_id}${report.dry_run ? ' (DRY RUN — nothing kept)' : ''}`);
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
nnew file mode 100755
iindex 0000000..cbaedc3
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -0,0 +1,42 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * The one-time move of Billing's SQLite ledger (BILLING_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is
++ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
++ *
++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json] [--replace-ledger]
++ *
++ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts
++ * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its
++ * name and columns; the append-only triggers guard UPDATE and DELETE, so the TRUNCATE that empties a table and the
++ * copy's INSERTs pass them.
++ */
++const { runSqliteMigration } = require('openvibe-sdk/db');
++const { loadConfig } = require('../server/config');
++const { MIGRATIONS } = require('../server/db');
++
++const TABLES = {};
++
++/**
++ * The copy empties its target tables first (a rehearsal can be repeated). Once PostgreSQL holds a journal of its own,
++ * that would erase money records: it refuses unless --replace-ledger says the rows there are to go.
++ */
++async function refuseOverLiveLedger(directUrl) {
++ if (!directUrl || process.argv.includes('--pglite') || process.argv.includes('--replace-ledger')) return;
++ const { createDb } = require('openvibe-sdk/db');
++ const db = createDb({ url: directUrl, service: 'billing-migrate-check', max: 1 });
++ try {
++ const t = await db.prepare("SELECT to_regclass('transactions') IS NOT NULL AS present").get();
++ const n = t.present ? Number((await db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n) : 0;
++ if (n > 0) throw new Error(`PostgreSQL already holds ${n} ledger transaction(s): refusing to replace them (--replace-ledger to do it anyway)`);
++ } finally { await db.close(); }
++}
++
++if (require.main === module) {
++ const config = loadConfig();
++ refuseOverLiveLedger(config.db.directUrl)
++ .then(() => runSqliteMigration({ service: 'billing', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES }))
++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
++}
++
++module.exports = { TABLES };
ddiff --git a/scripts/reconcile.js b/scripts/reconcile.js
iindex 822671c..c833b87 100644
---- a/scripts/reconcile.js
++++ b/scripts/reconcile.js
@@@ -4,23 +4,25 @@
* Run a reconciliation against BILLING_DB_PATH, store it in reconciliation_runs and print it.
* node scripts/reconcile.js [--json] exit 1 when any check fails
*/
--const { loadConfig } = require('../server/config');
--const { openDb } = require('../server/db');
--const { createRates } = require('../server/rates');
--const { reconcile } = require('../server/reconcile');
++(async () => {
++ const { loadConfig } = require('../server/config');
++ const { openDb } = require('../server/db');
++ const { createRates } = require('../server/rates');
++ const { reconcile } = require('../server/reconcile');
--const config = loadConfig();
--const db = openDb(config.dbPath);
--const report = reconcile({ db, config, rates: createRates(config.rates), now: () => Date.now() }, { trigger: 'script' });
--if (process.argv.includes('--json')) console.log(JSON.stringify(report, null, 2));
--else {
-- console.log(`reconciliation ${report.id}: ${report.ok ? 'OK' : 'FAILED'}`);
-- for (const c of report.checks) console.log(` ${c.ok ? 'ok ' : 'FAIL'} ${c.id}${c.ok ? '' : ` ${JSON.stringify(c.detail)}`}`);
-- const w = report.warnings;
-- console.log(` warnings: ${w.negative_balances.length} negative balances, ${w.unprocessed_events.length} unprocessed events, ${w.rejected_events.length} rejected events, ` +
-- `${w.events_for_review.length + w.transactions_for_review.length} for review, ${w.import_holds.length} import holds`);
-- console.log(` EXTERNAL receipts (test excluded): ${report.totals.external_receipts}, announced ${report.totals.external_announced}`);
-- console.log(` totals (test excluded): ${JSON.stringify(report.totals)}`);
--}
--db.close();
--process.exit(report.ok ? 0 : 1);
++ const config = loadConfig();
++ const db = await openDb(config);
++ const report = await reconcile({ db, config, rates: createRates(config.rates), now: () => Date.now() }, { trigger: 'script' });
++ if (process.argv.includes('--json')) console.log(JSON.stringify(report, null, 2));
++ else {
++ console.log(`reconciliation ${report.id}: ${report.ok ? 'OK' : 'FAILED'}`);
++ for (const c of report.checks) console.log(` ${c.ok ? 'ok ' : 'FAIL'} ${c.id}${c.ok ? '' : ` ${JSON.stringify(c.detail)}`}`);
++ const w = report.warnings;
++ console.log(` warnings: ${w.negative_balances.length} negative balances, ${w.unprocessed_events.length} unprocessed events, ${w.rejected_events.length} rejected events, ` +
++ `${w.events_for_review.length + w.transactions_for_review.length} for review, ${w.import_holds.length} import holds`);
++ console.log(` EXTERNAL receipts (test excluded): ${report.totals.external_receipts}, announced ${report.totals.external_announced}`);
++ console.log(` totals (test excluded): ${JSON.stringify(report.totals)}`);
++ }
++ db.close();
++ process.exit(report.ok ? 0 : 1);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
iindex c7f2f9c..c5af678 100644
---- a/server/api/actor-limits.js
++++ b/server/api/actor-limits.js
@@@ -21,7 +21,7 @@
* balance and entitlement reads (hot paths the services call for every page and chat message),
* /api/health, /api/ready, /release.json and /metrics.
*/
--const { createActorLimiter } = require('openvibe-sdk/limits');
++const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');
const { ids } = require('openvibe-contracts');
/** Each budget per person: a minute, an hour. */
@@@ -56,7 +56,7 @@ function personOf(v) {
* createActorLimits({ config, db, now, registry, log }) → { budget(name, personFrom), history, refund }.
* personFrom(req) names the person a request is counted against (or null: its principal).
*/
--function createActorLimits({ config, db, now = () => Date.now(), registry = null, log = console }) {
++function createActorLimits({ config, db, now = () => Date.now(), registry = null, log = console, valkey = null }) {
const refused = registry
? registry.counter({ name: 'billing_rate_limited_total', help: 'Requests refused 429 by a per-person limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
@@@ -65,6 +65,7 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
const limiter = createActorLimiter({
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
now,
++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
actor(req) {
if (!enabled) return null;
const person = req.limitPerson;
@@@ -83,8 +84,8 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
const own = BUDGETS[name];
if (!own) throw new Error(`limits: no budget named ${name}`);
const limit = limiter(name, own);
-- return function billingActorLimit(req, res, next) {
-- try { req.limitPerson = personFrom(req); } catch { req.limitPerson = null; }
++ return async function billingActorLimit(req, res, next) {
++ try { req.limitPerson = await personFrom(req); } catch { req.limitPerson = null; }
return limit(req, res, next);
};
}
@@@ -97,8 +98,8 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
}
/** The payer of the transfer a refund gives back (one primary-key read), else null. */
-- function payerOf(req) {
-- const row = db.prepare('SELECT from_subject FROM transactions WHERE id = ?').get(String(req.params.id));
++ async function payerOf(req) {
++ const row = await db.prepare('SELECT from_subject FROM transactions WHERE id = ?').get(String(req.params.id));
return row ? personOf(row.from_subject) : null;
}
ddiff --git a/server/api/idempotency.js b/server/api/idempotency.js
iindex 0a3a388..f3d39e3 100644
---- a/server/api/idempotency.js
++++ b/server/api/idempotency.js
@@@ -24,14 +24,14 @@ function stable(v) {
}
function idempotent(db, now) {
-- return function idempotencyKey(req, res, next) {
++ return async function idempotencyKey(req, res, next) {
const key = req.get('Idempotency-Key');
if (!key || !KEY_RE.test(key)) {
return http.sendProblem(res, 400, 'idempotency.key_required', { detail: 'mutating calls need an Idempotency-Key header (8-200 of A-Z a-z 0-9 . _ : -)', ctx: req.ov });
}
const scoped = `${req.principal.sub}:${key}`;
const hash = crypto.createHash('sha256').update(`${req.method} ${req.baseUrl}${req.path}\n${stable(req.body || {})}`).digest('hex');
-- const row = db.prepare('SELECT * FROM idempotency_keys WHERE key = ?').get(scoped);
++ const row = await db.prepare('SELECT * FROM idempotency_keys WHERE key = ?').get(scoped);
if (row) {
if (row.request_hash !== hash) {
return http.sendProblem(res, 422, 'idempotency.key_reused', { detail: 'this Idempotency-Key was used for a different request', ctx: req.ov });
@@@ -41,12 +41,12 @@ function idempotent(db, now) {
}
req.idempotencyKey = `api:${scoped}`;
const json = res.json.bind(res);
-- res.json = (body) => {
++ res.json = async (body) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
-- db.prepare('INSERT OR IGNORE INTO idempotency_keys (key, request_hash, method, path, status, response, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
++ await db.prepare('INSERT INTO idempotency_keys (key, request_hash, method, path, status, response, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING')
.run(scoped, hash, req.method, `${req.baseUrl}${req.path}`, res.statusCode, JSON.stringify(body), new Date(now()).toISOString());
}
-- return json(body);
++ return await json(body);
};
return next();
};
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex ec2ab0c..ddcc1d1 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -10,7 +10,7 @@
*/
const express = require('express');
const { http, capabilities } = require('openvibe-contracts');
--const { getTxn, present: presentTxn, balance, BillingError } = require('../ledger');
++const { getTxn, present: presentTxn, balance, BillingError, money } = require('../ledger');
const { A, userSubject, positiveInt, isFrozen, fail } = require('../ops/common');
const { idempotent } = require('./idempotency');
const { actorOf } = require('./auth');
@@@ -40,7 +40,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
const r = express.Router();
const { db } = ctx;
const idem = idempotent(db, ctx.now);
-- const notFrozen = (req, res, next) => (isFrozen(db)
++ const notFrozen = async (req, res, next) => (await isFrozen(db)
? http.sendProblem(res, 503, 'billing.frozen', { detail: 'the economy is frozen: writes are refused, reads are served', ctx: req.ov })
: next());
/**
@@@ -72,7 +72,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
const adapter = adapters[String(b.provider || '').toLowerCase()];
if (!adapter) fail(422, 'billing.invalid_input', `unknown provider ${b.provider}`);
if (!adapter.enabled) fail(409, 'billing.provider_disabled', `${adapter.name} is not enabled on Billing`);
-- const intent = intents.create(ctx, b);
++ const intent = await intents.create(ctx, b);
let checkoutUrl = null;
if (adapter.createCheckout) {
const name = intent.kind === 'purchase' ? `${intent.bits.toLocaleString('en-US')} Vibes` : 'Channel subscription (1 month)';
@@@ -81,35 +81,35 @@ function v1Router({ ctx, auth, adapters, limits }) {
name, successUrl: b.success_url || `${ctx.config.baseUrl}/checkout/success`, cancelUrl: b.cancel_url || `${ctx.config.baseUrl}/checkout/cancel`,
ipnUrl: `${ctx.config.baseUrl}/webhooks/${adapter.name}`,
});
-- if (out.providerRef) intents.setProviderRef(ctx, intent.id, out.providerRef);
++ if (out.providerRef) await intents.setProviderRef(ctx, intent.id, out.providerRef);
checkoutUrl = out.url || null;
} catch (e) {
-- intents.setStatus(ctx, intent.id, 'failed');
++ await intents.setStatus(ctx, intent.id, 'failed');
fail(502, 'billing.provider_error', `${adapter.name}: ${e.message}`);
}
}
-- res.status(201).json({ intent: intents.present(intents.find(db, intent.id)), checkout_url: checkoutUrl });
++ res.status(201).json({ intent: intents.present(await intents.find(db, intent.id)), checkout_url: checkoutUrl });
}, limits.budget('billing.intent.create', person('subject'))));
-- r.get('/intents/:id', ...read(CAP.intent, (req, res) => {
-- const i = intents.find(db, req.params.id);
++ r.get('/intents/:id', ...read(CAP.intent, async (req, res) => {
++ const i = await intents.find(db, req.params.id);
if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);
res.json({ intent: intents.present(i) });
}));
r.post('/intents/:id/capture', ...write(CAP.intent, async (req, res) => {
-- const i = intents.find(db, req.params.id);
++ const i = await intents.find(db, req.params.id);
if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);
const adapter = adapters[i.provider];
if (!adapter || !adapter.capture || !adapter.enabled) fail(409, 'billing.provider_disabled', `${i.provider} has no enabled capture`);
const plan = await adapter.capture(ctx, i);
-- const result = db.transaction(() => providers.applyPlan(ctx, plan, { id: null }))();
-- res.json({ result, intent: intents.present(intents.find(db, i.id)) });
++ const result = await money(db, async () => await providers.applyPlan(ctx, plan, { id: null }));
++ res.json({ result, intent: intents.present(await intents.find(db, i.id)) });
}));
// ── Purchases: settle a provider receipt (operator/reconciler grade) ──
-- r.post('/purchases/settle', ...write(CAP.admin, (req, res) => {
++ r.post('/purchases/settle', ...write(CAP.admin, async (req, res) => {
const b = req.body || {};
if (!b.provider || !b.provider_ref) fail(422, 'billing.invalid_input', 'provider and provider_ref (the provider payment id) are required');
-- const out = purchases.settle(ctx, {
++ const out = await purchases.settle(ctx, {
provider: String(b.provider).toLowerCase(), receiptRef: `${String(b.provider).toLowerCase()}:${b.provider_ref}`,
subject: b.subject ? userSubject(b.subject) : null, paidCents: b.amount_cents, bits: b.bits, intentId: b.intent_id,
test: !!b.test, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,
@@@ -118,55 +118,55 @@ function v1Router({ ctx, auth, adapters, limits }) {
}));
// ── Transfers (tips / donations / paid interactions) ─────
-- r.post('/transfers', ...write(CAP.transfer, (req, res) => {
++ r.post('/transfers', ...write(CAP.transfer, async (req, res) => {
const b = req.body || {};
-- const out = transfers.create(ctx, {
++ const out = await transfers.create(ctx, {
from: userSubject(b.from, 'from'), to: userSubject(b.to, 'to'), amount: b.amount, kind: b.kind, target: b.target,
message: b.message, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,
});
-- res.status(201).json(txnOut(out.txn, { balance: { credit: balance(db, A.credit(out.txn.from_subject)) } }));
++ res.status(201).json(txnOut(out.txn, { balance: { credit: await balance(db, A.credit(out.txn.from_subject)) } }));
}, limits.budget('billing.transfer.create', person('from'))));
-- r.post('/transfers/:id/refund', ...write(CAP.transfer, (req, res) => {
++ r.post('/transfers/:id/refund', ...write(CAP.transfer, async (req, res) => {
const b = req.body || {};
-- const out = transfers.refund(ctx, { txnId: req.params.id, amount: b.amount, reason: b.reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ const out = await transfers.refund(ctx, { txnId: req.params.id, amount: b.amount, reason: b.reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json(txnOut(out.txn));
}, limits.budget('billing.transfer.refund', limits.payerOf)));
// ── Recycle: creator payable → own spendable credit ──────
-- r.post('/recycle', ...write(CAP.cashoutRequest, (req, res) => {
++ r.post('/recycle', ...write(CAP.cashoutRequest, async (req, res) => {
const b = req.body || {};
const subject = userSubject(b.subject);
-- const out = cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
-- res.status(201).json(txnOut(out.txn, { balance: balanceOf(subject) }));
++ const out = await cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ res.status(201).json(txnOut(out.txn, { balance: await balanceOf(subject) }));
}, limits.budget('billing.recycle', person('subject'))));
// ── Cashouts ─────────────────────────────────────────────
-- r.post('/cashouts', ...write(CAP.cashoutRequest, (req, res) => {
++ r.post('/cashouts', ...write(CAP.cashoutRequest, async (req, res) => {
const b = req.body || {};
-- const out = cashouts.request(ctx, { subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ const out = await cashouts.request(ctx, { subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json({ cashout: cashouts.present(out.cashout) });
}, limits.budget('billing.cashout.request', person('subject'))));
-- r.get('/cashouts', ...read(CAP.cashoutManage, (req, res) => {
-- const list = cashouts.list(db, { status: req.query.status, subject: req.query.subject, limit: Number(req.query.limit) || 100 });
++ r.get('/cashouts', ...read(CAP.cashoutManage, async (req, res) => {
++ const list = await cashouts.list(db, { status: req.query.status, subject: req.query.subject, limit: Number(req.query.limit) || 100 });
res.json({ cashouts: list.map(cashouts.present) });
}));
-- r.get('/cashouts/:id', ...read([CAP.cashoutManage, CAP.cashoutRequest], (req, res) => {
-- const c = cashouts.find(db, req.params.id);
++ r.get('/cashouts/:id', ...read([CAP.cashoutManage, CAP.cashoutRequest], async (req, res) => {
++ const c = await cashouts.find(db, req.params.id);
if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${req.params.id}`);
res.json({ cashout: cashouts.present(c) });
}));
-- r.post('/cashouts/:id/approve', ...write(CAP.cashoutManage, (req, res) => {
++ r.post('/cashouts/:id/approve', ...write(CAP.cashoutManage, async (req, res) => {
const b = req.body || {};
-- const out = cashouts.approve(ctx, { id: req.params.id, payout_reference: b.payout_reference, payout_provider: b.payout_provider, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ const out = await cashouts.approve(ctx, { id: req.params.id, payout_reference: b.payout_reference, payout_provider: b.payout_provider, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.json({ cashout: cashouts.present(out.cashout) });
}));
-- r.post('/cashouts/:id/deny', ...write(CAP.cashoutManage, (req, res) => {
-- const out = cashouts.deny(ctx, { id: req.params.id, reason: (req.body || {}).reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ r.post('/cashouts/:id/deny', ...write(CAP.cashoutManage, async (req, res) => {
++ const out = await cashouts.deny(ctx, { id: req.params.id, reason: (req.body || {}).reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.json({ cashout: cashouts.present(out.cashout) });
}));
// ── Subscriptions and entitlements ───────────────────────
-- r.post('/subscriptions', ...write(CAP.subscription, (req, res) => {
++ r.post('/subscriptions', ...write(CAP.subscription, async (req, res) => {
const b = req.body || {};
const source = b.source || 'credit';
let receipt = null;
@@@ -180,48 +180,48 @@ function v1Router({ ctx, auth, adapters, limits }) {
const provider = String(rc.provider).toLowerCase();
receipt = { provider, receiptRef: `${provider}:${rc.provider_ref}`, paidCents: rc.amount_cents, feeCents: rc.fee_cents, route: rc.route, providerRef: rc.subscription_ref || null, test: !!rc.test };
}
-- const out = subscriptions.pay(ctx, {
++ const out = await subscriptions.pay(ctx, {
subscriber: userSubject(b.subscriber, 'subscriber'), streamer: userSubject(b.streamer, 'streamer'), source,
priceCents: b.price_cents, autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
});
res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });
}, limits.budget('billing.subscription.create', person('subscriber'))));
-- r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], (req, res) => {
-- const list = subscriptions.list(db, {
++ r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {
++ const list = await subscriptions.list(db, {
subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
streamer: req.query.streamer ? pathSubject(req.query.streamer) : null,
status: req.query.status || null,
});
res.json({ subscriptions: list.map(subscriptions.present) });
}));
-- r.get('/subscriptions/:id', ...read([CAP.entitlement, CAP.subscription], (req, res) => {
-- const s = subscriptions.find(db, req.params.id);
++ r.get('/subscriptions/:id', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {
++ const s = await subscriptions.find(db, req.params.id);
if (!s) fail(404, 'billing.subscription_not_found', `no subscription ${req.params.id}`);
-- res.json({ subscription: subscriptions.present(s), entitlement: subscriptions.entitlement(db, s.subscriber, s.streamer, ctx.now()) });
++ res.json({ subscription: subscriptions.present(s), entitlement: await subscriptions.entitlement(db, s.subscriber, s.streamer, ctx.now()) });
}));
r.post('/subscriptions/:id/cancel', ...write(CAP.subscription, async (req, res) => {
const out = await subscriptions.cancel(ctx, { id: req.params.id, actor: actorOf(req) }, adapters);
res.json({ subscription: subscriptions.present(out.subscription), provider_sync: out.provider_sync });
}));
-- r.get('/entitlements/:subject', ...read(CAP.entitlement, (req, res) => {
++ r.get('/entitlements/:subject', ...read(CAP.entitlement, async (req, res) => {
const subject = pathSubject(req.params.subject);
-- if (req.query.streamer) return res.json(subscriptions.entitlement(db, subject, pathSubject(req.query.streamer), ctx.now()));
-- return res.json({ subject: { type: 'user', id: subject }, entitlements: subscriptions.activeEntitlements(db, subject, ctx.now()) });
++ if (req.query.streamer) return res.json(await subscriptions.entitlement(db, subject, pathSubject(req.query.streamer), ctx.now()));
++ return res.json({ subject: { type: 'user', id: subject }, entitlements: await subscriptions.activeEntitlements(db, subject, ctx.now()) });
}));
// ── Balances and history ─────────────────────────────────
-- function balanceOf(subject) {
-- const credit = balance(db, A.credit(subject));
-- const payable = balance(db, A.payable(subject));
-- const pending = balance(db, A.pending(subject));
++ async function balanceOf(subject) {
++ const credit = await balance(db, A.credit(subject));
++ const payable = await balance(db, A.payable(subject));
++ const pending = await balance(db, A.pending(subject));
return {
subject: { type: 'user', id: subject }, currency: 'vibes-bits', credit, payable, pending_payouts: pending,
payable_value_cents: ctx.rates.valueCents(payable), bits_per_usd: ctx.rates.bitsPerUsd,
};
}
-- r.get('/balances/:subject', ...read(CAP.balance, (req, res) => res.json(balanceOf(pathSubject(req.params.subject)))));
++ r.get('/balances/:subject', ...read(CAP.balance, async (req, res) => res.json(await balanceOf(pathSubject(req.params.subject)))));
-- r.get('/transactions', ...read(CAP.balance, (req, res) => {
++ r.get('/transactions', ...read(CAP.balance, async (req, res) => {
const subject = req.query.subject ? pathSubject(req.query.subject) : null;
if (!subject) fail(422, 'billing.invalid_input', 'subject is required');
const limit = Math.min(200, positiveInt(req.query.limit || 50, 'limit'));
@@@ -229,40 +229,40 @@ function v1Router({ ctx, auth, adapters, limits }) {
if (req.query.cursor) {
try { cur = JSON.parse(Buffer.from(String(req.query.cursor), 'base64url').toString('utf8')); } catch { fail(422, 'billing.invalid_input', 'bad cursor'); }
}
-- const rows = db.prepare(`SELECT t.id, t.created_at FROM transactions t
++ const rows = await db.prepare(`SELECT t.id, t.created_at FROM transactions t
WHERE (t.from_subject = @s OR t.to_subject = @s OR t.id IN (SELECT e.txn_id FROM ledger_entries e JOIN accounts a ON a.id = e.account_id WHERE a.owner_subject = @s))
-- AND (@c IS NULL OR t.created_at < @c OR (t.created_at = @c AND t.id < @i))
++ AND (@c::text IS NULL OR t.created_at < @c::text OR (t.created_at = @c::text AND t.id < @i::text))
ORDER BY t.created_at DESC, t.id DESC LIMIT @n`).all({ s: subject, c: cur ? cur[0] : null, i: cur ? cur[1] : null, n: limit + 1 });
const page = rows.slice(0, limit);
const next = rows.length > limit ? Buffer.from(JSON.stringify([page[page.length - 1].created_at, page[page.length - 1].id])).toString('base64url') : null;
-- res.json({ transactions: page.map((x) => presentTxn(getTxn(db, x.id))), next_cursor: next });
++ res.json({ transactions: (await Promise.all(page.map(async (x) => presentTxn(await getTxn(db, x.id))))), next_cursor: next });
}, limits.history));
-- r.get('/transactions/:id', ...read(CAP.balance, (req, res) => {
-- const t = getTxn(db, req.params.id);
++ r.get('/transactions/:id', ...read(CAP.balance, async (req, res) => {
++ const t = await getTxn(db, req.params.id);
if (!t) fail(404, 'billing.transaction_not_found', `no transaction ${req.params.id}`);
-- const reversals = db.prepare('SELECT id FROM transactions WHERE reverses_txn = ? ORDER BY created_at').all(t.id).map((x) => x.id);
++ const reversals = (await db.prepare('SELECT id FROM transactions WHERE reverses_txn = ? ORDER BY created_at').all(t.id)).map((x) => x.id);
res.json({ transaction: presentTxn(t), reversed_by: reversals });
}));
// ── Admin (billing.ledger.admin) ─────────────────────────
-- r.get('/admin/freeze', ...read(CAP.admin, (req, res) => res.json(admin.freezeState(db))));
++ r.get('/admin/freeze', ...read(CAP.admin, async (req, res) => res.json(await admin.freezeState(db))));
r.post('/admin/freeze', auth.needs(CAP.admin), wrap(async (req, res) => {
const b = req.body || {};
if (typeof b.on !== 'boolean') fail(422, 'billing.invalid_input', 'on must be true or false');
-- const state = admin.setFreeze(ctx, { on: b.on, reason: b.reason, actor: actorOf(req) });
++ const state = await admin.setFreeze(ctx, { on: b.on, reason: b.reason, actor: actorOf(req) });
// Deliveries stored while frozen are processed now, in arrival order.
const drained = b.on ? null : await providers.processPending(ctx, adapters);
res.json({ ...state, processed_after_unfreeze: drained });
}));
-- r.get('/admin/reconcile', ...read(CAP.admin, (req, res) => {
-- const report = reconcile(ctx, { trigger: 'api' });
++ r.get('/admin/reconcile', ...read(CAP.admin, async (req, res) => {
++ const report = await reconcile(ctx, { trigger: 'api' });
res.status(200).json(report);
}));
// Stored runs (scheduled and on demand), newest first; ?failed=1 for failed runs only.
-- r.get('/admin/reconciliations', ...read(CAP.admin, (req, res) => {
++ r.get('/admin/reconciliations', ...read(CAP.admin, async (req, res) => {
const limit = Math.min(200, positiveInt(req.query.limit || 20, 'limit'));
const failed = req.query.failed === '1' || req.query.failed === 'true';
-- const rows = db.prepare(`SELECT id, started_at, finished_at, ok, report FROM reconciliation_runs ${failed ? 'WHERE ok = 0' : ''} ORDER BY finished_at DESC, id DESC LIMIT ?`).all(limit);
++ const rows = await db.prepare(`SELECT id, started_at, finished_at, ok, report FROM reconciliation_runs ${failed ? 'WHERE ok = 0' : ''} ORDER BY finished_at DESC, id DESC LIMIT ?`).all(limit);
res.json({
runs: rows.map((r) => {
const rep = JSON.parse(r.report);
@@@ -270,50 +270,50 @@ function v1Router({ ctx, auth, adapters, limits }) {
}),
});
}));
-- r.get('/admin/reconciliations/:id', ...read(CAP.admin, (req, res) => {
++ r.get('/admin/reconciliations/:id', ...read(CAP.admin, async (req, res) => {
const row = req.params.id === 'latest'
-- ? db.prepare('SELECT report FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT 1').get()
-- : db.prepare('SELECT report FROM reconciliation_runs WHERE id = ?').get(req.params.id);
++ ? await db.prepare('SELECT report FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT 1').get()
++ : await db.prepare('SELECT report FROM reconciliation_runs WHERE id = ?').get(req.params.id);
if (!row) fail(404, 'billing.reconciliation_not_found', `no reconciliation run ${req.params.id}`);
res.json(JSON.parse(row.report));
}));
// Provider account → creator (who an EXTERNAL tip on that account belongs to).
-- r.get('/admin/provider-accounts', ...read(CAP.admin, (req, res) => {
-- res.json({ accounts: db.prepare('SELECT * FROM provider_accounts ORDER BY provider, username').all() });
++ r.get('/admin/provider-accounts', ...read(CAP.admin, async (req, res) => {
++ res.json({ accounts: await db.prepare('SELECT * FROM provider_accounts ORDER BY provider, username').all() });
}));
// Not a money movement: allowed while frozen (the cutover maps accounts with Billing frozen).
-- r.post('/admin/provider-accounts', auth.needs(CAP.admin), idem, wrap((req, res) => {
++ r.post('/admin/provider-accounts', auth.needs(CAP.admin), idem, wrap(async (req, res) => {
const b = req.body || {};
const provider = String(b.provider || '').toLowerCase();
if (!adapters[provider]) fail(422, 'billing.invalid_input', `provider must be one of ${Object.keys(adapters).join(', ')}`);
-- const row = external.mapAccount(ctx, { provider, username: b.username, accountId: b.account_id, subject: b.subject, source: 'admin' });
++ const row = await external.mapAccount(ctx, { provider, username: b.username, accountId: b.account_id, subject: b.subject, source: 'admin' });
res.status(201).json({ account: row });
}));
-- r.post('/admin/adjustments', ...write(CAP.admin, (req, res) => {
++ r.post('/admin/adjustments', ...write(CAP.admin, async (req, res) => {
const b = req.body || {};
-- const out = admin.adjust(ctx, { from: b.from, to: b.to, amount: b.amount, reason: b.reason, relatesTo: b.relates_to, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ const out = await admin.adjust(ctx, { from: b.from, to: b.to, amount: b.amount, reason: b.reason, relatesTo: b.relates_to, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json(txnOut(out.txn));
}));
-- r.get('/admin/provider-events', ...read(CAP.admin, (req, res) => {
++ r.get('/admin/provider-events', ...read(CAP.admin, async (req, res) => {
const pending = req.query.pending === '1' || req.query.pending === 'true';
-- const rows = db.prepare(`SELECT * FROM provider_events ${pending ? 'WHERE processed_at IS NULL' : ''} ORDER BY id DESC LIMIT 200`).all().map(providers.parseRow);
++ const rows = (await db.prepare(`SELECT * FROM provider_events ${pending ? 'WHERE processed_at IS NULL' : ''} ORDER BY id DESC LIMIT 200`).all()).map(providers.parseRow);
res.json({ events: rows.map(providers.present) });
}));
r.post('/admin/provider-events/:id/reprocess', auth.needs(CAP.admin), notFrozen, wrap(async (req, res) => {
const row = await providers.reprocess(ctx, adapters, Number(req.params.id));
res.json({ event: providers.present(row) });
}));
-- r.post('/admin/sweep', auth.needs(CAP.admin), notFrozen, wrap((req, res) => res.json(subscriptions.sweep(ctx))));
-- r.get('/admin/import-holds', ...read(CAP.admin, (req, res) => res.json({ holds: db.prepare('SELECT * FROM import_holds ORDER BY live_user_id').all() })));
++ r.post('/admin/sweep', auth.needs(CAP.admin), notFrozen, wrap(async (req, res) => res.json(await subscriptions.sweep(ctx))));
++ r.get('/admin/import-holds', ...read(CAP.admin, async (req, res) => res.json({ holds: await db.prepare('SELECT * FROM import_holds ORDER BY live_user_id').all() })));
return r;
}
/** Async-safe handler: BillingError → problem+json; anything else → 500. */
function wrap(fn) {
-- return (req, res, next) => {
++ return async (req, res, next) => {
try {
-- const p = fn(req, res, next);
++ const p = await fn(req, res, next);
if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
} catch (e) { sendError(req, res, e); }
};
ddiff --git a/server/api/webhooks.js b/server/api/webhooks.js
iindex d328ad5..9440bf1 100644
---- a/server/api/webhooks.js
++++ b/server/api/webhooks.js
@@@ -36,8 +36,8 @@ function webhooksRouter({ ctx, adapters }) {
let parsed;
try { parsed = adapter.parse(req); } catch { return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'unparseable payload', ctx: req.ov }); }
if (!parsed.eventId) return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'no event id', ctx: req.ov });
-- const { row, duplicate } = providers.store(ctx, adapter.name, parsed);
-- if (isFrozen(ctx.db)) return res.status(202).json({ received: true, queued: true, event: row.id, duplicate });
++ const { row, duplicate } = await providers.store(ctx, adapter.name, parsed);
++ if (await isFrozen(ctx.db)) return res.status(202).json({ received: true, queued: true, event: row.id, duplicate });
const after = row.processed_at ? row : await providers.process(ctx, adapters, row);
return res.status(200).json({ received: true, duplicate, event: after.id, processed: !!after.processed_at, result: after.result || null });
} catch (e) {
ddiff --git a/server/app.js b/server/app.js
iindex b168c0b..55591bb 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -33,9 +33,9 @@ const { createMetrics } = require('./metrics');
const VERSION = require('../package.json').version;
--function createApp(opts = {}) {
++async function createApp(opts = {}) {
const config = opts.config || loadConfig();
-- const db = opts.db || openDb(config.dbPath);
++ const db = opts.db || await openDb(config, { log: opts.log || console });
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const log = opts.log || console;
const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
@@@ -63,26 +63,32 @@ function createApp(opts = {}) {
require('openvibe-shared/trace').install(app);
app.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
-- app.get('/api/health', (req, res) => res.json({
-- ok: true, service: 'billing', version: VERSION, frozen: isFrozen(db), authority: config.authority,
++ app.get('/api/health', async (req, res) => res.json({
++ ok: true, service: 'billing', version: VERSION, frozen: await isFrozen(db), authority: config.authority,
providers: Object.fromEntries(Object.values(adapters).map((a) => [a.name, a.enabled])),
events_relay: !!config.events.url,
}));
// Readiness in the openvibe-shared/ready shape (status, named checks, release): 503 when the ledger
// database or the Network key fails; the money freeze and the authority show without failing it.
++ // Valkey (ADR-035): the per-person limit counters, kept across restarts; opts.valkey for tests (null: none, counted
++ // in the process).
++ const valkey = opts.valkey !== undefined ? opts.valkey
++ : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix }) : null);
++ app.locals.valkey = valkey;
const readiness = require('openvibe-shared/ready').createReadiness({
service: 'billing', release: release.release,
checks: [
-- { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok FROM settings WHERE id = 1').get().ok === 1 || 'settings row missing' },
++ { name: 'db', required: true, check: async () => (await db.prepare('SELECT 1 AS ok FROM settings WHERE id = 1').get()).ok === 1 || 'settings row missing' },
{ name: 'network_jwks', required: true, check: () => Boolean(keys.get()) || 'Network public key not loaded' },
-- { name: 'money_writes', required: false, check: () => (isFrozen(db) ? 'frozen' : true) },
++ { name: 'money_writes', required: false, check: async () => (await isFrozen(db) ? 'frozen' : true) },
++ { name: 'valkey', required: false, check: async () => (valkey ? await valkey.ready() : { skipped: 'VALKEY_URL not set: per-person limits count in this process only' }) },
],
});
app.get('/api/ready', readiness.handler);
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
// Per-person limits on the money-creating routes (api/actor-limits.js); the webhooks above never are.
-- const limits = createActorLimits({ config, db, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
++ const limits = createActorLimits({ config, db, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));
// The billing policy is public and indexable; the staff console and the API are not.
ddiff --git a/server/config.js b/server/config.js
iindex 6a408d1..ad683f4 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -69,6 +69,10 @@ function loadConfig(env = process.env) {
host: env.HOST || '127.0.0.1',
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner). dbPath: the SQLite
++ // file of releases before PostgreSQL, read once by scripts/migrate-to-postgres.js.
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:billing:' },
dbPath: env.BILLING_DB_PATH || './data/billing.db',
// 'live' (default, shadow): EXTERNAL PowerChat tips are recorded but not announced, because
// Live announces them from its own webhook. 'billing': they are announced as
ddiff --git a/server/console/audit.js b/server/console/audit.js
iindex 88c1ef2..7b70749 100644
---- a/server/console/audit.js
++++ b/server/console/audit.js
@@@ -2,9 +2,9 @@
/**
* staff_audit: one append-only row per staff action (and per refused one). A done action is also
-- * announced as `billing.staff.action` (visibility internal) through the outbox, in the same SQLite
++ * announced as `billing.staff.action` (visibility internal) through the outbox, in the same
* transaction as the row — and, for money actions, as the effect itself (callers wrap the ops call
-- * and record() in one db.transaction).
++ * and record() in one money() transaction).
*
* The row holds no secrets: actor subject, action, target, reason, a small whitelisted detail
* (codes, payout reference, counts), the request id and a keyed hash of the client address.
@@@ -12,7 +12,7 @@
const { iso, prefixedId } = require('../ledger');
const { enqueue } = require('../outbox');
--function record(ctx, { actor, action, target, reason, outcome = 'done', detail, requestId, ipHash, traceId }) {
++async function record(ctx, { actor, action, target, reason, outcome = 'done', detail, requestId, ipHash, traceId }) {
const ms = ctx.now();
const id = prefixedId('sa', ms);
const row = {
@@@ -21,10 +21,10 @@ function record(ctx, { actor, action, target, reason, outcome = 'done', detail,
reason: reason ? String(reason).slice(0, 500) : null, outcome, detail: JSON.stringify(detail || {}),
request_id: requestId || null, ip_hash: ipHash || null,
};
-- ctx.db.prepare(`INSERT INTO staff_audit (id, at, actor_subject, actor_username, action, target_type, target_id, reason, outcome, detail, request_id, ip_hash)
++ await ctx.db.prepare(`INSERT INTO staff_audit (id, at, actor_subject, actor_username, action, target_type, target_id, reason, outcome, detail, request_id, ip_hash)
VALUES (@id, @at, @actor_subject, @actor_username, @action, @target_type, @target_id, @reason, @outcome, @detail, @request_id, @ip_hash)`).run(row);
if (outcome === 'done' && row.actor_subject) {
-- enqueue(ctx, {
++ await enqueue(ctx, {
event_type: 'billing.staff.action',
subject: { type: 'staff_action', id },
actor: { type: 'user', id: row.actor_subject },
@@@ -39,8 +39,8 @@ function record(ctx, { actor, action, target, reason, outcome = 'done', detail,
return row;
}
--function list(db, { limit = 200 } = {}) {
-- return db.prepare('SELECT * FROM staff_audit ORDER BY seq DESC LIMIT ?').all(Math.min(500, limit))
++async function list(db, { limit = 200 } = {}) {
++ return (await db.prepare('SELECT * FROM staff_audit ORDER BY seq DESC LIMIT ?').all(Math.min(500, limit)))
.map((r) => ({ ...r, detail: JSON.parse(r.detail || '{}') }));
}
ddiff --git a/server/console/index.js b/server/console/index.js
iindex 92b2c61..fd03219 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -26,12 +26,12 @@
* and each route names the one capability it needs. Every change goes through the same functions
* the API calls (server/ops/*, reconcile.js, providers.reprocess/processPending) with the same
* freeze guard; nothing here moves money on its own. Each staff action writes one staff_audit row
-- * and one billing.staff.action outbox event, in the same SQLite transaction as its effect.
++ * and one billing.staff.action outbox event, in the same transaction as its effect.
*/
const crypto = require('crypto');
const express = require('express');
const { capabilities, staff: staffMap } = require('openvibe-contracts');
--const { BillingError, balance } = require('../ledger');
++const { BillingError, balance, money } = require('../ledger');
const { A, assertNotFrozen, isFrozen, text } = require('../ops/common');
const cashouts = require('../ops/cashouts');
const admin = require('../ops/admin');
@@@ -108,11 +108,11 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
const requestId = (req) => (req.ov ? req.ov.requestId : null);
const trace = (req) => (req.ov ? req.ov.traceId : undefined);
const actorOf = (req) => ({ principal: req.staff.subject, via: 'staff-console', username: req.staff.username || undefined, request_id: requestId(req) });
-- const record = (req, fields) => audit.record(ctx, {
++ const record = async (req, fields) => await audit.record(ctx, {
actor: req.staff ? { subject: req.staff.subject, username: req.staff.username } : fields.actor,
requestId: requestId(req), ipHash: ipHash(req), traceId: trace(req), ...fields,
});
-- const common = (req) => ({ staff: req.staff, csrf: req.staff ? req.staff.csrf : '', frozen: isFrozen(db) });
++ const common = async (req) => ({ staff: req.staff, csrf: req.staff ? req.staff.csrf : '', frozen: await isFrozen(db) });
// ── Every console response ───────────────────────────────
r.use((req, res, next) => {
@@@ -128,14 +128,14 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
r.use(express.urlencoded({ extended: false, limit: '16kb', parameterLimit: 50 }));
// ── Session → req.staff ──────────────────────────────────
-- r.use((req, res, next) => {
++ r.use(async (req, res, next) => {
req.staff = null;
-- const row = sessions.read(req);
++ const row = await sessions.read(req);
if (!row) return next();
if (!cc.staffSubjects.includes(row.subject) || !staffMap.can(row.role || 'user', cc.staffCapability)) {
-- sessions.revoke(row);
++ await sessions.revoke(row);
sessions.clear(res);
-- audit.record(ctx, { actor: { subject: row.subject, username: row.username }, action: 'session.use', outcome: 'refused', reason: cc.staffSubjects.includes(row.subject) ? `the session's role does not hold ${cc.staffCapability}` : 'no longer listed in BILLING_STAFF_SUBJECTS', requestId: requestId(req), ipHash: ipHash(req) });
++ await audit.record(ctx, { actor: { subject: row.subject, username: row.username }, action: 'session.use', outcome: 'refused', reason: cc.staffSubjects.includes(row.subject) ? `the session's role does not hold ${cc.staffCapability}` : 'no longer listed in BILLING_STAFF_SUBJECTS', requestId: requestId(req), ipHash: ipHash(req) });
return send(res, 403, pages.message({ title: 'Not authorized', text: 'Your account is no longer Billing staff. The session was ended.' }));
}
req.staff = { subject: row.subject, username: row.username, role: row.role, csrf: row.csrf, session: row, principal: { sub: row.subject, cap: STAFF_CAPABILITIES } };
@@@ -144,17 +144,17 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
/** Staff only, holding `cap` through the staff mapping. POSTs also need the CSRF token. */
function needs(cap) {
-- return (req, res, next) => {
++ return async (req, res, next) => {
if (!req.staff) {
if (req.method === 'GET') return send(res, 401, pages.signIn({ next: sanitizeNext(req.originalUrl) }));
return send(res, 401, pages.signIn({ message: 'Your session has ended. Sign in again, then repeat the action.' }));
}
if (!capabilities.grants(req.staff.principal.cap, cap)) {
-- return send(res, 403, pages.message({ title: 'Not authorized', text: `This needs ${cap}.`, ...common(req) }));
++ return send(res, 403, pages.message({ title: 'Not authorized', text: `This needs ${cap}.`, ...await common(req) }));
}
if (req.method === 'POST' && !csrfOk(req)) {
-- record(req, { action: 'request.csrf', outcome: 'refused', reason: 'missing or invalid CSRF token / cross-site request', detail: { path: req.path } });
-- return send(res, 403, pages.message({ title: 'Request refused', text: 'The form was stale or came from another site. Reload the page and try again.', ...common(req) }));
++ await record(req, { action: 'request.csrf', outcome: 'refused', reason: 'missing or invalid CSRF token / cross-site request', detail: { path: req.path } });
++ return send(res, 403, pages.message({ title: 'Request refused', text: 'The form was stale or came from another site. Reload the page and try again.', ...await common(req) }));
}
return next();
};
@@@ -166,9 +166,9 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
if (origin && origin !== baseOrigin) return false;
return sameString((req.body || {})._csrf, req.staff.csrf);
}
-- const wrap = (fn) => (req, res, next) => Promise.resolve().then(() => fn(req, res, next)).catch((e) => {
++ const wrap = (fn) => (req, res, next) => Promise.resolve().then(async () => await fn(req, res, next)).catch(async (e) => {
log.error('[Billing] console error:', e);
-- if (!res.headersSent) send(res, 500, pages.message({ title: 'Error', text: 'Something went wrong. Nothing was changed unless the page says so; check the audit log.', ...common(req) }));
++ if (!res.headersSent) send(res, 500, pages.message({ title: 'Error', text: 'Something went wrong. Nothing was changed unless the page says so; check the audit log.', ...await common(req) }));
});
const actionKey = (v) => (ACTION_KEY_RE.test(String(v || '')) ? String(v) : null);
const doneNotice = (req) => NOTICES[req.query.done] ? `${NOTICES[req.query.done]}${req.query.processed ? ` ${Number(req.query.processed) || 0} held webhook(s) processed.` : ''}` : null;
@@@ -198,63 +198,63 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
}
const listed = !!who.subject && cc.staffSubjects.includes(who.subject);
if (!listed || !who.money) {
-- audit.record(ctx, {
++ await audit.record(ctx, {
actor: { subject: who.subject, username: who.username }, action: 'session.sign_in', outcome: 'refused',
reason: !who.subject ? 'token carries no subject' : !who.money ? `the token does not hold ${cc.staffCapability} (role ${who.role || 'none'})` : 'not listed in BILLING_STAFF_SUBJECTS',
requestId: requestId(req), ipHash: ipHash(req),
});
return send(res, 403, pages.message({ title: 'Not authorized', text: 'This console is only for OpenVibe.Network admins who are Billing staff. Your sign-in was recorded.' }));
}
-- db.transaction(() => {
-- sessions.create(res, { subject: who.subject, username: who.username, role: who.role, ip: req.ip });
-- audit.record(ctx, { actor: { subject: who.subject, username: who.username }, action: 'session.sign_in', requestId: requestId(req), ipHash: ipHash(req), traceId: trace(req) });
-- })();
++ await money(db, async () => {
++ await sessions.create(res, { subject: who.subject, username: who.username, role: who.role, ip: req.ip });
++ await audit.record(ctx, { actor: { subject: who.subject, username: who.username }, action: 'session.sign_in', requestId: requestId(req), ipHash: ipHash(req), traceId: trace(req) });
++ });
return res.redirect(303, flow.n || '/');
}));
-- r.post('/auth/logout', needs(CAP.cashoutManage), (req, res) => {
-- db.transaction(() => {
-- sessions.revoke(req.staff.session);
-- record(req, { action: 'session.sign_out' });
-- })();
++ r.post('/auth/logout', needs(CAP.cashoutManage), async (req, res) => {
++ await money(db, async () => {
++ await sessions.revoke(req.staff.session);
++ await record(req, { action: 'session.sign_out' });
++ });
sessions.clear(res);
res.redirect(303, '/');
});
// ── Dashboard ────────────────────────────────────────────
-- r.get('/', (req, res) => {
++ r.get('/', async (req, res) => {
if (!req.staff) return send(res, 200, pages.signIn({}));
return send(res, 200, pages.dashboard({
-- ...common(req), freeze: admin.freezeState(db), last: q.lastReconciliation(db), totals: q.outstanding(db),
-- counts: q.counts(db, ctx.now()), rates: ctx.rates,
++ ...await common(req), freeze: await admin.freezeState(db), last: await q.lastReconciliation(db), totals: await q.outstanding(db),
++ counts: await q.counts(db, ctx.now()), rates: ctx.rates,
}));
});
// ── Cashouts ─────────────────────────────────────────────
-- r.get('/cashouts', needs(CAP.cashoutManage), (req, res) => {
++ r.get('/cashouts', needs(CAP.cashoutManage), async (req, res) => {
const tab = q.CASHOUT_TABS[req.query.tab] ? req.query.tab : 'ready';
-- send(res, 200, pages.cashouts({ ...common(req), tab, tabs: q.CASHOUT_TABS, rows: q.cashoutQueue(db, tab, ctx.now()), counts: q.counts(db, ctx.now()), nowMs: ctx.now(), rates: ctx.rates }));
++ send(res, 200, pages.cashouts({ ...await common(req), tab, tabs: q.CASHOUT_TABS, rows: await q.cashoutQueue(db, tab, ctx.now()), counts: await q.counts(db, ctx.now()), nowMs: ctx.now(), rates: ctx.rates }));
});
-- function cashoutPage(req, res, status, c, extra = {}) {
++ async function cashoutPage(req, res, status, c, extra = {}) {
return send(res, status, pages.cashout({
-- ...common(req), c, nowMs: ctx.now(), approveKey: random(18), denyKey: random(18),
-- balances: { payable: balance(db, A.payable(c.subject)), pending: balance(db, A.pending(c.subject)) },
-- history: db.prepare("SELECT * FROM staff_audit WHERE target_type = 'cashout' AND target_id = ? ORDER BY seq DESC LIMIT 50").all(c.id).map((x) => ({ ...x, detail: JSON.parse(x.detail) })),
++ ...await common(req), c, nowMs: ctx.now(), approveKey: random(18), denyKey: random(18),
++ balances: { payable: await balance(db, A.payable(c.subject)), pending: await balance(db, A.pending(c.subject)) },
++ history: (await db.prepare("SELECT * FROM staff_audit WHERE target_type = 'cashout' AND target_id = ? ORDER BY seq DESC LIMIT 50").all(c.id)).map((x) => ({ ...x, detail: JSON.parse(x.detail) })),
notice: doneNotice(req), ...extra,
}));
}
-- r.get('/cashouts/:id', needs(CAP.cashoutManage), (req, res) => {
-- const c = cashouts.find(db, req.params.id);
-- if (!c) return send(res, 404, pages.message({ title: 'Not found', text: `No cashout ${req.params.id}.`, ...common(req) }));
-- return cashoutPage(req, res, 200, c);
++ r.get('/cashouts/:id', needs(CAP.cashoutManage), async (req, res) => {
++ const c = await cashouts.find(db, req.params.id);
++ if (!c) return send(res, 404, pages.message({ title: 'Not found', text: `No cashout ${req.params.id}.`, ...await common(req) }));
++ return await cashoutPage(req, res, 200, c);
});
/** Run a cashout decision through ops; audit once (not on a replay); refusals audited as refused. */
function decide(action, run, validate) {
-- return (req, res) => {
-- const c = cashouts.find(db, req.params.id);
-- if (!c) return send(res, 404, pages.message({ title: 'Not found', text: `No cashout ${req.params.id}.`, ...common(req) }));
++ return async (req, res) => {
++ const c = await cashouts.find(db, req.params.id);
++ if (!c) return send(res, 404, pages.message({ title: 'Not found', text: `No cashout ${req.params.id}.`, ...await common(req) }));
const b = req.body || {};
const target = { type: 'cashout', id: c.id };
const form = { payout_reference: String(b.payout_reference || '').trim().slice(0, 200), payout_provider: String(b.payout_provider || '').slice(0, 20), reason: String(b.reason || '').slice(0, 300) };
@@@ -262,11 +262,11 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
const key = actionKey(b.action_key);
if (!key) throw new BillingError(422, 'console.stale_form', 'the form is missing its action key; reload the page');
if (validate) validate(b);
-- const out = db.transaction(() => {
-- assertNotFrozen(ctx); // the API's notFrozen guard
-- const o = run(req, c, b, `staff:${action}:${key}`);
++ const out = await money(db, async () => {
++ await assertNotFrozen(ctx); // the API's notFrozen guard
++ const o = await run(req, c, b, `staff:${action}:${key}`);
if (!o.replay) {
-- record(req, {
++ await record(req, {
action, target, reason: action === 'cashout.deny' ? form.reason : null,
detail: action === 'cashout.approve'
? { payout_reference: o.cashout.payout_reference, payout_provider: o.cashout.payout_provider, amount_bits: c.amount_bits, value_cents: c.value_cents, txn: o.cashout.settle_txn }
@@@ -274,95 +274,95 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
});
}
return o;
-- })();
++ });
const done = action === 'cashout.approve' ? (out.replay ? 'already_paid' : 'approved') : (out.replay ? 'already_denied' : 'denied');
return res.redirect(303, `/cashouts/${encodeURIComponent(c.id)}?done=${done}`);
} catch (e) {
if (!(e instanceof BillingError)) throw e;
-- record(req, { action, target, outcome: 'refused', reason: action === 'cashout.deny' ? form.reason || null : null, detail: { code: e.code } });
-- return cashoutPage(req, res, e.status, cashouts.find(db, c.id), { error: `${e.detail || e.message} (${e.code})`, form });
++ await record(req, { action, target, outcome: 'refused', reason: action === 'cashout.deny' ? form.reason || null : null, detail: { code: e.code } });
++ return await cashoutPage(req, res, e.status, await cashouts.find(db, c.id), { error: `${e.detail || e.message} (${e.code})`, form });
}
};
}
-- r.post('/cashouts/:id/approve', needs(CAP.cashoutManage), wrap(decide('cashout.approve', (req, c, b, key) => {
++ r.post('/cashouts/:id/approve', needs(CAP.cashoutManage), wrap(decide('cashout.approve', async (req, c, b, key) => {
const provider = String(b.payout_provider || 'paypal').toLowerCase();
-- return cashouts.approve(ctx, { id: c.id, payout_reference: b.payout_reference, payout_provider: provider, idempotencyKey: key, actor: actorOf(req) });
++ return await cashouts.approve(ctx, { id: c.id, payout_reference: b.payout_reference, payout_provider: provider, idempotencyKey: key, actor: actorOf(req) });
}, (b) => {
if (!text(b.payout_reference, 'payout_reference', 200)) throw new BillingError(422, 'billing.payout_reference_required', 'approving a payout needs the payout reference from the provider');
if (!PAYOUT_PROVIDERS.includes(String(b.payout_provider || 'paypal').toLowerCase())) throw new BillingError(422, 'billing.invalid_input', `payout provider must be one of ${PAYOUT_PROVIDERS.join(', ')}`);
if (b.confirm !== 'yes') throw new BillingError(422, 'console.confirm_required', 'tick the confirmation that the payout was sent');
})));
-- r.post('/cashouts/:id/deny', needs(CAP.cashoutManage), wrap(decide('cashout.deny', (req, c, b, key) => (
-- cashouts.deny(ctx, { id: c.id, reason: b.reason, idempotencyKey: key, actor: actorOf(req) })
++ r.post('/cashouts/:id/deny', needs(CAP.cashoutManage), wrap(decide('cashout.deny', async (req, c, b, key) => (
++ await cashouts.deny(ctx, { id: c.id, reason: b.reason, idempotencyKey: key, actor: actorOf(req) })
), (b) => {
if (!text(b.reason, 'reason', 300)) throw new BillingError(422, 'console.reason_required', 'a denial needs a reason');
})));
// ── Receipts needing review ──────────────────────────────
-- r.get('/receipts', needs(CAP.admin), (req, res) => send(res, 200, pages.receipts({ ...common(req), q: q.reviewQueue(db), notice: doneNotice(req) })));
++ r.get('/receipts', needs(CAP.admin), async (req, res) => send(res, 200, pages.receipts({ ...await common(req), q: await q.reviewQueue(db), notice: doneNotice(req) })));
r.post('/receipts/:id/reprocess', needs(CAP.admin), wrap(async (req, res) => {
const id = Number(req.params.id);
const target = { type: 'provider_event', id: req.params.id };
try {
if (!Number.isInteger(id) || id <= 0) throw new BillingError(404, 'billing.event_not_found', `no provider event ${req.params.id}`);
-- assertNotFrozen(ctx);
++ await assertNotFrozen(ctx);
const row = await providers.reprocess(ctx, adapters, id);
-- record(req, { action: 'provider_event.reprocess', target, detail: { processed: !!row.processed_at, effect: row.result ? row.result.effect : null, code: row.result && row.result.code ? row.result.code : undefined } });
++ await record(req, { action: 'provider_event.reprocess', target, detail: { processed: !!row.processed_at, effect: row.result ? row.result.effect : null, code: row.result && row.result.code ? row.result.code : undefined } });
return res.redirect(303, '/receipts?done=reprocessed');
} catch (e) {
if (!(e instanceof BillingError)) throw e;
-- record(req, { action: 'provider_event.reprocess', target, outcome: 'refused', detail: { code: e.code } });
-- return send(res, e.status, pages.receipts({ ...common(req), q: q.reviewQueue(db), error: `${e.detail || e.message} (${e.code})` }));
++ await record(req, { action: 'provider_event.reprocess', target, outcome: 'refused', detail: { code: e.code } });
++ return send(res, e.status, pages.receipts({ ...await common(req), q: await q.reviewQueue(db), error: `${e.detail || e.message} (${e.code})` }));
}
}));
-- r.get('/import-holds', needs(CAP.admin), (req, res) => send(res, 200, pages.holds({ ...common(req), rows: q.importHolds(db) })));
++ r.get('/import-holds', needs(CAP.admin), async (req, res) => send(res, 200, pages.holds({ ...await common(req), rows: await q.importHolds(db) })));
// ── Reconciliation ───────────────────────────────────────
-- r.get('/reconciliation', needs(CAP.admin), (req, res) => send(res, 200, pages.reconciliation({ ...common(req), runs: q.reconciliationRuns(db), notice: doneNotice(req) })));
-- r.get('/reconciliation/:id', needs(CAP.admin), (req, res) => {
-- const run = q.reconciliationRun(db, req.params.id);
-- if (!run) return send(res, 404, pages.message({ title: 'Not found', text: `No reconciliation run ${req.params.id}.`, ...common(req) }));
-- return send(res, 200, pages.reconciliationRun({ ...common(req), run }));
++ r.get('/reconciliation', needs(CAP.admin), async (req, res) => send(res, 200, pages.reconciliation({ ...await common(req), runs: await q.reconciliationRuns(db), notice: doneNotice(req) })));
++ r.get('/reconciliation/:id', needs(CAP.admin), async (req, res) => {
++ const run = await q.reconciliationRun(db, req.params.id);
++ if (!run) return send(res, 404, pages.message({ title: 'Not found', text: `No reconciliation run ${req.params.id}.`, ...await common(req) }));
++ return send(res, 200, pages.reconciliationRun({ ...await common(req), run }));
});
-- r.post('/reconciliation', needs(CAP.admin), (req, res) => {
-- const report = db.transaction(() => {
-- const rep = reconcile(ctx, { trigger: 'console' });
-- record(req, { action: 'reconciliation.run', target: { type: 'reconciliation_run', id: rep.id }, detail: { ok: rep.ok, failed: rep.checks.filter((c) => !c.ok).map((c) => c.id) } });
++ r.post('/reconciliation', needs(CAP.admin), async (req, res) => {
++ const report = await money(db, async () => {
++ const rep = await reconcile(ctx, { trigger: 'console' });
++ await record(req, { action: 'reconciliation.run', target: { type: 'reconciliation_run', id: rep.id }, detail: { ok: rep.ok, failed: rep.checks.filter((c) => !c.ok).map((c) => c.id) } });
return rep;
-- })();
++ });
res.redirect(303, `/reconciliation/${encodeURIComponent(report.id)}`);
});
// ── Freeze ───────────────────────────────────────────────
-- const heldCount = () => db.prepare('SELECT COUNT(*) AS n FROM provider_events WHERE processed_at IS NULL').get().n;
-- r.get('/freeze', needs(CAP.admin), (req, res) => send(res, 200, pages.freeze({ ...common(req), state: admin.freezeState(db), held: heldCount(), notice: doneNotice(req) })));
++ const heldCount = async () => (await db.prepare('SELECT COUNT(*) AS n FROM provider_events WHERE processed_at IS NULL').get()).n;
++ r.get('/freeze', needs(CAP.admin), async (req, res) => send(res, 200, pages.freeze({ ...await common(req), state: await admin.freezeState(db), held: await heldCount(), notice: doneNotice(req) })));
r.post('/freeze', needs(CAP.admin), wrap(async (req, res) => {
const b = req.body || {};
const on = b.on === '1';
const action = on ? 'economy.freeze' : 'economy.unfreeze';
const target = { type: 'settings', id: 'freeze' };
-- const refuse = (status, code, detail) => {
-- record(req, { action, target, outcome: 'refused', reason: b.reason ? String(b.reason).slice(0, 300) : null, detail: { code } });
-- return send(res, status, pages.freeze({ ...common(req), state: admin.freezeState(db), held: heldCount(), error: detail }));
++ const refuse = async (status, code, detail) => {
++ await record(req, { action, target, outcome: 'refused', reason: b.reason ? String(b.reason).slice(0, 300) : null, detail: { code } });
++ return send(res, status, pages.freeze({ ...await common(req), state: await admin.freezeState(db), held: await heldCount(), error: detail }));
};
-- if (b.on !== '1' && b.on !== '0') return refuse(422, 'billing.invalid_input', 'on must be 1 or 0');
++ if (b.on !== '1' && b.on !== '0') return await refuse(422, 'billing.invalid_input', 'on must be 1 or 0');
let reason;
-- try { reason = text(b.reason, 'reason', 300); } catch (e) { return refuse(422, e.code, e.detail); }
-- if (!reason) return refuse(422, 'console.reason_required', `${on ? 'Freezing' : 'Unfreezing'} needs a reason.`);
-- if (isFrozen(db) === on) return refuse(409, 'console.no_change', `The economy is already ${on ? 'frozen' : 'open'}.`);
-- db.transaction(() => {
-- admin.setFreeze(ctx, { on, reason, actor: actorOf(req) });
-- record(req, { action, target, reason });
-- })();
++ try { reason = text(b.reason, 'reason', 300); } catch (e) { return await refuse(422, e.code, e.detail); }
++ if (!reason) return await refuse(422, 'console.reason_required', `${on ? 'Freezing' : 'Unfreezing'} needs a reason.`);
++ if (await isFrozen(db) === on) return await refuse(409, 'console.no_change', `The economy is already ${on ? 'frozen' : 'open'}.`);
++ await money(db, async () => {
++ await admin.setFreeze(ctx, { on, reason, actor: actorOf(req) });
++ await record(req, { action, target, reason });
++ });
// As POST /api/v1/admin/freeze: deliveries stored while frozen are processed now, in order.
const drained = on ? null : await providers.processPending(ctx, adapters);
return res.redirect(303, `/freeze?done=${on ? 'frozen' : 'unfrozen'}${drained ? `&processed=${drained.processed || 0}` : ''}`);
}));
-- r.get('/audit', needs(CAP.admin), (req, res) => send(res, 200, pages.audit({ ...common(req), rows: audit.list(db) })));
++ r.get('/audit', needs(CAP.admin), async (req, res) => send(res, 200, pages.audit({ ...await common(req), rows: await audit.list(db) })));
return r;
}
ddiff --git a/server/console/queries.js b/server/console/queries.js
iindex fdf203f..af3040a 100644
---- a/server/console/queries.js
++++ b/server/console/queries.js
@@@ -8,17 +8,17 @@
const { iso } = require('../ledger');
/** Outstanding balances per account kind, counting only non-test transactions (ADR-012 rule 9). */
--function outstanding(db) {
-- const sum = (kind, currency) => db.prepare(`SELECT COALESCE(SUM(e.amount), 0) AS n FROM ledger_entries e
++async function outstanding(db) {
++ const sum = async (kind, currency) => (await db.prepare(`SELECT COALESCE(SUM(e.amount), 0)::bigint AS n FROM ledger_entries e
JOIN accounts a ON a.id = e.account_id JOIN transactions t ON t.id = e.txn_id
-- WHERE a.kind = ? AND a.currency = ? AND t.test = 0`).get(kind, currency).n;
++ WHERE a.kind = ? AND a.currency = ? AND t.test = 0`).get(kind, currency)).n;
return {
-- credit_bits: sum('user_credit', 'vibes-bits'),
-- payable_bits: sum('creator_payable', 'vibes-bits'),
-- payouts_pending_bits: sum('payouts_pending', 'vibes-bits'),
-- platform_revenue_cents: sum('platform_revenue', 'usd-cents'),
-- chargeback_loss_cents: sum('chargeback_loss', 'usd-cents'),
-- test_transactions: db.prepare('SELECT COUNT(*) AS n FROM transactions WHERE test = 1').get().n,
++ credit_bits: await sum('user_credit', 'vibes-bits'),
++ payable_bits: await sum('creator_payable', 'vibes-bits'),
++ payouts_pending_bits: await sum('payouts_pending', 'vibes-bits'),
++ platform_revenue_cents: await sum('platform_revenue', 'usd-cents'),
++ chargeback_loss_cents: await sum('chargeback_loss', 'usd-cents'),
++ test_transactions: (await db.prepare('SELECT COUNT(*) AS n FROM transactions WHERE test = 1').get()).n,
};
}
@@@ -37,16 +37,16 @@ function summarizeReport(report) {
};
}
--function reconciliationRun(db, id) {
-- const row = db.prepare('SELECT * FROM reconciliation_runs WHERE id = ?').get(id);
++async function reconciliationRun(db, id) {
++ const row = await db.prepare('SELECT * FROM reconciliation_runs WHERE id = ?').get(id);
return row ? summarizeReport(JSON.parse(row.report)) : null;
}
--function lastReconciliation(db) {
-- const row = db.prepare('SELECT * FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT 1').get();
++async function lastReconciliation(db) {
++ const row = await db.prepare('SELECT * FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT 1').get();
return row ? summarizeReport(JSON.parse(row.report)) : null;
}
--function reconciliationRuns(db, limit = 50) {
-- return db.prepare('SELECT id, finished_at, ok, report FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT ?').all(limit)
++async function reconciliationRuns(db, limit = 50) {
++ return (await db.prepare('SELECT id, finished_at, ok, report FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT ?').all(limit))
.map((r) => {
const s = summarizeReport(JSON.parse(r.report));
return { id: r.id, finished_at: r.finished_at, ok: !!r.ok, trigger: s.trigger, failed: s.checks.filter((c) => !c.ok).map((c) => c.id) };
@@@ -70,19 +70,19 @@ const eventRow = (r) => ({ ...r, result: safeResult(r.result ? JSON.parse(r.resu
* the double-credit refusal — or a refund for an unknown payment), stored but not processed, and
* reversing transactions flagged review=required (chargebacks on credit already given away).
*/
--function reviewQueue(db) {
-- const review = db.prepare(`SELECT ${EVENT_COLUMNS} FROM provider_events WHERE json_extract(result, '$.review') = 1 ORDER BY id DESC LIMIT 200`).all().map(eventRow);
-- const rejected = db.prepare(`SELECT ${EVENT_COLUMNS} FROM provider_events WHERE json_extract(result, '$.effect') = 'rejected' ORDER BY id DESC LIMIT 200`).all().map(eventRow);
-- const unprocessed = db.prepare(`SELECT ${EVENT_COLUMNS} FROM provider_events WHERE processed_at IS NULL ORDER BY id LIMIT 200`).all().map(eventRow);
-- const transactions = db.prepare(`SELECT id, type, reverses_txn, created_at, provider, receipt_ref, from_subject, to_subject,
-- json_extract(metadata, '$.cents') AS cents, json_extract(metadata, '$.unrecovered_bits') AS unrecovered_bits,
-- json_extract(metadata, '$.creator_payable_kept_bits') AS creator_payable_kept_bits
++async function reviewQueue(db) {
++ const review = (await db.prepare(`SELECT ${EVENT_COLUMNS} FROM provider_events WHERE json_extract(result, '$.review') IN ('true', '1') ORDER BY id DESC LIMIT 200`).all()).map(eventRow);
++ const rejected = (await db.prepare(`SELECT ${EVENT_COLUMNS} FROM provider_events WHERE json_extract(result, '$.effect') = 'rejected' ORDER BY id DESC LIMIT 200`).all()).map(eventRow);
++ const unprocessed = (await db.prepare(`SELECT ${EVENT_COLUMNS} FROM provider_events WHERE processed_at IS NULL ORDER BY id LIMIT 200`).all()).map(eventRow);
++ const transactions = await db.prepare(`SELECT id, type, reverses_txn, created_at, provider, receipt_ref, from_subject, to_subject,
++ json_extract(metadata, '$.cents')::bigint AS cents, json_extract(metadata, '$.unrecovered_bits')::bigint AS unrecovered_bits,
++ json_extract(metadata, '$.creator_payable_kept_bits')::bigint AS creator_payable_kept_bits
FROM transactions WHERE json_extract(metadata, '$.review') = 'required' ORDER BY created_at DESC LIMIT 200`).all();
return { review, rejected, unprocessed, transactions };
}
--function importHolds(db) {
-- return db.prepare(`SELECT live_user_id, owner, reason, credit_bits, payable_bits, resolved_subject, first_seen_at, updated_at
++async function importHolds(db) {
++ return await db.prepare(`SELECT live_user_id, owner, reason, credit_bits, payable_bits, resolved_subject, first_seen_at, updated_at
FROM import_holds ORDER BY resolved_subject IS NOT NULL, live_user_id`).all();
}
@@@ -93,22 +93,22 @@ const CASHOUT_TABS = {
denied: { label: 'Denied', where: "status = 'denied'", order: 'updated_at DESC' },
};
--function cashoutQueue(db, tab, nowMs) {
++async function cashoutQueue(db, tab, nowMs) {
const t = CASHOUT_TABS[tab] || CASHOUT_TABS.ready;
-- return db.prepare(`SELECT id, subject, amount_bits, value_cents, status, payout_method, escrow_until, payout_provider, payout_reference, reason, created_at, updated_at
-- FROM cashouts WHERE ${t.where} ORDER BY ${t.order} LIMIT 300`).all({ now: iso(nowMs) })
++ return (await db.prepare(`SELECT id, subject, amount_bits, value_cents, status, payout_method, escrow_until, payout_provider, payout_reference, reason, created_at, updated_at
++ FROM cashouts WHERE ${t.where} ORDER BY ${t.order} LIMIT 300`).all(...(t.where.includes('@now') ? [{ now: iso(nowMs) }] : [])))
.map((r) => ({ ...r, payout_method: JSON.parse(r.payout_method || '{}') }));
}
--function counts(db, nowMs) {
++async function counts(db, nowMs) {
const now = iso(nowMs);
-- const one = (sql, ...a) => db.prepare(sql).get(...a).n;
++ const one = async (sql, ...a) => (await db.prepare(sql).get(...a)).n;
return {
-- escrow: one("SELECT COUNT(*) AS n FROM cashouts WHERE status = 'requested' AND escrow_until > ?", now),
-- ready: one("SELECT COUNT(*) AS n FROM cashouts WHERE status = 'requested' AND escrow_until <= ?", now),
-- review_events: one("SELECT COUNT(*) AS n FROM provider_events WHERE json_extract(result, '$.review') = 1 OR json_extract(result, '$.effect') = 'rejected' OR processed_at IS NULL"),
-- review_transactions: one("SELECT COUNT(*) AS n FROM transactions WHERE json_extract(metadata, '$.review') = 'required'"),
-- import_holds: one('SELECT COUNT(*) AS n FROM import_holds WHERE resolved_subject IS NULL'),
++ escrow: await one("SELECT COUNT(*) AS n FROM cashouts WHERE status = 'requested' AND escrow_until > ?", now),
++ ready: await one("SELECT COUNT(*) AS n FROM cashouts WHERE status = 'requested' AND escrow_until <= ?", now),
++ review_events: await one("SELECT COUNT(*) AS n FROM provider_events WHERE json_extract(result, '$.review') IN ('true', '1') OR json_extract(result, '$.effect') = 'rejected' OR processed_at IS NULL"),
++ review_transactions: await one("SELECT COUNT(*) AS n FROM transactions WHERE json_extract(metadata, '$.review') = 'required'"),
++ import_holds: await one('SELECT COUNT(*) AS n FROM import_holds WHERE resolved_subject IS NULL'),
};
}
ddiff --git a/server/console/session.js b/server/console/session.js
iindex f372e7b..594323a 100644
---- a/server/console/session.js
++++ b/server/console/session.js
@@@ -79,27 +79,27 @@ function createSessions(ctx, { secret }) {
}
// ── Sessions ─────────────────────────────────────────────
-- function create(res, { subject, username, role, ip }) {
++ async function create(res, { subject, username, role, ip }) {
const now = ctx.now();
-- db.prepare('DELETE FROM staff_sessions WHERE expires_at < ?').run(iso(now - 24 * 3600 * 1000));
++ await db.prepare('DELETE FROM staff_sessions WHERE expires_at < ?').run(iso(now - 24 * 3600 * 1000));
const id = random(32);
const ttlMs = cc.sessionTtlMin * 60 * 1000;
-- db.prepare(`INSERT INTO staff_sessions (id_hash, subject, username, role, csrf, created_at, expires_at, ip_hash)
++ await db.prepare(`INSERT INTO staff_sessions (id_hash, subject, username, role, csrf, created_at, expires_at, ip_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256(id), subject, username || null, role, random(32), iso(now), iso(now + ttlMs), ipHash(ip));
appendCookie(res, cookieHeader(sessionCookie, id, { maxAgeSec: ttlMs / 1000, secure: cc.cookieSecure }));
}
/** The live session of this request, or null (unknown, expired or revoked). */
-- function read(req) {
++ async function read(req) {
const id = parseCookies(req)[sessionCookie];
if (!id || !/^[A-Za-z0-9_-]{43}$/.test(id)) return null;
-- const row = db.prepare('SELECT * FROM staff_sessions WHERE id_hash = ?').get(sha256(id));
++ const row = await db.prepare('SELECT * FROM staff_sessions WHERE id_hash = ?').get(sha256(id));
if (!row || row.revoked_at || Date.parse(row.expires_at) <= ctx.now()) return null;
return row;
}
-- function revoke(row) {
-- if (row) db.prepare('UPDATE staff_sessions SET revoked_at = ? WHERE id_hash = ? AND revoked_at IS NULL').run(iso(ctx.now()), row.id_hash);
++ async function revoke(row) {
++ if (row) await db.prepare('UPDATE staff_sessions SET revoked_at = ? WHERE id_hash = ? AND revoked_at IS NULL').run(iso(ctx.now()), row.id_hash);
}
function clear(res) {
appendCookie(res, cookieHeader(sessionCookie, '', { maxAgeSec: 0, secure: cc.cookieSecure }));
ddiff --git a/server/db.js b/server/db.js
iindex 5015ebb..541fcbb 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -1,13 +1,13 @@
'use strict';
/**
-- * OpenVibe.Billing database (ADR-007: one SQLite database per service, BILLING_DB_PATH).
++ * OpenVibe.Billing database: PostgreSQL (ADR-035, roadmap WS-X2; ov_billing on the host's data role).
*
* The journal is three tables: accounts, transactions, ledger_entries. Every transaction's
* entries sum to zero per currency (enforced in ledger.js, verified by reconcile.js), and the
* journal is append-only: triggers refuse UPDATE/DELETE on ledger_entries and transactions.
* Corrections are new, reversing transactions. account_balances is a cache of SUM(entries)
-- * per account, updated in the same SQLite transaction as the entries and verified by
++ * per account, updated in the same transaction as the entries and verified by
* reconciliation.
*
* Operational state that does change (intents, provider events, cashouts, subscriptions,
@@@ -15,7 +15,7 @@
*/
const fs = require('fs');
const path = require('path');
--const Database = require('better-sqlite3');
++const { createDb } = require('openvibe-sdk/db');
const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',
'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion'];
@@@ -23,305 +23,29 @@ const CURRENCIES = ['vibes-bits', 'usd-cents'];
const TXN_TYPES = ['purchase', 'donation', 'subscription', 'subscription_share', 'cashout_request', 'cashout_paid',
'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import'];
--const inList = (xs) => xs.map((x) => `'${x}'`).join(', ');
--const SCHEMA = `
--CREATE TABLE IF NOT EXISTS accounts (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- kind TEXT NOT NULL CHECK (kind IN (${inList(ACCOUNT_KINDS)})),
-- owner_subject TEXT,
-- currency TEXT NOT NULL CHECK (currency IN (${inList(CURRENCIES)})),
-- created_at TEXT NOT NULL,
-- UNIQUE (kind, owner_subject, currency)
--);
---- SQLite treats NULLs as distinct in UNIQUE; platform accounts (owner NULL) need this one.
--CREATE UNIQUE INDEX IF NOT EXISTS accounts_identity ON accounts (kind, COALESCE(owner_subject, ''), currency);
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
--CREATE TABLE IF NOT EXISTS transactions (
-- id TEXT PRIMARY KEY,
-- type TEXT NOT NULL CHECK (type IN (${inList(TXN_TYPES)})),
-- status TEXT NOT NULL DEFAULT 'settled' CHECK (status IN ('settled', 'imported')),
-- idempotency_key TEXT NOT NULL UNIQUE,
-- reverses_txn TEXT REFERENCES transactions (id),
-- test INTEGER NOT NULL DEFAULT 0 CHECK (test IN (0, 1)),
-- actor TEXT NOT NULL DEFAULT '{}',
-- metadata TEXT NOT NULL DEFAULT '{}',
-- from_subject TEXT,
-- to_subject TEXT,
-- provider TEXT,
-- receipt_ref TEXT,
-- source_event_id INTEGER REFERENCES provider_events (id),
-- created_at TEXT NOT NULL
--);
---- One settlement per provider payment, whatever event or retry carried it.
--CREATE UNIQUE INDEX IF NOT EXISTS transactions_receipt ON transactions (receipt_ref) WHERE receipt_ref IS NOT NULL;
--CREATE INDEX IF NOT EXISTS transactions_from ON transactions (from_subject);
--CREATE INDEX IF NOT EXISTS transactions_to ON transactions (to_subject);
--CREATE INDEX IF NOT EXISTS transactions_reverses ON transactions (reverses_txn);
--CREATE INDEX IF NOT EXISTS transactions_event ON transactions (source_event_id);
--
--CREATE TABLE IF NOT EXISTS ledger_entries (
-- txn_id TEXT NOT NULL REFERENCES transactions (id),
-- account_id INTEGER NOT NULL REFERENCES accounts (id),
-- amount INTEGER NOT NULL CHECK (amount <> 0),
-- PRIMARY KEY (txn_id, account_id)
--);
--CREATE INDEX IF NOT EXISTS ledger_entries_account ON ledger_entries (account_id);
--
--CREATE TABLE IF NOT EXISTS account_balances (
-- account_id INTEGER PRIMARY KEY REFERENCES accounts (id),
-- balance INTEGER NOT NULL DEFAULT 0,
-- updated_at TEXT
--);
--
--CREATE TRIGGER IF NOT EXISTS ledger_entries_no_update BEFORE UPDATE ON ledger_entries
--BEGIN SELECT RAISE(ABORT, 'ledger_entries are append-only'); END;
--CREATE TRIGGER IF NOT EXISTS ledger_entries_no_delete BEFORE DELETE ON ledger_entries
--BEGIN SELECT RAISE(ABORT, 'ledger_entries are append-only'); END;
--CREATE TRIGGER IF NOT EXISTS transactions_no_update BEFORE UPDATE ON transactions
--BEGIN SELECT RAISE(ABORT, 'transactions are append-only; post a reversing transaction'); END;
--CREATE TRIGGER IF NOT EXISTS transactions_no_delete BEFORE DELETE ON transactions
--BEGIN SELECT RAISE(ABORT, 'transactions are append-only; post a reversing transaction'); END;
--
--CREATE TABLE IF NOT EXISTS provider_events (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- provider TEXT NOT NULL,
-- provider_event_id TEXT NOT NULL,
-- type TEXT,
-- payload_hash TEXT NOT NULL,
-- payload TEXT NOT NULL,
-- received_at TEXT NOT NULL,
-- processed_at TEXT,
-- result TEXT,
-- attempts INTEGER NOT NULL DEFAULT 0,
-- last_error TEXT,
-- UNIQUE (provider, provider_event_id)
--);
--CREATE INDEX IF NOT EXISTS provider_events_pending ON provider_events (processed_at);
--CREATE TRIGGER IF NOT EXISTS provider_events_immutable BEFORE UPDATE OF provider, provider_event_id, payload, payload_hash, received_at ON provider_events
--BEGIN SELECT RAISE(ABORT, 'provider receipts are immutable'); END;
--CREATE TRIGGER IF NOT EXISTS provider_events_no_delete BEFORE DELETE ON provider_events
--BEGIN SELECT RAISE(ABORT, 'provider receipts are immutable'); END;
--
---- EXTERNAL receipts (ADR-012): tips paid on a streamer's OWN provider account. No journal entry,
---- no liability — one row per provider payment (receipt_ref), whatever delivery carried it, so the
---- announcement (billing.receipt.external) happens at most once. status says whether it was
---- announced and, if not, why (Live still the authority, an account no creator has connected).
--CREATE TABLE IF NOT EXISTS external_receipts (
-- receipt_ref TEXT PRIMARY KEY,
-- provider TEXT NOT NULL,
-- provider_event INTEGER NOT NULL REFERENCES provider_events (id),
-- receiving_account TEXT,
-- streamer_subject TEXT,
-- amount_cents INTEGER NOT NULL,
-- test INTEGER NOT NULL DEFAULT 0 CHECK (test IN (0, 1)),
-- status TEXT NOT NULL CHECK (status IN ('announced', 'not_announced')),
-- reason TEXT,
-- event_id TEXT,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
--);
--CREATE INDEX IF NOT EXISTS external_receipts_streamer ON external_receipts (streamer_subject);
--
---- Which creator a provider account belongs to (a PowerChat username / account id → Network subject):
---- the importer copies Live's powerchat_connections; an operator can add or correct one.
--CREATE TABLE IF NOT EXISTS provider_accounts (
-- provider TEXT NOT NULL,
-- username TEXT NOT NULL,
-- account_id TEXT,
-- subject TEXT NOT NULL,
-- source TEXT NOT NULL,
-- live_user_id INTEGER,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL,
-- PRIMARY KEY (provider, username)
--);
--CREATE INDEX IF NOT EXISTS provider_accounts_account ON provider_accounts (provider, account_id);
--
--CREATE TABLE IF NOT EXISTS payment_intents (
-- id TEXT PRIMARY KEY,
-- provider TEXT NOT NULL,
-- provider_ref TEXT,
-- kind TEXT NOT NULL CHECK (kind IN ('purchase', 'subscription')),
-- subject TEXT NOT NULL,
-- streamer_subject TEXT,
-- amount_cents INTEGER NOT NULL,
-- fee_cents INTEGER NOT NULL DEFAULT 0,
-- bits INTEGER NOT NULL DEFAULT 0,
-- route TEXT,
-- auto_renew INTEGER NOT NULL DEFAULT 0,
-- status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'settled', 'failed', 'expired', 'canceled', 'refunded')),
-- settled_txn TEXT REFERENCES transactions (id),
-- legacy_order_id INTEGER UNIQUE,
-- metadata TEXT NOT NULL DEFAULT '{}',
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
--);
--CREATE UNIQUE INDEX IF NOT EXISTS payment_intents_ref ON payment_intents (provider, provider_ref) WHERE provider_ref IS NOT NULL;
--CREATE INDEX IF NOT EXISTS payment_intents_subject ON payment_intents (subject);
--
--CREATE TABLE IF NOT EXISTS cashouts (
-- id TEXT PRIMARY KEY,
-- subject TEXT NOT NULL,
-- amount_bits INTEGER NOT NULL CHECK (amount_bits > 0),
-- value_cents INTEGER NOT NULL,
-- status TEXT NOT NULL CHECK (status IN ('requested', 'paid', 'denied')),
-- payout_method TEXT NOT NULL DEFAULT '{}',
-- escrow_until TEXT NOT NULL,
-- request_txn TEXT NOT NULL REFERENCES transactions (id),
-- settle_txn TEXT REFERENCES transactions (id),
-- payout_provider TEXT,
-- payout_reference TEXT,
-- decided_by TEXT,
-- reason TEXT,
-- legacy_live_txn INTEGER UNIQUE,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
--);
--CREATE INDEX IF NOT EXISTS cashouts_status ON cashouts (status);
--CREATE INDEX IF NOT EXISTS cashouts_subject ON cashouts (subject);
--
--CREATE TABLE IF NOT EXISTS subscriptions (
-- id TEXT PRIMARY KEY,
-- subscriber TEXT NOT NULL,
-- streamer TEXT NOT NULL,
-- tier INTEGER NOT NULL DEFAULT 1,
-- provider TEXT NOT NULL,
-- provider_ref TEXT,
-- route TEXT,
-- status TEXT NOT NULL CHECK (status IN ('active', 'canceled', 'expired')),
-- auto_renew INTEGER NOT NULL DEFAULT 0,
-- cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
-- price_cents INTEGER NOT NULL DEFAULT 0,
-- current_period_end TEXT,
-- legacy_live_id INTEGER UNIQUE,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL,
-- UNIQUE (subscriber, streamer)
--);
--CREATE INDEX IF NOT EXISTS subscriptions_provider_ref ON subscriptions (provider, provider_ref);
--CREATE INDEX IF NOT EXISTS subscriptions_streamer ON subscriptions (streamer, status);
--
--CREATE TABLE IF NOT EXISTS entitlements (
-- id TEXT PRIMARY KEY,
-- subject TEXT NOT NULL,
-- kind TEXT NOT NULL,
-- scope TEXT NOT NULL,
-- subscription_id TEXT REFERENCES subscriptions (id),
-- starts_at TEXT NOT NULL,
-- ends_at TEXT NOT NULL,
-- source_txn TEXT REFERENCES transactions (id),
-- revoked_at TEXT,
-- revoked_reason TEXT,
-- created_at TEXT NOT NULL
--);
--CREATE INDEX IF NOT EXISTS entitlements_lookup ON entitlements (subject, kind, scope);
--CREATE INDEX IF NOT EXISTS entitlements_source ON entitlements (source_txn);
--
--CREATE TABLE IF NOT EXISTS idempotency_keys (
-- key TEXT PRIMARY KEY,
-- request_hash TEXT NOT NULL,
-- method TEXT NOT NULL,
-- path TEXT NOT NULL,
-- status INTEGER NOT NULL,
-- response TEXT NOT NULL,
-- created_at TEXT NOT NULL
--);
--
--CREATE TABLE IF NOT EXISTS settings (
-- id INTEGER PRIMARY KEY CHECK (id = 1),
-- freeze INTEGER NOT NULL DEFAULT 0,
-- freeze_reason TEXT,
-- frozen_at TEXT,
-- frozen_by TEXT
--);
--INSERT OR IGNORE INTO settings (id, freeze) VALUES (1, 0);
--
--CREATE TABLE IF NOT EXISTS reconciliation_runs (
-- id TEXT PRIMARY KEY,
-- started_at TEXT NOT NULL,
-- finished_at TEXT NOT NULL,
-- ok INTEGER NOT NULL,
-- report TEXT NOT NULL
--);
--
--CREATE TABLE IF NOT EXISTS import_runs (
-- id TEXT PRIMARY KEY,
-- source TEXT NOT NULL,
-- dry_run INTEGER NOT NULL,
-- started_at TEXT NOT NULL,
-- finished_at TEXT,
-- report TEXT
--);
--
--CREATE TABLE IF NOT EXISTS import_holds (
-- live_user_id INTEGER PRIMARY KEY,
-- owner TEXT NOT NULL,
-- reason TEXT NOT NULL,
-- credit_bits INTEGER NOT NULL DEFAULT 0,
-- payable_bits INTEGER NOT NULL DEFAULT 0,
-- resolved_subject TEXT,
-- first_seen_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
--);
--
--CREATE TABLE IF NOT EXISTS outbox (
-- seq INTEGER PRIMARY KEY AUTOINCREMENT,
-- event_id TEXT NOT NULL UNIQUE,
-- event TEXT NOT NULL,
-- created_at TEXT NOT NULL,
-- sent_at TEXT,
-- attempts INTEGER NOT NULL DEFAULT 0,
-- last_error TEXT
--);
--CREATE INDEX IF NOT EXISTS outbox_unsent ON outbox (sent_at, seq);
--
---- Staff console (server/console). Every staff action — and every refused one — is one row here,
---- never updated or deleted. Done actions are also announced as billing.staff.action (outbox).
--CREATE TABLE IF NOT EXISTS staff_audit (
-- seq INTEGER PRIMARY KEY AUTOINCREMENT,
-- id TEXT NOT NULL UNIQUE,
-- at TEXT NOT NULL,
-- actor_subject TEXT,
-- actor_username TEXT,
-- action TEXT NOT NULL,
-- target_type TEXT,
-- target_id TEXT,
-- reason TEXT,
-- outcome TEXT NOT NULL CHECK (outcome IN ('done', 'refused')),
-- detail TEXT NOT NULL DEFAULT '{}',
-- request_id TEXT,
-- ip_hash TEXT
--);
--CREATE INDEX IF NOT EXISTS staff_audit_target ON staff_audit (target_type, target_id);
--CREATE TRIGGER IF NOT EXISTS staff_audit_no_update BEFORE UPDATE ON staff_audit
--BEGIN SELECT RAISE(ABORT, 'staff_audit is append-only'); END;
--CREATE TRIGGER IF NOT EXISTS staff_audit_no_delete BEFORE DELETE ON staff_audit
--BEGIN SELECT RAISE(ABORT, 'staff_audit is append-only'); END;
--
---- Console sessions: the cookie holds a random id, the table only its SHA-256.
--CREATE TABLE IF NOT EXISTS staff_sessions (
-- id_hash TEXT PRIMARY KEY,
-- subject TEXT NOT NULL,
-- username TEXT,
-- role TEXT NOT NULL,
-- csrf TEXT NOT NULL,
-- created_at TEXT NOT NULL,
-- expires_at TEXT NOT NULL,
-- revoked_at TEXT,
-- ip_hash TEXT
--);
--CREATE INDEX IF NOT EXISTS staff_sessions_expiry ON staff_sessions (expires_at);
--`;
--
--function openDb(dbPath) {
-- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
-- const db = new Database(dbPath);
-- if (dbPath !== ':memory:') db.pragma('journal_mode = WAL');
-- db.pragma('foreign_keys = ON');
-- db.pragma('busy_timeout = 5000');
-- db.exec(SCHEMA);
-- return db;
++/**
++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
++ * in data/pglite. Migrations (migrations/NNNN_*.sql) run first, as the owner (DATABASE_DIRECT_URL), or on the embedded
++ * handle. Money moves in serializable transactions (ledger.js money()).
++ */
++async function openDb(config, { log = console, registry } = {}) {
++ if (!config.db.url) {
++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh billing)');
++ const dir = process.env.BILLING_PGLITE_DIR || DEV_PGLITE;
++ log.warn(`[Billing] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'billing', registry, log });
++ await db.migrate({ dir: MIGRATIONS, log });
++ return db;
++ }
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
++ const owner = createDb({ url: config.db.directUrl, service: 'billing-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'billing', registry, log });
}
--module.exports = { openDb, ACCOUNT_KINDS, CURRENCIES, TXN_TYPES };
++module.exports = { openDb, MIGRATIONS, ACCOUNT_KINDS, CURRENCIES, TXN_TYPES };
ddiff --git a/server/importer/live.js b/server/importer/live.js
iindex 4fc4637..f240905 100644
---- a/server/importer/live.js
++++ b/server/importer/live.js
@@@ -34,7 +34,7 @@
* cutover) only adds what changed. --dry-run does everything inside a transaction that is rolled
* back, and stores only the report.
*/
--const { post, balance, iso, prefixedId } = require('../ledger');
++const { post, balance, iso, prefixedId, money } = require('../ledger');
const { A, entry } = require('../ops/common');
const external = require('../ops/external');
@@@ -47,16 +47,17 @@ const liveTs = (s) => {
return Number.isFinite(t) ? new Date(t).toISOString() : null;
};
--function tableExists(db, name) { return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name); }
--function columns(db, table) { return tableExists(db, table) ? db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name) : []; }
++// Live's own database, still SQLite (read-only here): its catalog.
++function tableExists(live, name) { return !!live.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name); }
++function columns(live, table) { return tableExists(live, table) ? live.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name) : []; }
class DryRun extends Error {}
/** Live's PowerChat connections with the Network subject of their owner (resolved here when `map` lacks it). */
async function readProviderAccounts(live, resolveLiveUsers, map = new Map()) {
-- if (!tableExists(live, 'powerchat_connections')) return [];
-- const hasId = columns(live, 'powerchat_connections').includes('powerchat_user_id');
-- const rows = live.prepare(`SELECT user_id, powerchat_username${hasId ? ', powerchat_user_id' : ''} FROM powerchat_connections
++ if (!await tableExists(live, 'powerchat_connections')) return [];
++ const hasId = (await columns(live, 'powerchat_connections')).includes('powerchat_user_id');
++ const rows = await live.prepare(`SELECT user_id, powerchat_username${hasId ? ', powerchat_user_id' : ''} FROM powerchat_connections
WHERE powerchat_username IS NOT NULL AND TRIM(powerchat_username) <> '' ORDER BY user_id`).all();
const missing = [...new Set(rows.map((r) => String(r.user_id)).filter((id) => !map.has(id)))];
const more = missing.length ? await resolveLiveUsers(missing) : new Map();
@@@ -68,15 +69,15 @@ async function readProviderAccounts(live, resolveLiveUsers, map = new Map()) {
}
/** Inside the import transaction: upsert the mappings; fills report.provider_accounts. */
--function applyProviderAccounts(ctx, rows, report) {
++async function applyProviderAccounts(ctx, rows, report) {
const out = { mapped: 0, unchanged: 0, unmapped: [], kept_admin: [] };
for (const r of rows) {
if (!r.subject) { out.unmapped.push({ live_user_id: r.live_user_id, username: r.username, reason: 'no Network subject' }); continue; }
if (!external.USERNAME_RE.test(r.username)) { out.unmapped.push({ live_user_id: r.live_user_id, username: r.username.slice(0, 80), reason: 'not a PowerChat username' }); continue; }
-- const cur = ctx.db.prepare("SELECT * FROM provider_accounts WHERE provider = 'powerchat' AND username = ?").get(r.username);
++ const cur = await ctx.db.prepare("SELECT * FROM provider_accounts WHERE provider = 'powerchat' AND username = ?").get(r.username);
if (cur && cur.source === 'admin') { if (cur.subject !== r.subject) out.kept_admin.push({ username: r.username, admin_subject: cur.subject, live_subject: r.subject }); continue; }
if (cur && cur.subject === r.subject && (cur.account_id || null) === (r.account_id || cur.account_id || null)) { out.unchanged++; continue; }
-- external.mapAccount(ctx, { provider: 'powerchat', username: r.username, accountId: r.account_id, subject: r.subject, source: 'live-import', liveUserId: r.live_user_id });
++ await external.mapAccount(ctx, { provider: 'powerchat', username: r.username, accountId: r.account_id, subject: r.subject, source: 'live-import', liveUserId: r.live_user_id });
out.mapped++;
}
report.provider_accounts = out;
@@@ -91,13 +92,13 @@ async function importProviderAccounts(ctx, { live, resolveLiveUsers, dryRun = fa
const report = { run_id: runId, dry_run: !!dryRun, started_at: iso(started), source: 'openvibe-live snapshot (provider accounts only)' };
const rows = await readProviderAccounts(live, resolveLiveUsers);
try {
-- db.transaction(() => {
-- applyProviderAccounts(ctx, rows, report);
++ await money(db, async () => {
++ await applyProviderAccounts(ctx, rows, report);
if (dryRun) throw new DryRun();
-- })();
++ });
} catch (e) { if (!(e instanceof DryRun)) throw e; }
report.finished_at = iso(ctx.now());
-- db.prepare('INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report) VALUES (?, ?, ?, ?, ?, ?)')
++ await db.prepare('INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report) VALUES (?, ?, ?, ?, ?, ?)')
.run(runId, 'live-accounts', dryRun ? 1 : 0, report.started_at, report.finished_at, JSON.stringify(report));
const a = report.provider_accounts;
log.log && log.log(`[Billing] provider accounts ${runId}${dryRun ? ' (dry run, rolled back)' : ''}: ${a.mapped} mapped, ${a.unchanged} unchanged, ${a.unmapped.length} unmapped, ${a.kept_admin.length} kept (operator)`);
@@@ -115,20 +116,20 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
};
// ── Read the snapshot ────────────────────────────────────
-- const userCols = columns(live, 'users');
++ const userCols = await columns(live, 'users');
const hasCashout = userCols.includes('openvibe_bucks_cashout_balance');
-- const users = live.prepare(`SELECT id, username, COALESCE(openvibe_bucks_balance, 0) AS credit${hasCashout ? ', COALESCE(openvibe_bucks_cashout_balance, 0) AS payable' : ', 0 AS payable'} FROM users`).all();
-- const txns = tableExists(live, 'transactions') ? live.prepare('SELECT * FROM transactions ORDER BY id').all() : [];
-- const orders = tableExists(live, 'payment_orders') ? live.prepare('SELECT * FROM payment_orders ORDER BY id').all() : [];
++ const users = await live.prepare(`SELECT id, username, COALESCE(openvibe_bucks_balance, 0) AS credit${hasCashout ? ', COALESCE(openvibe_bucks_cashout_balance, 0) AS payable' : ', 0 AS payable'} FROM users`).all();
++ const txns = await tableExists(live, 'transactions') ? await live.prepare('SELECT * FROM transactions ORDER BY id').all() : [];
++ const orders = await tableExists(live, 'payment_orders') ? await live.prepare('SELECT * FROM payment_orders ORDER BY id').all() : [];
// A direct subscription settles only on the streamer's own PowerChat account (providers/powerchat.js).
-- const pcAccount = new Map(tableExists(live, 'powerchat_connections')
-- ? live.prepare('SELECT user_id, powerchat_username FROM powerchat_connections WHERE powerchat_username IS NOT NULL').all().map(r => [r.user_id, String(r.powerchat_username).toLowerCase()])
++ const pcAccount = new Map(await tableExists(live, 'powerchat_connections')
++ ? (await live.prepare('SELECT user_id, powerchat_username FROM powerchat_connections WHERE powerchat_username IS NOT NULL').all()).map(r => [r.user_id, String(r.powerchat_username).toLowerCase()])
: []);
-- const subCols = columns(live, 'subscriptions');
-- const subs = subCols.length ? live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
++ const subCols = await columns(live, 'subscriptions');
++ const subs = subCols.length ? await live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
let resetAt = null;
-- if (tableExists(live, 'site_settings')) {
-- const r = live.prepare("SELECT value FROM site_settings WHERE key = 'stats_vibes_reset_at'").get();
++ if (await tableExists(live, 'site_settings')) {
++ const r = await live.prepare("SELECT value FROM site_settings WHERE key = 'stats_vibes_reset_at'").get();
if (r && r.value && !Number.isNaN(Date.parse(r.value))) resetAt = new Date(r.value).toISOString();
}
report.test_before = resetAt;
@@@ -141,7 +142,7 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
for (const o of orders) { ids.add(String(o.user_id)); if (o.streamer_id) ids.add(String(o.streamer_id)); }
for (const s of subs) { ids.add(String(s.subscriber_id)); ids.add(String(s.streamer_id)); }
// Holds from earlier runs are re-resolved too, so a newly mapped user is released.
-- for (const h of db.prepare('SELECT live_user_id FROM import_holds WHERE resolved_subject IS NULL').all()) ids.add(String(h.live_user_id));
++ for (const h of await db.prepare('SELECT live_user_id FROM import_holds WHERE resolved_subject IS NULL').all()) ids.add(String(h.live_user_id));
const map = ids.size ? await resolveLiveUsers([...ids]) : new Map();
const subjectOf = (liveId) => (liveId == null ? null : map.get(String(liveId)) || null);
const owner = (liveId) => (liveId == null ? null : subjectOf(liveId) || `hold:live:${liveId}`);
@@@ -149,20 +150,20 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
report.counts.identities = { resolved: [...ids].filter((i) => subjectOf(i)).length, unmapped: [...ids].filter((i) => !subjectOf(i)).length };
const accountRows = await readProviderAccounts(live, resolveLiveUsers, map);
-- const before = db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n;
++ const before = (await db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n;
const ms = ctx.now();
const at = iso(ms);
const snap = rates.snapshot();
-- const run = () => {
-- const importTxn = (spec) => post(ctx, { type: 'import', status: 'imported', actor: { principal: 'svc:billing', job: 'import-live', run: runId }, ...spec });
++ const run = async () => {
++ const importTxn = async (spec) => await post(ctx, { type: 'import', status: 'imported', actor: { principal: 'svc:billing', job: 'import-live', run: runId }, ...spec });
// ── 2. payment_orders → intents + receipts ───────────
-- const refSeen = new Map(db.prepare('SELECT provider, provider_ref, legacy_order_id FROM payment_intents WHERE provider_ref IS NOT NULL').all()
++ const refSeen = new Map((await db.prepare('SELECT provider, provider_ref, legacy_order_id FROM payment_intents WHERE provider_ref IS NOT NULL').all())
.map((r) => [`${r.provider}|${r.provider_ref}`, r.legacy_order_id]));
let intentsNew = 0; let receiptsNew = 0; let intentsUpdated = 0;
for (const o of orders) {
-- const existing = db.prepare('SELECT id, status, settled_txn FROM payment_intents WHERE legacy_order_id = ?').get(o.id);
++ const existing = await db.prepare('SELECT id, status, settled_txn FROM payment_intents WHERE legacy_order_id = ?').get(o.id);
if (existing && !existing.settled_txn) {
// Live moved the order on since an earlier run (credited or failed after the shadow
// import): follow it, so a later delivery of a Live-credited order is recognised as
@@@ -170,7 +171,7 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
// Billing settled itself (settled_txn) is never rewound.
const want = o.status === 'credited' ? 'settled' : (o.status === 'failed' && ['created', 'expired'].includes(existing.status) ? 'failed' : null);
if (want && existing.status !== want && existing.status !== 'settled') {
-- db.prepare('UPDATE payment_intents SET status = ?, updated_at = ? WHERE id = ?').run(want, at, existing.id);
++ await db.prepare('UPDATE payment_intents SET status = ?, updated_at = ? WHERE id = ?').run(want, at, existing.id);
intentsUpdated++;
}
}
@@@ -199,7 +200,7 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
const stale = Date.parse(created) < ms - 3 * 86_400_000;
const status = o.status === 'credited' ? 'settled' : o.status === 'failed' ? 'failed' : (o.status === 'pending' && stale ? 'expired' : 'created');
if (o.status === 'paid') report.anomalies.push({ kind: 'order_paid_not_credited', order: o.id, provider: o.provider, amount_cents: o.amount_cents });
-- db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route, auto_renew,
++ await db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route, auto_renew,
status, legacy_order_id, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(prefixedId('pi', Date.parse(created)), o.provider, ref, o.kind === 'subscription' ? 'subscription' : 'purchase', owner(o.user_id),
o.streamer_id ? owner(o.streamer_id) : null, o.amount_cents || 0, fee, o.bucks || 0, route, renew, status, o.id, JSON.stringify(meta), created, at);
@@@ -207,8 +208,8 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
}
if (o.status === 'credited' || o.status === 'paid') {
const payload = JSON.stringify(o);
-- const r = db.prepare(`INSERT OR IGNORE INTO provider_events (provider, provider_event_id, type, payload_hash, payload, received_at, processed_at, result, attempts)
-- VALUES (?, ?, 'live.payment_order', ?, ?, ?, ?, ?, 0)`).run(o.provider, `live-order:${o.id}`,
++ const r = await db.prepare(`INSERT INTO provider_events (provider, provider_event_id, type, payload_hash, payload, received_at, processed_at, result, attempts)
++ VALUES (?, ?, 'live.payment_order', ?, ?, ?, ?, ?, 0) ON CONFLICT DO NOTHING`).run(o.provider, `live-order:${o.id}`,
require('crypto').createHash('sha256').update(payload).digest('hex'), payload, liveTs(o.updated_at || o.created_at) || at, at,
JSON.stringify({ effect: 'imported', live_order: o.id, live_status: o.status }));
receiptsNew += r.changes;
@@@ -219,11 +220,11 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
report.counts.receipts_new = receiptsNew;
// ── 3. history ───────────────────────────────────────
-- const openCashout = (t, liveTxn, status) => {
-- if (db.prepare('SELECT 1 FROM cashouts WHERE legacy_live_txn = ?').get(liveTxn.id)) return;
++ const openCashout = async (t, liveTxn, status) => {
++ if (await db.prepare('SELECT 1 FROM cashouts WHERE legacy_live_txn = ?').get(liveTxn.id)) return;
const created = liveTs(liveTxn.created_at) || at;
const subject = owner(liveTxn.from_user_id);
-- db.prepare(`INSERT INTO cashouts (id, subject, amount_bits, value_cents, status, payout_method, escrow_until, request_txn, settle_txn,
++ await db.prepare(`INSERT INTO cashouts (id, subject, amount_bits, value_cents, status, payout_method, escrow_until, request_txn, settle_txn,
payout_provider, payout_reference, reason, legacy_live_txn, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(prefixedId('co', Date.parse(created)), subject, liveTxn.amount, rates.valueCents(liveTxn.amount), status,
JSON.stringify({ type: 'paypal', legacy_message: liveTxn.message || null }),
@@@ -245,8 +246,8 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
const moved = t.status === 'completed' || (t.type === 'cashout' && t.status === 'escrow');
if (a <= 0) note = 'non-positive amount';
else if (!moved && !(t.type === 'cashout' && t.status === 'refunded')) note = `status ${t.status}: no balance moved`;
-- else if (t.type === 'purchase' && to) entries = [...money(a), entry(A.credit(to), a)];
-- else if (t.type === 'donation' && to) entries = from ? [entry(A.credit(from), -a), entry(A.payable(to), a)] : [...money(a), entry(A.payable(to), a)];
++ else if (t.type === 'purchase' && to) entries = [...await money(a), entry(A.credit(to), a)];
++ else if (t.type === 'donation' && to) entries = from ? [entry(A.credit(from), -a), entry(A.payable(to), a)] : [...await money(a), entry(A.payable(to), a)];
else if (t.type === 'cashout' && from) {
if (t.status === 'escrow') entries = [entry(A.payable(from), -a), entry(A.pending(from), a)];
else if (t.status === 'completed') entries = [entry(A.payable(from), -a), entry(A.fxBits(), a), entry(A.fxCents(), -rates.valueCents(a)), entry(A.clearing('paypal_payouts'), rates.valueCents(a))];
@@@ -256,14 +257,14 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
else if (t.type === 'recycle' && from) entries = [entry(A.payable(from), -a), entry(A.credit(from), a)];
else note = `unreplayable ${t.type} (from ${t.from_user_id || '-'}, to ${t.to_user_id || '-'})`;
if (note) report.unreplayable.push({ live_txn: t.id, type: t.type, status: t.status, amount: t.amount, reason: note });
-- const r = importTxn({
++ const r = await importTxn({
idempotencyKey: `import:live:txn:${t.id}`, entries, test, createdAt: created,
fromSubject: t.from_user_id ? subjectOf(t.from_user_id) : null, toSubject: t.to_user_id ? subjectOf(t.to_user_id) : null,
metadata: { live: { id: t.id, type: t.type, status: t.status, amount: t.amount, from_user_id: t.from_user_id, to_user_id: t.to_user_id, stream_id: t.stream_id, message: t.message }, note, rates: snap },
});
if (!r.replay) {
historyNew++;
-- if (t.type === 'cashout' && from && a > 0) openCashout(r.txn, t, t.status === 'escrow' ? 'requested' : t.status === 'completed' ? 'paid' : t.status === 'refunded' ? 'denied' : null);
++ if (t.type === 'cashout' && from && a > 0) await openCashout(r.txn, t, t.status === 'escrow' ? 'requested' : t.status === 'completed' ? 'paid' : t.status === 'refunded' ? 'denied' : null);
}
}
report.counts.history_new = historyNew;
@@@ -276,10 +277,10 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
// holds) — the part the Live column describes. Anything Billing did natively (a legacy checkout
// that settled here, and everything after the cutover) is not Live's to overwrite, so a later
// run (a hold release, a late snapshot) never books an adjustment against it.
-- const importedStmt = db.prepare(`SELECT COALESCE(SUM(e.amount), 0) AS n FROM ledger_entries e
++ const importedStmt = db.prepare(`SELECT COALESCE(SUM(e.amount), 0)::bigint AS n FROM ledger_entries e
JOIN accounts a ON a.id = e.account_id JOIN transactions t ON t.id = e.txn_id
-- WHERE a.kind = ? AND a.owner_subject IS ? AND a.currency = ? AND t.type = 'import'`);
-- const imported = (acct) => importedStmt.get(acct.kind, acct.owner, acct.currency).n;
++ WHERE a.kind = ? AND a.owner_subject IS NOT DISTINCT FROM ? AND a.currency = ? AND t.type = 'import'`);
++ const imported = async (acct) => (await importedStmt.get(acct.kind, acct.owner, acct.currency)).n;
const openingIds = new Set([...ids].filter((i) => byId.has(i)));
for (const i of openingIds) {
const u = byId.get(i);
@@@ -289,21 +290,21 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
// A user the Network now knows: move what an earlier run parked on the hold.
if (subject) {
for (const [acct, name] of [[A.credit, 'user_credit'], [A.payable, 'creator_payable'], [A.pending, 'payouts_pending']]) {
-- const held = balance(db, acct(hold));
++ const held = await balance(db, acct(hold));
if (held !== 0) {
-- importTxn({ idempotencyKey: `import:release-hold:${runId}:${i}:${name}`, entries: [entry(acct(hold), -held), entry(acct(subject), held)], metadata: { release_hold: i, account: name, amount: held, rates: snap } });
++ await importTxn({ idempotencyKey: `import:release-hold:${runId}:${i}:${name}`, entries: [entry(acct(hold), -held), entry(acct(subject), held)], metadata: { release_hold: i, account: name, amount: held, rates: snap } });
}
}
-- db.prepare('UPDATE cashouts SET subject = ? WHERE subject = ?').run(subject, hold);
-- db.prepare('UPDATE import_holds SET resolved_subject = ?, updated_at = ? WHERE live_user_id = ? AND resolved_subject IS NULL').run(subject, at, Number(i));
++ await db.prepare('UPDATE cashouts SET subject = ? WHERE subject = ?').run(subject, hold);
++ await db.prepare('UPDATE import_holds SET resolved_subject = ?, updated_at = ? WHERE live_user_id = ? AND resolved_subject IS NULL').run(subject, at, Number(i));
}
for (const [acct, name, raw] of [[A.credit, 'user_credit', u.credit], [A.payable, 'creator_payable', u.payable]]) {
const target = Math.round(Number(raw) || 0);
if (target !== Number(raw)) report.anomalies.push({ kind: 'fractional_balance', live_user_id: u.id, account: name, value: raw, imported_as: target });
-- const current = imported(acct(own));
++ const current = await imported(acct(own));
const d = target - current;
if (d !== 0) {
-- importTxn({
++ await importTxn({
idempotencyKey: `import:opening:${runId}:${own}:${name}`,
entries: [entry(acct(own), d), entry(A.importAdj('vibes-bits'), -d)],
fromSubject: null, toSubject: subject,
@@@ -313,14 +314,14 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
}
}
if (!subject) {
-- upsertHold.run(Number(i), hold, 'no Network subject for this Live user (resolve-batch returned null)', Math.round(u.credit), Math.round(u.payable), at, at);
++ await upsertHold.run(Number(i), hold, 'no Network subject for this Live user (resolve-batch returned null)', Math.round(u.credit), Math.round(u.payable), at, at);
report.holds.push({ live_user_id: u.id, username: u.username, credit_bits: Math.round(u.credit), payable_bits: Math.round(u.payable) });
}
}
// Unmapped ids that only appear in money rows (no users row left) are held too.
for (const i of ids) {
if (!subjectOf(i) && !byId.has(i)) {
-- upsertHold.run(Number(i), `hold:live:${i}`, 'no Live users row and no Network subject', 0, 0, at, at);
++ await upsertHold.run(Number(i), `hold:live:${i}`, 'no Live users row and no Network subject', 0, 0, at, at);
report.holds.push({ live_user_id: Number(i), username: null, credit_bits: 0, payable_bits: 0, note: 'no users row' });
}
}
@@@ -334,23 +335,23 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
const liveStatus = String(s.status || (s.is_active ? 'active' : 'expired'));
const status = liveStatus === 'active' && end && Date.parse(end) > ms ? 'active' : (liveStatus === 'canceled' ? 'canceled' : (liveStatus === 'active' && !end ? 'active' : 'expired'));
const provider = s.provider === 'bucks' ? 'credit' : (s.provider || 'legacy');
-- const existing = db.prepare('SELECT * FROM subscriptions WHERE legacy_live_id = ?').get(s.id);
++ const existing = await db.prepare('SELECT * FROM subscriptions WHERE legacy_live_id = ?').get(s.id);
let subId;
if (existing) {
subId = existing.id;
const changed = existing.status !== status || existing.current_period_end !== end || !!existing.auto_renew !== !!s.auto_renew || !!existing.cancel_at_period_end !== !!s.cancel_at_period_end;
if (changed) {
-- db.prepare('UPDATE subscriptions SET status = ?, current_period_end = ?, auto_renew = ?, cancel_at_period_end = ?, updated_at = ? WHERE id = ?')
++ await db.prepare('UPDATE subscriptions SET status = ?, current_period_end = ?, auto_renew = ?, cancel_at_period_end = ?, updated_at = ? WHERE id = ?')
.run(status, end, s.auto_renew ? 1 : 0, s.cancel_at_period_end ? 1 : 0, at, subId);
report.subscriptions.updated++;
}
} else {
-- if (db.prepare('SELECT 1 FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(subscriber, streamer)) {
++ if (await db.prepare('SELECT 1 FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(subscriber, streamer)) {
report.subscriptions.skipped.push({ live_sub: s.id, reason: 'a Billing subscription for this pair already exists' });
continue;
}
subId = prefixedId('sub', Date.parse(liveTs(s.created_at || s.started_at) || at));
-- db.prepare(`INSERT INTO subscriptions (id, subscriber, streamer, tier, provider, provider_ref, route, status, auto_renew, cancel_at_period_end,
++ await db.prepare(`INSERT INTO subscriptions (id, subscriber, streamer, tier, provider, provider_ref, route, status, auto_renew, cancel_at_period_end,
price_cents, current_period_end, legacy_live_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(subId, subscriber, streamer, s.tier || 1, provider, s.provider_ref || null, status, s.auto_renew ? 1 : 0, s.cancel_at_period_end ? 1 : 0,
s.price_cents || 0, end, s.id, liveTs(s.created_at || s.started_at) || at, at);
@@@ -358,28 +359,28 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
}
// Entitlement for the paid period Live shows, extended when a later snapshot extends it.
if (status !== 'expired' && end && Date.parse(end) > ms) {
-- const covered = db.prepare("SELECT MAX(ends_at) AS e FROM entitlements WHERE subscription_id = ? AND revoked_at IS NULL").get(subId).e;
++ const covered = (await db.prepare("SELECT MAX(ends_at) AS e FROM entitlements WHERE subscription_id = ? AND revoked_at IS NULL").get(subId)).e;
if (!covered || covered < end) {
const start = covered && covered > at ? covered : (liveTs(s.started_at || s.created_at) || at);
-- db.prepare(`INSERT INTO entitlements (id, subject, kind, scope, subscription_id, starts_at, ends_at, source_txn, created_at)
++ await db.prepare(`INSERT INTO entitlements (id, subject, kind, scope, subscription_id, starts_at, ends_at, source_txn, created_at)
VALUES (?, ?, 'channel_subscription', ?, ?, ?, ?, NULL, ?)`).run(prefixedId('ent', ms), subscriber, streamer, subId, start < end ? start : at, end, at);
}
}
}
// ── 6. provider accounts ─────────────────────────────
-- applyProviderAccounts(ctx, accountRows, report);
++ await applyProviderAccounts(ctx, accountRows, report);
-- report.new_transactions = db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n - before;
++ report.new_transactions = (await db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n - before;
if (dryRun) throw new DryRun();
};
-- try { db.transaction(run)(); }
++ try { await money(db, run); }
catch (e) { if (!(e instanceof DryRun)) throw e; }
report.finished_at = iso(ctx.now());
report.usernames_held = report.holds.map((h) => h.username || username.get(String(h.live_user_id)) || null);
-- db.prepare('INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report) VALUES (?, ?, ?, ?, ?, ?)')
++ await db.prepare('INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report) VALUES (?, ?, ?, ?, ?, ?)')
.run(runId, 'live', dryRun ? 1 : 0, report.started_at, report.finished_at, JSON.stringify(report));
log.log && log.log(`[Billing] import ${runId}${dryRun ? ' (dry run, rolled back)' : ''}: ${report.new_transactions} new transactions, ${report.adjustments.length} opening adjustments, ${report.holds.length} holds, ${report.duplicate_provider_refs.length} duplicate refs`);
return report;
ddiff --git a/server/index.js b/server/index.js
iindex 6c0644a..367fa4a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -19,47 +19,52 @@ const { createRelay } = require('./outbox');
const { isFrozen } = require('./ops/common');
const { runScheduled } = require('./reconcile');
--const config = loadConfig();
--const app = createApp({ config });
--const { ctx, adapters, keys } = app.locals;
--keys.start();
++(async () => {
++ const config = loadConfig();
++ const app = await createApp({ config });
++ const { ctx, adapters, keys } = app.locals;
++ keys.start();
--const timers = [];
--const relay = createRelay({ db: ctx.db, config });
--if (config.jobs.enabled) {
-- const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Billing] job:', e.message)); }, ms); t.unref(); timers.push(t); };
-- every(config.jobs.webhookRetryMs, () => providers.processPending(ctx, adapters));
-- every(config.jobs.sweepIntervalMs, () => {
-- if (isFrozen(ctx.db)) return;
-- const out = subscriptions.sweep(ctx);
-- if (out.renewed.length || out.expired.length || out.canceled.length) console.log(`[Billing] subscription sweep: ${out.renewed.length} renewed, ${out.expired.length} expired, ${out.canceled.length} canceled`);
-- });
-- if (config.jobs.reconcileIntervalMs > 0) {
-- const reconcileNow = () => runScheduled(ctx, { keepDays: config.jobs.reconcileKeepDays });
-- every(config.jobs.reconcileIntervalMs, reconcileNow);
-- // A first run shortly after boot, so /metrics and the console have a fresh result.
-- const first = setTimeout(() => { try { reconcileNow(); } catch (e) { console.warn('[Billing] job:', e.message); } }, 60_000);
-- first.unref();
-- timers.push(first);
++ const timers = [];
++ const relay = createRelay({ db: ctx.db, config });
++ if (config.jobs.enabled) {
++ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Billing] job:', e.message)); }, ms); t.unref(); timers.push(t); };
++ every(config.jobs.webhookRetryMs, async () => await providers.processPending(ctx, adapters));
++ every(config.jobs.sweepIntervalMs, async () => {
++ if (await isFrozen(ctx.db)) return;
++ const out = await subscriptions.sweep(ctx);
++ if (out.renewed.length || out.expired.length || out.canceled.length) console.log(`[Billing] subscription sweep: ${out.renewed.length} renewed, ${out.expired.length} expired, ${out.canceled.length} canceled`);
++ });
++ if (config.jobs.reconcileIntervalMs > 0) {
++ const reconcileNow = async () => await runScheduled(ctx, { keepDays: config.jobs.reconcileKeepDays });
++ every(config.jobs.reconcileIntervalMs, reconcileNow);
++ // A first run shortly after boot, so /metrics and the console have a fresh result.
++ const first = setTimeout(async () => { try { await reconcileNow(); } catch (e) { console.warn('[Billing] job:', e.message); } }, 60_000);
++ first.unref();
++ timers.push(first);
++ }
++ relay.start();
}
-- relay.start();
--}
--const server = app.listen(config.port, config.host, () => {
-- const enabled = Object.values(adapters).filter((a) => a.enabled).map((a) => a.name);
-- console.log(`[Billing] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
-- console.log(`[Billing] providers enabled: ${enabled.length ? enabled.join(', ') : 'none'}; economy ${isFrozen(ctx.db) ? 'FROZEN' : 'open'}; events relay ${config.events.url ? `→ ${config.events.url}` : 'off (outbox accumulates)'}`);
-- console.log(`[Billing] money authority: ${config.authority}${config.authority === 'live' ? ' (shadow: EXTERNAL tips are recorded, not announced)' : ' (EXTERNAL tips are announced as billing.receipt.external)'}`);
--});
--server.keepAliveTimeout = 65_000;
++ const server = app.listen(config.port, config.host, async () => {
++ const enabled = Object.values(adapters).filter((a) => a.enabled).map((a) => a.name);
++ console.log(`[Billing] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (PostgreSQL)`);
++ console.log(`[Billing] providers enabled: ${enabled.length ? enabled.join(', ') : 'none'}; economy ${await isFrozen(ctx.db) ? 'FROZEN' : 'open'}; events relay ${config.events.url ? `→ ${config.events.url}` : 'off (outbox accumulates)'}`);
++ console.log(`[Billing] money authority: ${config.authority}${config.authority === 'live' ? ' (shadow: EXTERNAL tips are recorded, not announced)' : ' (EXTERNAL tips are announced as billing.receipt.external)'}`);
++ });
++ server.keepAliveTimeout = 65_000;
--function shutdown(signal) {
-- console.log(`[Billing] ${signal} — closing`);
-- timers.forEach((t) => { clearInterval(t); clearTimeout(t); });
-- relay.stop();
-- keys.stop();
-- server.close(() => { try { ctx.db.close(); } catch { /* */ } process.exit(0); });
-- setTimeout(() => process.exit(0), 5000).unref();
--}
--process.on('SIGTERM', () => shutdown('SIGTERM'));
--process.on('SIGINT', () => shutdown('SIGINT'));
++ function shutdown(signal) {
++ console.log(`[Billing] ${signal} — closing`);
++ timers.forEach((t) => { clearInterval(t); clearTimeout(t); });
++ relay.stop();
++ keys.stop();
++ server.close(() => { if (app.locals.valkey) app.locals.valkey.close().catch(() => {}); ctx.db.close().catch(() => {}).finally(() => process.exit(0)); });
++ setTimeout(() => process.exit(0), 5000).unref();
++ }
++ process.on('SIGTERM', () => shutdown('SIGTERM'));
++ process.on('SIGINT', () => shutdown('SIGINT'));
++})().catch((err) => {
++ console.error('[Billing] failed to start:', err);
++ process.exit(1);
++});
ddiff --git a/server/ledger.js b/server/ledger.js
iindex 4a612d1..99bdfdd 100644
---- a/server/ledger.js
++++ b/server/ledger.js
@@@ -13,13 +13,13 @@
* Invariants (enforced here, verified again by reconcile.js):
* - every transaction's entries sum to zero per currency;
* - entries are never updated or deleted (db triggers); corrections are reversing transactions;
-- * - account_balances changes only here, in the same SQLite transaction as the entries;
++ * - account_balances changes only here, in the same transaction as the entries;
* - a transaction's idempotency_key is unique: posting the same key again returns the
* original transaction and moves nothing;
* - every amount and every resulting balance is a safe integer (|n| <= 2^53 - 1): past that JS
-- * arithmetic silently rounds and SQLite would store a REAL, so such a posting is refused whole.
++ * arithmetic silently rounds (and the bigint columns would refuse it), so such a posting is refused whole.
*
-- * Callers run post() inside db.transaction() together with their funds checks, so a check and
++ * Callers run post() inside money() (a serializable transaction) together with their funds checks, so a check and
* the movement it guards are one atomic unit.
*/
const { ids } = require('openvibe-contracts');
@@@ -38,18 +38,18 @@ const iso = (ms) => new Date(ms).toISOString();
const newTxnId = (ms) => `txn_${ids.ulid(ms)}`;
const prefixedId = (prefix, ms) => `${prefix}_${ids.ulid(ms)}`;
--function accountId(db, { kind, owner = null, currency }, nowIso) {
-- const row = db.prepare('SELECT id FROM accounts WHERE kind = ? AND owner_subject IS ? AND currency = ?').get(kind, owner, currency);
++async function accountId(db, { kind, owner = null, currency }, nowIso) {
++ const row = await db.prepare('SELECT id FROM accounts WHERE kind = ? AND owner_subject IS NOT DISTINCT FROM ? AND currency = ?').get(kind, owner, currency);
if (row) return row.id;
-- const r = db.prepare('INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES (?, ?, ?, ?)').run(kind, owner, currency, nowIso || new Date().toISOString());
-- db.prepare('INSERT INTO account_balances (account_id, balance, updated_at) VALUES (?, 0, ?)').run(r.lastInsertRowid, nowIso || null);
++ const r = await db.prepare('INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES (?, ?, ?, ?) RETURNING id').run(kind, owner, currency, nowIso || new Date().toISOString());
++ await db.prepare('INSERT INTO account_balances (account_id, balance, updated_at) VALUES (?, 0, ?)').run(r.lastInsertRowid, nowIso || null);
return Number(r.lastInsertRowid);
}
/** Cached balance of an account (0 when it does not exist yet). */
--function balance(db, { kind, owner = null, currency }) {
-- const row = db.prepare(`SELECT b.balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
-- WHERE a.kind = ? AND a.owner_subject IS ? AND a.currency = ?`).get(kind, owner, currency);
++async function balance(db, { kind, owner = null, currency }) {
++ const row = await db.prepare(`SELECT b.balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
++ WHERE a.kind = ? AND a.owner_subject IS NOT DISTINCT FROM ? AND a.currency = ?`).get(kind, owner, currency);
return row ? row.balance : 0;
}
@@@ -58,31 +58,31 @@ function parseTxn(row) {
return { ...row, test: !!row.test, actor: JSON.parse(row.actor || '{}'), metadata: JSON.parse(row.metadata || '{}') };
}
--function entriesOf(db, txnId) {
-- return db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, e.amount FROM ledger_entries e
++async function entriesOf(db, txnId) {
++ return await db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, e.amount FROM ledger_entries e
JOIN accounts a ON a.id = e.account_id WHERE e.txn_id = ? ORDER BY a.currency, a.kind, a.owner_subject`).all(txnId);
}
--function getTxn(db, id) {
-- const t = parseTxn(db.prepare('SELECT * FROM transactions WHERE id = ?').get(id));
-- if (t) t.entries = entriesOf(db, id);
++async function getTxn(db, id) {
++ const t = parseTxn(await db.prepare('SELECT * FROM transactions WHERE id = ?').get(id));
++ if (t) t.entries = await entriesOf(db, id);
return t;
}
--function getTxnByKey(db, key) {
-- const row = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(key);
-- return row ? getTxn(db, row.id) : null;
++async function getTxnByKey(db, key) {
++ const row = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(key);
++ return row ? await getTxn(db, row.id) : null;
}
/**
* Post one balanced transaction. Returns { txn, replay }.
* Throws BillingError(500, 'ledger.unbalanced') when entries do not sum to zero per currency.
*/
--function post(ctx, spec) {
++async function post(ctx, spec) {
const { db } = ctx;
-- const run = () => {
++ const run = async () => {
if (!spec.idempotencyKey) throw new BillingError(500, 'ledger.no_key', 'every transaction needs an idempotency key');
-- const existing = getTxnByKey(db, spec.idempotencyKey);
++ const existing = await getTxnByKey(db, spec.idempotencyKey);
if (existing) return { txn: existing, replay: true };
const ms = ctx.now();
const nowIso = iso(ms);
@@@ -106,7 +106,7 @@ function post(ctx, spec) {
}
const id = newTxnId(ms);
-- db.prepare(`INSERT INTO transactions (id, type, status, idempotency_key, reverses_txn, test, actor, metadata,
++ await db.prepare(`INSERT INTO transactions (id, type, status, idempotency_key, reverses_txn, test, actor, metadata,
from_subject, to_subject, provider, receipt_ref, source_event_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
id, spec.type, spec.status || 'settled', spec.idempotencyKey, spec.reversesTxn || null, spec.test ? 1 : 0,
@@@ -119,24 +119,33 @@ function post(ctx, spec) {
for (const m of merged.values()) {
if (!Number.isSafeInteger(m.amount)) throw new BillingError(422, 'billing.amount_overflow', `the ${m.kind} entry overflows the exact integer range`);
if (m.amount === 0) continue;
-- if (!Number.isSafeInteger(balance(db, m) + m.amount)) {
++ if (!Number.isSafeInteger(await balance(db, m) + m.amount)) {
throw new BillingError(422, 'billing.amount_overflow', `${m.kind}${m.owner ? `:${m.owner}` : ''} would leave the exact integer range`);
}
}
for (const m of merged.values()) {
if (m.amount === 0) continue;
-- const acct = accountId(db, m, nowIso);
-- insEntry.run(id, acct, m.amount);
-- bump.run(m.amount, nowIso, acct);
++ const acct = await accountId(db, m, nowIso);
++ await insEntry.run(id, acct, m.amount);
++ await bump.run(m.amount, nowIso, acct);
}
-- return { txn: getTxn(db, id), replay: false };
++ return { txn: await getTxn(db, id), replay: false };
};
-- return db.inTransaction ? run() : db.transaction(run)();
++ return db.inTransaction() ? await run() : await money(db, run);
++}
++
++/**
++ * Money moves in a serializable transaction (openvibe-sdk/db retries it on a serialization failure or deadlock): the
++ * funds checks and the entries they allow commit together or not at all, whatever runs beside them. A replayed
++ * idempotency key finds the first transaction on the retry. fn is database work only (it may run more than once).
++ */
++async function money(db, fn) {
++ return await db.tx(fn, { isolation: 'serializable' });
}
/** Throws billing.insufficient_funds unless the account holds at least `amount`. */
--function requireFunds(db, account, amount, what = 'balance') {
-- const have = balance(db, account);
++async function requireFunds(db, account, amount, what = 'balance') {
++ const have = await balance(db, account);
if (have < amount) {
throw new BillingError(409, 'billing.insufficient_funds', `insufficient ${what}: ${have} < ${amount} ${account.currency}`, { available: have, required: amount });
}
@@@ -156,4 +165,4 @@ function present(txn) {
};
}
--module.exports = { BillingError, post, balance, accountId, getTxn, getTxnByKey, entriesOf, requireFunds, present, iso, prefixedId, newTxnId };
++module.exports = { BillingError, money, post, balance, accountId, getTxn, getTxnByKey, entriesOf, requireFunds, present, iso, prefixedId, newTxnId };
ddiff --git a/server/metrics.js b/server/metrics.js
iindex f43678b..9241af3 100644
---- a/server/metrics.js
++++ b/server/metrics.js
@@@ -26,44 +26,44 @@ const { latest } = require('./reconcile');
function createMetrics({ db, config, now = () => Date.now() }) {
const registry = createRegistry();
-- const one = (sql, ...a) => db.prepare(sql).get(...a).n;
++ const one = async (sql, ...a) => (await db.prepare(sql).get(...a)).n;
-- registry.gauge({ name: 'billing_frozen', help: 'Whether the economy is frozen (1) or open (0)', collect: () => (isFrozen(db) ? 1 : 0) });
++ registry.gauge({ name: 'billing_frozen', help: 'Whether the economy is frozen (1) or open (0)', collect: async () => (await isFrozen(db) ? 1 : 0) });
registry.gauge({
name: 'billing_authority', help: 'The configured money authority (BILLING_AUTHORITY); the value is always 1', labelNames: ['authority'],
collect: () => [{ labels: { authority: config.authority }, value: 1 }],
});
registry.gauge({
name: 'billing_provider_events', help: 'Stored provider receipts by provider and processing state', labelNames: ['provider', 'state'],
-- collect: () => db.prepare(`SELECT provider, CASE
++ collect: async () => (await db.prepare(`SELECT provider, CASE
WHEN processed_at IS NULL THEN 'pending'
WHEN json_extract(result, '$.effect') = 'rejected' THEN 'rejected'
-- WHEN json_extract(result, '$.review') = 1 THEN 'review'
++ WHEN json_extract(result, '$.review') IN ('true', '1') THEN 'review'
WHEN json_extract(result, '$.effect') IN ('settled', 'external') THEN json_extract(result, '$.effect')
ELSE 'other' END AS state, COUNT(*) AS n
-- FROM provider_events GROUP BY 1, 2`).all().map((r) => ({ labels: { provider: r.provider, state: r.state }, value: r.n })),
++ FROM provider_events GROUP BY 1, 2`).all()).map((r) => ({ labels: { provider: r.provider, state: r.state }, value: r.n })),
});
registry.gauge({
name: 'billing_provider_event_oldest_pending_seconds', help: 'Age of the oldest stored-but-unprocessed provider receipt (0 when none)',
-- collect: () => {
-- const r = db.prepare('SELECT MIN(received_at) AS at FROM provider_events WHERE processed_at IS NULL').get();
++ collect: async () => {
++ const r = await db.prepare('SELECT MIN(received_at) AS at FROM provider_events WHERE processed_at IS NULL').get();
return r && r.at ? Math.max(0, Math.round((now() - Date.parse(r.at)) / 1000)) : 0;
},
});
registry.gauge({
name: 'billing_external_receipts', help: 'EXTERNAL receipts (tips on a streamer\'s own provider account) by announcement status', labelNames: ['status'],
-- collect: () => ['announced', 'not_announced'].map((status) => ({ labels: { status }, value: one('SELECT COUNT(*) AS n FROM external_receipts WHERE status = ?', status) })),
++ collect: async () => (await Promise.all(['announced', 'not_announced'].map(async (status) => ({ labels: { status }, value: await one('SELECT COUNT(*) AS n FROM external_receipts WHERE status = ?', status) })))),
});
-- registry.gauge({ name: 'billing_outbox_pending', help: 'Outbox events not yet relayed to OpenVibe.Events', collect: () => one('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL') });
-- registry.gauge({ name: 'billing_outbox_failing', help: 'Unsent outbox events whose last relay attempt failed', collect: () => one('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL AND last_error IS NOT NULL') });
++ registry.gauge({ name: 'billing_outbox_pending', help: 'Outbox events not yet relayed to OpenVibe.Events', collect: async () => await one('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL') });
++ registry.gauge({ name: 'billing_outbox_failing', help: 'Unsent outbox events whose last relay attempt failed', collect: async () => await one('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL AND last_error IS NOT NULL') });
registry.gauge({
name: 'billing_cashouts', help: 'Cashouts by status', labelNames: ['status'],
-- collect: () => ['requested', 'paid', 'denied'].map((status) => ({ labels: { status }, value: one('SELECT COUNT(*) AS n FROM cashouts WHERE status = ?', status) })),
++ collect: async () => (await Promise.all(['requested', 'paid', 'denied'].map(async (status) => ({ labels: { status }, value: await one('SELECT COUNT(*) AS n FROM cashouts WHERE status = ?', status) })))),
});
// The latest stored reconciliation (scheduled or on demand). No run yet: the gauges are left out.
-- registry.gauge({ name: 'billing_reconciliation_ok', help: 'Whether the latest stored reconciliation passed (1) or failed (0)', collect: () => { const r = latest(db); return r ? (r.ok ? 1 : 0) : undefined; } });
-- registry.gauge({ name: 'billing_reconciliation_last_run_timestamp_seconds', help: 'When the latest stored reconciliation finished (Unix seconds)', collect: () => { const r = latest(db); return r ? Math.round(Date.parse(r.finished_at) / 1000) : undefined; } });
-- registry.gauge({ name: 'billing_reconciliation_failed_checks', help: 'Failed checks in the latest stored reconciliation', collect: () => { const r = latest(db); return r ? r.checks.filter((c) => !c.ok).length : undefined; } });
++ registry.gauge({ name: 'billing_reconciliation_ok', help: 'Whether the latest stored reconciliation passed (1) or failed (0)', collect: async () => { const r = await latest(db); return r ? (r.ok ? 1 : 0) : undefined; } });
++ registry.gauge({ name: 'billing_reconciliation_last_run_timestamp_seconds', help: 'When the latest stored reconciliation finished (Unix seconds)', collect: async () => { const r = await latest(db); return r ? Math.round(Date.parse(r.finished_at) / 1000) : undefined; } });
++ registry.gauge({ name: 'billing_reconciliation_failed_checks', help: 'Failed checks in the latest stored reconciliation', collect: async () => { const r = await latest(db); return r ? r.checks.filter((c) => !c.ok).length : undefined; } });
return { registry };
}
ddiff --git a/server/network.js b/server/network.js
iindex cea1e54..ff1fb09 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -37,7 +37,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
if (config.network.publicKey || timer) return;
const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
retry();
-- timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);
++ timer = setInterval(async () => { load(); }, 6 * 60 * 60 * 1000);
timer.unref();
}
function stop() { if (timer) clearInterval(timer); timer = null; }
@@@ -62,7 +62,7 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
-- if (res.status === 401 && !retried) { tokens.invalidate(); return post(body, true); }
++ if (res.status === 401 && !retried) { tokens.invalidate(); return await post(body, true); }
const data = await res.json().catch(() => null);
if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
return data.results;
ddiff --git a/server/ops/admin.js b/server/ops/admin.js
iindex 983beb2..697d61a 100644
---- a/server/ops/admin.js
++++ b/server/ops/admin.js
@@@ -7,22 +7,22 @@
* mandatory reason — the only way an operator changes a balance (e.g. resolving a chargeback
* flagged for review, or settling an import hold once its owner is known).
*/
--const { post, getTxn, iso } = require('../ledger');
++const { post, getTxn, iso, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { entry, fail, positiveInt, text } = require('./common');
const { ACCOUNT_KINDS, CURRENCIES } = require('../db');
const { summary } = require('./purchases');
--function freezeState(db) {
-- const r = db.prepare('SELECT * FROM settings WHERE id = 1').get();
++async function freezeState(db) {
++ const r = await db.prepare('SELECT * FROM settings WHERE id = 1').get();
return { frozen: !!r.freeze, reason: r.freeze_reason || null, frozen_at: r.frozen_at || null, frozen_by: r.frozen_by ? JSON.parse(r.frozen_by) : null };
}
--function setFreeze(ctx, { on, reason, actor }) {
++async function setFreeze(ctx, { on, reason, actor }) {
const { db } = ctx;
-- db.prepare('UPDATE settings SET freeze = ?, freeze_reason = ?, frozen_at = ?, frozen_by = ? WHERE id = 1')
++ await db.prepare('UPDATE settings SET "freeze" = ?, freeze_reason = ?, frozen_at = ?, frozen_by = ? WHERE id = 1')
.run(on ? 1 : 0, on ? text(reason, 'reason', 300) : null, on ? iso(ctx.now()) : null, on ? JSON.stringify(actor || {}) : null);
-- return freezeState(db);
++ return await freezeState(db);
}
function account(v, field) {
@@@ -33,17 +33,17 @@ function account(v, field) {
}
/** Move `amount` from `from` to `to` (same currency). */
--function adjust(ctx, input) {
++async function adjust(ctx, input) {
const from = account(input.from, 'from');
const to = account(input.to, 'to');
if (from.currency !== to.currency) fail(422, 'billing.invalid_input', 'an adjustment moves value within one currency');
const amount = positiveInt(input.amount, 'amount', 1_000_000_000);
const reason = text(input.reason, 'reason', 500);
if (!reason) fail(422, 'billing.invalid_input', 'an adjustment needs a reason');
-- return ctx.db.transaction(() => {
-- const existing = ctx.db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
-- if (existing) return { txn: getTxn(ctx.db, existing.id), replay: true };
-- const { txn } = post(ctx, {
++ return await money(ctx.db, async () => {
++ const existing = await ctx.db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { txn: await getTxn(ctx.db, existing.id), replay: true };
++ const { txn } = await post(ctx, {
type: 'adjustment',
idempotencyKey: input.idempotencyKey,
entries: [entry(from, -amount), entry(to, amount)],
@@@ -52,9 +52,9 @@ function adjust(ctx, input) {
toSubject: to.owner && to.owner.startsWith('usr_') ? to.owner : null,
metadata: { reason, amount, currency: from.currency, relates_to: input.relatesTo || null },
});
-- enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
++ await enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
return { txn, replay: false };
-- })();
++ });
}
module.exports = { freezeState, setFreeze, adjust };
ddiff --git a/server/ops/cashouts.js b/server/ops/cashouts.js
iindex 9ff34f9..2971a21 100644
---- a/server/ops/cashouts.js
++++ b/server/ops/cashouts.js
@@@ -9,7 +9,7 @@
* deny() reverses the request: payouts_pending → creator_payable
* recycle() creator_payable → user_credit, an explicit journal entry
*/
--const { post, getTxn, requireFunds, iso, prefixedId } = require('../ledger');
++const { post, getTxn, requireFunds, iso, prefixedId, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { A, entry, fail, positiveInt, text } = require('./common');
const { summary } = require('./purchases');
@@@ -17,7 +17,7 @@ const { summary } = require('./purchases');
function parse(row) {
return row ? { ...row, payout_method: JSON.parse(row.payout_method || '{}'), decided_by: row.decided_by ? JSON.parse(row.decided_by) : null } : null;
}
--function find(db, id) { return parse(db.prepare('SELECT * FROM cashouts WHERE id = ?').get(id)); }
++async function find(db, id) { return parse(await db.prepare('SELECT * FROM cashouts WHERE id = ?').get(id)); }
function present(c) {
if (!c) return null;
@@@ -51,19 +51,19 @@ function payoutMethod(v) {
return { type, address };
}
--function request(ctx, input) {
++async function request(ctx, input) {
const { db, rates } = ctx;
const amount = positiveInt(input.amount, 'amount', rates.maxBits);
if (amount < rates.minCashoutBits) fail(422, 'billing.amount_too_small', `the minimum cashout is ${rates.minCashoutBits} bits`);
const method = payoutMethod(input.payout_method);
-- return db.transaction(() => {
-- const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
-- if (existing) return { cashout: parse(db.prepare('SELECT * FROM cashouts WHERE request_txn = ?').get(existing.id)), replay: true };
-- requireFunds(db, A.payable(input.subject), amount, 'creator payable (only money given to you can be cashed out)');
++ return await money(db, async () => {
++ const existing = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { cashout: parse(await db.prepare('SELECT * FROM cashouts WHERE request_txn = ?').get(existing.id)), replay: true };
++ await requireFunds(db, A.payable(input.subject), amount, 'creator payable (only money given to you can be cashed out)');
const ms = ctx.now();
const id = prefixedId('co', ms);
const escrowUntil = iso(ms + rates.escrowDays * 86_400_000);
-- const { txn } = post(ctx, {
++ const { txn } = await post(ctx, {
type: 'cashout_request',
idempotencyKey: input.idempotencyKey,
entries: [entry(A.payable(input.subject), -amount), entry(A.pending(input.subject), amount)],
@@@ -71,27 +71,27 @@ function request(ctx, input) {
fromSubject: input.subject,
metadata: { cashout_id: id, amount_bits: amount, value_cents: rates.valueCents(amount), escrow_until: escrowUntil, escrow_days: rates.escrowDays, rates: rates.snapshot() },
});
-- db.prepare(`INSERT INTO cashouts (id, subject, amount_bits, value_cents, status, payout_method, escrow_until, request_txn, created_at, updated_at)
++ await db.prepare(`INSERT INTO cashouts (id, subject, amount_bits, value_cents, status, payout_method, escrow_until, request_txn, created_at, updated_at)
VALUES (?, ?, ?, ?, 'requested', ?, ?, ?, ?, ?)`).run(id, input.subject, amount, rates.valueCents(amount), JSON.stringify(method), escrowUntil, txn.id, iso(ms), iso(ms));
-- const cashout = find(db, id);
-- enqueue(ctx, { event_type: 'billing.cashout.requested', subject: { type: 'cashout', id }, payload: { cashout: eventView(cashout), transaction_id: txn.id } });
++ const cashout = await find(db, id);
++ await enqueue(ctx, { event_type: 'billing.cashout.requested', subject: { type: 'cashout', id }, payload: { cashout: eventView(cashout), transaction_id: txn.id } });
return { cashout, replay: false };
-- })();
++ });
}
--function approve(ctx, input) {
++async function approve(ctx, input) {
const { db, rates } = ctx;
const ref = text(input.payout_reference, 'payout_reference', 200);
if (!ref) fail(422, 'billing.payout_reference_required', 'approving a payout needs the payout reference from the provider');
const provider = String(input.payout_provider || 'paypal').toLowerCase();
-- return db.transaction(() => {
-- const c = find(db, input.id);
++ return await money(db, async () => {
++ const c = await find(db, input.id);
if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${input.id}`);
if (c.status === 'paid' && c.payout_reference === ref) return { cashout: c, replay: true };
if (c.status !== 'requested') fail(409, 'billing.cashout_not_pending', `cashout ${c.id} is ${c.status}`);
if (ctx.now() < Date.parse(c.escrow_until)) fail(409, 'billing.escrow_active', `cashout ${c.id} is in escrow until ${c.escrow_until}`, { escrow_until: c.escrow_until });
const value = c.value_cents;
-- const { txn } = post(ctx, {
++ const { txn } = await post(ctx, {
type: 'cashout_paid',
idempotencyKey: input.idempotencyKey,
entries: [
@@@ -104,23 +104,23 @@ function approve(ctx, input) {
receiptRef: `${provider}_payouts:${ref}`,
metadata: { cashout_id: c.id, amount_bits: c.amount_bits, value_cents: value, payout_reference: ref, rates: rates.snapshot() },
});
-- db.prepare(`UPDATE cashouts SET status = 'paid', settle_txn = ?, payout_provider = ?, payout_reference = ?, decided_by = ?, updated_at = ? WHERE id = ?`)
++ await db.prepare(`UPDATE cashouts SET status = 'paid', settle_txn = ?, payout_provider = ?, payout_reference = ?, decided_by = ?, updated_at = ? WHERE id = ?`)
.run(txn.id, provider, ref, JSON.stringify(input.actor || {}), iso(ctx.now()), c.id);
-- const cashout = find(db, c.id);
-- enqueue(ctx, { event_type: 'billing.cashout.paid', subject: { type: 'cashout', id: c.id }, payload: { cashout: eventView(cashout), transaction_id: txn.id } });
++ const cashout = await find(db, c.id);
++ await enqueue(ctx, { event_type: 'billing.cashout.paid', subject: { type: 'cashout', id: c.id }, payload: { cashout: eventView(cashout), transaction_id: txn.id } });
return { cashout, replay: false };
-- })();
++ });
}
--function deny(ctx, input) {
++async function deny(ctx, input) {
const { db } = ctx;
-- return db.transaction(() => {
-- const c = find(db, input.id);
++ return await money(db, async () => {
++ const c = await find(db, input.id);
if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${input.id}`);
if (c.status === 'denied') return { cashout: c, replay: true };
if (c.status !== 'requested') fail(409, 'billing.cashout_not_pending', `cashout ${c.id} is ${c.status}`);
const reason = text(input.reason, 'reason', 300);
-- const { txn } = post(ctx, {
++ const { txn } = await post(ctx, {
type: 'cashout_denied',
idempotencyKey: input.idempotencyKey,
reversesTxn: c.request_txn,
@@@ -129,23 +129,23 @@ function deny(ctx, input) {
toSubject: c.subject,
metadata: { cashout_id: c.id, amount_bits: c.amount_bits, reason },
});
-- db.prepare(`UPDATE cashouts SET status = 'denied', settle_txn = ?, reason = ?, decided_by = ?, updated_at = ? WHERE id = ?`)
++ await db.prepare(`UPDATE cashouts SET status = 'denied', settle_txn = ?, reason = ?, decided_by = ?, updated_at = ? WHERE id = ?`)
.run(txn.id, reason, JSON.stringify(input.actor || {}), iso(ctx.now()), c.id);
-- const cashout = find(db, c.id);
-- enqueue(ctx, { event_type: 'billing.cashout.denied', subject: { type: 'cashout', id: c.id }, payload: { cashout: eventView(cashout), transaction_id: txn.id } });
-- enqueue(ctx, { event_type: 'billing.transaction.reversed', subject: { type: 'transaction', id: txn.id }, payload: { ...summary(txn), reverses_txn: c.request_txn } });
++ const cashout = await find(db, c.id);
++ await enqueue(ctx, { event_type: 'billing.cashout.denied', subject: { type: 'cashout', id: c.id }, payload: { cashout: eventView(cashout), transaction_id: txn.id } });
++ await enqueue(ctx, { event_type: 'billing.transaction.reversed', subject: { type: 'transaction', id: txn.id }, payload: { ...summary(txn), reverses_txn: c.request_txn } });
return { cashout, replay: false };
-- })();
++ });
}
--function recycle(ctx, input) {
++async function recycle(ctx, input) {
const { db, rates } = ctx;
const amount = positiveInt(input.amount, 'amount', rates.maxBits);
-- return db.transaction(() => {
-- const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
-- if (existing) return { txn: getTxn(db, existing.id), replay: true };
-- requireFunds(db, A.payable(input.subject), amount, 'creator payable');
-- const { txn } = post(ctx, {
++ return await money(db, async () => {
++ const existing = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { txn: await getTxn(db, existing.id), replay: true };
++ await requireFunds(db, A.payable(input.subject), amount, 'creator payable');
++ const { txn } = await post(ctx, {
type: 'recycle',
idempotencyKey: input.idempotencyKey,
entries: [entry(A.payable(input.subject), -amount), entry(A.credit(input.subject), amount)],
@@@ -154,18 +154,18 @@ function recycle(ctx, input) {
toSubject: input.subject,
metadata: { amount_bits: amount },
});
-- enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
++ await enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
return { txn, replay: false };
-- })();
++ });
}
--function list(db, { status, subject, limit = 100 } = {}) {
++async function list(db, { status, subject, limit = 100 } = {}) {
const where = [];
const args = [];
if (status) { where.push('status = ?'); args.push(status); }
if (subject) { where.push('subject = ?'); args.push(subject); }
-- return db.prepare(`SELECT * FROM cashouts ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at ASC LIMIT ?`)
-- .all(...args, Math.min(500, limit)).map(parse);
++ return (await db.prepare(`SELECT * FROM cashouts ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at ASC LIMIT ?`)
++ .all(...args, Math.min(500, limit))).map(parse);
}
module.exports = { request, approve, deny, recycle, find, list, present };
ddiff --git a/server/ops/common.js b/server/ops/common.js
iindex f8c26ac..7ad3136 100644
---- a/server/ops/common.js
++++ b/server/ops/common.js
@@@ -73,11 +73,11 @@ function text(v, field, max = 300) {
return s || null;
}
--function isFrozen(db) {
-- return !!db.prepare('SELECT freeze FROM settings WHERE id = 1').get().freeze;
++async function isFrozen(db) {
++ return !!(await db.prepare('SELECT "freeze" FROM settings WHERE id = 1').get()).freeze;
}
--function assertNotFrozen(ctx) {
-- if (isFrozen(ctx.db)) fail(503, 'billing.frozen', 'the economy is frozen: writes are refused, reads are served');
++async function assertNotFrozen(ctx) {
++ if (await isFrozen(ctx.db)) fail(503, 'billing.frozen', 'the economy is frozen: writes are refused, reads are served');
}
/** Movement summary for a subject from a transaction's entries (used in API responses). */
ddiff --git a/server/ops/external.js b/server/ops/external.js
iindex 2848879..2194f3f 100644
---- a/server/ops/external.js
++++ b/server/ops/external.js
@@@ -17,7 +17,7 @@
* }
*
* OpenVibe.Tips consumes it (the chat line through Live's /internal/tips/deliveries, the overlay
-- * alert, goal progress). Written to the outbox in the same SQLite transaction that marks the
++ * alert, goal progress). Written to the outbox in the same transaction that marks the
* provider event processed.
*
* Announced at most once per payment: external_receipts is keyed by receipt_ref, so a redelivery
@@@ -35,32 +35,32 @@ const { MAX_RECEIPT_CENTS, fail, userSubject } = require('./common');
const EVENT_TYPE = 'billing.receipt.external';
/** The creator a provider account belongs to, by account id, then by (lower-case) username. */
--function accountSubject(db, provider, { id, username } = {}) {
++async function accountSubject(db, provider, { id, username } = {}) {
if (id != null && id !== '') {
-- const r = db.prepare('SELECT subject FROM provider_accounts WHERE provider = ? AND account_id = ?').get(provider, String(id));
++ const r = await db.prepare('SELECT subject FROM provider_accounts WHERE provider = ? AND account_id = ?').get(provider, String(id));
if (r) return r.subject;
}
const name = String(username || '').trim().toLowerCase();
if (!name) return null;
-- const r = db.prepare('SELECT subject FROM provider_accounts WHERE provider = ? AND username = ?').get(provider, name);
++ const r = await db.prepare('SELECT subject FROM provider_accounts WHERE provider = ? AND username = ?').get(provider, name);
return r ? r.subject : null;
}
const USERNAME_RE = /^[a-z0-9_.-]{1,64}$/;
/** Add or correct a mapping (operator or importer). Returns the stored row. */
--function mapAccount(ctx, { provider, username, accountId, subject, source, liveUserId }) {
++async function mapAccount(ctx, { provider, username, accountId, subject, source, liveUserId }) {
const { db } = ctx;
const name = String(username || '').trim().toLowerCase();
if (!USERNAME_RE.test(name)) fail(422, 'billing.invalid_input', 'username must be the provider account name (letters, digits, _ . -)');
const owner = userSubject(subject, 'subject');
const at = iso(ctx.now());
-- db.prepare(`INSERT INTO provider_accounts (provider, username, account_id, subject, source, live_user_id, created_at, updated_at)
++ await db.prepare(`INSERT INTO provider_accounts (provider, username, account_id, subject, source, live_user_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
-- ON CONFLICT (provider, username) DO UPDATE SET account_id = COALESCE(excluded.account_id, account_id), subject = excluded.subject,
-- source = excluded.source, live_user_id = COALESCE(excluded.live_user_id, live_user_id), updated_at = excluded.updated_at`)
++ ON CONFLICT (provider, username) DO UPDATE SET account_id = COALESCE(excluded.account_id, provider_accounts.account_id), subject = excluded.subject,
++ source = excluded.source, live_user_id = COALESCE(excluded.live_user_id, provider_accounts.live_user_id), updated_at = excluded.updated_at`)
.run(provider, name, accountId != null && accountId !== '' ? String(accountId).slice(0, 100) : null, owner, source, liveUserId || null, at, at);
-- return db.prepare('SELECT * FROM provider_accounts WHERE provider = ? AND username = ?').get(provider, name);
++ return await db.prepare('SELECT * FROM provider_accounts WHERE provider = ? AND username = ?').get(provider, name);
}
/**
@@@ -68,21 +68,21 @@ function mapAccount(ctx, { provider, username, accountId, subject, source, liveU
* sourceEventId, account: { id, username }, streamer?, amountCents, donorName, anonymous, message,
* appRef, appPurpose, occurredAt, test }. Returns the provider event result.
*/
--function record(ctx, input) {
++async function record(ctx, input) {
const { db, config, rates } = ctx;
const cents = Number(input.amountCents);
if (cents === 0) return { effect: 'none', reason: 'EXTERNAL: a zero-amount tip on the streamer\'s own PowerChat — nothing to record' };
if (!Number.isSafeInteger(cents) || cents < 1 || cents > MAX_RECEIPT_CENTS) {
return { effect: 'none', review: true, reason: `EXTERNAL tip with an amount outside 1..${MAX_RECEIPT_CENTS} cents (${String(input.amountCents).slice(0, 40)}) — not recorded, held for review` };
}
-- const prev = db.prepare('SELECT * FROM external_receipts WHERE receipt_ref = ?').get(input.receiptRef);
++ const prev = await db.prepare('SELECT * FROM external_receipts WHERE receipt_ref = ?').get(input.receiptRef);
if (prev && prev.status === 'announced') {
return { effect: 'duplicate_receipt', external: true, announced: false, reason: `payment ${input.receiptRef} was already announced (provider event ${prev.provider_event})` };
}
const username = String((input.account && input.account.username) || '').trim().toLowerCase() || null;
// The creator: named by the caller (a direct-subscription intent already verified the account),
// else whoever the receiving account is mapped to.
-- const streamer = input.streamer || accountSubject(db, input.provider, input.account || {});
++ const streamer = input.streamer || await accountSubject(db, input.provider, input.account || {});
let reason = null;
let review = false;
if (config.authority !== 'billing') {
@@@ -96,7 +96,7 @@ function record(ctx, input) {
let env = null;
if (!reason) {
const anonymous = !!input.anonymous;
-- env = enqueue(ctx, {
++ env = await enqueue(ctx, {
event_type: EVENT_TYPE,
subject: { type: 'provider_receipt', id: input.receiptRef },
payload: {
@@@ -123,10 +123,10 @@ function record(ctx, input) {
}
const status = env ? 'announced' : 'not_announced';
if (prev) {
-- db.prepare('UPDATE external_receipts SET status = ?, reason = ?, event_id = ?, streamer_subject = ?, updated_at = ? WHERE receipt_ref = ?')
++ await db.prepare('UPDATE external_receipts SET status = ?, reason = ?, event_id = ?, streamer_subject = ?, updated_at = ? WHERE receipt_ref = ?')
.run(status, reason, env ? env.event_id : null, streamer, at, input.receiptRef);
} else {
-- db.prepare(`INSERT INTO external_receipts (receipt_ref, provider, provider_event, receiving_account, streamer_subject, amount_cents, test, status, reason, event_id, created_at, updated_at)
++ await db.prepare(`INSERT INTO external_receipts (receipt_ref, provider, provider_event, receiving_account, streamer_subject, amount_cents, test, status, reason, event_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(input.receiptRef, input.provider, input.sourceEventId, username, streamer, cents, input.test ? 1 : 0, status, reason, env ? env.event_id : null, at, at);
}
ddiff --git a/server/ops/intents.js b/server/ops/intents.js
iindex e1e5e0d..df15d20 100644
---- a/server/ops/intents.js
++++ b/server/ops/intents.js
@@@ -15,17 +15,17 @@ function parse(row) {
}
/** By id, or by a Live order id for intents imported from payment_orders. */
--function find(db, ref) {
++async function find(db, ref) {
const s = String(ref || '');
-- if (/^\d+$/.test(s)) return parse(db.prepare('SELECT * FROM payment_intents WHERE legacy_order_id = ?').get(Number(s)));
-- return parse(db.prepare('SELECT * FROM payment_intents WHERE id = ?').get(s));
++ if (/^\d+$/.test(s)) return parse(await db.prepare('SELECT * FROM payment_intents WHERE legacy_order_id = ?').get(Number(s)));
++ return parse(await db.prepare('SELECT * FROM payment_intents WHERE id = ?').get(s));
}
--function findByProviderRef(db, provider, ref) {
-- return parse(db.prepare('SELECT * FROM payment_intents WHERE provider = ? AND provider_ref = ?').get(provider, ref));
++async function findByProviderRef(db, provider, ref) {
++ return parse(await db.prepare('SELECT * FROM payment_intents WHERE provider = ? AND provider_ref = ?').get(provider, ref));
}
--function create(ctx, input) {
++async function create(ctx, input) {
const { db, rates } = ctx;
const provider = String(input.provider || '').toLowerCase();
if (!/^[a-z][a-z0-9_-]{1,39}$/.test(provider)) fail(422, 'billing.invalid_input', 'provider is required');
@@@ -60,11 +60,11 @@ function create(ctx, input) {
}
row.metadata = { rates: rates.snapshot({ price_tiers: kind === 'purchase' ? rates.priceTiers : undefined, sub_share_pct: rates.subSharePct, site_route_fee_pct: rates.siteRouteFeePct }) };
if (row.receiving_account) row.metadata.receiving_account = row.receiving_account;
-- db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route,
++ await db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route,
auto_renew, status, metadata, created_at, updated_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)`).run(row.id, provider, kind, subject, row.streamer_subject,
row.amount_cents, row.fee_cents, row.bits, row.route, row.auto_renew, JSON.stringify(row.metadata), iso(ms), iso(ms));
-- return find(db, row.id);
++ return await find(db, row.id);
}
/**
@@@ -82,18 +82,18 @@ function refuseIfSettledInLive(i) {
}
}
--function setProviderRef(ctx, id, ref) {
-- ctx.db.prepare('UPDATE payment_intents SET provider_ref = ?, updated_at = ? WHERE id = ?').run(ref, iso(ctx.now()), id);
++async function setProviderRef(ctx, id, ref) {
++ await ctx.db.prepare('UPDATE payment_intents SET provider_ref = ?, updated_at = ? WHERE id = ?').run(ref, iso(ctx.now()), id);
}
--function mergeMetadata(ctx, id, extra) {
-- const row = find(ctx.db, id);
-- ctx.db.prepare('UPDATE payment_intents SET metadata = ?, updated_at = ? WHERE id = ?').run(JSON.stringify({ ...row.metadata, ...extra }), iso(ctx.now()), id);
++async function mergeMetadata(ctx, id, extra) {
++ const row = await find(ctx.db, id);
++ await ctx.db.prepare('UPDATE payment_intents SET metadata = ?, updated_at = ? WHERE id = ?').run(JSON.stringify({ ...row.metadata, ...extra }), iso(ctx.now()), id);
}
--function setStatus(ctx, id, status) {
-- ctx.db.prepare('UPDATE payment_intents SET status = ?, updated_at = ? WHERE id = ?').run(status, iso(ctx.now()), id);
++async function setStatus(ctx, id, status) {
++ await ctx.db.prepare('UPDATE payment_intents SET status = ?, updated_at = ? WHERE id = ?').run(status, iso(ctx.now()), id);
}
--function markSettled(ctx, id, txnId) {
-- ctx.db.prepare("UPDATE payment_intents SET status = 'settled', settled_txn = ?, updated_at = ? WHERE id = ?").run(txnId, iso(ctx.now()), id);
++async function markSettled(ctx, id, txnId) {
++ await ctx.db.prepare("UPDATE payment_intents SET status = 'settled', settled_txn = ?, updated_at = ? WHERE id = ?").run(txnId, iso(ctx.now()), id);
}
function present(i) {
ddiff --git a/server/ops/purchases.js b/server/ops/purchases.js
iindex 4da513a..33ed30c 100644
---- a/server/ops/purchases.js
++++ b/server/ops/purchases.js
@@@ -11,28 +11,28 @@
* - an explicit `bits` when its value is covered by the payment;
* - otherwise what the payment buys under the price tiers (never the package on faith).
*/
--const { post, getTxn, BillingError, present } = require('../ledger');
++const { post, getTxn, BillingError, present, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { A, MAX_RECEIPT_CENTS, receiptEntries, fail, positiveInt } = require('./common');
const intents = require('./intents');
--function settle(ctx, input) {
++async function settle(ctx, input) {
const { db, rates } = ctx;
-- return db.transaction(() => {
++ return await money(db, async () => {
const receiptRef = input.receiptRef;
if (!receiptRef) fail(422, 'billing.invalid_input', 'a provider receipt reference is required');
-- const dup = db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(receiptRef);
-- if (dup) return { txn: getTxn(db, dup.id), replay: true, duplicateReceipt: true };
++ const dup = await db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(receiptRef);
++ if (dup) return { txn: await getTxn(db, dup.id), replay: true, duplicateReceipt: true };
const paidCents = positiveInt(input.paidCents, 'amount_cents', MAX_RECEIPT_CENTS);
let intent = null;
if (input.intentId) {
-- intent = intents.find(db, input.intentId);
++ intent = await intents.find(db, input.intentId);
if (!intent) fail(404, 'billing.intent_not_found', `no payment intent ${input.intentId}`);
if (intent.kind !== 'purchase') fail(422, 'billing.intent_mismatch', `intent ${intent.id} is a ${intent.kind}, not a purchase`);
if (intent.provider !== input.provider) fail(422, 'billing.intent_mismatch', `intent ${intent.id} belongs to ${intent.provider}, not ${input.provider}`);
if (input.subject && input.subject !== intent.subject) fail(422, 'billing.intent_mismatch', 'the intent belongs to another subject');
-- if (intent.status === 'settled' && intent.settled_txn) return { txn: getTxn(db, intent.settled_txn), replay: true };
++ if (intent.status === 'settled' && intent.settled_txn) return { txn: await getTxn(db, intent.settled_txn), replay: true };
intents.refuseIfSettledInLive(intent);
}
const subject = intent ? intent.subject : input.subject;
@@@ -50,7 +50,7 @@ function settle(ctx, input) {
if (bits < 1) fail(422, 'billing.amount_too_small', `a payment of ${paidCents} cents buys no Vibes`);
if (bits > rates.maxBits) fail(422, 'billing.invalid_amount', `bits exceed the maximum of ${rates.maxBits}`);
-- const { txn } = post(ctx, {
++ const { txn } = await post(ctx, {
type: 'purchase',
idempotencyKey: input.idempotencyKey,
entries: receiptEntries(rates, { provider: input.provider, paidCents, bits, target: A.credit(subject) }),
@@@ -65,10 +65,10 @@ function settle(ctx, input) {
pricing: basis, intent_id: intent ? intent.id : null, rates: rates.snapshot(), ...(input.metadata || {}),
},
});
-- if (intent) intents.markSettled(ctx, intent.id, txn.id);
-- enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn), traceId: input.traceId });
++ if (intent) await intents.markSettled(ctx, intent.id, txn.id);
++ await enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn), traceId: input.traceId });
return { txn, replay: false };
-- })();
++ });
}
function summary(txn) {
ddiff --git a/server/ops/reversals.js b/server/ops/reversals.js
iindex f45ae9f..e8e03d1 100644
---- a/server/ops/reversals.js
++++ b/server/ops/reversals.js
@@@ -14,30 +14,30 @@
*
* Partial reversals are supported; the cumulative reversed amount never exceeds what was paid.
*/
--const { post, getTxn, balance } = require('../ledger');
++const { post, getTxn, balance, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { A, entry, fail } = require('./common');
const { summary } = require('./purchases');
const intents = require('./intents');
--function reversedCents(db, txnId) {
-- return db.prepare(`SELECT COALESCE(SUM(json_extract(metadata, '$.cents')), 0) AS n FROM transactions WHERE reverses_txn = ? AND type IN ('refund', 'chargeback')`).get(txnId).n;
++async function reversedCents(db, txnId) {
++ return (await db.prepare(`SELECT COALESCE(SUM(json_extract(metadata, '$.cents')::bigint), 0)::bigint AS n FROM transactions WHERE reverses_txn = ? AND type IN ('refund', 'chargeback')`).get(txnId)).n;
}
/**
* input: { kind: 'refund'|'chargeback', provider, originalReceiptRef, reversalRef, cents?, cumulativeCents?,
* idempotencyKey, sourceEventId, actor, reason }
*/
--function reverseReceipt(ctx, input) {
++async function reverseReceipt(ctx, input) {
const { db, rates } = ctx;
-- return db.transaction(() => {
-- const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ? OR (receipt_ref IS NOT NULL AND receipt_ref = ?)').get(input.idempotencyKey, input.reversalRef || null);
-- if (existing) return { txn: getTxn(db, existing.id), replay: true };
-- const origRow = db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(input.originalReceiptRef);
++ return await money(db, async () => {
++ const existing = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ? OR (receipt_ref IS NOT NULL AND receipt_ref = ?)').get(input.idempotencyKey, input.reversalRef || null);
++ if (existing) return { txn: await getTxn(db, existing.id), replay: true };
++ const origRow = await db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(input.originalReceiptRef);
if (!origRow) fail(404, 'billing.original_not_found', `no settled receipt ${input.originalReceiptRef}`);
-- const orig = getTxn(db, origRow.id);
++ const orig = await getTxn(db, origRow.id);
const paid = Number(orig.metadata.paid_cents) || 0;
-- const done = reversedCents(db, orig.id);
++ const done = await reversedCents(db, orig.id);
const remaining = paid - done;
let cents = input.cumulativeCents != null ? Number(input.cumulativeCents) - done : (input.cents != null ? Number(input.cents) : remaining);
cents = Math.min(Math.round(cents), remaining);
@@@ -54,7 +54,7 @@ function reverseReceipt(ctx, input) {
const B = Number(orig.metadata.bits) || 0;
const b = Math.round((B * (done + cents)) / paid) - Math.round((B * done) / paid);
const owner = orig.to_subject;
-- const clawed = Math.max(0, Math.min(balance(db, A.credit(owner)), b));
++ const clawed = Math.max(0, Math.min(await balance(db, A.credit(owner)), b));
const unrecovered = b - clawed;
const nonFx = cents - rates.valueCents(clawed);
entries.push(entry(A.credit(owner), -clawed), entry(A.fxBits(), clawed), entry(A.fxCents(), -rates.valueCents(clawed)), entry(A.clearing(orig.provider), cents));
@@@ -79,7 +79,7 @@ function reverseReceipt(ctx, input) {
}
if (review) meta.review = 'required';
-- const { txn } = post(ctx, {
++ const { txn } = await post(ctx, {
type: kind,
idempotencyKey: input.idempotencyKey,
reversesTxn: orig.id,
@@@ -93,14 +93,14 @@ function reverseReceipt(ctx, input) {
sourceEventId: input.sourceEventId,
metadata: { ...meta, rates: rates.snapshot() },
});
-- if (orig.type === 'subscription') revoked = require('./subscriptions').revokeForTxn(ctx, orig.id, kind);
-- if (done + cents >= paid && orig.metadata.intent_id) intents.setStatus(ctx, orig.metadata.intent_id, 'refunded');
-- enqueue(ctx, {
++ if (orig.type === 'subscription') revoked = await require('./subscriptions').revokeForTxn(ctx, orig.id, kind);
++ if (done + cents >= paid && orig.metadata.intent_id) await intents.setStatus(ctx, orig.metadata.intent_id, 'refunded');
++ await enqueue(ctx, {
event_type: 'billing.transaction.reversed', subject: { type: 'transaction', id: txn.id },
payload: { ...summary(txn), reverses_txn: orig.id, review: review ? 'required' : null, entitlements_revoked: revoked },
});
return { txn, replay: false, review };
-- })();
++ });
}
module.exports = { reverseReceipt, reversedCents };
ddiff --git a/server/ops/subscriptions.js b/server/ops/subscriptions.js
iindex db7420a..778f169 100644
---- a/server/ops/subscriptions.js
++++ b/server/ops/subscriptions.js
@@@ -16,7 +16,7 @@
* Entitlement truth is the entitlements table: one row per granted period; a subject is
* entitled to a streamer while a non-revoked row covers now. It needs nothing from Live.
*/
--const { post, getTxn, requireFunds, iso, prefixedId } = require('../ledger');
++const { post, getTxn, requireFunds, iso, prefixedId, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { A, MAX_RECEIPT_CENTS, entry, receiptEntries, fail, positiveInt } = require('./common');
const { summary } = require('./purchases');
@@@ -25,9 +25,9 @@ const intents = require('./intents');
const KIND = 'channel_subscription';
function parse(row) { return row ? { ...row, auto_renew: !!row.auto_renew, cancel_at_period_end: !!row.cancel_at_period_end } : null; }
--function find(db, id) { return parse(db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id)); }
--function findPair(db, subscriber, streamer) { return parse(db.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(subscriber, streamer)); }
--function findByProviderRef(db, provider, ref) { return ref ? parse(db.prepare('SELECT * FROM subscriptions WHERE provider = ? AND provider_ref = ?').get(provider, ref)) : null; }
++async function find(db, id) { return parse(await db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id)); }
++async function findPair(db, subscriber, streamer) { return parse(await db.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(subscriber, streamer)); }
++async function findByProviderRef(db, provider, ref) { return ref ? parse(await db.prepare('SELECT * FROM subscriptions WHERE provider = ? AND provider_ref = ?').get(provider, ref)) : null; }
function present(s) {
if (!s) return null;
@@@ -40,21 +40,21 @@ function present(s) {
}
/** Entitlement of `subject` to `streamer` at `ms`. */
--function entitlement(db, subject, streamer, ms) {
++async function entitlement(db, subject, streamer, ms) {
const at = iso(ms);
-- const row = db.prepare(`SELECT MAX(ends_at) AS ends_at, MIN(starts_at) AS starts_at FROM entitlements
++ const row = await db.prepare(`SELECT MAX(ends_at) AS ends_at, MIN(starts_at) AS starts_at FROM entitlements
WHERE subject = ? AND kind = ? AND scope = ? AND revoked_at IS NULL AND starts_at <= ? AND ends_at > ?`).get(subject, KIND, streamer, at, at);
// Chained future periods (a renewal paid early) extend the expiry.
let expires = row && row.ends_at;
if (expires) {
for (;;) {
-- const next = db.prepare(`SELECT MAX(ends_at) AS e FROM entitlements WHERE subject = ? AND kind = ? AND scope = ? AND revoked_at IS NULL
-- AND starts_at <= ? AND ends_at > ?`).get(subject, KIND, streamer, expires, expires).e;
++ const next = (await db.prepare(`SELECT MAX(ends_at) AS e FROM entitlements WHERE subject = ? AND kind = ? AND scope = ? AND revoked_at IS NULL
++ AND starts_at <= ? AND ends_at > ?`).get(subject, KIND, streamer, expires, expires)).e;
if (!next) break;
expires = next;
}
}
-- const sub = findPair(db, subject, streamer);
++ const sub = await findPair(db, subject, streamer);
return {
subject: { type: 'user', id: subject }, streamer: { type: 'user', id: streamer }, kind: KIND,
active: !!expires, expires_at: expires || null,
@@@ -62,10 +62,10 @@ function entitlement(db, subject, streamer, ms) {
};
}
--function activeEntitlements(db, subject, ms) {
++async function activeEntitlements(db, subject, ms) {
const at = iso(ms);
-- return db.prepare(`SELECT DISTINCT scope FROM entitlements WHERE subject = ? AND kind = ? AND revoked_at IS NULL AND starts_at <= ? AND ends_at > ?`)
-- .all(subject, KIND, at, at).map((r) => entitlement(db, subject, r.scope, ms));
++ return (await Promise.all((await db.prepare(`SELECT DISTINCT scope FROM entitlements WHERE subject = ? AND kind = ? AND revoked_at IS NULL AND starts_at <= ? AND ends_at > ?`)
++ .all(subject, KIND, at, at)).map(async (r) => await entitlement(db, subject, r.scope, ms))));
}
/**
@@@ -73,22 +73,22 @@ function activeEntitlements(db, subject, ms) {
* receipt?: { provider, receiptRef, paidCents, feeCents, route, providerRef, test, periodEnd },
* intentId?, idempotencyKey, actor, sourceEventId, renewal? }
*/
--function pay(ctx, input) {
++async function pay(ctx, input) {
const { db, rates } = ctx;
const { subscriber, streamer } = input;
if (subscriber === streamer) fail(422, 'billing.self_dealing', 'you cannot subscribe to yourself');
-- return db.transaction(() => {
-- const existingTxn = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
-- if (existingTxn) return result(db, getTxn(db, existingTxn.id), ctx, true);
-- if (input.intentId) intents.refuseIfSettledInLive(intents.find(db, input.intentId));
++ return await money(db, async () => {
++ const existingTxn = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existingTxn) return await result(db, await getTxn(db, existingTxn.id), ctx, true);
++ if (input.intentId) intents.refuseIfSettledInLive(await intents.find(db, input.intentId));
const r = input.receipt;
if (r) {
-- const dup = db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(r.receiptRef);
-- if (dup) return { ...result(db, getTxn(db, dup.id), ctx, true), duplicateReceipt: true };
++ const dup = await db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(r.receiptRef);
++ if (dup) return { ...await result(db, await getTxn(db, dup.id), ctx, true), duplicateReceipt: true };
}
const ms = ctx.now();
const priceCents = input.priceCents != null ? positiveInt(input.priceCents, 'price_cents', 10_000_000) : rates.subPriceCents;
-- const prior = findPair(db, subscriber, streamer);
++ const prior = await findPair(db, subscriber, streamer);
let entries = [];
let provider = 'credit';
@@@ -97,7 +97,7 @@ function pay(ctx, input) {
if (input.source === 'credit') {
const cost = rates.bitsForValueCents(priceCents);
const share = Math.min(cost, rates.subShareBits(priceCents));
-- requireFunds(db, A.credit(subscriber), cost, 'credit');
++ await requireFunds(db, A.credit(subscriber), cost, 'credit');
entries = [entry(A.credit(subscriber), -cost), entry(A.payable(streamer), share), entry(A.revenue('vibes-bits'), cost - share)];
meta = { source: 'credit', price_cents: priceCents, cost_bits: cost, share_bits: share, sub_share_pct: rates.subSharePct };
} else if (input.source === 'receipt' && r) {
@@@ -124,7 +124,7 @@ function pay(ctx, input) {
const end = r && r.periodEnd ? Math.max(Date.parse(r.periodEnd), start + 1) : start + rates.subPeriodDays * 86_400_000;
const renewal = !!(prior && (prior.status === 'active' || input.renewal));
-- const { txn } = post(ctx, {
++ const { txn } = await post(ctx, {
type: 'subscription',
idempotencyKey: input.idempotencyKey,
entries,
@@@ -142,34 +142,34 @@ function pay(ctx, input) {
const autoRenew = input.autoRenew != null ? (input.autoRenew ? 1 : 0) : (prior ? (prior.auto_renew ? 1 : 0) : (input.source === 'credit' ? 1 : 0));
let sub;
if (prior) {
-- db.prepare(`UPDATE subscriptions SET provider = ?, provider_ref = COALESCE(?, provider_ref), route = ?, status = 'active', auto_renew = ?,
++ await db.prepare(`UPDATE subscriptions SET provider = ?, provider_ref = COALESCE(?, provider_ref), route = ?, status = 'active', auto_renew = ?,
cancel_at_period_end = 0, price_cents = ?, current_period_end = ?, updated_at = ? WHERE id = ?`)
.run(provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, prior.id);
-- sub = find(db, prior.id);
++ sub = await find(db, prior.id);
} else {
const id = prefixedId('sub', ms);
-- db.prepare(`INSERT INTO subscriptions (id, subscriber, streamer, tier, provider, provider_ref, route, status, auto_renew, cancel_at_period_end,
++ await db.prepare(`INSERT INTO subscriptions (id, subscriber, streamer, tier, provider, provider_ref, route, status, auto_renew, cancel_at_period_end,
price_cents, current_period_end, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?, ?, 'active', ?, 0, ?, ?, ?, ?)`)
.run(id, subscriber, streamer, provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, nowIso);
-- sub = find(db, id);
++ sub = await find(db, id);
}
-- db.prepare(`INSERT INTO entitlements (id, subject, kind, scope, subscription_id, starts_at, ends_at, source_txn, created_at)
++ await db.prepare(`INSERT INTO entitlements (id, subject, kind, scope, subscription_id, starts_at, ends_at, source_txn, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prefixedId('ent', ms), subscriber, KIND, streamer, sub.id, iso(start), iso(end), txn.id, nowIso);
-- if (input.intentId) intents.markSettled(ctx, input.intentId, txn.id);
++ if (input.intentId) await intents.markSettled(ctx, input.intentId, txn.id);
-- if (txn.entries.length) enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
-- enqueue(ctx, {
++ if (txn.entries.length) await enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
++ await enqueue(ctx, {
event_type: 'billing.entitlement.changed', subject: { type: 'entitlement', id: `${subscriber}:${KIND}:${streamer}` },
-- payload: { ...entitlement(db, subscriber, streamer, ms), reason: renewal ? 'renewed' : 'granted', transaction_id: txn.id },
++ payload: { ...await entitlement(db, subscriber, streamer, ms), reason: renewal ? 'renewed' : 'granted', transaction_id: txn.id },
});
-- return result(db, txn, ctx, false);
-- })();
++ return await result(db, txn, ctx, false);
++ });
}
--function result(db, txn, ctx, replay) {
-- const ent = db.prepare('SELECT * FROM entitlements WHERE source_txn = ?').get(txn.id);
-- const sub = ent ? find(db, ent.subscription_id) : null;
-- return { txn, subscription: sub, entitlement: sub ? entitlement(db, sub.subscriber, sub.streamer, ctx.now()) : null, replay };
++async function result(db, txn, ctx, replay) {
++ const ent = await db.prepare('SELECT * FROM entitlements WHERE source_txn = ?').get(txn.id);
++ const sub = ent ? await find(db, ent.subscription_id) : null;
++ return { txn, subscription: sub, entitlement: sub ? await entitlement(db, sub.subscriber, sub.streamer, ctx.now()) : null, replay };
}
/**
@@@ -178,7 +178,7 @@ function result(db, txn, ctx, replay) {
*/
async function cancel(ctx, { id, actor }, adapters) {
const { db } = ctx;
-- const sub = find(db, id);
++ const sub = await find(db, id);
if (!sub) fail(404, 'billing.subscription_not_found', `no subscription ${id}`);
if (sub.status !== 'active') return { subscription: sub, provider_sync: 'not_needed', replay: true };
let providerSync = 'not_needed';
@@@ -190,47 +190,47 @@ async function cancel(ctx, { id, actor }, adapters) {
catch (e) { fail(502, 'billing.provider_error', `Stripe refused the cancellation: ${e.message}`); }
providerSync = 'stripe_cancel_at_period_end';
}
-- db.transaction(() => {
-- db.prepare('UPDATE subscriptions SET cancel_at_period_end = 1, auto_renew = 0, updated_at = ? WHERE id = ?').run(iso(ctx.now()), id);
-- enqueue(ctx, {
++ await money(db, async () => {
++ await db.prepare('UPDATE subscriptions SET cancel_at_period_end = 1, auto_renew = 0, updated_at = ? WHERE id = ?').run(iso(ctx.now()), id);
++ await enqueue(ctx, {
event_type: 'billing.subscription.canceled', subject: { type: 'subscription', id },
-- payload: { subscription: present(find(db, id)), provider_sync: providerSync, actor },
++ payload: { subscription: present(await find(db, id)), provider_sync: providerSync, actor },
});
-- })();
-- return { subscription: find(db, id), provider_sync: providerSync, replay: false };
++ });
++ return { subscription: await find(db, id), provider_sync: providerSync, replay: false };
}
--function setStatus(ctx, id, status, reason) {
++async function setStatus(ctx, id, status, reason) {
const { db } = ctx;
-- const sub = find(db, id);
++ const sub = await find(db, id);
if (!sub || sub.status === status) return sub;
-- db.prepare('UPDATE subscriptions SET status = ?, auto_renew = CASE WHEN ? = \'active\' THEN auto_renew ELSE 0 END, updated_at = ? WHERE id = ?')
++ await db.prepare('UPDATE subscriptions SET status = ?, auto_renew = CASE WHEN ? = \'active\' THEN auto_renew ELSE 0 END, updated_at = ? WHERE id = ?')
.run(status, status, iso(ctx.now()), id);
-- enqueue(ctx, {
++ await enqueue(ctx, {
event_type: 'billing.entitlement.changed', subject: { type: 'entitlement', id: `${sub.subscriber}:${KIND}:${sub.streamer}` },
-- payload: { ...entitlement(db, sub.subscriber, sub.streamer, ctx.now()), reason: reason || status },
++ payload: { ...await entitlement(db, sub.subscriber, sub.streamer, ctx.now()), reason: reason || status },
});
-- return find(db, id);
++ return await find(db, id);
}
/** Revoke the periods a reversed payment granted. Returns the number of periods revoked. */
--function revokeForTxn(ctx, txnId, reason) {
++async function revokeForTxn(ctx, txnId, reason) {
const { db } = ctx;
-- const ents = db.prepare('SELECT * FROM entitlements WHERE source_txn = ? AND revoked_at IS NULL').all(txnId);
++ const ents = await db.prepare('SELECT * FROM entitlements WHERE source_txn = ? AND revoked_at IS NULL').all(txnId);
if (!ents.length) return 0;
const at = iso(ctx.now());
for (const e of ents) {
-- db.prepare('UPDATE entitlements SET revoked_at = ?, revoked_reason = ? WHERE id = ?').run(at, reason, e.id);
-- const sub = find(db, e.subscription_id);
++ await db.prepare('UPDATE entitlements SET revoked_at = ?, revoked_reason = ? WHERE id = ?').run(at, reason, e.id);
++ const sub = await find(db, e.subscription_id);
if (sub) {
-- const left = db.prepare('SELECT MAX(ends_at) AS e FROM entitlements WHERE subscription_id = ? AND revoked_at IS NULL').get(sub.id).e;
++ const left = (await db.prepare('SELECT MAX(ends_at) AS e FROM entitlements WHERE subscription_id = ? AND revoked_at IS NULL').get(sub.id)).e;
const stillActive = left && Date.parse(left) > ctx.now();
-- db.prepare('UPDATE subscriptions SET current_period_end = ?, status = ?, auto_renew = CASE WHEN ? THEN auto_renew ELSE 0 END, updated_at = ? WHERE id = ?')
++ await db.prepare('UPDATE subscriptions SET current_period_end = ?, status = ?, auto_renew = CASE WHEN ? THEN auto_renew ELSE 0 END, updated_at = ? WHERE id = ?')
.run(left || at, stillActive ? sub.status : 'expired', stillActive ? 1 : 0, at, sub.id);
}
-- enqueue(ctx, {
++ await enqueue(ctx, {
event_type: 'billing.entitlement.changed', subject: { type: 'entitlement', id: `${e.subject}:${KIND}:${e.scope}` },
-- payload: { ...entitlement(db, e.subject, e.scope, ctx.now()), reason, revoked_period: { starts_at: e.starts_at, ends_at: e.ends_at } },
++ payload: { ...await entitlement(db, e.subject, e.scope, ctx.now()), reason, revoked_period: { starts_at: e.starts_at, ends_at: e.ends_at } },
});
}
return ents.length;
@@@ -243,24 +243,24 @@ function revokeForTxn(ctx, txnId, reason) {
* - otherwise renew from the subscriber's credit (deterministic key per period), or end it
* when the credit does not cover the price.
*/
--function sweep(ctx, { limit = 200 } = {}) {
++async function sweep(ctx, { limit = 200 } = {}) {
const { db, rates } = ctx;
const out = { renewed: [], expired: [], canceled: [], skipped: 0 };
const now = ctx.now();
-- const due = db.prepare("SELECT * FROM subscriptions WHERE status = 'active' AND current_period_end IS NOT NULL AND current_period_end <= ? ORDER BY current_period_end LIMIT ?")
-- .all(iso(now), limit).map(parse);
++ const due = (await db.prepare("SELECT * FROM subscriptions WHERE status = 'active' AND current_period_end IS NOT NULL AND current_period_end <= ? ORDER BY current_period_end LIMIT ?")
++ .all(iso(now), limit)).map(parse);
for (const sub of due) {
const end = Date.parse(sub.current_period_end);
if (sub.provider === 'stripe') {
if (now - end < rates.stripeGraceDays * 86_400_000) { out.skipped++; continue; }
-- setStatus(ctx, sub.id, 'expired', 'stripe_not_renewed'); out.expired.push(sub.id); continue;
++ await setStatus(ctx, sub.id, 'expired', 'stripe_not_renewed'); out.expired.push(sub.id); continue;
}
if (sub.cancel_at_period_end || !sub.auto_renew) {
const st = sub.cancel_at_period_end ? 'canceled' : 'expired';
-- setStatus(ctx, sub.id, st, st); (st === 'canceled' ? out.canceled : out.expired).push(sub.id); continue;
++ await setStatus(ctx, sub.id, st, st); (st === 'canceled' ? out.canceled : out.expired).push(sub.id); continue;
}
try {
-- pay(ctx, {
++ await pay(ctx, {
subscriber: sub.subscriber, streamer: sub.streamer, source: 'credit', priceCents: sub.price_cents || rates.subPriceCents,
autoRenew: true, renewal: true, idempotencyKey: `renew:${sub.id}:${sub.current_period_end}`,
actor: { principal: 'svc:billing', job: 'renewal-sweep' },
@@@ -268,19 +268,19 @@ function sweep(ctx, { limit = 200 } = {}) {
out.renewed.push(sub.id);
} catch (e) {
if (e.code !== 'billing.insufficient_funds') throw e;
-- setStatus(ctx, sub.id, 'expired', 'renewal_insufficient_credit'); out.expired.push(sub.id);
++ await setStatus(ctx, sub.id, 'expired', 'renewal_insufficient_credit'); out.expired.push(sub.id);
}
}
return out;
}
--function list(db, { subscriber, streamer, status } = {}) {
++async function list(db, { subscriber, streamer, status } = {}) {
const where = [];
const args = [];
if (subscriber) { where.push('subscriber = ?'); args.push(subscriber); }
if (streamer) { where.push('streamer = ?'); args.push(streamer); }
if (status) { where.push('status = ?'); args.push(status); }
-- return db.prepare(`SELECT * FROM subscriptions ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC LIMIT 500`).all(...args).map(parse);
++ return (await db.prepare(`SELECT * FROM subscriptions ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC LIMIT 500`).all(...args)).map(parse);
}
module.exports = { pay, cancel, sweep, entitlement, activeEntitlements, revokeForTxn, setStatus, find, findPair, findByProviderRef, list, present, KIND };
ddiff --git a/server/ops/transfers.js b/server/ops/transfers.js
iindex 6447b37..a5523f4 100644
---- a/server/ops/transfers.js
++++ b/server/ops/transfers.js
@@@ -11,14 +11,14 @@
* their payable (MONEY); the tipper may be unknown.
*/
const { validate } = require('openvibe-contracts');
--const { post, getTxn, requireFunds } = require('../ledger');
++const { post, getTxn, requireFunds, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { A, MAX_RECEIPT_CENTS, entry, receiptEntries, fail, positiveInt, text } = require('./common');
const { summary } = require('./purchases');
const KINDS = ['tip', 'donation', 'paid_interaction'];
--function create(ctx, input) {
++async function create(ctx, input) {
const { db, rates } = ctx;
const amount = positiveInt(input.amount, 'amount', rates.maxBits);
const from = input.from;
@@@ -33,11 +33,11 @@ function create(ctx, input) {
target = input.target;
}
const message = text(input.message, 'message', 500);
-- return db.transaction(() => {
-- const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
-- if (existing) return { txn: getTxn(db, existing.id), replay: true };
-- requireFunds(db, A.credit(from), amount, 'credit');
-- const { txn } = post(ctx, {
++ return await money(db, async () => {
++ const existing = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { txn: await getTxn(db, existing.id), replay: true };
++ await requireFunds(db, A.credit(from), amount, 'credit');
++ const { txn } = await post(ctx, {
type: 'donation',
idempotencyKey: input.idempotencyKey,
entries: [entry(A.credit(from), -amount), entry(A.payable(to), amount)],
@@@ -46,25 +46,25 @@ function create(ctx, input) {
toSubject: to,
metadata: { kind, amount_bits: amount, target, message, rates: rates.snapshot() },
});
-- enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn), traceId: input.traceId });
++ await enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn), traceId: input.traceId });
return { txn, replay: false };
-- })();
++ });
}
--function refund(ctx, input) {
++async function refund(ctx, input) {
const { db } = ctx;
-- return db.transaction(() => {
-- const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
-- if (existing) return { txn: getTxn(db, existing.id), replay: true };
-- const orig = getTxn(db, input.txnId);
++ return await money(db, async () => {
++ const existing = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { txn: await getTxn(db, existing.id), replay: true };
++ const orig = await getTxn(db, input.txnId);
if (!orig || orig.type !== 'donation') fail(404, 'billing.transaction_not_found', `no donation ${input.txnId}`);
if (!orig.from_subject || orig.receipt_ref) fail(422, 'billing.not_refundable', 'only credit-funded transfers are refunded this way; provider receipts are reversed by their provider events');
-- const done = db.prepare(`SELECT COALESCE(SUM(json_extract(metadata, '$.amount_bits')), 0) AS n FROM transactions WHERE reverses_txn = ? AND type = 'refund'`).get(orig.id).n;
++ const done = (await db.prepare(`SELECT COALESCE(SUM(json_extract(metadata, '$.amount_bits')::bigint), 0)::bigint AS n FROM transactions WHERE reverses_txn = ? AND type = 'refund'`).get(orig.id)).n;
const remaining = orig.metadata.amount_bits - done;
const amount = input.amount != null ? positiveInt(input.amount, 'amount') : remaining;
if (amount > remaining) fail(409, 'billing.already_reversed', `only ${remaining} bits of ${orig.id} remain refundable`);
-- requireFunds(db, A.payable(orig.to_subject), amount, "recipient's payable");
-- const { txn } = post(ctx, {
++ await requireFunds(db, A.payable(orig.to_subject), amount, "recipient's payable");
++ const { txn } = await post(ctx, {
type: 'refund',
idempotencyKey: input.idempotencyKey,
reversesTxn: orig.id,
@@@ -75,20 +75,20 @@ function refund(ctx, input) {
test: orig.test,
metadata: { amount_bits: amount, reason: text(input.reason, 'reason', 300) },
});
-- enqueue(ctx, { event_type: 'billing.transaction.reversed', subject: { type: 'transaction', id: txn.id }, payload: { ...summary(txn), reverses_txn: orig.id } });
++ await enqueue(ctx, { event_type: 'billing.transaction.reversed', subject: { type: 'transaction', id: txn.id }, payload: { ...summary(txn), reverses_txn: orig.id } });
return { txn, replay: false };
-- })();
++ });
}
--function fromReceipt(ctx, input) {
++async function fromReceipt(ctx, input) {
const { db, rates } = ctx;
-- return db.transaction(() => {
-- const dup = db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(input.receiptRef);
-- if (dup) return { txn: getTxn(db, dup.id), replay: true, duplicateReceipt: true };
++ return await money(db, async () => {
++ const dup = await db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(input.receiptRef);
++ if (dup) return { txn: await getTxn(db, dup.id), replay: true, duplicateReceipt: true };
const paidCents = positiveInt(input.paidCents, 'amount_cents', MAX_RECEIPT_CENTS);
if (input.from && input.from === input.to) fail(422, 'billing.self_dealing', 'a creator cannot route a tip to themselves');
const bits = rates.bitsForValueCents(paidCents);
-- const { txn } = post(ctx, {
++ const { txn } = await post(ctx, {
type: 'donation',
idempotencyKey: input.idempotencyKey,
entries: receiptEntries(rates, { provider: input.provider, paidCents, bits, target: A.payable(input.to) }),
@@@ -101,9 +101,9 @@ function fromReceipt(ctx, input) {
sourceEventId: input.sourceEventId,
metadata: { kind: 'tip', route: 'site', paid_cents: paidCents, amount_bits: bits, rates: rates.snapshot(), ...(input.metadata || {}) },
});
-- enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
++ await enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
return { txn, replay: false };
-- })();
++ });
}
module.exports = { create, refund, fromReceipt };
ddiff --git a/server/outbox.js b/server/outbox.js
iindex 37714fe..93bca5a 100644
---- a/server/outbox.js
++++ b/server/outbox.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * Transactional outbox (events.event-envelope@1). enqueue() is called inside the same SQLite
++ * Transactional outbox (events.event-envelope@1). enqueue() is called inside the same
* transaction as the money movement it announces, so an event exists exactly when its effect
* does. The relay publishes unsent rows to OpenVibe.Events (POST /api/v1/events, service token
* for audience openvibe.events with events.event.publish) and runs only when EVENTS_URL is set;
@@@ -13,7 +13,7 @@ const { ids, validate, serviceAuth } = require('openvibe-contracts');
const ACTOR = { type: 'service', id: 'billing' };
/** actor defaults to the service itself; staff actions pass the person ({ type: 'user', id }). */
--function enqueue(ctx, { event_type, subject, payload, priority = 'important', traceId, actor }) {
++async function enqueue(ctx, { event_type, subject, payload, priority = 'important', traceId, actor }) {
const ms = ctx.now();
const env = {
event_id: ids.newId('event', ms),
@@@ -30,7 +30,7 @@ function enqueue(ctx, { event_type, subject, payload, priority = 'important', tr
if (traceId && /^[0-9a-f]{32}$/.test(traceId)) env.trace_id = traceId;
const v = validate('events.event-envelope@1', env);
if (!v.valid) throw new Error(`outbox: invalid envelope for ${event_type}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
-- ctx.db.prepare('INSERT INTO outbox (event_id, event, created_at) VALUES (?, ?, ?)').run(env.event_id, JSON.stringify(env), env.timestamp);
++ await ctx.db.prepare('INSERT INTO outbox (event_id, event, created_at) VALUES (?, ?, ?)').run(env.event_id, JSON.stringify(env), env.timestamp);
return env;
}
@@@ -53,7 +53,7 @@ function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, lo
let sent = 0;
try {
for (;;) {
-- const rows = db.prepare('SELECT seq, event_id, event FROM outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100').all();
++ const rows = await db.prepare('SELECT seq, event_id, event FROM outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100').all();
if (!rows.length) break;
const body = { events: rows.map((r) => JSON.parse(r.event)) };
let res;
@@@ -65,18 +65,18 @@ function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, lo
signal: AbortSignal.timeout(10000),
});
} catch (e) {
-- markFailed(rows, e.message);
++ await markFailed(rows, e.message);
break;
}
if (res.status === 401) tokens.invalidate();
if (!res.ok) {
const text = await res.text().catch(() => '');
-- markFailed(rows, `${res.status} ${text.slice(0, 300)}`);
++ await markFailed(rows, `${res.status} ${text.slice(0, 300)}`);
break;
}
const at = new Date().toISOString();
const mark = db.prepare('UPDATE outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?');
-- db.transaction(() => { for (const r of rows) mark.run(at, r.seq); })();
++ await db.tx(async () => { for (const r of rows) await mark.run(at, r.seq); });
sent += rows.length;
}
} finally {
@@@ -85,9 +85,9 @@ function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, lo
return { sent };
}
-- function markFailed(rows, message) {
++ async function markFailed(rows, message) {
const mark = db.prepare('UPDATE outbox SET attempts = attempts + 1, last_error = ? WHERE seq = ?');
-- db.transaction(() => { for (const r of rows) mark.run(String(message).slice(0, 500), r.seq); })();
++ await db.tx(async () => { for (const r of rows) await mark.run(String(message).slice(0, 500), r.seq); });
log.warn(`[Billing] outbox relay: ${rows.length} event(s) not published: ${message}`);
}
ddiff --git a/server/providers/ccbill.js b/server/providers/ccbill.js
iindex 78b0357..57a3414 100644
---- a/server/providers/ccbill.js
++++ b/server/providers/ccbill.js
@@@ -55,7 +55,7 @@ function createCcbill(cfg) {
};
}
if (type !== 'NewSaleSuccess') return { effect: 'none', reason: `${type || 'event'} is not handled` };
-- const intent = intents.find(ctx.db, p['X-intent'] || p['X-order'] || p.order);
++ const intent = await intents.find(ctx.db, p['X-intent'] || p['X-order'] || p.order);
if (!intent) return { effect: 'none', reason: 'sale without a Billing intent' };
if (price == null) return { effect: 'none', reason: 'sale carried no price — not credited', review: true };
if (!txId) return { effect: 'none', reason: 'sale without a transaction id', review: true };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex ac4617c..f4fe32c 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -6,7 +6,7 @@
* receive() verify → store the delivery in provider_events BEFORE doing anything with it
* (UNIQUE(provider, provider_event_id): a redelivery is stored once)
* process() interpret the stored payload into a plan, then apply the plan and mark the event
-- * processed in ONE SQLite transaction. Plans settle through the same operations the
++ * processed in ONE transaction. Plans settle through the same operations the
* API uses, keyed pe:<provider>:<event id>, and every settling transaction carries its
* provider receipt reference (unique) and source_event_id.
* While the economy is frozen events are stored but not processed; processPending() runs them in
@@@ -19,13 +19,13 @@
* none (no money effect: test, echo, not handled; `review` marks ones an operator should see) |
* rejected (a 4xx business refusal, e.g. a refund for an unknown payment).
*
-- * Races: the stored row is the lock. Applying a plan re-reads processed_at inside an IMMEDIATE
-- * transaction (the write lock is taken first, so a second process or connection waits rather than
-- * failing to upgrade), and a payment's receipt_ref is UNIQUE in the journal, so concurrent deliveries
++ * Races: the stored row is the lock. Applying a plan re-reads processed_at inside a serializable
++ * transaction that locks the row first (FOR UPDATE: a second process or connection waits, then retries
++ * and finds it processed), and a payment's receipt_ref is UNIQUE in the journal, so concurrent deliveries
* of one payment — same delivery id or not, same process or not — settle it once.
*/
const crypto = require('crypto');
--const { iso, BillingError } = require('../ledger');
++const { iso, BillingError, money } = require('../ledger');
const { isFrozen } = require('../ops/common');
const purchases = require('../ops/purchases');
const transfers = require('../ops/transfers');
@@@ -52,27 +52,27 @@ function createAdapters(config, deps = {}) {
function parseRow(row) { return row ? { ...row, result: row.result ? JSON.parse(row.result) : null } : null; }
/** Store a verified delivery. Returns { row, duplicate }. */
--function store(ctx, provider, { eventId, type, payload }) {
++async function store(ctx, provider, { eventId, type, payload }) {
const text = JSON.stringify(payload);
const hash = crypto.createHash('sha256').update(text).digest('hex');
-- const r = ctx.db.prepare(`INSERT OR IGNORE INTO provider_events (provider, provider_event_id, type, payload_hash, payload, received_at)
-- VALUES (?, ?, ?, ?, ?, ?)`).run(provider, eventId, type, hash, text, iso(ctx.now()));
-- const row = parseRow(ctx.db.prepare('SELECT * FROM provider_events WHERE provider = ? AND provider_event_id = ?').get(provider, eventId));
++ const r = await ctx.db.prepare(`INSERT INTO provider_events (provider, provider_event_id, type, payload_hash, payload, received_at)
++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).run(provider, eventId, type, hash, text, iso(ctx.now()));
++ const row = parseRow(await ctx.db.prepare('SELECT * FROM provider_events WHERE provider = ? AND provider_event_id = ?').get(provider, eventId));
return { row, duplicate: r.changes === 0 };
}
--function applyPlan(ctx, plan, row) {
++async function applyPlan(ctx, plan, row) {
const withEvent = (args) => ({ ...args, sourceEventId: row.id });
const settled = (r) => (r.replay ? { effect: 'duplicate_receipt', txn_id: r.txn && r.txn.id } : { effect: 'settled', txn_id: r.txn.id });
switch (plan.effect) {
case 'none': return { effect: 'none', reason: plan.reason, review: plan.review || plan.hold || undefined };
case 'update': plan.apply(ctx); return { effect: 'updated', reason: plan.reason };
-- case 'purchase': return settled(purchases.settle(ctx, withEvent(plan.args)));
-- case 'subscription': return settled(subscriptions.pay(ctx, withEvent(plan.args)));
-- case 'tip': return settled(transfers.fromReceipt(ctx, withEvent(plan.args)));
-- case 'external': return external.record(ctx, withEvent(plan.args));
++ case 'purchase': return settled(await purchases.settle(ctx, withEvent(plan.args)));
++ case 'subscription': return settled(await subscriptions.pay(ctx, withEvent(plan.args)));
++ case 'tip': return settled(await transfers.fromReceipt(ctx, withEvent(plan.args)));
++ case 'external': return await external.record(ctx, withEvent(plan.args));
case 'reverse': {
-- const r = reversals.reverseReceipt(ctx, withEvent(plan.args));
++ const r = await reversals.reverseReceipt(ctx, withEvent(plan.args));
if (r.noop) return { effect: 'none', reason: r.noop };
return { ...settled(r), review: r.review || undefined };
}
@@@ -83,50 +83,51 @@ function applyPlan(ctx, plan, row) {
/** Process one stored event. Returns the updated row (processed or not). */
async function process(ctx, adapters, rowOrId) {
const { db } = ctx;
-- let row = typeof rowOrId === 'object' ? rowOrId : parseRow(db.prepare('SELECT * FROM provider_events WHERE id = ?').get(rowOrId));
++ let row = typeof rowOrId === 'object' ? rowOrId : parseRow(await db.prepare('SELECT * FROM provider_events WHERE id = ?').get(rowOrId));
if (!row || row.processed_at) return row;
-- if (isFrozen(db)) return row;
++ if (await isFrozen(db)) return row;
const adapter = adapters[row.provider];
try {
if (!adapter) throw new Error(`no adapter for ${row.provider}`);
const plan = await adapter.interpret(ctx, JSON.parse(row.payload), row);
-- db.transaction(() => {
-- const fresh = db.prepare('SELECT processed_at FROM provider_events WHERE id = ?').get(row.id);
++ // Serializable, with the event row locked: a second process of the same event waits, then sees it processed.
++ await money(db, async () => {
++ const fresh = await db.prepare('SELECT processed_at FROM provider_events WHERE id = ? FOR UPDATE').get(row.id);
if (fresh.processed_at) return;
let result;
try {
-- result = db.transaction(() => applyPlan(ctx, plan, row))();
++ result = await db.tx(async () => await applyPlan(ctx, plan, row)); // a savepoint: a refusal undoes only the plan
} catch (e) {
if (!(e instanceof BillingError) || e.status >= 500) throw e;
result = { effect: 'rejected', code: e.code, reason: e.detail || e.message };
}
-- db.prepare('UPDATE provider_events SET processed_at = ?, result = ?, attempts = attempts + 1, last_error = NULL WHERE id = ?')
++ await db.prepare('UPDATE provider_events SET processed_at = ?, result = ?, attempts = attempts + 1, last_error = NULL WHERE id = ?')
.run(iso(ctx.now()), JSON.stringify(result), row.id);
-- }).immediate();
++ });
} catch (e) {
-- db.prepare('UPDATE provider_events SET attempts = attempts + 1, last_error = ? WHERE id = ?').run(String(e.message).slice(0, 500), row.id);
++ await db.prepare('UPDATE provider_events SET attempts = attempts + 1, last_error = ? WHERE id = ?').run(String(e.message).slice(0, 500), row.id);
(ctx.log || console).warn(`[Billing] ${row.provider} event ${row.provider_event_id} not processed: ${e.message}`);
}
-- row = parseRow(db.prepare('SELECT * FROM provider_events WHERE id = ?').get(row.id));
++ row = parseRow(await db.prepare('SELECT * FROM provider_events WHERE id = ?').get(row.id));
return row;
}
/** Process every stored-but-unprocessed event in arrival order. */
async function processPending(ctx, adapters, { limit = 500 } = {}) {
-- if (isFrozen(ctx.db)) return { processed: 0, frozen: true };
-- const rows = ctx.db.prepare('SELECT * FROM provider_events WHERE processed_at IS NULL ORDER BY id LIMIT ?').all(limit).map(parseRow);
++ if (await isFrozen(ctx.db)) return { processed: 0, frozen: true };
++ const rows = (await ctx.db.prepare('SELECT * FROM provider_events WHERE processed_at IS NULL ORDER BY id LIMIT ?').all(limit)).map(parseRow);
let processed = 0;
for (const r of rows) {
const after = await process(ctx, adapters, r);
if (after && after.processed_at) processed++;
-- if (isFrozen(ctx.db)) break;
++ if (await isFrozen(ctx.db)) break;
}
return { processed, pending: rows.length - processed };
}
/** Reprocess an event (operator): only an unprocessed or rejected one; settled events never re-apply. */
async function reprocess(ctx, adapters, id) {
-- const row = parseRow(ctx.db.prepare('SELECT * FROM provider_events WHERE id = ?').get(id));
++ const row = parseRow(await ctx.db.prepare('SELECT * FROM provider_events WHERE id = ?').get(id));
if (!row) throw new BillingError(404, 'billing.event_not_found', `no provider event ${id}`);
// An EXTERNAL receipt that was not announced (e.g. its account was mapped since) may be retried;
// one that was announced never is (external_receipts would answer duplicate_receipt anyway).
@@@ -134,8 +135,8 @@ async function reprocess(ctx, adapters, id) {
if (row.processed_at && row.result && !retryable(row.result)) {
throw new BillingError(409, 'billing.event_settled', `event ${id} already had effect ${row.result.effect}`);
}
-- if (row.processed_at) ctx.db.prepare('UPDATE provider_events SET processed_at = NULL WHERE id = ?').run(id);
-- return process(ctx, adapters, id);
++ if (row.processed_at) await ctx.db.prepare('UPDATE provider_events SET processed_at = NULL WHERE id = ?').run(id);
++ return await process(ctx, adapters, id);
}
function present(row) {
ddiff --git a/server/providers/nowpayments.js b/server/providers/nowpayments.js
iindex 6e0780f..bff40a6 100644
---- a/server/providers/nowpayments.js
++++ b/server/providers/nowpayments.js
@@@ -45,7 +45,7 @@ function createNowpayments(cfg, { fetchImpl = globalThis.fetch } = {}) {
return { effect: 'reverse', args: { kind: 'refund', provider: 'nowpayments', originalReceiptRef: `nowpayments:${b.payment_id}`, reversalRef: `nowpayments:refund:${b.payment_id}`, idempotencyKey: key, actor, reason: 'refunded' } };
}
if (!['finished', 'confirmed', 'sending'].includes(status)) return { effect: 'none', reason: `payment ${status}` };
-- const intent = intents.find(ctx.db, b.order_id);
++ const intent = await intents.find(ctx.db, b.order_id);
if (!intent) return { effect: 'none', reason: 'payment without a Billing intent' };
if (String(b.price_currency || 'usd').toLowerCase() !== 'usd' || b.price_amount == null) return { effect: 'none', reason: 'payment without a USD price', review: true };
const receipt = { provider: 'nowpayments', receiptRef: `nowpayments:${b.payment_id}`, paidCents: Math.round(Number(b.price_amount) * 100) };
ddiff --git a/server/providers/paypal.js b/server/providers/paypal.js
iindex 513274b..291b9cd 100644
---- a/server/providers/paypal.js
++++ b/server/providers/paypal.js
@@@ -52,8 +52,8 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
return { eventId: event.id ? String(event.id) : null, type: String(event.event_type || 'unknown'), payload: event };
}
-- function settlePlan(ctx, capture, key, actor) {
-- const intent = intents.find(ctx.db, capture.custom_id || (capture.supplementary_data && capture.supplementary_data.related_ids && capture.supplementary_data.related_ids.order_id));
++ async function settlePlan(ctx, capture, key, actor) {
++ const intent = await intents.find(ctx.db, capture.custom_id || (capture.supplementary_data && capture.supplementary_data.related_ids && capture.supplementary_data.related_ids.order_id));
if (!intent) return { effect: 'none', reason: 'capture without a Billing intent' };
const paid = capture.amount && capture.amount.currency_code === 'USD' ? cents(capture.amount.value) : null;
if (!paid) return { effect: 'none', reason: 'capture without a USD amount' };
@@@ -67,7 +67,7 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
const r = event.resource || {};
const key = `pe:paypal:${row.provider_event_id}`;
const actor = { principal: 'provider:paypal', provider_event: row.provider_event_id };
-- if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') return settlePlan(ctx, r, key, actor);
++ if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') return await settlePlan(ctx, r, key, actor);
if (event.event_type === 'PAYMENT.CAPTURE.REFUNDED' || event.event_type === 'PAYMENT.CAPTURE.REVERSED') {
const up = (r.links || []).find((l) => l.rel === 'up');
const captureId = up ? String(up.href).split('/').filter(Boolean).pop() : r.capture_id;
@@@ -104,7 +104,7 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
});
const cap = j.purchase_units && j.purchase_units[0] && j.purchase_units[0].payments && j.purchase_units[0].payments.captures && j.purchase_units[0].payments.captures[0];
if (j.status !== 'COMPLETED' || !cap) return { effect: 'none', reason: `order ${j.status}` };
-- return settlePlan(ctx, { ...cap, custom_id: cap.custom_id || intent.id }, `paypal-capture:${cap.id}`, { principal: 'provider:paypal', capture: cap.id });
++ return await settlePlan(ctx, { ...cap, custom_id: cap.custom_id || intent.id }, `paypal-capture:${cap.id}`, { principal: 'provider:paypal', capture: cap.id });
}
return { name: 'paypal', enabled, verify, parse, interpret, createCheckout, capture };
ddiff --git a/server/providers/powerchat.js b/server/providers/powerchat.js
iindex 1f6b77b..310a74a 100644
---- a/server/providers/powerchat.js
++++ b/server/providers/powerchat.js
@@@ -126,13 +126,13 @@ function createPowerchat(cfg, { network } = {}) {
let m;
if ((m = ref.match(/^pcorder:(.+)$/))) {
if (!onSite) return offSite('purchase checkout');
-- const intent = intents.find(ctx.db, m[1]);
++ const intent = await intents.find(ctx.db, m[1]);
if (!intent || intent.kind !== 'purchase') return { effect: 'none', reason: `purchase checkout ${ref} has no purchase intent` };
if (cents < 1) return { effect: 'none', reason: 'zero-amount purchase' };
return { effect: 'purchase', args: { provider: 'powerchat', receiptRef, intentId: intent.id, paidCents: cents, test: isTest, idempotencyKey: key, actor, metadata: meta } };
}
if ((m = ref.match(/^pcsub:(.+)$/))) {
-- const intent = intents.find(ctx.db, m[1]);
++ const intent = await intents.find(ctx.db, m[1]);
if (!intent || intent.kind !== 'subscription' || !intent.streamer_subject) return { effect: 'none', reason: `subscription checkout ${ref} has no subscription intent` };
const route = intent.route === 'direct' ? 'direct' : 'site';
if (route === 'site' && !onSite) return offSite('site-routed subscription checkout');
ddiff --git a/server/providers/stripe.js b/server/providers/stripe.js
iindex e75eb53..f91f303 100644
---- a/server/providers/stripe.js
++++ b/server/providers/stripe.js
@@@ -50,9 +50,9 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
return { eventId: event.id ? String(event.id) : null, type: String(event.type || 'unknown'), payload: event };
}
-- function intentFrom(db, meta) {
++ async function intentFrom(db, meta) {
if (!meta) return null;
-- return intents.find(db, meta.intent_id || meta.order_id);
++ return await intents.find(db, meta.intent_id || meta.order_id);
}
async function interpret(ctx, event, row) {
@@@ -61,10 +61,10 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
const actor = { principal: 'provider:stripe', provider_event: row.provider_event_id };
switch (event.type) {
case 'checkout.session.completed': {
-- const intent = intentFrom(ctx.db, obj.metadata) || intents.find(ctx.db, obj.client_reference_id);
++ const intent = await intentFrom(ctx.db, obj.metadata) || await intents.find(ctx.db, obj.client_reference_id);
if (!intent) return { effect: 'none', reason: 'checkout without a Billing intent' };
if (obj.mode === 'subscription') {
-- return { effect: 'update', reason: 'linked Stripe subscription', apply: (c) => intents.mergeMetadata(c, intent.id, { stripe_subscription: obj.subscription }) };
++ return { effect: 'update', reason: 'linked Stripe subscription', apply: async (c) => await intents.mergeMetadata(c, intent.id, { stripe_subscription: obj.subscription }) };
}
if (obj.payment_status && obj.payment_status !== 'paid') return { effect: 'none', reason: `checkout ${obj.payment_status}` };
return { effect: 'purchase', args: { provider: 'stripe', receiptRef: `stripe:${obj.payment_intent || obj.id}`, intentId: intent.id, paidCents: obj.amount_total, idempotencyKey: key, actor } };
@@@ -73,13 +73,13 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
case 'invoice.payment_succeeded': {
if (!(obj.amount_paid > 0)) return { effect: 'none', reason: 'zero-amount invoice' };
const stripeSub = obj.subscription;
-- const existing = subscriptions.findByProviderRef(ctx.db, 'stripe', stripeSub);
++ const existing = await subscriptions.findByProviderRef(ctx.db, 'stripe', stripeSub);
let subscriber; let streamer; let intentId = null;
if (existing) { subscriber = existing.subscriber; streamer = existing.streamer; }
else {
const meta = (obj.subscription_details && obj.subscription_details.metadata) || obj.metadata || {};
-- let intent = intentFrom(ctx.db, meta);
-- if (!intent && stripeSub) intent = intents.parse(ctx.db.prepare("SELECT * FROM payment_intents WHERE provider = 'stripe' AND json_extract(metadata, '$.stripe_subscription') = ?").get(stripeSub));
++ let intent = await intentFrom(ctx.db, meta);
++ if (!intent && stripeSub) intent = intents.parse(await ctx.db.prepare("SELECT * FROM payment_intents WHERE provider = 'stripe' AND json_extract(metadata, '$.stripe_subscription') = ?").get(stripeSub));
if (!intent) return { effect: 'none', reason: `invoice for unknown Stripe subscription ${stripeSub}` };
subscriber = intent.subject; streamer = intent.streamer_subject; intentId = intent.id;
}
@@@ -112,9 +112,9 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
},
};
case 'customer.subscription.deleted': {
-- const sub = subscriptions.findByProviderRef(ctx.db, 'stripe', obj.id);
++ const sub = await subscriptions.findByProviderRef(ctx.db, 'stripe', obj.id);
if (!sub) return { effect: 'none', reason: 'unknown Stripe subscription' };
-- return { effect: 'update', reason: 'Stripe subscription ended', apply: (c) => subscriptions.setStatus(c, sub.id, 'canceled', 'stripe_deleted') };
++ return { effect: 'update', reason: 'Stripe subscription ended', apply: async (c) => await subscriptions.setStatus(c, sub.id, 'canceled', 'stripe_deleted') };
}
default:
return { effect: 'none', reason: `${event.type} is not handled` };
@@@ -144,7 +144,7 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
async function cancelAtPeriodEnd(stripeSubscriptionId) {
const p = new URLSearchParams({ cancel_at_period_end: 'true' });
-- return api('POST', `/subscriptions/${encodeURIComponent(stripeSubscriptionId)}`, p);
++ return await api('POST', `/subscriptions/${encodeURIComponent(stripeSubscriptionId)}`, p);
}
return { name: 'stripe', enabled, verify, parse, interpret, createCheckout, cancelAtPeriodEnd };
ddiff --git a/server/reconcile.js b/server/reconcile.js
iindex bbff7d1..2d797c9 100644
---- a/server/reconcile.js
++++ b/server/reconcile.js
@@@ -35,28 +35,28 @@ const { isFrozen } = require('./ops/common');
const SETTLING = ['purchase', 'donation', 'subscription'];
const REVERSING = ['refund', 'chargeback'];
--function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
++async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
const { db } = ctx;
const started = ctx.now();
const checks = [];
const check = (id, ok, detail) => checks.push({ id, ok, ...(detail ? { detail } : {}) });
-- const sums = db.prepare('SELECT a.currency, SUM(e.amount) AS total, COUNT(*) AS n FROM ledger_entries e JOIN accounts a ON a.id = e.account_id GROUP BY a.currency').all();
++ const sums = await db.prepare('SELECT a.currency, SUM(e.amount)::bigint AS total, COUNT(*) AS n FROM ledger_entries e JOIN accounts a ON a.id = e.account_id GROUP BY a.currency').all();
check('journal.zero_sum', sums.every((s) => s.total === 0), { per_currency: sums.map((s) => ({ currency: s.currency, sum: s.total, entries: s.n })) });
-- const unbalanced = db.prepare(`SELECT e.txn_id, a.currency, SUM(e.amount) AS total FROM ledger_entries e JOIN accounts a ON a.id = e.account_id
++ const unbalanced = await db.prepare(`SELECT e.txn_id, a.currency, SUM(e.amount)::bigint AS total FROM ledger_entries e JOIN accounts a ON a.id = e.account_id
GROUP BY e.txn_id, a.currency HAVING SUM(e.amount) <> 0 LIMIT 100`).all();
check('journal.txn_balanced', !unbalanced.length, unbalanced.length ? { offenders: unbalanced } : null);
-- const cache = db.prepare(`SELECT a.id, a.kind, a.owner_subject AS owner, a.currency, COALESCE(b.balance, 0) AS cached,
-- COALESCE((SELECT SUM(amount) FROM ledger_entries WHERE account_id = a.id), 0) AS derived
++ const cache = await db.prepare(`SELECT a.id, a.kind, a.owner_subject AS owner, a.currency, COALESCE(b.balance, 0) AS cached,
++ COALESCE((SELECT SUM(amount) FROM ledger_entries WHERE account_id = a.id), 0)::bigint AS derived
FROM accounts a LEFT JOIN account_balances b ON b.account_id = a.id`).all();
const mismatches = cache.filter((r) => r.cached !== r.derived);
check('balances.cache', !mismatches.length, mismatches.length ? { mismatches } : { accounts: cache.length });
-- const fxRows = db.prepare(`SELECT t.id, json_extract(t.metadata, '$.rates.bits_per_usd') AS rate,
-- SUM(CASE WHEN a.currency = 'vibes-bits' THEN e.amount ELSE 0 END) AS bits,
-- SUM(CASE WHEN a.currency = 'usd-cents' THEN e.amount ELSE 0 END) AS cents
++ const fxRows = await db.prepare(`SELECT t.id, json_extract(t.metadata, '$.rates.bits_per_usd')::float8 AS rate,
++ SUM(CASE WHEN a.currency = 'vibes-bits' THEN e.amount ELSE 0 END)::bigint AS bits,
++ SUM(CASE WHEN a.currency = 'usd-cents' THEN e.amount ELSE 0 END)::bigint AS cents
FROM ledger_entries e JOIN accounts a ON a.id = e.account_id JOIN transactions t ON t.id = e.txn_id
WHERE a.kind = 'fx_conversion' GROUP BY t.id`).all();
const fxBad = fxRows.filter((r) => {
@@@ -66,25 +66,25 @@ function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
});
check('fx.mirror', !fxBad.length, fxBad.length ? { offenders: fxBad } : { transactions: fxRows.length });
-- const evRows = db.prepare(`SELECT p.id, p.provider, p.provider_event_id, json_extract(p.result, '$.effect') AS effect,
++ const evRows = await db.prepare(`SELECT p.id, p.provider, p.provider_event_id, json_extract(p.result, '$.effect') AS effect,
(SELECT COUNT(*) FROM transactions t WHERE t.source_event_id = p.id) AS txns
FROM provider_events p WHERE p.processed_at IS NOT NULL`).all();
const evBad = evRows.filter((r) => (r.effect === 'settled' ? r.txns !== 1 : r.txns !== 0));
check('events.settlement', !evBad.length, evBad.length ? { offenders: evBad } : { processed_events: evRows.length });
-- const paidBad = db.prepare("SELECT id, subject, payout_reference, settle_txn FROM cashouts WHERE status = 'paid' AND (payout_reference IS NULL OR TRIM(payout_reference) = '' OR settle_txn IS NULL)").all();
++ const paidBad = await db.prepare("SELECT id, subject, payout_reference, settle_txn FROM cashouts WHERE status = 'paid' AND (payout_reference IS NULL OR TRIM(payout_reference) = '' OR settle_txn IS NULL)").all();
check('payouts.references', !paidBad.length, paidBad.length ? { offenders: paidBad } : null);
-- const pendingBad = db.prepare(`SELECT a.owner_subject AS subject, b.balance,
-- COALESCE((SELECT SUM(amount_bits) FROM cashouts c WHERE c.subject = a.owner_subject AND c.status = 'requested'), 0) AS requested
-- FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'payouts_pending'`).all().filter((r) => r.balance !== r.requested);
++ const pendingBad = (await db.prepare(`SELECT a.owner_subject AS subject, b.balance,
++ COALESCE((SELECT SUM(amount_bits) FROM cashouts c WHERE c.subject = a.owner_subject AND c.status = 'requested'), 0)::bigint AS requested
++ FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'payouts_pending'`).all()).filter((r) => r.balance !== r.requested);
check('payouts.pending', !pendingBad.length, pendingBad.length ? { offenders: pendingBad } : null);
-- const rcpt = db.prepare(`SELECT t.id, t.type, t.provider, t.receipt_ref, p.id AS event, p.provider AS event_provider,
-- json_extract(t.metadata, '$.paid_cents') AS paid_cents, json_extract(t.metadata, '$.cents') AS reversed_cents,
++ const rcpt = await db.prepare(`SELECT t.id, t.type, t.provider, t.receipt_ref, p.id AS event, p.provider AS event_provider,
++ json_extract(t.metadata, '$.paid_cents')::bigint AS paid_cents, json_extract(t.metadata, '$.cents')::bigint AS reversed_cents,
(SELECT COUNT(*) FROM ledger_entries e WHERE e.txn_id = t.id) AS entries,
COALESCE((SELECT SUM(e.amount) FROM ledger_entries e JOIN accounts a ON a.id = e.account_id
-- WHERE e.txn_id = t.id AND a.kind = 'provider_clearing' AND a.owner_subject = p.provider), 0) AS clearing
++ WHERE e.txn_id = t.id AND a.kind = 'provider_clearing' AND a.owner_subject = p.provider), 0)::bigint AS clearing
FROM transactions t JOIN provider_events p ON p.id = t.source_event_id`).all();
const rcptBad = rcpt.filter((r) => {
if (r.provider !== r.event_provider) return true;
@@@ -94,59 +94,59 @@ function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
});
check('receipts.ledger', !rcptBad.length, rcptBad.length ? { offenders: rcptBad } : { transactions: rcpt.length });
-- const extBooked = db.prepare('SELECT x.receipt_ref, t.id AS txn_id FROM external_receipts x JOIN transactions t ON t.receipt_ref = x.receipt_ref').all();
-- const extUnsent = db.prepare(`SELECT x.receipt_ref, x.event_id FROM external_receipts x
++ const extBooked = await db.prepare('SELECT x.receipt_ref, t.id AS txn_id FROM external_receipts x JOIN transactions t ON t.receipt_ref = x.receipt_ref').all();
++ const extUnsent = await db.prepare(`SELECT x.receipt_ref, x.event_id FROM external_receipts x
WHERE x.status = 'announced' AND (x.event_id IS NULL OR NOT EXISTS (SELECT 1 FROM outbox o WHERE o.event_id = x.event_id))`).all();
-- const extTwice = db.prepare(`SELECT json_extract(event, '$.subject.id') AS receipt_ref, COUNT(*) AS events FROM outbox
++ const extTwice = await db.prepare(`SELECT json_extract(event, '$.subject.id') AS receipt_ref, COUNT(*) AS events FROM outbox
WHERE json_extract(event, '$.event_type') = 'billing.receipt.external' GROUP BY 1 HAVING COUNT(*) > 1`).all();
const extOffenders = [...extBooked.map((r) => ({ ...r, problem: 'booked in the journal' })), ...extUnsent.map((r) => ({ ...r, problem: 'announced without an outbox event' })),
...extTwice.map((r) => ({ ...r, problem: 'announced more than once' }))];
check('receipts.external', !extOffenders.length, extOffenders.length ? { offenders: extOffenders }
-- : { receipts: db.prepare('SELECT COUNT(*) AS n FROM external_receipts').get().n });
++ : { receipts: (await db.prepare('SELECT COUNT(*) AS n FROM external_receipts').get()).n });
-- const frozen = isFrozen(db);
++ const frozen = await isFrozen(db);
const staleMin = (ctx.config && ctx.config.reconcile && ctx.config.reconcile.staleReceiptMin) || 60;
-- const stale = frozen ? [] : db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL AND received_at < ? ORDER BY id LIMIT 100')
++ const stale = frozen ? [] : await db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL AND received_at < ? ORDER BY id LIMIT 100')
.all(iso(started - staleMin * 60_000));
check('receipts.stale', !stale.length, stale.length ? { offenders: stale, older_than_min: staleMin } : { older_than_min: staleMin, ...(frozen ? { frozen: true } : {}) });
const warnings = {
-- negative_balances: db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, b.balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
++ negative_balances: await db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, b.balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
WHERE a.kind IN ('user_credit', 'creator_payable', 'payouts_pending') AND b.balance < 0`).all(),
-- unprocessed_events: db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL ORDER BY id').all(),
-- rejected_events: db.prepare("SELECT id, provider, provider_event_id, type, result FROM provider_events WHERE json_extract(result, '$.effect') = 'rejected'").all()
++ unprocessed_events: await db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL ORDER BY id').all(),
++ rejected_events: (await db.prepare("SELECT id, provider, provider_event_id, type, result FROM provider_events WHERE json_extract(result, '$.effect') = 'rejected'").all())
.map((r) => ({ ...r, result: JSON.parse(r.result) })),
-- events_for_review: db.prepare("SELECT id, provider, provider_event_id, type, result FROM provider_events WHERE json_extract(result, '$.review') = 1")
-- .all().map((r) => ({ ...r, result: JSON.parse(r.result) })),
-- transactions_for_review: db.prepare("SELECT id, type, reverses_txn, created_at, metadata FROM transactions WHERE json_extract(metadata, '$.review') = 'required' ORDER BY created_at")
-- .all().map((r) => ({ ...r, metadata: JSON.parse(r.metadata) })),
-- import_holds: db.prepare('SELECT live_user_id, owner, reason, credit_bits, payable_bits FROM import_holds WHERE resolved_subject IS NULL').all(),
++ events_for_review: (await db.prepare("SELECT id, provider, provider_event_id, type, result FROM provider_events WHERE json_extract(result, '$.review') IN ('true', '1')")
++ .all()).map((r) => ({ ...r, result: JSON.parse(r.result) })),
++ transactions_for_review: (await db.prepare("SELECT id, type, reverses_txn, created_at, metadata FROM transactions WHERE json_extract(metadata, '$.review') = 'required' ORDER BY created_at")
++ .all()).map((r) => ({ ...r, metadata: JSON.parse(r.metadata) })),
++ import_holds: await db.prepare('SELECT live_user_id, owner, reason, credit_bits, payable_bits FROM import_holds WHERE resolved_subject IS NULL').all(),
};
-- const sumKind = (kind, currency, excludeTest = true) => db.prepare(`SELECT COALESCE(SUM(e.amount), 0) AS n FROM ledger_entries e
++ const sumKind = async (kind, currency, excludeTest = true) => (await db.prepare(`SELECT COALESCE(SUM(e.amount), 0)::bigint AS n FROM ledger_entries e
JOIN accounts a ON a.id = e.account_id JOIN transactions t ON t.id = e.txn_id
-- WHERE a.kind = ? AND a.currency = ? ${excludeTest ? 'AND t.test = 0' : ''}`).get(kind, currency).n;
++ WHERE a.kind = ? AND a.currency = ? ${excludeTest ? 'AND t.test = 0' : ''}`).get(kind, currency)).n;
const totals = {
excludes_test_transactions: true,
-- platform_revenue_cents: sumKind('platform_revenue', 'usd-cents'),
-- platform_revenue_bits: sumKind('platform_revenue', 'vibes-bits'),
-- refunds_cents: sumKind('refunds', 'usd-cents'),
-- chargeback_loss_cents: sumKind('chargeback_loss', 'usd-cents'),
-- credit_outstanding_bits: sumKind('user_credit', 'vibes-bits', false),
-- payable_outstanding_bits: sumKind('creator_payable', 'vibes-bits', false),
-- payouts_pending_bits: sumKind('payouts_pending', 'vibes-bits', false),
-- import_adjustment_bits: sumKind('import_adjustment', 'vibes-bits', false),
-- external_receipts: db.prepare('SELECT COUNT(*) AS n FROM external_receipts WHERE test = 0').get().n,
-- external_announced: db.prepare("SELECT COUNT(*) AS n FROM external_receipts WHERE test = 0 AND status = 'announced'").get().n,
-- external_cents: db.prepare('SELECT COALESCE(SUM(amount_cents), 0) AS n FROM external_receipts WHERE test = 0').get().n,
-- transactions: db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n,
-- test_transactions: db.prepare('SELECT COUNT(*) AS n FROM transactions WHERE test = 1').get().n,
++ platform_revenue_cents: await sumKind('platform_revenue', 'usd-cents'),
++ platform_revenue_bits: await sumKind('platform_revenue', 'vibes-bits'),
++ refunds_cents: await sumKind('refunds', 'usd-cents'),
++ chargeback_loss_cents: await sumKind('chargeback_loss', 'usd-cents'),
++ credit_outstanding_bits: await sumKind('user_credit', 'vibes-bits', false),
++ payable_outstanding_bits: await sumKind('creator_payable', 'vibes-bits', false),
++ payouts_pending_bits: await sumKind('payouts_pending', 'vibes-bits', false),
++ import_adjustment_bits: await sumKind('import_adjustment', 'vibes-bits', false),
++ external_receipts: (await db.prepare('SELECT COUNT(*) AS n FROM external_receipts WHERE test = 0').get()).n,
++ external_announced: (await db.prepare("SELECT COUNT(*) AS n FROM external_receipts WHERE test = 0 AND status = 'announced'").get()).n,
++ external_cents: (await db.prepare('SELECT COALESCE(SUM(amount_cents), 0)::bigint AS n FROM external_receipts WHERE test = 0').get()).n,
++ transactions: (await db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n,
++ test_transactions: (await db.prepare('SELECT COUNT(*) AS n FROM transactions WHERE test = 1').get()).n,
};
const finished = ctx.now();
const report = { id: prefixedId('rec', finished), ok: checks.every((c) => c.ok), trigger, started_at: iso(started), finished_at: iso(finished), checks, warnings, totals };
if (store) {
-- db.prepare('INSERT INTO reconciliation_runs (id, started_at, finished_at, ok, report) VALUES (?, ?, ?, ?, ?)')
++ await db.prepare('INSERT INTO reconciliation_runs (id, started_at, finished_at, ok, report) VALUES (?, ?, ?, ?, ?)')
.run(report.id, report.started_at, report.finished_at, report.ok ? 1 : 0, JSON.stringify(report));
}
return report;
@@@ -157,10 +157,10 @@ function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
* it also runs while the economy is frozen). Stored like any run; passing scheduled runs older than
* keepDays are pruned so the table does not grow without bound — failed runs and on-demand runs stay.
*/
--function runScheduled(ctx, { keepDays = 30 } = {}) {
-- const report = reconcile(ctx, { trigger: 'scheduled' });
++async function runScheduled(ctx, { keepDays = 30 } = {}) {
++ const report = await reconcile(ctx, { trigger: 'scheduled' });
if (keepDays > 0) {
-- ctx.db.prepare("DELETE FROM reconciliation_runs WHERE ok = 1 AND json_extract(report, '$.trigger') = 'scheduled' AND finished_at < ?")
++ await ctx.db.prepare("DELETE FROM reconciliation_runs WHERE ok = 1 AND json_extract(report, '$.trigger') = 'scheduled' AND finished_at < ?")
.run(iso(ctx.now() - keepDays * 86_400_000));
}
const log = ctx.log || console;
@@@ -169,8 +169,8 @@ function runScheduled(ctx, { keepDays = 30 } = {}) {
}
/** The most recent stored run (any trigger), parsed; null when none. */
--function latest(db) {
-- const row = db.prepare('SELECT report FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT 1').get();
++async function latest(db) {
++ const row = await db.prepare('SELECT report FROM reconciliation_runs ORDER BY finished_at DESC, id DESC LIMIT 1').get();
return row ? JSON.parse(row.report) : null;
}
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 3493448..cf3bf8c 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -20,20 +20,20 @@ const { boot, fund, check, done } = require('./helpers/app');
const creator = t.user(33);
const second = t.user(34);
console.log('actor limits');
-- const count = (table) => t.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
++ const count = async (table) => (await t.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()).n;
const checkout = (subject, o = {}) => t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'purchase', subject, bits: 500 }, ...o });
await check('checkouts: 5 a minute per person, then 429 rate_limited with Retry-After; nothing is stored', async () => {
for (let i = 0; i < 5; i++) assert.strictEqual((await checkout(fan)).status, 201, `checkout ${i + 1}`);
-- const before = count('payment_intents');
++ const before = await count('payment_intents');
const r = await checkout(fan, { key: 'limits-retry-key-1' });
assert.strictEqual(r.status, 429, r.text);
assert.strictEqual(r.headers.get('retry-after'), '45');
assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);
assert.ok(r.json.detail.includes('billing.intent.create'), r.json.detail);
-- assert.strictEqual(count('payment_intents'), before, 'no intent stored');
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM idempotency_keys WHERE key = 'limits-retry-key-1'").get().n, 0, 'the refusal is not an idempotent answer');
++ assert.strictEqual(await count('payment_intents'), before, 'no intent stored');
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM idempotency_keys WHERE key = 'limits-retry-key-1'").get()).n, 0, 'the refusal is not an idempotent answer');
});
await check('counted per person, not per service: another service for the same person is refused, another person passes', async () => {
@@@ -83,10 +83,10 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(tipSecond.status, 201, tipSecond.text);
const cashout = (subject) => t.call('POST', '/api/v1/cashouts', { body: { subject, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });
for (let i = 0; i < 3; i++) assert.strictEqual((await cashout(creator)).status, 201, `cashout ${i + 1}`);
-- const before = count('cashouts');
++ const before = await count('cashouts');
const r = await cashout(creator);
assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
-- assert.strictEqual(count('cashouts'), before, 'nothing escrowed');
++ assert.strictEqual(await count('cashouts'), before, 'nothing escrowed');
const ok = await cashout(second);
assert.strictEqual(ok.status, 201, ok.text);
});
@@@ -120,7 +120,7 @@ const { boot, fund, check, done } = require('./helpers/app');
for (const [name, n] of [['billing.intent.create', 2], ['billing.transfer.create', 1], ['billing.transfer.refund', 1], ['billing.cashout.request', 1], ['billing.history.read', 1]]) {
assert.ok(new RegExp(`billing_rate_limited_total\\{limit="${name.replace(/\./g, '\\.')}",window="minute"\\} ${n}`).test(m), `${name}:\n${lines}`);
}
-- t.assertReconciled('after the refusals');
++ await t.assertReconciled('after the refusals');
});
await t.close();
ddiff --git a/test/auth-outbox.test.js b/test/auth-outbox.test.js
iindex 47e546d..e15144d 100644
---- a/test/auth-outbox.test.js
++++ b/test/auth-outbox.test.js
@@@ -42,7 +42,7 @@ const { startEvents } = require('./helpers/stubs');
assert.strictEqual((await t.call('POST', '/api/v1/transfers', { body: { from: a, to: b, amount: 600 } })).status, 201);
await t.call('POST', '/api/v1/cashouts', { body: { subject: b, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });
assert.strictEqual((await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: a, streamer: b, source: 'credit', auto_renew: false } })).status, 201);
-- const rows = t.db.prepare('SELECT event FROM outbox ORDER BY seq').all().map((r) => JSON.parse(r.event));
++ const rows = (await t.db.prepare('SELECT event FROM outbox ORDER BY seq').all()).map((r) => JSON.parse(r.event));
const types = rows.map((e) => e.event_type);
for (const type of ['billing.transaction.settled', 'billing.cashout.requested', 'billing.entitlement.changed']) assert.ok(types.includes(type), `${type} in ${types}`);
for (const e of rows) {
@@@ -51,21 +51,21 @@ const { startEvents } = require('./helpers/stubs');
assert.deepStrictEqual([e.source, e.actor.type, e.actor.id], ['billing', 'service', 'billing']);
assert.ok(['transaction', 'entitlement', 'cashout', 'subscription'].includes(e.subject.type));
}
-- const txnCount = t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n;
++ const txnCount = (await t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n;
const refused = await t.call('POST', '/api/v1/transfers', { body: { from: a, to: b, amount: 999999 } });
assert.strictEqual(refused.status, 409);
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM outbox').get().n, rows.length, 'a refused operation writes no event');
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n, txnCount);
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM outbox').get()).n, rows.length, 'a refused operation writes no event');
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n, txnCount);
});
await check('the relay publishes unsent events to OpenVibe.Events with an events.event.publish token', async () => {
const events = await startEvents();
const { createRelay } = require('../server/outbox');
const relay = createRelay({ db: t.db, config: { ...t.config, events: { url: events.url, intervalMs: 1000 } }, log: { warn() {} } });
-- const n = t.db.prepare('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL').get().n;
++ const n = (await t.db.prepare('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL').get()).n;
const r = await relay.flush();
assert.strictEqual(r.sent, n);
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL').get().n, 0);
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL').get()).n, 0);
const token = events.tokens[0].slice(7);
const v = serviceAuth.verifyServiceToken(token, { publicKey: t.network.publicPem, audience: 'openvibe.events' });
assert.ok(v.ok, v.reason);
@@@ -80,12 +80,12 @@ const { startEvents } = require('./helpers/stubs');
assert.strictEqual(ok.status, 200, ok.text);
assert.strictEqual(ok.json.ok, true, JSON.stringify(ok.json.checks.filter((c) => !c.ok)));
assert.strictEqual(ok.json.totals.excludes_test_transactions, true);
-- t.db.prepare("UPDATE account_balances SET balance = balance - 7 WHERE account_id = (SELECT id FROM accounts WHERE kind = 'creator_payable' AND owner_subject = ?)").run(b.id);
++ await t.db.prepare("UPDATE account_balances SET balance = balance - 7 WHERE account_id = (SELECT id FROM accounts WHERE kind = 'creator_payable' AND owner_subject = ?)").run(b.id);
const bad = await t.call('GET', '/api/v1/admin/reconcile', { cap: ['billing.ledger.admin'] });
assert.strictEqual(bad.json.ok, false);
const c = bad.json.checks.find((x) => x.id === 'balances.cache');
assert.strictEqual(c.detail.mismatches[0].owner, b.id);
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs').get().n, 2);
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs').get()).n, 2);
});
await t.close();
ddiff --git a/test/console.test.js b/test/console.test.js
iindex e7cc216..c597876 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -53,8 +53,8 @@ const DAY = 86_400_000;
}
const csrfOf = (page) => (page.text.match(/name="_csrf" value="([^"]+)"/) || [])[1];
const keyOf = (page, action) => (page.text.match(new RegExp(`action="${action}"><input type="hidden" name="_csrf" value="[^"]+"><input type="hidden" name="action_key" value="([^"]+)"`)) || [])[1];
-- const auditRows = (action, outcome = 'done') => t.db.prepare('SELECT * FROM staff_audit WHERE action = ? AND outcome = ?').all(action, outcome);
-- const staffEvents = (action) => t.db.prepare("SELECT event FROM outbox WHERE json_extract(event, '$.event_type') = 'billing.staff.action' AND json_extract(event, '$.payload.action') = ?").all(action).map((r) => JSON.parse(r.event));
++ const auditRows = async (action, outcome = 'done') => await t.db.prepare('SELECT * FROM staff_audit WHERE action = ? AND outcome = ?').all(action, outcome);
++ const staffEvents = async (action) => (await t.db.prepare("SELECT event FROM outbox WHERE json_extract(event, '$.event_type') = 'billing.staff.action' AND json_extract(event, '$.payload.action') = ?").all(action)).map((r) => JSON.parse(r.event));
async function newCashout(amount = 600) {
const r = await t.call('POST', '/api/v1/cashouts', { body: { subject: creator, amount, payout_method: { type: 'paypal', address: '[email protected]' } } });
assert.strictEqual(r.status, 201, r.text);
@@@ -94,7 +94,7 @@ const DAY = 86_400_000;
assert.strictEqual((await get(`/auth/callback?code=${code}&state=wrong`, flow)).status, 400, 'wrong state');
const [body] = flow.slice('ovb_flow='.length).split('.');
assert.strictEqual((await get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, `ovb_flow=${body}.forged`)).status, 400, 'forged signature');
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM staff_sessions').get().n, 0);
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM staff_sessions').get()).n, 0);
});
await check('a code bound to another PKCE challenge is refused by the Network, no session', async () => {
@@@ -116,10 +116,10 @@ const DAY = 86_400_000;
assert.strictEqual(b.cookie, null);
const c = await signIn(listedNonAdmin, { role: 'admin', is_owner: false });
assert.strictEqual(c.cb.status, 403, 'a listed admin who is not the owner: money is the owner\'s (staff.money.cashouts)');
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM staff_sessions').get().n, 0);
-- const refused = auditRows('session.sign_in', 'refused');
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM staff_sessions').get()).n, 0);
++ const refused = await auditRows('session.sign_in', 'refused');
assert.deepStrictEqual(refused.map((r) => r.actor_subject).sort(), [listedNonAdmin, listedNonAdmin, unlistedAdmin].sort());
-- assert.strictEqual(staffEvents('session.sign_in').length, 0, 'refusals are not published');
++ assert.strictEqual((await staffEvents('session.sign_in')).length, 0, 'refusals are not published');
});
await check('staff sign in: host-only, HttpOnly, Secure, SameSite=Strict, short-lived session cookie', async () => {
@@@ -132,10 +132,10 @@ const DAY = 86_400_000;
const maxAge = Number((r.setCookie.match(/Max-Age=(\d+)/) || [])[1]);
assert.ok(maxAge > 0 && maxAge <= 3600, `max-age ${maxAge}`);
s = r.cookie;
-- const row = t.db.prepare('SELECT * FROM staff_sessions').get();
++ const row = await t.db.prepare('SELECT * FROM staff_sessions').get();
assert.notStrictEqual(row.id_hash, s.split('=')[1], 'only a hash of the session id is stored');
-- assert.strictEqual(auditRows('session.sign_in').length, 1);
-- assert.strictEqual(staffEvents('session.sign_in').length, 1);
++ assert.strictEqual((await auditRows('session.sign_in')).length, 1);
++ assert.strictEqual((await staffEvents('session.sign_in')).length, 1);
});
await check('pages: staff only, server-rendered, no script, noindex, no-store, strict CSP', async () => {
@@@ -175,9 +175,9 @@ const DAY = 86_400_000;
assert.strictEqual((await post(`/cashouts/${co1}/approve`, s, { ...form, _csrf: csrf }, { 'Sec-Fetch-Site': 'cross-site' })).status, 403);
assert.strictEqual((await post('/freeze', s, { on: '1', reason: 'csrf test' })).status, 403);
assert.strictEqual((await post('/auth/logout', s, {})).status, 403);
-- assert.strictEqual(t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co1).status, 'requested');
-- assert.strictEqual(t.db.prepare('SELECT freeze FROM settings').get().freeze, 0);
-- assert.ok(auditRows('request.csrf', 'refused').length >= 5);
++ assert.strictEqual((await t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co1)).status, 'requested');
++ assert.strictEqual((await t.db.prepare('SELECT "freeze" FROM settings').get()).freeze, 0);
++ assert.ok((await auditRows('request.csrf', 'refused')).length >= 5);
assert.strictEqual((await get('/cashouts', s)).status, 200, 'the session survived');
});
@@@ -194,10 +194,10 @@ const DAY = 86_400_000;
const early = await post(`/cashouts/${co1}/approve`, s, { _csrf: csrf, action_key: key, payout_reference: 'PP-1', payout_provider: 'paypal', confirm: 'yes' });
assert.strictEqual(early.status, 409);
assert.ok(early.text.includes('billing.escrow_active'));
-- assert.strictEqual(t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co1).status, 'requested');
-- assert.strictEqual(auditRows('cashout.approve').length, 0);
-- assert.strictEqual(auditRows('cashout.approve', 'refused').length, 3);
-- assert.strictEqual(staffEvents('cashout.approve').length, 0);
++ assert.strictEqual((await t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co1)).status, 'requested');
++ assert.strictEqual((await auditRows('cashout.approve')).length, 0);
++ assert.strictEqual((await auditRows('cashout.approve', 'refused')).length, 3);
++ assert.strictEqual((await staffEvents('cashout.approve')).length, 0);
});
let co2;
@@@ -216,25 +216,25 @@ const DAY = 86_400_000;
const again = await post(`/cashouts/${co1}/approve`, s, form);
assert.strictEqual(again.status, 303);
assert.strictEqual(again.headers.get('location'), `/cashouts/${co1}?done=already_paid`);
-- const c = t.db.prepare('SELECT * FROM cashouts WHERE id = ?').get(co1);
++ const c = await t.db.prepare('SELECT * FROM cashouts WHERE id = ?').get(co1);
assert.strictEqual(c.status, 'paid');
assert.strictEqual(c.payout_reference, 'PAYOUT-7Q2');
assert.strictEqual(JSON.parse(c.decided_by).principal, staff);
-- const rows = auditRows('cashout.approve');
++ const rows = await auditRows('cashout.approve');
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].actor_subject, staff);
assert.strictEqual(rows[0].target_id, co1);
assert.ok(rows[0].request_id && rows[0].ip_hash && !rows[0].ip_hash.includes('127.0.0.1'));
assert.strictEqual(JSON.parse(rows[0].detail).payout_reference, 'PAYOUT-7Q2');
-- const ev = staffEvents('cashout.approve');
++ const ev = await staffEvents('cashout.approve');
assert.strictEqual(ev.length, 1);
assert.deepStrictEqual(ev[0].actor, { type: 'user', id: staff });
assert.strictEqual(ev[0].visibility, 'internal');
assert.deepStrictEqual(ev[0].payload.target, { type: 'cashout', id: co1 });
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE json_extract(event, '$.event_type') = 'billing.cashout.paid'").get().n, 1);
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE json_extract(event, '$.event_type') = 'billing.cashout.paid'").get()).n, 1);
const shown = await get(`/cashouts/${co1}?done=approved`, s);
assert.ok(shown.text.includes('Payout recorded') && shown.text.includes('PAYOUT-7Q2'));
-- t.assertReconciled('after a console approval');
++ await t.assertReconciled('after a console approval');
});
await check('deny needs a reason; it returns the amount to payable and is audited once', async () => {
@@@ -247,15 +247,15 @@ const DAY = 86_400_000;
const r = await post(`/cashouts/${co2}/deny`, s, { _csrf: csrf, action_key: key, reason: 'payout address bounced' });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual((await post(`/cashouts/${co2}/deny`, s, { _csrf: csrf, action_key: key, reason: 'payout address bounced' })).headers.get('location'), `/cashouts/${co2}?done=already_denied`);
-- assert.strictEqual(t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co2).status, 'denied');
++ assert.strictEqual((await t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co2)).status, 'denied');
assert.strictEqual((await t.balances(creator.id)).payable, before + 700);
-- const rows = auditRows('cashout.deny');
++ const rows = await auditRows('cashout.deny');
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].reason, 'payout address bounced');
-- assert.strictEqual(staffEvents('cashout.deny').length, 1);
++ assert.strictEqual((await staffEvents('cashout.deny')).length, 1);
const approveDenied = await post(`/cashouts/${co2}/approve`, s, { _csrf: csrf, action_key: keyOf(page, `/cashouts/${co2}/approve`), payout_reference: 'X-1', payout_provider: 'paypal', confirm: 'yes' });
assert.strictEqual(approveDenied.status, 409, 'a denied cashout cannot be paid');
-- t.assertReconciled('after a console denial');
++ await t.assertReconciled('after a console denial');
});
await check('freeze toggles with a reason, refuses money actions while on, and is audited', async () => {
@@@ -264,7 +264,7 @@ const DAY = 86_400_000;
assert.strictEqual((await post('/freeze', s, { _csrf: csrf, on: '1', reason: '' })).status, 422);
const on = await post('/freeze', s, { _csrf: csrf, on: '1', reason: 'cutover rehearsal' });
assert.strictEqual(on.status, 303);
-- assert.strictEqual(t.db.prepare('SELECT freeze FROM settings').get().freeze, 1);
++ assert.strictEqual((await t.db.prepare('SELECT "freeze" FROM settings').get()).freeze, 1);
assert.strictEqual((await post('/freeze', s, { _csrf: csrf, on: '1', reason: 'again' })).status, 409, 'no double toggle');
const co3 = await newCashout(500).catch(() => null);
assert.strictEqual(co3, null, 'the API refuses writes while frozen');
@@@ -278,14 +278,14 @@ const DAY = 86_400_000;
const off = await post('/freeze', s, { _csrf: csrf, on: '0', reason: 'rehearsal done' });
assert.strictEqual(off.status, 303);
assert.match(off.headers.get('location'), /done=unfrozen&processed=1/);
-- assert.strictEqual(t.db.prepare('SELECT freeze FROM settings').get().freeze, 0);
-- assert.strictEqual(auditRows('economy.freeze').length, 1);
-- assert.strictEqual(auditRows('economy.freeze')[0].reason, 'cutover rehearsal');
-- assert.strictEqual(auditRows('economy.unfreeze').length, 1);
-- assert.strictEqual(auditRows('economy.freeze', 'refused').length, 2);
-- assert.strictEqual(staffEvents('economy.freeze').length, 1);
-- assert.strictEqual(staffEvents('economy.unfreeze').length, 1);
-- assert.strictEqual(JSON.parse(t.db.prepare('SELECT frozen_by FROM settings').get().frozen_by || 'null'), null);
++ assert.strictEqual((await t.db.prepare('SELECT "freeze" FROM settings').get()).freeze, 0);
++ assert.strictEqual((await auditRows('economy.freeze')).length, 1);
++ assert.strictEqual((await auditRows('economy.freeze'))[0].reason, 'cutover rehearsal');
++ assert.strictEqual((await auditRows('economy.unfreeze')).length, 1);
++ assert.strictEqual((await auditRows('economy.freeze', 'refused')).length, 2);
++ assert.strictEqual((await staffEvents('economy.freeze')).length, 1);
++ assert.strictEqual((await staffEvents('economy.unfreeze')).length, 1);
++ assert.strictEqual(JSON.parse((await t.db.prepare('SELECT frozen_by FROM settings').get()).frozen_by || 'null'), null);
});
await check('a money action is refused while frozen (same guard as the API)', async () => {
@@@ -296,7 +296,7 @@ const DAY = 86_400_000;
const r = await post(`/cashouts/${co4}/deny`, s, { _csrf: csrf, action_key: keyOf(page, `/cashouts/${co4}/deny`), reason: 'while frozen' });
assert.strictEqual(r.status, 503);
assert.ok(r.text.includes('billing.frozen'));
-- assert.strictEqual(t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co4).status, 'requested');
++ assert.strictEqual((await t.db.prepare('SELECT status FROM cashouts WHERE id = ?').get(co4)).status, 'requested');
await post('/freeze', s, { _csrf: csrf, on: '0', reason: 'guard test done' });
});
@@@ -311,8 +311,8 @@ const DAY = 86_400_000;
assert.ok(run.text.includes('journal.zero_sum'));
assert.ok((await get('/reconciliation', s)).text.includes(id));
assert.ok((await get('/', s)).text.includes(`/reconciliation/${id}`), 'the dashboard shows the last run');
-- assert.strictEqual(auditRows('reconciliation.run').length, 1);
-- assert.strictEqual(auditRows('reconciliation.run')[0].target_id, id);
++ assert.strictEqual((await auditRows('reconciliation.run')).length, 1);
++ assert.strictEqual((await auditRows('reconciliation.run'))[0].target_id, id);
});
await check('receipts needing review are listed without payloads; the console shows no secrets', async () => {
@@@ -328,16 +328,16 @@ const DAY = 86_400_000;
const csrf = csrfOf(page);
const re = await post(`/receipts/${rejected.json.event}/reprocess`, s, { _csrf: csrf });
assert.strictEqual(re.status, 303, re.text);
-- assert.strictEqual(auditRows('provider_event.reprocess').length, 1);
++ assert.strictEqual((await auditRows('provider_event.reprocess')).length, 1);
assert.strictEqual((await post('/receipts/99999/reprocess', s, { _csrf: csrf })).status, 404);
-- assert.strictEqual(auditRows('provider_event.reprocess', 'refused').length, 1);
++ assert.strictEqual((await auditRows('provider_event.reprocess', 'refused')).length, 1);
const secrets = [marker, '[email protected]', 'Donor Name', 'pc-secret', SESSION_SECRET, s.split('=')[1], '"payload"'];
for (const p of ['/', '/receipts', '/cashouts', `/cashouts/${co1}`, '/reconciliation', '/freeze', '/audit', '/import-holds']) {
const body = (await get(p, s)).text;
for (const x of secrets) assert.ok(!body.includes(x), `${p} shows ${x}`);
}
-- const runId = t.db.prepare('SELECT id FROM reconciliation_runs ORDER BY finished_at DESC LIMIT 1').get().id;
++ const runId = (await t.db.prepare('SELECT id FROM reconciliation_runs ORDER BY finished_at DESC LIMIT 1').get()).id;
const csrf2 = csrfOf(await get('/reconciliation', s));
const rid = (await post('/reconciliation', s, { _csrf: csrf2 })).headers.get('location');
const rep = (await get(rid, s)).text;
@@@ -345,8 +345,8 @@ const DAY = 86_400_000;
});
await check('the audit log is append-only and shown to staff', async () => {
-- assert.throws(() => t.db.prepare("UPDATE staff_audit SET reason = 'x'").run(), /append-only/);
-- assert.throws(() => t.db.prepare('DELETE FROM staff_audit').run(), /append-only/);
++ await assert.rejects(t.db.prepare("UPDATE staff_audit SET reason = 'x'").run(), /append-only/);
++ await assert.rejects(t.db.prepare('DELETE FROM staff_audit').run(), /append-only/);
const page = await get('/audit', s);
assert.ok(page.text.includes('cashout.approve') && page.text.includes('economy.freeze'));
});
@@@ -368,7 +368,7 @@ const DAY = 86_400_000;
assert.strictEqual(r.status, 303);
assert.ok(r.cookies.some((c) => c.startsWith('__Host-ovb_staff=;') && c.includes('Max-Age=0')));
assert.strictEqual((await get('/cashouts', s)).status, 401);
-- assert.strictEqual(auditRows('session.sign_out').length, 1);
++ assert.strictEqual((await auditRows('session.sign_out')).length, 1);
assert.ok(network.revoked.length >= 3, 'every Network refresh token handed to Billing was revoked, never kept');
});
ddiff --git a/test/cutover.test.js b/test/cutover.test.js
iindex 24972b6..963d238 100644
---- a/test/cutover.test.js
++++ b/test/cutover.test.js
@@@ -13,7 +13,7 @@ const path = require('path');
const Database = require('better-sqlite3');
const { boot, check, done } = require('./helpers/app');
--function fabricateLive(file) {
++async function fabricateLive(file) {
const live = new Database(file);
const recent = new Date(Date.now() - 30 * 60_000).toISOString().replace('T', ' ').slice(0, 19);
live.exec(`
@@@ -30,13 +30,13 @@ function fabricateLive(file) {
INSERT INTO users (id, username, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (1, 'alice', 500, 0), (2, 'bob', 0, 349), (3, 'carol', 0, 0);
INSERT INTO transactions (id, from_user_id, to_user_id, amount, type, status, created_at) VALUES (1, NULL, 1, 500, 'purchase', 'completed', '${recent}');
`);
-- live.prepare(`INSERT INTO payment_orders (id, user_id, provider, provider_ref, kind, amount_cents, bucks, streamer_id, status, created_at) VALUES
++ await live.prepare(`INSERT INTO payment_orders (id, user_id, provider, provider_ref, kind, amount_cents, bucks, streamer_id, status, created_at) VALUES
(1, 1, 'powerchat', NULL, 'bucks', 700, 500, NULL, 'credited', ?),
(2, 3, 'powerchat', NULL, 'bucks', 700, 500, NULL, 'pending', ?),
(3, 1, 'powerchat', 'site:fee=50', 'subscription', 549, 0, 2, 'credited', ?),
(4, 3, 'powerchat', NULL, 'bucks', 1300, 1000, NULL, 'pending', ?)`).run(recent, recent, recent, recent);
const end = new Date(Date.now() + 20 * 86_400_000).toISOString().replace('T', ' ').slice(0, 19);
-- live.prepare(`INSERT INTO subscriptions (id, subscriber_id, streamer_id, provider, price_cents, status, current_period_end, created_at)
++ await live.prepare(`INSERT INTO subscriptions (id, subscriber_id, streamer_id, provider, price_cents, status, current_period_end, created_at)
VALUES (1, 1, 2, 'powerchat', 549, 'active', ?, ?)`).run(end, recent);
live.close();
}
@@@ -47,7 +47,7 @@ function fabricateLive(file) {
const bob = t.network.addUser(2);
const carol = t.network.addUser(3);
const file = path.join(t.dir, 'live-snapshot.db');
-- fabricateLive(file);
++ await fabricateLive(file);
const { importLive } = require('../server/importer/live');
const { createIdentity } = require('../server/network');
const identity = createIdentity(t.config);
@@@ -55,31 +55,31 @@ function fabricateLive(file) {
const live = new Database(file, { readonly: true, fileMustExist: true });
try { return await importLive(t.ctx, { live, resolveLiveUsers: identity.resolveLiveUsers, log: { log() {} } }); } finally { live.close(); }
};
-- const bal = (kind, owner) => { const { balance } = require('../server/ledger'); return balance(t.db, { kind, owner, currency: 'vibes-bits' }); };
-- const intent = (order) => t.db.prepare('SELECT * FROM payment_intents WHERE legacy_order_id = ?').get(order);
++ const bal = async (kind, owner) => { const { balance } = require('../server/ledger'); return await balance(t.db, { kind, owner, currency: 'vibes-bits' }); };
++ const intent = async (order) => await t.db.prepare('SELECT * FROM payment_intents WHERE legacy_order_id = ?').get(order);
let n = 0;
const tip = (ref, cents) => t.powerchat({ type: 'donation.completed', streamer: { username: 'openvibe' }, data: { eventId: `ev_cutover_${++n}`, appExternalRef: ref, amountUsdCents: cents } });
-- const result = (r) => t.db.prepare('SELECT result FROM provider_events WHERE id = ?').get(r.json.event).result;
++ const result = async (r) => (await t.db.prepare('SELECT result FROM provider_events WHERE id = ?').get(r.json.event)).result;
console.log('cutover guards');
await run();
await check('an order Live credited is imported as settled-in-Live', async () => {
-- assert.strictEqual(intent(1).status, 'settled');
-- assert.strictEqual(intent(1).settled_txn, null);
-- assert.strictEqual(bal('user_credit', alice), 500);
++ assert.strictEqual((await intent(1)).status, 'settled');
++ assert.strictEqual((await intent(1)).settled_txn, null);
++ assert.strictEqual(await bal('user_credit', alice), 500);
});
await check('its PowerChat delivery reaching Billing is refused (rejected, for review), never credited twice', async () => {
const r = await tip('pcorder:1', 700);
assert.strictEqual(r.status, 200, JSON.stringify(r.json));
-- const res = JSON.parse(result(r));
++ const res = JSON.parse(await result(r));
assert.strictEqual(res.effect, 'rejected');
assert.strictEqual(res.code, 'billing.intent_settled_in_live');
-- assert.strictEqual(bal('user_credit', alice), 500);
-- const rec = t.assertReconciled('after a refused legacy delivery');
++ assert.strictEqual(await bal('user_credit', alice), 500);
++ const rec = await t.assertReconciled('after a refused legacy delivery');
assert.ok(rec.warnings.rejected_events.some((e) => e.id === r.json.event), 'listed for review');
-- const queue = require('../server/console/queries').reviewQueue(t.db);
++ const queue = await require('../server/console/queries').reviewQueue(t.db);
const shown = queue.rejected.find((e) => e.id === r.json.event);
assert.ok(shown, 'listed in the staff console review queue');
assert.strictEqual(shown.result.code, 'billing.intent_settled_in_live');
@@@ -87,15 +87,15 @@ function fabricateLive(file) {
});
await check('a subscription order Live credited is not paid (or granted) again either', async () => {
-- const ents = t.db.prepare('SELECT COUNT(*) AS n FROM entitlements').get().n;
++ const ents = (await t.db.prepare('SELECT COUNT(*) AS n FROM entitlements').get()).n;
const r = await tip('pcsub:3', 549);
-- assert.strictEqual(JSON.parse(result(r)).code, 'billing.intent_settled_in_live');
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM entitlements').get().n, ents);
-- assert.strictEqual(bal('creator_payable', bob), 349);
++ assert.strictEqual(JSON.parse(await result(r)).code, 'billing.intent_settled_in_live');
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM entitlements').get()).n, ents);
++ assert.strictEqual(await bal('creator_payable', bob), 349);
});
await check('an order Live credited AFTER the shadow import is settled-in-Live after the final import', async () => {
-- assert.strictEqual(intent(4).status, 'created');
++ assert.strictEqual((await intent(4)).status, 'created');
const live = new Database(file);
live.exec(`UPDATE payment_orders SET status = 'credited' WHERE id = 4;
UPDATE users SET openvibe_bucks_balance = 1000 WHERE id = 3;
@@@ -103,37 +103,34 @@ function fabricateLive(file) {
live.close();
const report = await run();
assert.ok(report.counts.intents_updated >= 1, JSON.stringify(report.counts));
-- assert.strictEqual(intent(4).status, 'settled');
-- assert.strictEqual(bal('user_credit', carol), 1000);
++ assert.strictEqual((await intent(4)).status, 'settled');
++ assert.strictEqual(await bal('user_credit', carol), 1000);
const r = await tip('pcorder:4', 1300);
-- assert.strictEqual(JSON.parse(result(r)).code, 'billing.intent_settled_in_live');
-- assert.strictEqual(bal('user_credit', carol), 1000);
++ assert.strictEqual(JSON.parse(await result(r)).code, 'billing.intent_settled_in_live');
++ assert.strictEqual(await bal('user_credit', carol), 1000);
});
await check('a legacy order Live never credited (a checkout in flight) settles exactly once', async () => {
const r = await tip('pcorder:2', 700);
-- assert.strictEqual(JSON.parse(result(r)).effect, 'settled');
-- assert.strictEqual(bal('user_credit', carol), 1500);
-- assert.strictEqual(intent(2).status, 'settled');
++ assert.strictEqual(JSON.parse(await result(r)).effect, 'settled');
++ assert.strictEqual(await bal('user_credit', carol), 1500);
++ assert.strictEqual((await intent(2)).status, 'settled');
const again = await t.powerchat({ type: 'donation.completed', streamer: { username: 'openvibe' }, data: { eventId: `ev_cutover_${n}`, appExternalRef: 'pcorder:2', amountUsdCents: 700 } });
-- assert.strictEqual(JSON.parse(result(again)).effect, 'duplicate_receipt');
-- assert.strictEqual(bal('user_credit', carol), 1500);
++ assert.strictEqual(JSON.parse(await result(again)).effect, 'duplicate_receipt');
++ assert.strictEqual(await bal('user_credit', carol), 1500);
const report = await run();
-- assert.strictEqual(intent(2).status, 'settled', 'a re-import never rewinds what Billing settled');
++ assert.strictEqual((await intent(2)).status, 'settled', 'a re-import never rewinds what Billing settled');
assert.strictEqual(report.new_transactions, 0);
assert.deepStrictEqual(report.adjustments, [], 'no adjustment against a Billing-native settlement');
-- assert.strictEqual(bal('user_credit', carol), 1500);
-- t.assertReconciled('after the final import');
++ assert.strictEqual(await bal('user_credit', carol), 1500);
++ await t.assertReconciled('after the final import');
});
await check('the host freeze CLI holds webhooks and refuses writes; after "off" the held delivery settles once', async () => {
-- const { spawnSync } = require('child_process');
-- const cli = (...args) => {
-- const r = spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'freeze.js'), ...args], { env: { ...process.env, BILLING_DB_PATH: t.config.dbPath }, encoding: 'utf8' });
-- assert.strictEqual(r.status, 0, r.stderr);
-- return JSON.parse(r.stdout);
-- };
-- assert.strictEqual(cli('on', 'Live cutover').frozen, true);
++ // The script's command on this test's database (the CLI wrapper only opens the service's own).
++ const { freeze } = require('../scripts/freeze');
++ const cli = async (...args) => await freeze(args, { db: t.db });
++ assert.strictEqual((await cli('on', 'Live cutover')).frozen, true);
const w = await t.call('POST', '/api/v1/transfers', { body: { from: { type: 'user', id: carol }, to: { type: 'user', id: bob }, amount: 5 } });
assert.strictEqual(w.status, 503); assert.strictEqual(w.json.code, 'billing.frozen');
const live = new Database(file);
@@@ -142,12 +139,12 @@ function fabricateLive(file) {
await run(); // the final import, while Billing is frozen
const held = await tip('pcorder:5', 700);
assert.strictEqual(held.status, 202, JSON.stringify(held.json));
-- assert.strictEqual(cli('status').held_webhooks, 1);
-- assert.strictEqual(cli('off').frozen, false);
++ assert.strictEqual((await cli('status')).held_webhooks, 1);
++ assert.strictEqual((await cli('off')).frozen, false);
await require('../server/providers').processPending(t.ctx, t.app.locals.adapters); // the service's retry tick
-- assert.strictEqual(JSON.parse(result(held)).effect, 'settled');
-- assert.strictEqual(bal('user_credit', carol), 2000);
-- t.assertReconciled('after the freeze window');
++ assert.strictEqual(JSON.parse(await result(held)).effect, 'settled');
++ assert.strictEqual(await bal('user_credit', carol), 2000);
++ await t.assertReconciled('after the freeze window');
});
await t.close();
ddiff --git a/test/external.test.js b/test/external.test.js
iindex dd3a5f6..26bcd9b 100644
---- a/test/external.test.js
++++ b/test/external.test.js
@@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/app');
const { startEvents } = require('./helpers/stubs');
const donation = (data, streamer = { id: 'pc-77', username: 'StreamerPC' }) => ({ type: 'donation.completed', streamer, data: { eventId: `don-${Math.random().toString(36).slice(2)}`, ...data } });
--const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_extract(event, '$.event_type') = 'billing.receipt.external' ORDER BY seq").all().map((r) => JSON.parse(r.event));
++const externalEvents = async (db) => (await db.prepare("SELECT event FROM outbox WHERE json_extract(event, '$.event_type') = 'billing.receipt.external' ORDER BY seq").all()).map((r) => JSON.parse(r.event));
(async () => {
console.log('external receipts');
@@@ -33,17 +33,17 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
await t.call('POST', '/api/v1/admin/provider-accounts', { body: { provider: 'powerchat', username: 'StreamerPC', account_id: 'pc-77', subject: streamer.id } });
await check('while Live is the authority an EXTERNAL tip is recorded, not announced, and nothing is booked', async () => {
-- const before = t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n;
++ const before = (await t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n;
const r = await t.powerchat(donation({ eventId: 'ext-L1', amountUsdCents: 500, donorName: 'Fan', message: 'hi' }));
assert.strictEqual(r.status, 200);
assert.strictEqual(r.json.result.effect, 'external');
assert.strictEqual(r.json.result.announced, false);
assert.match(r.json.result.reason, /BILLING_AUTHORITY=live/);
-- assert.strictEqual(externalEvents(t.db).length, 0, 'no billing.receipt.external while Live announces');
-- const row = t.db.prepare("SELECT * FROM external_receipts WHERE receipt_ref = 'powerchat:ext-L1'").get();
++ assert.strictEqual((await externalEvents(t.db)).length, 0, 'no billing.receipt.external while Live announces');
++ const row = await t.db.prepare("SELECT * FROM external_receipts WHERE receipt_ref = 'powerchat:ext-L1'").get();
assert.deepStrictEqual([row.status, row.amount_cents, row.streamer_subject], ['not_announced', 500, streamer.id]);
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n, before, 'EXTERNAL money is never journaled');
-- const rec = t.assertReconciled('external under live');
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n, before, 'EXTERNAL money is never journaled');
++ const rec = await t.assertReconciled('external under live');
assert.strictEqual(rec.totals.external_receipts, 1);
assert.strictEqual(rec.totals.external_announced, 0);
});
@@@ -76,7 +76,7 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
const r = await t.powerchat(donation({ eventId: 'ext-1', amountUsdCents: 1234, donorName: 'Generous Fan', message: 'love the stream', appPurpose: 'goal:12', occurredAt: '2026-09-23T20:00:00Z' }), { deliveryId: 'dlv-ext-1' });
assert.strictEqual(r.status, 200);
assert.deepStrictEqual([r.json.result.effect, r.json.result.announced, r.json.result.streamer], ['external', true, streamer.id]);
-- const evs = externalEvents(t.db);
++ const evs = await externalEvents(t.db);
assert.strictEqual(evs.length, 1);
first = evs[0];
assert.ok(validate('events.event-envelope@1', first).valid);
@@@ -88,8 +88,8 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
assert.deepStrictEqual([p.provider, p.provider_event_id, p.delivery_id, p.receipt_ref], ['powerchat', 'ext-1', 'dlv-ext-1', 'powerchat:ext-1']);
assert.deepStrictEqual(p.receiving_account, { provider: 'powerchat', id: 'pc-77', username: 'streamerpc' });
assert.deepStrictEqual([p.classification, p.app_purpose, p.test], ['EXTERNAL', 'goal:12', false]);
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:ext-1'").get().n, 0);
-- t.assertReconciled('after an announced external tip');
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:ext-1'").get()).n, 0);
++ await t.assertReconciled('after an announced external tip');
});
await check('a redelivery — same delivery id or a new one — announces nothing more', async () => {
@@@ -97,20 +97,20 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
assert.strictEqual(same.json.duplicate, true);
const again = await t.powerchat(donation({ eventId: 'ext-1', amountUsdCents: 1234 }), { deliveryId: 'dlv-ext-1-retry' });
assert.strictEqual(again.json.result.effect, 'duplicate_receipt');
-- assert.strictEqual(externalEvents(t.db).length, 1);
-- const firstEvent = t.db.prepare("SELECT id FROM provider_events WHERE provider_event_id = 'dlv-ext-1'").get().id;
++ assert.strictEqual((await externalEvents(t.db)).length, 1);
++ const firstEvent = (await t.db.prepare("SELECT id FROM provider_events WHERE provider_event_id = 'dlv-ext-1'").get()).id;
for (const id of [firstEvent, again.json.event]) {
const refused = await t.call('POST', `/api/v1/admin/provider-events/${id}/reprocess`, { key: null });
assert.strictEqual(refused.status, 409, 'an announced receipt (or its duplicate) is never re-run');
}
-- assert.strictEqual(externalEvents(t.db).length, 1);
-- t.assertReconciled('after external redeliveries');
++ assert.strictEqual((await externalEvents(t.db)).length, 1);
++ await t.assertReconciled('after external redeliveries');
});
await check('an anonymous tip carries no donor name; the account is found by id when the username changed', async () => {
const r = await t.powerchat(donation({ eventId: 'ext-anon', amountUsdCents: 300, donorName: 'Real Name', isAnonymous: true }, { id: 'pc-77', username: 'renamedpc' }));
assert.strictEqual(r.json.result.announced, true);
-- const p = externalEvents(t.db).find((e) => e.payload.provider_event_id === 'ext-anon').payload;
++ const p = (await externalEvents(t.db)).find((e) => e.payload.provider_event_id === 'ext-anon').payload;
assert.deepStrictEqual([p.donor_name, p.anonymous, p.streamer.id], [null, true, streamer.id]);
});
@@@ -118,16 +118,16 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
const r = await t.powerchat(donation({ eventId: 'ext-unknown', amountUsdCents: 700, donorName: 'Someone' }, { id: 'pc-99', username: 'OtherPC' }));
assert.deepStrictEqual([r.json.result.effect, r.json.result.announced, r.json.result.review], ['external', false, true]);
assert.match(r.json.result.reason, /otherpc/);
-- const rec = t.assertReconciled('with an unmapped external tip');
++ const rec = await t.assertReconciled('with an unmapped external tip');
assert.ok(rec.warnings.events_for_review.some((e) => e.id === r.json.event));
-- const n = externalEvents(t.db).length;
++ const n = (await externalEvents(t.db)).length;
await t.call('POST', '/api/v1/admin/provider-accounts', { body: { provider: 'powerchat', username: 'otherpc', subject: other.id } });
const re = await t.call('POST', `/api/v1/admin/provider-events/${r.json.event}/reprocess`, { key: null });
assert.strictEqual(re.status, 200, re.text);
assert.deepStrictEqual([re.json.event.result.effect, re.json.event.result.announced], ['external', true]);
-- assert.strictEqual(externalEvents(t.db).length, n + 1);
-- assert.strictEqual(externalEvents(t.db).at(-1).payload.streamer.id, other.id);
-- assert.strictEqual(t.db.prepare("SELECT status FROM external_receipts WHERE receipt_ref = 'powerchat:ext-unknown'").get().status, 'announced');
++ assert.strictEqual((await externalEvents(t.db)).length, n + 1);
++ assert.strictEqual((await externalEvents(t.db)).at(-1).payload.streamer.id, other.id);
++ assert.strictEqual((await t.db.prepare("SELECT status FROM external_receipts WHERE receipt_ref = 'powerchat:ext-unknown'").get()).status, 'announced');
});
await check('an underpaid direct subscription is an EXTERNAL tip to the intent\'s streamer', async () => {
@@@ -140,7 +140,7 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
});
await check('zero, test and out-of-range amounts announce nothing', async () => {
-- const n = externalEvents(t.db).length;
++ const n = (await externalEvents(t.db)).length;
const zero = await t.powerchat(donation({ amountUsdCents: 0 }));
assert.strictEqual(zero.json.result.effect, 'none');
const test = await t.powerchat(donation({ amountUsdCents: 500, isTest: true }));
@@@ -149,8 +149,8 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
assert.deepStrictEqual([huge.json.result.effect, huge.json.result.review], ['none', true]);
const inf = await t.powerchat(donation({ amountUsdCents: '1e400' }));
assert.strictEqual(inf.json.result.effect, 'none');
-- assert.strictEqual(externalEvents(t.db).length, n);
-- t.assertReconciled('after refused external amounts');
++ assert.strictEqual((await externalEvents(t.db)).length, n);
++ await t.assertReconciled('after refused external amounts');
});
await check('the outbox relay publishes billing.receipt.external to OpenVibe.Events', async () => {
@@@ -159,7 +159,7 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
const relay = createRelay({ db: t.db, config: { ...t.config, events: { url: events.url, intervalMs: 1000 } }, log: { warn() {} } });
await relay.flush();
const sent = events.batches.flatMap((b) => b.events).filter((e) => e.event_type === 'billing.receipt.external');
-- assert.strictEqual(sent.length, externalEvents(t.db).length);
++ assert.strictEqual(sent.length, (await externalEvents(t.db)).length);
assert.strictEqual(sent[0].event_id, first.event_id);
await events.close();
});
@@@ -179,21 +179,21 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
const ro = new Database(file, { readonly: true });
const dry = await importProviderAccounts(t.ctx, { live: ro, resolveLiveUsers: t.ctx.network.resolveLiveUsers, dryRun: true, log: {} });
assert.strictEqual(dry.provider_accounts.mapped, 1);
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM provider_accounts WHERE username = 'newbiepc'").get().n, 0, 'dry run keeps nothing');
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM provider_accounts WHERE username = 'newbiepc'").get()).n, 0, 'dry run keeps nothing');
const rep = await importProviderAccounts(t.ctx, { live: ro, resolveLiveUsers: t.ctx.network.resolveLiveUsers, log: {} });
const a = rep.provider_accounts;
assert.strictEqual(a.mapped, 1);
assert.deepStrictEqual(a.unmapped.map((u) => [u.live_user_id, u.reason]), [[51, 'no Network subject'], [53, 'not a PowerChat username']]);
assert.deepStrictEqual(a.kept_admin.map((k) => k.username), ['streamerpc']);
-- const row = t.db.prepare("SELECT * FROM provider_accounts WHERE username = 'newbiepc'").get();
++ const row = await t.db.prepare("SELECT * FROM provider_accounts WHERE username = 'newbiepc'").get();
assert.deepStrictEqual([row.subject, row.account_id, row.source, row.live_user_id], [newbie, 'pc-50', 'live-import', 50]);
-- assert.strictEqual(t.db.prepare("SELECT subject FROM provider_accounts WHERE username = 'streamerpc'").get().subject, streamer.id);
++ assert.strictEqual((await t.db.prepare("SELECT subject FROM provider_accounts WHERE username = 'streamerpc'").get()).subject, streamer.id);
const full = await importLive(t.ctx, { live: ro, resolveLiveUsers: t.ctx.network.resolveLiveUsers, log: {} });
assert.strictEqual(full.provider_accounts.unchanged, 1, 'the full import runs the same step; a re-run changes nothing');
ro.close();
const r = await t.powerchat(donation({ eventId: 'ext-newbie', amountUsdCents: 100 }, { id: 'pc-50', username: 'NewbiePC' }));
assert.strictEqual(r.json.result.streamer, newbie);
-- t.assertReconciled('after the accounts import');
++ await t.assertReconciled('after the accounts import');
});
await t.close();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 9cd3f99..fe48a48 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -36,7 +36,12 @@ async function boot(opts = {}) {
const logs = [];
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
// opts.limitsNow: the per-person limiter's clock (default the wall clock).
-- const app = createApp({ config, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow });
++ // A migrated database of its own (./db.js: PGlite, or the containers under npm run test:pg).
++ const db = opts.db || await require('./db').testDb();
++ // npm run test:pg: the per-person limits count in the Valkey container (a prefix of this app's own), as in production.
++ const valkey = process.env.BILLING_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL
++ ? require('openvibe-sdk/valkey').createValkey({ url: process.env.OV_TEST_VALKEY_URL, prefix: `ov:billing-test:${crypto.randomBytes(6).toString('hex')}:` }) : null;
++ const app = await createApp({ config, db, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow, valkey });
await app.locals.keys.load();
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
@@@ -75,9 +80,9 @@ async function boot(opts = {}) {
}
/** Reconciliation must pass (journal balanced, caches right, events settled once). */
-- function assertReconciled(label = '') {
++ async function assertReconciled(label = '') {
const { reconcile } = require('../../server/reconcile');
-- const r = reconcile(ctx, { store: false });
++ const r = await reconcile(ctx, { store: false });
const failed = r.checks.filter((c) => !c.ok);
assert.deepStrictEqual(failed, [], `reconciliation failed ${label}: ${JSON.stringify(failed)}`);
return r;
@@@ -97,7 +102,8 @@ async function boot(opts = {}) {
await new Promise((r) => server.close(r));
if (!opts.network) await network.close();
if (stripe) await stripe.close();
-- try { ctx.db.close(); } catch { /* */ }
++ if (valkey) await valkey.close().catch(() => {});
++ await ctx.db.close().catch(() => {});
fs.rmSync(dir, { recursive: true, force: true });
},
};
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..cee72c0
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,18 @@
++'use strict';
++/**
++ * A migrated database for one test (ADR-035), from openvibe-sdk/testing: PGlite by default; with BILLING_TEST_STORE=pg
++ * (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of its own. The handle's close()
++ * also drops the test database.
++ */
++const { createTestDb } = require('openvibe-sdk/testing');
++const { MIGRATIONS } = require('../../server/db');
++
++async function testDb({ store = process.env.BILLING_TEST_STORE || 'pglite', max = 6 } = {}) {
++ const t = await createTestDb({ migrations: MIGRATIONS, store, service: 'billing', max });
++ const own = t.db.close;
++ let closing = null;
++ t.db.close = () => { if (!closing) { t.db.close = own; closing = t.close(); } return closing; };
++ return t.db;
++}
++
++module.exports = { testDb };
ddiff --git a/test/hygiene.test.js b/test/hygiene.test.js
iindex 4014b8a..84059e2 100644
---- a/test/hygiene.test.js
++++ b/test/hygiene.test.js
@@@ -24,13 +24,13 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
assert.strictEqual(r.status, 201, r.text);
return r.json.intent;
};
-- const txnCount = () => t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n;
++ const txnCount = async () => (await t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n;
const providers = require('../server/providers');
console.log('hygiene');
// ── Overflow ─────────────────────────────────────────────
await check('huge provider amounts are rejected for review, never booked', async () => {
-- const before = txnCount();
++ const before = await txnCount();
const i = await intent({ kind: 'purchase', subject: buyer, bits: 100 });
for (const cents of [1e20, 2 ** 53, 9007199254740993, 100_000_001, -500]) {
const r = await t.powerchat(donation({ amountUsdCents: cents, appExternalRef: i.checkout_ref }));
@@@ -40,8 +40,8 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
const tip = await t.powerchat(donation({ amountUsdCents: 1e17, appExternalRef: `pcdon:${streamerLiveId}:0` }));
assert.strictEqual(tip.json.result.effect, 'rejected');
assert.strictEqual(tip.json.result.code, 'billing.invalid_amount');
-- assert.strictEqual(txnCount(), before);
-- t.assertReconciled('after refused huge receipts');
++ assert.strictEqual(await txnCount(), before);
++ await t.assertReconciled('after refused huge receipts');
});
await check('the API refuses amounts past the exact integer range or the per-call maximum', async () => {
@@@ -59,15 +59,15 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
const { A, entry } = require('../server/ops/common');
const acct = A.credit(buyer.id);
const big = Number.MAX_SAFE_INTEGER - 10;
-- assert.throws(() => post(t.ctx, { type: 'adjustment', idempotencyKey: 'ovf-1', entries: [entry(acct, 2 ** 53), entry(A.importAdj('vibes-bits'), -(2 ** 53))] }), /exact integer range/);
-- post(t.ctx, { type: 'adjustment', idempotencyKey: 'ovf-2', entries: [entry(acct, big), entry(A.importAdj('vibes-bits'), -big)] });
-- const at = balance(t.db, acct);
-- const count = txnCount();
-- assert.throws(() => post(t.ctx, { type: 'adjustment', idempotencyKey: 'ovf-3', entries: [entry(acct, 100), entry(A.importAdj('vibes-bits'), -100)] }), (e) => e.code === 'billing.amount_overflow');
-- assert.strictEqual(balance(t.db, acct), at, 'nothing moved');
-- assert.strictEqual(txnCount(), count, 'no transaction row');
-- post(t.ctx, { type: 'adjustment', idempotencyKey: 'ovf-4', entries: [entry(acct, -big), entry(A.importAdj('vibes-bits'), big)] });
-- t.assertReconciled('after overflow refusals');
++ await assert.rejects(async () => await post(t.ctx, { type: 'adjustment', idempotencyKey: 'ovf-1', entries: [entry(acct, 2 ** 53), entry(A.importAdj('vibes-bits'), -(2 ** 53))] }), /exact integer range/);
++ await post(t.ctx, { type: 'adjustment', idempotencyKey: 'ovf-2', entries: [entry(acct, big), entry(A.importAdj('vibes-bits'), -big)] });
++ const at = await balance(t.db, acct);
++ const count = await txnCount();
++ await assert.rejects(async () => await post(t.ctx, { type: 'adjustment', idempotencyKey: 'ovf-3', entries: [entry(acct, 100), entry(A.importAdj('vibes-bits'), -100)] }), (e) => e.code === 'billing.amount_overflow');
++ assert.strictEqual(await balance(t.db, acct), at, 'nothing moved');
++ assert.strictEqual(await txnCount(), count, 'no transaction row');
++ await post(t.ctx, { type: 'adjustment', idempotencyKey: 'ovf-4', entries: [entry(acct, -big), entry(A.importAdj('vibes-bits'), big)] });
++ await t.assertReconciled('after overflow refusals');
});
await check('a refund claiming more than was paid reverses at most what was paid', async () => {
@@@ -78,7 +78,7 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
assert.strictEqual(rf.json.result.effect, 'settled', JSON.stringify(rf.json));
const tx = (await t.call('GET', `/api/v1/transactions/${rf.json.result.txn_id}`)).json.transaction;
assert.strictEqual(tx.metadata.cents, 700);
-- t.assertReconciled('after an over-claimed refund');
++ await t.assertReconciled('after an over-claimed refund');
});
// ── Concurrent settlement of one provider payment ────────
@@@ -88,10 +88,10 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
const env = donation({ eventId: 'don-race-1', amountUsdCents: 1300, appExternalRef: i.checkout_ref });
const rs = await Promise.all(Array.from({ length: 20 }, () => t.powerchat(env, { deliveryId: 'dlv-race-1' })));
assert.ok(rs.every((r) => r.status === 200), rs.map((r) => r.status).join(','));
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM provider_events WHERE provider_event_id = 'dlv-race-1'").get().n, 1);
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-race-1'").get().n, 1);
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM provider_events WHERE provider_event_id = 'dlv-race-1'").get()).n, 1);
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-race-1'").get()).n, 1);
assert.strictEqual((await t.balances(buyer.id)).credit, credit + 1000);
-- t.assertReconciled('after a delivery race');
++ await t.assertReconciled('after a delivery race');
});
await check('20 concurrent redeliveries of one payment under different delivery ids settle it once', async () => {
@@@ -103,49 +103,28 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
assert.strictEqual(effects.filter((e) => e === 'settled').length, 1, effects.join(','));
assert.strictEqual(effects.filter((e) => e === 'duplicate_receipt').length, 19);
assert.strictEqual((await t.balances(siteStreamer)).payable, credit + 250);
-- t.assertReconciled('after a redelivery race');
++ await t.assertReconciled('after a redelivery race');
});
-- await check('four processes racing on one stored receipt (the service, a script, a retry) settle it once', async () => {
++ await check('four processings racing on one stored receipt (the service, a script, a retry) settle it once', async () => {
const i = await intent({ kind: 'purchase', subject: buyer, bits: 500 });
await t.call('POST', '/api/v1/admin/freeze', { key: null, body: { on: true, reason: 'race test' } });
const q = await t.powerchat(donation({ eventId: 'don-race-3', amountUsdCents: 700, appExternalRef: i.checkout_ref }), { deliveryId: 'dlv-race-3' });
assert.strictEqual(q.status, 202);
-- require('../server/ops/admin').setFreeze(t.ctx, { on: false });
++ await require('../server/ops/admin').setFreeze(t.ctx, { on: false });
const credit = (await t.balances(buyer.id)).credit;
-- // Each child opens its own connection to the same database file and, at the same instant,
-- // processes the same stored receipt — what the service's retry tick and an operator's
-- // unfreeze from scripts/ could do at once.
-- const child = `
-- const { loadConfig } = require('./server/config');
-- const { openDb } = require('./server/db');
-- const { createRates } = require('./server/rates');
-- const providers = require('./server/providers');
-- const config = loadConfig({ NODE_ENV: 'test', BILLING_DB_PATH: process.env.DB, POWERCHAT_WEBHOOK_SECRET: 'pc-secret', POWERCHAT_SITE_USERNAME: 'openvibe' });
-- const db = openDb(config.dbPath);
-- const ctx = { db, config, rates: createRates(config.rates), now: () => Date.now(), log: { warn: (...a) => console.error(...a) } };
-- const adapters = providers.createAdapters(config, {});
-- while (Date.now() < Number(process.env.AT)) { /* line up */ }
-- providers.process(ctx, adapters, Number(process.env.EVENT)).then((row) => { console.log(JSON.stringify({ processed: !!row.processed_at, result: row.result })); db.close(); });`;
-- const { spawn } = require('child_process');
-- const at = Date.now() + 1500;
-- const run = () => new Promise((resolve) => {
-- const c = spawn(process.execPath, ['-e', child], { cwd: require('path').join(__dirname, '..'), env: { ...process.env, DB: t.config.dbPath, AT: String(at), EVENT: String(q.json.event) } });
-- let out = '';
-- c.stdout.on('data', (d) => { out += d; });
-- c.stderr.on('data', (d) => { out += d; });
-- c.on('close', (code) => resolve({ code, out }));
-- });
-- const results = await Promise.all([run(), run(), run(), run()]);
-- for (const r of results) {
-- assert.strictEqual(r.code, 0, r.out);
-- assert.strictEqual(JSON.parse(r.out.trim().split('\n').pop()).processed, true, r.out);
-- }
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-race-3'").get().n, 1);
-- const row = t.db.prepare('SELECT attempts, last_error, result FROM provider_events WHERE id = ?').get(q.json.event);
++ // Four processings of the same stored receipt at once — what the service's retry tick and an operator's
++ // unfreeze from scripts/ could do. Each runs on a pool connection of its own (one at a time on PGlite); the
++ // event row's lock makes one apply it and the others find it processed.
++ const providers = require('../server/providers');
++ const adapters = providers.createAdapters(t.config, {});
++ const rows = await Promise.all([1, 2, 3, 4].map(async () => await providers.process(t.ctx, adapters, Number(q.json.event))));
++ for (const row of rows) assert.strictEqual(!!row.processed_at, true, JSON.stringify(row));
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-race-3'").get()).n, 1);
++ const row = await t.db.prepare('SELECT attempts, last_error, result FROM provider_events WHERE id = ?').get(q.json.event);
assert.deepStrictEqual([row.attempts, row.last_error, JSON.parse(row.result).effect], [1, null, 'settled'], 'applied once; the others waited for the lock and found it processed');
assert.strictEqual((await t.balances(buyer.id)).credit, credit + 500);
-- t.assertReconciled('after a multi-process race');
++ await t.assertReconciled('after a four-way race');
});
await check('an operator reprocess racing the retry job does not settle twice', async () => {
@@@ -159,7 +138,7 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
providers.processPending(t.ctx, t.app.locals.adapters),
providers.processPending(t.ctx, t.app.locals.adapters),
]);
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-race-4'").get().n, 1);
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-race-4'").get()).n, 1);
assert.strictEqual((await t.balances(siteStreamer)).payable, payable + 300);
});
@@@ -167,31 +146,31 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
let heldEvent;
await check('the Network down while a site tip resolves its creator: stored, 200 unprocessed, nothing booked, retried later', async () => {
t.network.state.down = true;
-- const before = txnCount();
++ const before = await txnCount();
const r = await t.powerchat(donation({ eventId: 'don-out-1', amountUsdCents: 400, appExternalRef: `pcdon:${streamerLiveId}:0` }), { deliveryId: 'dlv-out-1' });
assert.strictEqual(r.status, 200, 'acknowledged: the receipt is stored, PowerChat must not retry forever');
assert.strictEqual(r.json.processed, false);
heldEvent = r.json.event;
-- const row = t.db.prepare('SELECT * FROM provider_events WHERE id = ?').get(heldEvent);
++ const row = await t.db.prepare('SELECT * FROM provider_events WHERE id = ?').get(heldEvent);
assert.strictEqual(row.processed_at, null);
assert.ok(row.last_error, 'the failure is recorded');
-- assert.strictEqual(txnCount(), before);
++ assert.strictEqual(await txnCount(), before);
const pending = await providers.processPending(t.ctx, t.app.locals.adapters);
assert.strictEqual(pending.processed, 0, 'still down: still pending');
-- const rec = t.assertReconciled('with a pending receipt');
++ const rec = await t.assertReconciled('with a pending receipt');
assert.strictEqual(rec.warnings.unprocessed_events.length, 1);
});
await check('a receipt stuck past BILLING_RECONCILE_STALE_RECEIPT_MIN fails reconciliation (not while frozen)', async () => {
const { reconcile } = require('../server/reconcile');
t.clock.offset = 61 * 60_000;
-- const r = reconcile(t.ctx, { store: false });
++ const r = await reconcile(t.ctx, { store: false });
const stale = r.checks.find((c) => c.id === 'receipts.stale');
assert.strictEqual(stale.ok, false);
assert.deepStrictEqual(stale.detail.offenders.map((o) => o.id), [heldEvent]);
-- require('../server/ops/admin').setFreeze(t.ctx, { on: true, reason: 'x' });
-- assert.strictEqual(reconcile(t.ctx, { store: false }).checks.find((c) => c.id === 'receipts.stale').ok, true, 'held on purpose while frozen');
-- require('../server/ops/admin').setFreeze(t.ctx, { on: false });
++ await require('../server/ops/admin').setFreeze(t.ctx, { on: true, reason: 'x' });
++ assert.strictEqual((await reconcile(t.ctx, { store: false })).checks.find((c) => c.id === 'receipts.stale').ok, true, 'held on purpose while frozen');
++ await require('../server/ops/admin').setFreeze(t.ctx, { on: false });
const m = await fetch(`${t.base}/metrics`).then((x) => x.text());
assert.match(m, /billing_provider_event_oldest_pending_seconds (36[6-9]\d|3[7-9]\d\d)/);
t.clock.offset = 0;
@@@ -204,47 +183,47 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
assert.strictEqual(out.processed, 1);
await providers.processPending(t.ctx, t.app.locals.adapters);
assert.strictEqual((await t.balances(siteStreamer)).payable, payable + 400);
-- const row = t.db.prepare('SELECT attempts, last_error, result FROM provider_events WHERE id = ?').get(heldEvent);
++ const row = await t.db.prepare('SELECT attempts, last_error, result FROM provider_events WHERE id = ?').get(heldEvent);
assert.strictEqual(JSON.parse(row.result).effect, 'settled');
assert.strictEqual(row.last_error, null);
-- t.assertReconciled('after recovery');
++ await t.assertReconciled('after recovery');
});
await check('a failure inside the settlement transaction rolls everything back; the retry settles once', async () => {
const intents = require('../server/ops/intents');
const i = await intent({ kind: 'purchase', subject: buyer, bits: 100 });
-- const before = txnCount();
++ const before = await txnCount();
const credit = (await t.balances(buyer.id)).credit;
const real = intents.markSettled;
let fails = 1;
// Marking the intent settled comes after the journal posting, in the same transaction: failing
// it must take the posting, the outbox event and the processed mark down with it.
-- intents.markSettled = (...a) => { if (fails-- > 0) throw new Error('disk full'); return real(...a); };
-- const events = t.db.prepare('SELECT COUNT(*) AS n FROM outbox').get().n;
++ intents.markSettled = async (...a) => { if (fails-- > 0) throw new Error('disk full'); return await real(...a); };
++ const events = (await t.db.prepare('SELECT COUNT(*) AS n FROM outbox').get()).n;
let r;
try { r = await t.powerchat(donation({ eventId: 'don-crash', amountUsdCents: 150, appExternalRef: i.checkout_ref })); }
finally { intents.markSettled = real; }
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM outbox').get().n, events, 'no event for a settlement that did not happen');
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM outbox').get()).n, events, 'no event for a settlement that did not happen');
assert.strictEqual(r.json.processed, false);
-- assert.strictEqual(txnCount(), before);
++ assert.strictEqual(await txnCount(), before);
assert.strictEqual((await t.balances(buyer.id)).credit, credit);
assert.strictEqual((await t.call('GET', `/api/v1/intents/${i.id}`)).json.intent.status, 'created');
-- assert.match(t.db.prepare('SELECT last_error FROM provider_events WHERE id = ?').get(r.json.event).last_error, /disk full/);
++ assert.match((await t.db.prepare('SELECT last_error FROM provider_events WHERE id = ?').get(r.json.event)).last_error, /disk full/);
await providers.processPending(t.ctx, t.app.locals.adapters);
assert.strictEqual((await t.balances(buyer.id)).credit, credit + 100);
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-crash'").get().n, 1);
-- t.assertReconciled('after a mid-settlement failure');
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-crash'").get()).n, 1);
++ await t.assertReconciled('after a mid-settlement failure');
});
await check('OpenVibe.Events down: settlement is unaffected, events wait in the outbox and go out once it is back', async () => {
const { createRelay } = require('../server/outbox');
const log = { warn() {} };
-- const unsent = () => t.db.prepare('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL').get().n;
++ const unsent = async () => (await t.db.prepare('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL').get()).n;
const down = createRelay({ db: t.db, config: { ...t.config, events: { url: 'http://127.0.0.1:9', intervalMs: 1000 } }, log });
await fund(t, buyer, 200);
const r = await down.flush();
assert.strictEqual(r.sent, 0);
-- const waiting = unsent();
++ const waiting = await unsent();
assert.ok(waiting > 0);
const m = await fetch(`${t.base}/metrics`).then((x) => x.text());
assert.match(m, new RegExp(`billing_outbox_pending ${waiting}\\b`));
@@@ -252,7 +231,7 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
const events = await startEvents();
const up = createRelay({ db: t.db, config: { ...t.config, events: { url: events.url, intervalMs: 1000 } }, log });
await up.flush();
-- assert.strictEqual(unsent(), 0);
++ assert.strictEqual(await unsent(), 0);
const ids = events.batches.flatMap((b) => b.events).map((e) => e.event_id);
assert.strictEqual(new Set(ids).size, ids.length, 'each event published once');
await events.close();
@@@ -261,12 +240,12 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
// ── Scheduled reconciliation, exposed ────────────────────
await check('the scheduled run is stored with its trigger, listed by the API and old passing runs are pruned', async () => {
const { runScheduled } = require('../server/reconcile');
-- const old = runScheduled({ ...t.ctx, now: () => Date.now() - 40 * 86_400_000 }, { keepDays: 0 });
++ const old = await runScheduled({ ...t.ctx, now: () => Date.now() - 40 * 86_400_000 }, { keepDays: 0 });
assert.strictEqual(old.ok, true);
-- const rep = runScheduled(t.ctx, { keepDays: 30 });
++ const rep = await runScheduled(t.ctx, { keepDays: 30 });
assert.strictEqual(rep.trigger, 'scheduled');
assert.ok(rep.checks.some((c) => c.id === 'receipts.ledger' && c.ok));
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs WHERE id = ?').get(old.id).n, 0, 'a 40-day-old passing scheduled run is pruned');
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs WHERE id = ?').get(old.id)).n, 0, 'a 40-day-old passing scheduled run is pruned');
const list = await t.call('GET', '/api/v1/admin/reconciliations?limit=5', { cap: ['billing.ledger.admin'] });
assert.strictEqual(list.status, 200, list.text);
assert.deepStrictEqual([list.json.runs[0].id, list.json.runs[0].trigger, list.json.runs[0].ok], [rep.id, 'scheduled', true]);
@@@ -282,20 +261,20 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
// A settlement whose clearing entry disagrees with the receipt it names.
const { post } = require('../server/ledger');
const { A, entry } = require('../server/ops/common');
-- const ev = t.db.prepare("INSERT INTO provider_events (provider, provider_event_id, type, payload_hash, payload, received_at, processed_at, result) VALUES ('powerchat', 'forged', 'donation.completed', 'x', '{}', ?, ?, ?)")
-- .run(new Date().toISOString(), new Date().toISOString(), JSON.stringify({ effect: 'settled' })).lastInsertRowid;
-- post(t.ctx, {
++ const ev = (await t.db.prepare("INSERT INTO provider_events (provider, provider_event_id, type, payload_hash, payload, received_at, processed_at, result) VALUES ('powerchat', 'forged', 'donation.completed', 'x', '{}', ?, ?, ?) RETURNING id")
++ .run(new Date().toISOString(), new Date().toISOString(), JSON.stringify({ effect: 'settled' }))).lastInsertRowid;
++ await post(t.ctx, {
type: 'purchase', idempotencyKey: 'forged-1', provider: 'powerchat', receiptRef: 'powerchat:forged', sourceEventId: Number(ev), toSubject: buyer.id,
entries: [entry(A.clearing('powerchat'), -100), entry(A.fxCents(), 100), entry(A.fxBits(), -100), entry(A.credit(buyer.id), 100)],
metadata: { paid_cents: 900, bits: 100, rates: { bits_per_usd: 100 } },
});
-- const rep = runScheduled({ ...t.ctx, log: { warn() {} } }, { keepDays: 30 });
++ const rep = await runScheduled({ ...t.ctx, log: { warn() {} } }, { keepDays: 30 });
assert.strictEqual(rep.ok, false);
const c = rep.checks.find((x) => x.id === 'receipts.ledger');
assert.strictEqual(c.ok, false);
assert.deepStrictEqual(c.detail.offenders.map((o) => [o.receipt_ref, o.clearing, o.paid_cents]), [['powerchat:forged', -100, 900]]);
-- runScheduled({ ...t.ctx, log: { warn() {} } }, { keepDays: 0 });
-- assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs WHERE id = ?').get(rep.id).n, 1, 'failed runs are never pruned');
++ await runScheduled({ ...t.ctx, log: { warn() {} } }, { keepDays: 0 });
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs WHERE id = ?').get(rep.id)).n, 1, 'failed runs are never pruned');
const m = await fetch(`${t.base}/metrics`).then((x) => x.text());
assert.match(m, /^billing_reconciliation_ok 0$/m);
assert.match(m, /^billing_reconciliation_failed_checks 1$/m);
ddiff --git a/test/import.test.js b/test/import.test.js
iindex e091964..d7cf525 100644
---- a/test/import.test.js
++++ b/test/import.test.js
@@@ -10,7 +10,7 @@ const path = require('path');
const Database = require('better-sqlite3');
const { boot, check, done } = require('./helpers/app');
--function fabricateLive(file) {
++async function fabricateLive(file) {
const live = new Database(file);
live.exec(`
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, openvibe_bucks_balance REAL DEFAULT 0.00,
@@@ -48,7 +48,7 @@ function fabricateLive(file) {
(5, 3, 'powerchat', NULL, 'bucks', 325, 250, NULL, 'paid', '2026-07-06 09:00:00');
`);
const future = new Date(Date.now() + 20 * 86_400_000).toISOString().replace('T', ' ').slice(0, 19);
-- live.prepare(`INSERT INTO subscriptions (id, subscriber_id, streamer_id, provider, price_cents, status, auto_renew, current_period_end, created_at)
++ await live.prepare(`INSERT INTO subscriptions (id, subscriber_id, streamer_id, provider, price_cents, status, auto_renew, current_period_end, created_at)
VALUES (1, 1, 2, 'powerchat', 549, 'active', 1, ?, '2026-07-08 09:00:00'), (2, 4, 2, 'bucks', 499, 'expired', 0, '2026-07-01 00:00:00', '2026-06-01 00:00:00')`).run(future);
live.close();
return new Database(file, { readonly: true, fileMustExist: true });
@@@ -60,32 +60,32 @@ function fabricateLive(file) {
const bob = t.network.addUser(2);
const dave = t.network.addUser(4);
t.network.addUser(5);
-- const live = fabricateLive(path.join(t.dir, 'live-snapshot.db'));
++ const live = await fabricateLive(path.join(t.dir, 'live-snapshot.db'));
const { importLive } = require('../server/importer/live');
const { createIdentity } = require('../server/network');
const identity = createIdentity(t.config);
-- const run = (opts = {}) => importLive(t.ctx, { live, resolveLiveUsers: identity.resolveLiveUsers, log: { log() {} }, ...opts });
-- const bal = (kind, owner) => { const { balance } = require('../server/ledger'); return balance(t.db, { kind, owner, currency: 'vibes-bits' }); };
-- const count = (table) => t.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
++ const run = async (opts = {}) => await importLive(t.ctx, { live, resolveLiveUsers: identity.resolveLiveUsers, log: { log() {} }, ...opts });
++ const bal = async (kind, owner) => { const { balance } = require('../server/ledger'); return await balance(t.db, { kind, owner, currency: 'vibes-bits' }); };
++ const count = async (table) => (await t.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()).n;
console.log('import from live.db');
await check('a dry run reports everything and keeps nothing but its report', async () => {
const r = await run({ dryRun: true });
assert.strictEqual(r.dry_run, true);
assert.ok(r.new_transactions > 0);
-- assert.strictEqual(count('transactions'), 0);
-- assert.strictEqual(count('payment_intents'), 0);
-- assert.strictEqual(t.db.prepare('SELECT dry_run FROM import_runs').get().dry_run, 1);
++ assert.strictEqual(await count('transactions'), 0);
++ assert.strictEqual(await count('payment_intents'), 0);
++ assert.strictEqual((await t.db.prepare('SELECT dry_run FROM import_runs').get()).dry_run, 1);
});
let report;
await check('opening balances equal the Live columns', async () => {
report = await run();
-- assert.strictEqual(bal('user_credit', alice), 1500);
-- assert.strictEqual(bal('creator_payable', bob), 700);
-- assert.strictEqual(bal('payouts_pending', bob), 500, 'the escrowed cashout sits in payouts_pending');
-- assert.strictEqual(bal('user_credit', dave), 101);
-- assert.strictEqual(bal('user_credit', 'hold:live:3'), 250);
++ assert.strictEqual(await bal('user_credit', alice), 1500);
++ assert.strictEqual(await bal('creator_payable', bob), 700);
++ assert.strictEqual(await bal('payouts_pending', bob), 500, 'the escrowed cashout sits in payouts_pending');
++ assert.strictEqual(await bal('user_credit', dave), 101);
++ assert.strictEqual(await bal('user_credit', 'hold:live:3'), 250);
assert.ok(t.network.resolveCalls.every((c) => c.system === 'live' && c.type === 'user'));
});
@@@ -93,35 +93,35 @@ function fabricateLive(file) {
const adj = report.adjustments.map((a) => [a.live_user_id, a.account, a.replayed_history, a.live_column, a.adjustment]);
assert.deepStrictEqual(adj.sort(), [[2, 'creator_payable', 300, 700, 400], [4, 'user_credit', 100, 101, 1]].sort());
assert.ok(report.anomalies.some((a) => a.kind === 'fractional_balance' && a.live_user_id === 4));
-- assert.strictEqual(bal('import_adjustment', null), -401);
++ assert.strictEqual(await bal('import_adjustment', null), -401);
});
await check('unmapped users are held, never dropped', async () => {
assert.deepStrictEqual(report.holds.map((h) => [h.live_user_id, h.credit_bits]), [[3, 250]]);
-- const hold = t.db.prepare('SELECT * FROM import_holds WHERE live_user_id = 3').get();
++ const hold = await t.db.prepare('SELECT * FROM import_holds WHERE live_user_id = 3').get();
assert.strictEqual(hold.resolved_subject, null);
-- const i = t.db.prepare('SELECT subject FROM payment_intents WHERE legacy_order_id = 5').get();
++ const i = await t.db.prepare('SELECT subject FROM payment_intents WHERE legacy_order_id = 5').get();
assert.strictEqual(i.subject, 'hold:live:3');
});
await check('history rows become import transactions; test-era rows are flagged test', async () => {
-- const txn = (id) => t.db.prepare('SELECT * FROM transactions WHERE idempotency_key = ?').get(`import:live:txn:${id}`);
-- assert.deepStrictEqual([txn(1).test, txn(2).test], [1, 0]);
-- assert.deepStrictEqual([txn(1).type, txn(1).status], ['import', 'imported']);
-- assert.strictEqual(txn(3).created_at, '2026-07-02T10:00:00.000Z');
++ const txn = async (id) => await t.db.prepare('SELECT * FROM transactions WHERE idempotency_key = ?').get(`import:live:txn:${id}`);
++ assert.deepStrictEqual([(await txn(1)).test, (await txn(2)).test], [1, 0]);
++ assert.deepStrictEqual([(await txn(1)).type, (await txn(1)).status], ['import', 'imported']);
++ assert.strictEqual((await txn(3)).created_at, '2026-07-02T10:00:00.000Z');
assert.ok(report.unreplayable.some((u) => u.live_txn === 9));
-- const co = t.db.prepare('SELECT status, amount_bits FROM cashouts ORDER BY legacy_live_txn').all();
++ const co = await t.db.prepare('SELECT status, amount_bits FROM cashouts ORDER BY legacy_live_txn').all();
assert.deepStrictEqual(co.map((c) => [c.status, c.amount_bits]), [['requested', 500], ['denied', 100]]);
});
await check('payment orders become intents and receipts; duplicate refs and route markers handled', async () => {
assert.deepStrictEqual(report.duplicate_provider_refs.map((d) => [d.order, d.kept_on_order]), [[3, 2]]);
assert.strictEqual(report.route_markers, 1);
-- const sub = t.db.prepare('SELECT * FROM payment_intents WHERE legacy_order_id = 4').get();
++ const sub = await t.db.prepare('SELECT * FROM payment_intents WHERE legacy_order_id = 4').get();
assert.deepStrictEqual([sub.provider_ref, sub.route, sub.fee_cents, sub.auto_renew, sub.status], [null, 'site', 50, 1, 'settled']);
-- assert.strictEqual(t.db.prepare('SELECT status FROM payment_intents WHERE legacy_order_id = 3').get().status, 'expired');
++ assert.strictEqual((await t.db.prepare('SELECT status FROM payment_intents WHERE legacy_order_id = 3').get()).status, 'expired');
assert.ok(report.anomalies.some((a) => a.kind === 'order_paid_not_credited' && a.order === 5));
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM provider_events WHERE type = 'live.payment_order'").get().n, 4);
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM provider_events WHERE type = 'live.payment_order'").get()).n, 4);
});
await check('subscriptions become entitlements', async () => {
@@@ -134,28 +134,28 @@ function fabricateLive(file) {
});
await check('the journal reconciles after import', async () => {
-- const rec = t.assertReconciled('after import');
++ const rec = await t.assertReconciled('after import');
assert.strictEqual(rec.warnings.import_holds.length, 1);
assert.ok(rec.totals.test_transactions >= 1);
});
await check('a re-run over the same snapshot changes nothing', async () => {
-- const before = { txns: count('transactions'), entries: count('ledger_entries'), intents: count('payment_intents'), subs: count('subscriptions'), ents: count('entitlements'), cashouts: count('cashouts') };
++ const before = { txns: await count('transactions'), entries: await count('ledger_entries'), intents: await count('payment_intents'), subs: await count('subscriptions'), ents: await count('entitlements'), cashouts: await count('cashouts') };
const again = await run();
assert.strictEqual(again.new_transactions, 0);
assert.deepStrictEqual(again.adjustments, []);
-- assert.deepStrictEqual({ txns: count('transactions'), entries: count('ledger_entries'), intents: count('payment_intents'), subs: count('subscriptions'), ents: count('entitlements'), cashouts: count('cashouts') }, before);
-- assert.strictEqual(bal('creator_payable', bob), 700);
++ assert.deepStrictEqual({ txns: await count('transactions'), entries: await count('ledger_entries'), intents: await count('payment_intents'), subs: await count('subscriptions'), ents: await count('entitlements'), cashouts: await count('cashouts') }, before);
++ assert.strictEqual(await bal('creator_payable', bob), 700);
});
await check('a hold is released to the subject once the Network maps the user', async () => {
const carol = t.network.addUser(3);
const r = await run();
assert.deepStrictEqual(r.holds, []);
-- assert.strictEqual(bal('user_credit', carol), 250);
-- assert.strictEqual(bal('user_credit', 'hold:live:3'), 0);
-- assert.strictEqual(t.db.prepare('SELECT resolved_subject FROM import_holds WHERE live_user_id = 3').get().resolved_subject, carol);
-- t.assertReconciled('after release');
++ assert.strictEqual(await bal('user_credit', carol), 250);
++ assert.strictEqual(await bal('user_credit', 'hold:live:3'), 0);
++ assert.strictEqual((await t.db.prepare('SELECT resolved_subject FROM import_holds WHERE live_user_id = 3').get()).resolved_subject, carol);
++ await t.assertReconciled('after release');
assert.strictEqual((await run()).new_transactions, 0);
});
ddiff --git a/test/ledger.test.js b/test/ledger.test.js
iindex 9e35d9f..d5e7b7e 100644
---- a/test/ledger.test.js
++++ b/test/ledger.test.js
@@@ -1,7 +1,7 @@
'use strict';
/** The journal itself: balance invariant, append-only, idempotent posting, cached balances. */
const assert = require('assert');
--const { openDb } = require('../server/db');
++const { testDb } = require('./helpers/db');
const { loadConfig } = require('../server/config');
const { createRates } = require('../server/rates');
const { post, balance, BillingError } = require('../server/ledger');
@@@ -9,56 +9,57 @@ const { A, entry } = require('../server/ops/common');
const { reconcile } = require('../server/reconcile');
const { check, done } = require('./helpers/app');
--const db = openDb(':memory:');
const config = loadConfig({ NODE_ENV: 'test' });
--const ctx = { db, config, rates: createRates(config.rates), now: () => Date.now() };
++let db, ctx;
const U = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';
(async () => {
++ db = await testDb();
++ ctx = { db, config, rates: createRates(config.rates), now: () => Date.now() };
console.log('ledger');
-- await check('an unbalanced transaction is refused and leaves nothing behind', () => {
-- assert.throws(() => post(ctx, { type: 'adjustment', idempotencyKey: 'k1', entries: [entry(A.credit(U), 5), entry(A.importAdj('vibes-bits'), -4)] }),
++ await check('an unbalanced transaction is refused and leaves nothing behind', async () => {
++ await assert.rejects(async () => await post(ctx, { type: 'adjustment', idempotencyKey: 'k1', entries: [entry(A.credit(U), 5), entry(A.importAdj('vibes-bits'), -4)] }),
(e) => e instanceof BillingError && e.code === 'ledger.unbalanced');
-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n, 0);
-- assert.strictEqual(balance(db, A.credit(U)), 0);
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n, 0);
++ assert.strictEqual(await balance(db, A.credit(U)), 0);
});
-- await check('balance is per currency: bits and cents never net against each other', () => {
-- assert.throws(() => post(ctx, { type: 'adjustment', idempotencyKey: 'k2', entries: [entry(A.credit(U), 5), entry(A.refunds(), -5)] }),
++ await check('balance is per currency: bits and cents never net against each other', async () => {
++ await assert.rejects(async () => await post(ctx, { type: 'adjustment', idempotencyKey: 'k2', entries: [entry(A.credit(U), 5), entry(A.refunds(), -5)] }),
(e) => e.code === 'ledger.unbalanced');
});
-- await check('a balanced transaction moves the cached balances', () => {
-- const { txn } = post(ctx, { type: 'adjustment', idempotencyKey: 'k3', entries: [entry(A.credit(U), 70), entry(A.importAdj('vibes-bits'), -70)] });
++ await check('a balanced transaction moves the cached balances', async () => {
++ const { txn } = await post(ctx, { type: 'adjustment', idempotencyKey: 'k3', entries: [entry(A.credit(U), 70), entry(A.importAdj('vibes-bits'), -70)] });
assert.strictEqual(txn.entries.length, 2);
-- assert.strictEqual(balance(db, A.credit(U)), 70);
-- assert.strictEqual(balance(db, A.importAdj('vibes-bits')), -70);
++ assert.strictEqual(await balance(db, A.credit(U)), 70);
++ assert.strictEqual(await balance(db, A.importAdj('vibes-bits')), -70);
});
-- await check('posting the same idempotency key again returns the original and moves nothing', () => {
-- const first = db.prepare("SELECT id FROM transactions WHERE idempotency_key = 'k3'").get().id;
-- const r = post(ctx, { type: 'adjustment', idempotencyKey: 'k3', entries: [entry(A.credit(U), 999), entry(A.importAdj('vibes-bits'), -999)] });
++ await check('posting the same idempotency key again returns the original and moves nothing', async () => {
++ const first = (await db.prepare("SELECT id FROM transactions WHERE idempotency_key = 'k3'").get()).id;
++ const r = await post(ctx, { type: 'adjustment', idempotencyKey: 'k3', entries: [entry(A.credit(U), 999), entry(A.importAdj('vibes-bits'), -999)] });
assert.strictEqual(r.replay, true);
assert.strictEqual(r.txn.id, first);
-- assert.strictEqual(balance(db, A.credit(U)), 70);
++ assert.strictEqual(await balance(db, A.credit(U)), 70);
});
-- await check('ledger entries and transactions cannot be updated or deleted', () => {
-- assert.throws(() => db.prepare('UPDATE ledger_entries SET amount = amount + 1').run(), /append-only/);
-- assert.throws(() => db.prepare('DELETE FROM ledger_entries').run(), /append-only/);
-- assert.throws(() => db.prepare("UPDATE transactions SET status = 'imported'").run(), /append-only/);
-- assert.throws(() => db.prepare('DELETE FROM transactions').run(), /append-only/);
++ await check('ledger entries and transactions cannot be updated or deleted', async () => {
++ await assert.rejects(db.prepare('UPDATE ledger_entries SET amount = amount + 1').run(), /append-only/);
++ await assert.rejects(db.prepare('DELETE FROM ledger_entries').run(), /append-only/);
++ await assert.rejects(db.prepare("UPDATE transactions SET status = 'imported'").run(), /append-only/);
++ await assert.rejects(db.prepare('DELETE FROM transactions').run(), /append-only/);
});
-- await check('platform accounts (no owner) are unique despite NULL owners', () => {
-- post(ctx, { type: 'adjustment', idempotencyKey: 'k4', entries: [entry(A.refunds(), 1), entry(A.loss(), -1)] });
-- post(ctx, { type: 'adjustment', idempotencyKey: 'k5', entries: [entry(A.refunds(), 1), entry(A.loss(), -1)] });
-- assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM accounts WHERE kind = 'refunds'").get().n, 1);
-- assert.throws(() => db.prepare("INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES ('refunds', NULL, 'usd-cents', 'x')").run(), /UNIQUE/);
++ await check('platform accounts (no owner) are unique despite NULL owners', async () => {
++ await post(ctx, { type: 'adjustment', idempotencyKey: 'k4', entries: [entry(A.refunds(), 1), entry(A.loss(), -1)] });
++ await post(ctx, { type: 'adjustment', idempotencyKey: 'k5', entries: [entry(A.refunds(), 1), entry(A.loss(), -1)] });
++ assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM accounts WHERE kind = 'refunds'").get()).n, 1);
++ await assert.rejects(db.prepare("INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES ('refunds', NULL, 'usd-cents', 'x')").run(), /duplicate key/);
});
-- await check('rates: tiers price like Live and invert without overcharging', () => {
++ await check('rates: tiers price like Live and invert without overcharging', async () => {
const r = ctx.rates;
assert.strictEqual(r.priceCents(100), 150);
assert.strictEqual(r.priceCents(1000), 1300);
@@@ -70,15 +71,15 @@ const U = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';
assert.strictEqual(r.siteFeeCents(499), 50); // Live: round(499 * 10 / 100)
});
-- await check('reconciliation passes on a consistent journal and catches a corrupted cache', () => {
-- assert.strictEqual(reconcile(ctx).ok, true);
-- db.prepare("UPDATE account_balances SET balance = balance + 1 WHERE account_id = (SELECT id FROM accounts WHERE kind = 'user_credit')").run();
-- const r = reconcile(ctx);
++ await check('reconciliation passes on a consistent journal and catches a corrupted cache', async () => {
++ assert.strictEqual((await reconcile(ctx)).ok, true);
++ await db.prepare("UPDATE account_balances SET balance = balance + 1 WHERE account_id = (SELECT id FROM accounts WHERE kind = 'user_credit')").run();
++ const r = await reconcile(ctx);
assert.strictEqual(r.ok, false);
const c = r.checks.find((x) => x.id === 'balances.cache');
assert.strictEqual(c.ok, false);
assert.strictEqual(c.detail.mismatches[0].cached - c.detail.mismatches[0].derived, 1);
-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs WHERE ok = 0').get().n, 1);
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs WHERE ok = 0').get()).n, 1);
});
done();
ddiff --git a/test/operations.test.js b/test/operations.test.js
iindex bbc34db..ba34f1c 100644
---- a/test/operations.test.js
++++ b/test/operations.test.js
@@@ -37,7 +37,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(r.json.transaction.metadata.revenue_cents, 300);
assert.strictEqual(r.json.transaction.metadata.rates.bits_per_usd, 100);
assert.strictEqual((await t.balances(viewer.id)).credit, 1000);
-- t.assertReconciled('after purchase');
++ await t.assertReconciled('after purchase');
});
await check('the same provider payment settles once, whatever key carries it', async () => {
@@@ -59,7 +59,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.deepStrictEqual(donation.metadata.target, { service: 'live', type: 'stream', id: '77' });
assert.strictEqual(r.json.balance.credit, 600);
assert.strictEqual((await t.balances(streamer.id)).payable, 400);
-- t.assertReconciled('after transfer');
++ await t.assertReconciled('after transfer');
});
await check('replaying an Idempotency-Key returns the original response and moves nothing', async () => {
@@@ -115,7 +115,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(over.json.code, 'billing.already_reversed');
const tx = await t.call('GET', `/api/v1/transactions/${donation.id}`);
assert.deepStrictEqual(tx.json.reversed_by, [r.json.transaction.id]);
-- t.assertReconciled('after transfer refund');
++ await t.assertReconciled('after transfer refund');
});
await check('recycle moves payable back to spendable credit as a journal entry', async () => {
@@@ -123,7 +123,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json.transaction.type, 'recycle');
assert.deepStrictEqual([r.json.balance.credit, r.json.balance.payable], [50, 250]);
-- t.assertReconciled('after recycle');
++ await t.assertReconciled('after recycle');
});
let cashout;
@@@ -140,9 +140,9 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(cashout.value_cents, 1000);
const b = await t.balances(streamer.id);
assert.deepStrictEqual([b.payable, b.pending_payouts], [250, 1000]);
-- t.assertReconciled('after cashout request');
++ await t.assertReconciled('after cashout request');
// The event stream gets the payout TYPE only, never the creator's address (a PayPal email).
-- const outboxText = JSON.stringify(t.db.prepare("SELECT event FROM outbox WHERE event LIKE '%billing.cashout.%'").all());
++ const outboxText = JSON.stringify(await t.db.prepare("SELECT event FROM outbox WHERE event ILIKE '%billing.cashout.%'").all());
assert.ok(outboxText.includes('billing.cashout.requested'), 'the requested event is queued');
assert.ok(!outboxText.includes('[email protected]'), 'no payout address in any cashout event');
assert.strictEqual(cashout.payout_method.address, '[email protected]', 'the API itself still returns it to the caller');
@@@ -163,7 +163,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(ok.json.cashout.status, 'paid');
assert.strictEqual(ok.json.cashout.payout_reference, 'PAYPAL-BATCH-1');
assert.strictEqual((await t.balances(streamer.id)).pending_payouts, 0);
-- const r = t.assertReconciled('after payout');
++ const r = await t.assertReconciled('after payout');
assert.strictEqual(r.checks.find((c) => c.id === 'payouts.references').ok, true);
});
@@@ -181,7 +181,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(tx.json.transaction.reverses_txn, r.json.cashout.request_txn);
const list = await t.call('GET', '/api/v1/cashouts?status=denied', { cap: ['billing.cashout.manage'] });
assert.strictEqual(list.json.cashouts.length, 1);
-- t.assertReconciled('after deny');
++ await t.assertReconciled('after deny');
});
await check('an operator adjustment is a balanced transaction with a reason', async () => {
@@@ -193,7 +193,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json.transaction.type, 'adjustment');
assert.strictEqual(r.json.transaction.metadata.reason, 'goodwill after outage');
-- t.assertReconciled('after adjustment');
++ await t.assertReconciled('after adjustment');
const denied = await t.call('POST', '/api/v1/admin/adjustments', { cap: ['billing.transfer.create'], body: {} });
assert.strictEqual(denied.status, 403);
});
ddiff --git a/test/providers.test.js b/test/providers.test.js
iindex 63272a1..b2260c9 100644
---- a/test/providers.test.js
++++ b/test/providers.test.js
@@@ -64,14 +64,14 @@ async function startProviderStub() {
assert.strictEqual(noPrice.json.result.review, true);
const ok = await post('secret=cc-secret&eventType=NewSaleSuccess', { transactionId: 'T1', 'X-intent': i.id, billedInitialPrice: '13.00' });
assert.strictEqual(ok.json.result.effect, 'settled', JSON.stringify(ok.json));
-- assert.strictEqual(t.db.prepare("SELECT payload FROM provider_events WHERE provider = 'ccbill' AND provider_event_id = 'NewSaleSuccess:T1'").get().payload.includes('cc-secret'), false, 'the secret is never stored');
++ assert.strictEqual((await t.db.prepare("SELECT payload FROM provider_events WHERE provider = 'ccbill' AND provider_event_id = 'NewSaleSuccess:T1'").get()).payload.includes('cc-secret'), false, 'the secret is never stored');
const again = await post('secret=cc-secret&eventType=NewSaleSuccess', { transactionId: 'T1', 'X-intent': i.id, billedInitialPrice: '13.00' });
assert.strictEqual(again.json.duplicate, true);
assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
const cb = await post('secret=cc-secret&eventType=Chargeback', { transactionId: 'T1', amount: '13.00' });
assert.strictEqual(cb.json.result.effect, 'settled', JSON.stringify(cb.json));
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
-- t.assertReconciled('after ccbill');
++ await t.assertReconciled('after ccbill');
});
await check('NOWPayments: HMAC-SHA512 IPN, several statuses settle one payment once, refunded reverses', async () => {
@@@ -92,7 +92,7 @@ async function startProviderStub() {
const r = await ipn({ ...body, payment_status: 'refunded' });
assert.strictEqual(r.json.result.effect, 'settled');
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
-- t.assertReconciled('after nowpayments');
++ await t.assertReconciled('after nowpayments');
});
await check('PayPal: verified webhooks, capture after return settles once, reversal is a chargeback', async () => {
@@@ -115,7 +115,7 @@ async function startProviderStub() {
const tx = (await t.call('GET', `/api/v1/transactions/${rev.json.result.txn_id}`)).json.transaction;
assert.strictEqual(tx.type, 'chargeback');
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
-- t.assertReconciled('after paypal');
++ await t.assertReconciled('after paypal');
});
await t.close();
ddiff --git a/test/security.test.js b/test/security.test.js
iindex 675e7a6..0a17c1a 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -64,7 +64,7 @@ const { boot, check, done } = require('./helpers/app');
const r = await tipTo('openvibe', { amountUsdCents: 250, appExternalRef: `pcdon:${attackerLiveId}:0` });
assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
assert.strictEqual((await t.balances(attacker.id)).payable, 250);
-- t.assertReconciled('after site pcdon');
++ await t.assertReconciled('after site pcdon');
});
await check('without POWERCHAT_SITE_USERNAME site-routed refs are held, never credited', async () => {
ddiff --git a/test/stripe.test.js b/test/stripe.test.js
iindex 735ccca..b3c08f7 100644
---- a/test/stripe.test.js
++++ b/test/stripe.test.js
@@@ -67,7 +67,7 @@ const { boot, check, done } = require('./helpers/app');
const e = await t.call('GET', `/api/v1/entitlements/${buyer.id}?streamer=${streamer.id}`);
assert.strictEqual(e.json.active, true);
assert.strictEqual(Date.parse(e.json.expires_at), (end1 + 30 * 86400) * 1000);
-- t.assertReconciled('after stripe invoices');
++ await t.assertReconciled('after stripe invoices');
});
await check('a refunded renewal is a reversal: the period is revoked, the share stays (review)', async () => {
@@@ -80,7 +80,7 @@ const { boot, check, done } = require('./helpers/app');
const e = await t.call('GET', `/api/v1/entitlements/${buyer.id}?streamer=${streamer.id}`);
assert.strictEqual(e.json.active, true, 'the first paid period still holds');
assert.ok(Date.parse(e.json.expires_at) < Date.now() + 31 * 86_400_000);
-- t.assertReconciled('after stripe refund');
++ await t.assertReconciled('after stripe refund');
});
await check('a partial refund then a dispute on the purchase trace to the original', async () => {
@@@ -97,7 +97,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
const orig = (await t.call('GET', `/api/v1/transactions/${tx.reverses_txn}`)).json;
assert.strictEqual(orig.reversed_by.length, 2);
-- t.assertReconciled('after dispute');
++ await t.assertReconciled('after dispute');
});
await check('cancel is sent to Stripe (cancel_at_period_end) before Billing records it', async () => {
ddiff --git a/test/subscriptions.test.js b/test/subscriptions.test.js
iindex 8c6fd4f..d94ba3d 100644
---- a/test/subscriptions.test.js
++++ b/test/subscriptions.test.js
@@@ -31,7 +31,7 @@ const DAY = 86_400_000;
assert.strictEqual(rev.amount, 150);
assert.strictEqual(r.json.entitlement.active, true);
assert.ok(Math.abs(Date.parse(r.json.entitlement.expires_at) - (Date.now() + 31 * DAY)) < 60_000);
-- t.assertReconciled('after credit sub');
++ await t.assertReconciled('after credit sub');
});
await check('an early renewal credits the share again and extends the entitlement', async () => {
@@@ -42,7 +42,7 @@ const DAY = 86_400_000;
assert.strictEqual((await t.balances(streamer.id)).payable, 698);
const after = Date.parse(r.json.entitlement.expires_at);
assert.strictEqual(after - before, 31 * DAY);
-- t.assertReconciled('after renewal');
++ await t.assertReconciled('after renewal');
});
await check('insufficient credit refuses a subscription and changes nothing', async () => {
@@@ -74,7 +74,7 @@ const DAY = 86_400_000;
assert.strictEqual(e.json.active, false);
assert.strictEqual(e.json.subscription.status, 'expired');
t.clock.offset = 0;
-- t.assertReconciled('after sweeps');
++ await t.assertReconciled('after sweeps');
});
await check('cancel keeps access until the period ends, then the sweep ends it', async () => {
ddiff --git a/test/webhooks.test.js b/test/webhooks.test.js
iindex 4398372..1ad8d38 100644
---- a/test/webhooks.test.js
++++ b/test/webhooks.test.js
@@@ -20,14 +20,14 @@ const { boot, check, done } = require('./helpers/app');
return r.json.intent;
};
const donation = (data, streamerName = 'openvibe') => ({ type: 'donation.completed', streamer: { id: 'pc1', username: streamerName }, data: { eventId: `don-${Math.random().toString(36).slice(2)}`, ...data } });
-- const eventCount = () => t.db.prepare('SELECT COUNT(*) AS n FROM provider_events').get().n;
++ const eventCount = async () => (await t.db.prepare('SELECT COUNT(*) AS n FROM provider_events').get()).n;
await check('an unsigned or stale delivery is refused and not stored', async () => {
const bad = await t.powerchat(donation({ amountUsdCents: 100 }), { secret: 'wrong' });
assert.strictEqual(bad.status, 401);
const stale = await t.powerchat(donation({ amountUsdCents: 100 }), { ts: Date.now() - 16 * 60 * 1000 });
assert.strictEqual(stale.status, 401);
-- assert.strictEqual(eventCount(), 0);
++ assert.strictEqual(await eventCount(), 0);
});
let buyIntent;
@@@ -41,7 +41,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
const i = await t.call('GET', `/api/v1/intents/${buyIntent.id}`);
assert.strictEqual(i.json.intent.status, 'settled');
-- t.assertReconciled('after pcorder');
++ await t.assertReconciled('after pcorder');
});
await check('a duplicated delivery produces exactly one accounting effect', async () => {
@@@ -51,8 +51,8 @@ const { boot, check, done } = require('./helpers/app');
const redelivered = await t.powerchat(donation({ eventId: 'don-A', amountUsdCents: 1300, appExternalRef: buyIntent.checkout_ref }), { deliveryId: 'dlv-A-retry' });
assert.strictEqual(redelivered.json.result.effect, 'duplicate_receipt');
assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-A'").get().n, 1);
-- t.assertReconciled('after duplicates');
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-A'").get()).n, 1);
++ await t.assertReconciled('after duplicates');
});
await check('test deliveries and app echoes move no money', async () => {
@@@ -72,10 +72,10 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.balances(streamer.id)).payable, 349);
const e = await t.call('GET', `/api/v1/entitlements/${buyer.id}?streamer=${streamer.id}`, { cap: ['billing.entitlement.check'] });
assert.strictEqual(e.json.active, true);
-- const tx = t.db.prepare('SELECT metadata FROM transactions WHERE id = ?').get(r.json.result.txn_id);
++ const tx = await t.db.prepare('SELECT metadata FROM transactions WHERE id = ?').get(r.json.result.txn_id);
const m = JSON.parse(tx.metadata);
assert.deepStrictEqual([m.paid_cents, m.fee_cents, m.share_bits], [549, 50, 349]);
-- t.assertReconciled('after site sub');
++ await t.assertReconciled('after site sub');
});
await check('pcsub direct is EXTERNAL: the entitlement is granted, no money is booked', async () => {
@@@ -116,7 +116,7 @@ const { boot, check, done } = require('./helpers/app');
assert.match(ext.json.result.reason, /EXTERNAL/);
const site = await t.powerchat(donation({ amountUsdCents: 1000 }, 'openvibe'));
assert.strictEqual(site.json.result.review, true);
-- const rec = t.assertReconciled('after none-effects');
++ const rec = await t.assertReconciled('after none-effects');
assert.ok(rec.warnings.events_for_review.length >= 2);
});
@@@ -135,7 +135,7 @@ const { boot, check, done } = require('./helpers/app');
const orig = await t.call('GET', `/api/v1/transactions/${pay.json.result.txn_id}`);
assert.deepStrictEqual(orig.json.reversed_by, [tx.json.transaction.id]);
assert.strictEqual(orig.json.transaction.entries.length, 5, 'the original stays as it was');
-- t.assertReconciled('after refund');
++ await t.assertReconciled('after refund');
});
await check('a chargeback on credit already donated leaves the payable intact and books the loss for review', async () => {
@@@ -157,7 +157,7 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual([tx.metadata.bits_clawed_back, tx.metadata.unrecovered_bits, tx.metadata.review], [200, 800, 'required']);
const loss = tx.entries.find((e) => e.account.kind === 'chargeback_loss');
assert.strictEqual(loss.amount, -1100, 'unrecovered 800 + spread 300');
-- const rec = t.assertReconciled('after chargeback');
++ const rec = await t.assertReconciled('after chargeback');
assert.ok(rec.warnings.transactions_for_review.some((x) => x.id === tx.id));
assert.strictEqual(rec.totals.chargeback_loss_cents, -1100);
const again = await t.powerchat({ type: 'donation.chargeback', streamer: { username: 'openvibe' }, data: { eventId: 'cb-2', originalEventId: 'don-C' } });
@@@ -191,12 +191,12 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(q.status, 202);
assert.strictEqual(q.json.queued, true);
assert.strictEqual((await t.balances(buyer.id)).credit, credit);
-- assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM provider_events WHERE processed_at IS NULL").get().n, 1);
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM provider_events WHERE processed_at IS NULL").get()).n, 1);
const off = await t.call('POST', '/api/v1/admin/freeze', { key: null, body: { on: false } });
assert.strictEqual(off.json.frozen, false);
assert.strictEqual(off.json.processed_after_unfreeze.processed, 1);
assert.strictEqual((await t.balances(buyer.id)).credit, credit + 100);
-- t.assertReconciled('after unfreeze');
++ await t.assertReconciled('after unfreeze');
});
await t.close();