openvibers/openvibe.billing Commits
Billing on PostgreSQL (ADR-035, roadmap WS-X2): openvibe-sdk/db, async throughout, every money movement serializable.
Changes
@@@ -11,7 +11,15 @@ NODE_ENV=developmentBASE_URL=http://localhost:4600TRUST_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@@@ -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@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with: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@@@ -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 sumsto zero **per currency** (`vibes-bits`, `usd-cents`); triggers refuse UPDATE/DELETE on entries andtransactions; 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 pairat the recorded value rate (`bits_per_usd`, 100 today); reconciliation checks that each transaction'scents 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`, unit70 % streamer share, 10 % site-route fee, minimum cashout, escrow days. Each transaction records therates 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 paymentsettles 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 refuin 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 IPkeyed 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 sametransaction 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)@@@ -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",@@@ -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:/binExecStart=/usr/bin/env node /opt/openvibe.billing/server/index.jsRestart=alwaysRestartSec=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-billingStateDirectoryMode=0700Environment=BILLING_DB_PATH=/var/lib/openvibe-billing/billing.dbnnew file mode 100644@@@ -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));@@@ -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"++ }}}}@@@ -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"}}@@@ -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 };@@@ -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)' : ''}`);nnew file mode 100755@@@ -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 };@@@ -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); });@@@ -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 = nullconst 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 = nullconst 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;}@@@ -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();};@@@ -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 tWHERE (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); }};@@@ -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) {@@@ -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.@@@ -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@@@ -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 || '{}') }));}@@@ -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;}@@@ -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 eJOIN 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_bitsFROM 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_atFROM 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'),};}@@@ -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 }));@@@ -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)--);--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--);--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;----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);----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);----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;----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 };@@@ -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_connectionsWHERE 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 = faconst 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 = cfor (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 = creport.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 = cconst 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 = creport.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 = cconst 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 = celse 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 eJOIN 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 = cconst 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;@@@ -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);++});@@@ -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 eJOIN 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 };@@@ -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, CASEWHEN 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 };}@@@ -37,7 +37,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = consoleif (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;@@@ -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 };@@@ -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 };@@@ -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). */@@@ -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);}@@@ -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) {@@@ -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) {@@@ -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 };@@@ -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 entitlementsWHERE 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 };@@@ -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 };@@@ -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', trif (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, lolet 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, losignal: 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, loreturn { 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}`);}@@@ -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 };@@@ -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) {@@@ -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) };@@@ -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 };@@@ -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');@@@ -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 };@@@ -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_idGROUP 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 derivedFROM 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 centsFROM ledger_entries e JOIN accounts a ON a.id = e.account_id JOIN transactions t ON t.id = e.txn_idWHERE 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 txnsFROM 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 clearingFROM 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 xWHERE 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 outboxWHERE 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.idWHERE 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 eJOIN 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;}@@@ -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();@@@ -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();@@@ -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');});@@@ -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 frozenconst 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();@@@ -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_eawait 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_econst 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_eassert.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_eassert.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_econst 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_eassert.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_econst 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_econst 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();@@@ -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 });},};nnew file mode 100644@@@ -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 };@@@ -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);@@@ -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);});@@@ -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();@@@ -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);});@@@ -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();@@@ -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 () => {@@@ -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 () => {@@@ -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 () => {@@@ -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();
@@ -11,7 +11,15 @@ NODE_ENV=development
@@ -11,7 +11,15 @@ NODE_ENV=development
BASE_URL=http://localhost:4600
BASE_URL=http://localhost:4600
TRUST_PROXY=1
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
BILLING_DB_PATH=./data/billing.db
# Who keeps the money books: live (default — OpenVibe.Live is the ledger and receives the PowerChat
# Who keeps the money books: live (default — OpenVibe.Live is the ledger and receives the PowerChat
@@ -4,11 +4,14 @@ on:
@@ -4,11 +4,14 @@ on:
branches: [main]
branches: [main]
pull_request:
pull_request:
jobs:
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:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
contracts-service: billing
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).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
@@ -31,7 +31,8 @@ webhooks land here and nowhere else.
@@ -31,7 +31,8 @@ webhooks land here and nowhere else.
- the journal (`accounts`, `transactions`, `ledger_entries`, `account_balances`), provider receipts
- the journal (`accounts`, `transactions`, `ledger_entries`, `account_balances`), provider receipts
(`provider_events`, `external_receipts`), intents, subscriptions and entitlements, cashouts, the
(`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
- 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)
- 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.
@@ -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.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)
- OpenVibe.Events (the outbox relay, only when `EVENTS_URL` is set)
- the payment providers whose secrets are configured (none in production today)
- 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
## Capabilities
@@ -85,8 +88,9 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
@@ -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
`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
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
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
- **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
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`.
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
@@ -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
70 % streamer share, 10 % site-route fee, minimum cashout, escrow days. Each transaction records the
rates it used.
rates it used.
- **Receipts**: webhooks are verified, stored in `provider_events` (UNIQUE provider + event id) before
- **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
Every settling transaction has a unique `receipt_ref` (`<provider>:<payment id>`), so a payment
settles once whatever event, retry or API call carries it.
settles once whatever event, retry or API call carries it.
- **Freeze** (ADR-012 rule 11): `settings.freeze`; while on, every mutating endpoint answers
- **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
@@ -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,
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
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
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
transaction as their effect. Adjustments are not in the console; they stay on the API
(`POST /api/v1/admin/adjustments`).
(`POST /api/v1/admin/adjustments`).
@@ -322,13 +326,16 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -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,
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`).
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`,
[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).
`/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
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.
`BILLING_LIMITS=off` turns the per-person limits off without a deploy.
## Cutover runbook (Live → Billing)
## Cutover runbook (Live → Billing)
@@ -8,8 +8,8 @@
@@ -8,8 +8,8 @@
"authoritative": false,
"authoritative": false,
"deployed": true,
"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.",
"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",
"updated": "2026-09-28",
"features": [
"features": [
"append-only double-entry journal in bits and cents, balanced per currency",
"append-only double-entry journal in bits and cents, balanced per currency",
@@ -1,9 +1,10 @@
@@ -1,9 +1,10 @@
[Unit]
[Unit]
Description=OpenVibe.Billing — the isolated money ledger (journal, provider receipts, subscriptions, payouts)
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);
# 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]
[Service]
Type=simple
Type=simple
@@ -18,8 +19,8 @@ Environment=PATH=/usr/local/bin:/usr/bin:/bin
@@ -18,8 +19,8 @@ Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/env node /opt/openvibe.billing/server/index.js
ExecStart=/usr/bin/env node /opt/openvibe.billing/server/index.js
Restart=always
Restart=always
RestartSec=5
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
StateDirectory=openvibe-billing
StateDirectoryMode=0700
StateDirectoryMode=0700
Environment=BILLING_DB_PATH=/var/lib/openvibe-billing/billing.db
Environment=BILLING_DB_PATH=/var/lib/openvibe-billing/billing.db
new file mode 100644
new file mode 100644
@@ -0,0 +1,296 @@
@@ -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));
@@ -12,14 +12,32 @@
@@ -12,14 +12,32 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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": {
"engines": {
"node": ">=22.0.0"
"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": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -221,6 +239,15 @@
@@ -221,6 +239,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"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": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -290,6 +317,15 @@
@@ -290,6 +317,15 @@
"node": ">=4.0.0"
"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": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -699,6 +735,49 @@
@@ -699,6 +735,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"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": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -763,12 +842,24 @@
@@ -763,12 +842,24 @@
"safe-buffer": "^5.0.1"
"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": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"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": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -968,9 +1059,9 @@
@@ -968,9 +1059,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -984,18 +1075,38 @@
@@ -984,18 +1075,38 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"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": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1016,6 +1127,134 @@
@@ -1016,6 +1127,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"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": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1139,6 +1378,27 @@
@@ -1139,6 +1378,27 @@
"node": ">= 6"
"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": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1354,6 +1614,21 @@
@@ -1354,6 +1614,21 @@
"simple-concat": "^1.0.0"
"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": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1481,6 +1756,15 @@
@@ -1481,6 +1756,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"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"
+ }
}
}
}
}
}
}
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-billing",
"name": "openvibe-billing",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"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",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -10,20 +10,31 @@
@@ -10,20 +10,31 @@
"test": "node test/run.js",
"test": "node test/run.js",
"reconcile": "node scripts/reconcile.js",
"reconcile": "node scripts/reconcile.js",
"freeze": "node scripts/freeze.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",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
}
}
}
}
@@ -10,18 +10,28 @@
@@ -10,18 +10,28 @@
* node scripts/freeze.js off the running service processes the held webhooks in
* node scripts/freeze.js off the running service processes the held webhooks in
* arrival order on its next retry tick (≤ BILLING_WEBHOOK_RETRY_MS)
* 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 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 };
@@ -34,7 +34,7 @@ async function main() {
@@ -34,7 +34,7 @@ async function main() {
const config = loadConfig();
const config = loadConfig();
if (path.resolve(livePath) === path.resolve(config.dbPath)) throw new Error('--live-db points at the Billing database');
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 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 ctx = { db, config, rates: createRates(config.rates), now: () => Date.now(), log: console };
const identity = createIdentity(config);
const identity = createIdentity(config);
if (flag('accounts-only')) {
if (flag('accounts-only')) {
@@ -50,7 +50,7 @@ async function main() {
@@ -50,7 +50,7 @@ async function main() {
return;
return;
}
}
const report = await importLive(ctx, { live, resolveLiveUsers: identity.resolveLiveUsers, dryRun: flag('dry-run') });
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));
if (flag('json')) console.log(JSON.stringify({ import: report, reconciliation: rec }, null, 2));
else {
else {
console.log(`import ${report.run_id}${report.dry_run ? ' (DRY RUN — nothing kept)' : ''}`);
console.log(`import ${report.run_id}${report.dry_run ? ' (DRY RUN — nothing kept)' : ''}`);
new file mode 100755
new file mode 100755
@@ -0,0 +1,42 @@
@@ -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 };
@@ -4,23 +4,25 @@
@@ -4,23 +4,25 @@
* Run a reconciliation against BILLING_DB_PATH, store it in reconciliation_runs and print it.
* 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
* 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); });
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
* balance and entitlement reads (hot paths the services call for every page and chat message),
* balance and entitlement reads (hot paths the services call for every page and chat message),
* /api/health, /api/ready, /release.json and /metrics.
* /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');
const { ids } = require('openvibe-contracts');
/** Each budget per person: a minute, an hour. */
/** Each budget per person: a minute, an hour. */
@@ -56,7 +56,7 @@ function personOf(v) {
@@ -56,7 +56,7 @@ function personOf(v) {
* createActorLimits({ config, db, now, registry, log }) → { budget(name, personFrom), history, refund }.
* 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).
* 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
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'] })
? 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;
: null;
@@ -65,6 +65,7 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
@@ -65,6 +65,7 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
const limiter = createActorLimiter({
const limiter = createActorLimiter({
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
now,
now,
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
actor(req) {
actor(req) {
if (!enabled) return null;
if (!enabled) return null;
const person = req.limitPerson;
const person = req.limitPerson;
@@ -83,8 +84,8 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
@@ -83,8 +84,8 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
const own = BUDGETS[name];
const own = BUDGETS[name];
if (!own) throw new Error(`limits: no budget named ${name}`);
if (!own) throw new Error(`limits: no budget named ${name}`);
const limit = limiter(name, own);
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);
return limit(req, res, next);
};
};
}
}
@@ -97,8 +98,8 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
@@ -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. */
/** 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;
return row ? personOf(row.from_subject) : null;
}
}
@@ -24,14 +24,14 @@ function stable(v) {
@@ -24,14 +24,14 @@ function stable(v) {
}
}
function idempotent(db, now) {
function idempotent(db, now) {
- return function idempotencyKey(req, res, next) {
+ return async function idempotencyKey(req, res, next) {
const key = req.get('Idempotency-Key');
const key = req.get('Idempotency-Key');
if (!key || !KEY_RE.test(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 });
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 scoped = `${req.principal.sub}:${key}`;
const hash = crypto.createHash('sha256').update(`${req.method} ${req.baseUrl}${req.path}\n${stable(req.body || {})}`).digest('hex');
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) {
if (row.request_hash !== hash) {
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 });
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) {
@@ -41,12 +41,12 @@ function idempotent(db, now) {
}
}
req.idempotencyKey = `api:${scoped}`;
req.idempotencyKey = `api:${scoped}`;
const json = res.json.bind(res);
const json = res.json.bind(res);
- res.json = (body) => {
+ res.json = async (body) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
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());
.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();
return next();
};
};
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
*/
*/
const express = require('express');
const express = require('express');
const { http, capabilities } = require('openvibe-contracts');
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 { A, userSubject, positiveInt, isFrozen, fail } = require('../ops/common');
const { idempotent } = require('./idempotency');
const { idempotent } = require('./idempotency');
const { actorOf } = require('./auth');
const { actorOf } = require('./auth');
@@ -40,7 +40,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -40,7 +40,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
const r = express.Router();
const r = express.Router();
const { db } = ctx;
const { db } = ctx;
const idem = idempotent(db, ctx.now);
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 })
? http.sendProblem(res, 503, 'billing.frozen', { detail: 'the economy is frozen: writes are refused, reads are served', ctx: req.ov })
: next());
: next());
/**
/**
@@ -72,7 +72,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -72,7 +72,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
const adapter = adapters[String(b.provider || '').toLowerCase()];
const adapter = adapters[String(b.provider || '').toLowerCase()];
if (!adapter) fail(422, 'billing.invalid_input', `unknown provider ${b.provider}`);
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`);
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;
let checkoutUrl = null;
if (adapter.createCheckout) {
if (adapter.createCheckout) {
const name = intent.kind === 'purchase' ? `${intent.bits.toLocaleString('en-US')} Vibes` : 'Channel subscription (1 month)';
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 }) {
@@ -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`,
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}`,
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;
checkoutUrl = out.url || null;
} catch (e) {
} catch (e) {
- intents.setStatus(ctx, intent.id, 'failed');
+ await intents.setStatus(ctx, intent.id, 'failed');
fail(502, 'billing.provider_error', `${adapter.name}: ${e.message}`);
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'))));
}, 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}`);
if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);
res.json({ intent: intents.present(i) });
res.json({ intent: intents.present(i) });
}));
}));
r.post('/intents/:id/capture', ...write(CAP.intent, async (req, res) => {
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}`);
if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);
const adapter = adapters[i.provider];
const adapter = adapters[i.provider];
if (!adapter || !adapter.capture || !adapter.enabled) fail(409, 'billing.provider_disabled', `${i.provider} has no enabled capture`);
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 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) ──
// ── 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 || {};
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');
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}`,
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,
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,
test: !!b.test, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,
@@ -118,55 +118,55 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -118,55 +118,55 @@ function v1Router({ ctx, auth, adapters, limits }) {
}));
}));
// ── Transfers (tips / donations / paid interactions) ─────
// ── 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 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,
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,
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'))));
}, 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 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));
res.status(201).json(txnOut(out.txn));
}, limits.budget('billing.transfer.refund', limits.payerOf)));
}, limits.budget('billing.transfer.refund', limits.payerOf)));
// ── Recycle: creator payable → own spendable credit ──────
// ── 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 b = req.body || {};
const subject = userSubject(b.subject);
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'))));
}, limits.budget('billing.recycle', person('subject'))));
// ── Cashouts ─────────────────────────────────────────────
// ── Cashouts ─────────────────────────────────────────────
- r.post('/cashouts', ...write(CAP.cashoutRequest, (req, res) => {
+ r.post('/cashouts', ...write(CAP.cashoutRequest, async (req, res) => {
const b = req.body || {};
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) });
res.status(201).json({ cashout: cashouts.present(out.cashout) });
}, limits.budget('billing.cashout.request', person('subject'))));
}, 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) });
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}`);
if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${req.params.id}`);
res.json({ cashout: cashouts.present(c) });
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 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) });
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) });
res.json({ cashout: cashouts.present(out.cashout) });
}));
}));
// ── Subscriptions and entitlements ───────────────────────
// ── 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 b = req.body || {};
const source = b.source || 'credit';
const source = b.source || 'credit';
let receipt = null;
let receipt = null;
@@ -180,48 +180,48 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -180,48 +180,48 @@ function v1Router({ ctx, auth, adapters, limits }) {
const provider = String(rc.provider).toLowerCase();
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 };
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,
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),
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) });
res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });
}, limits.budget('billing.subscription.create', person('subscriber'))));
}, 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,
subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
streamer: req.query.streamer ? pathSubject(req.query.streamer) : null,
streamer: req.query.streamer ? pathSubject(req.query.streamer) : null,
status: req.query.status || null,
status: req.query.status || null,
});
});
res.json({ subscriptions: list.map(subscriptions.present) });
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}`);
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) => {
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);
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 });
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);
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 ─────────────────────────────────
// ── 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 {
return {
subject: { type: 'user', id: subject }, currency: 'vibes-bits', credit, payable, pending_payouts: pending,
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,
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;
const subject = req.query.subject ? pathSubject(req.query.subject) : null;
if (!subject) fail(422, 'billing.invalid_input', 'subject is required');
if (!subject) fail(422, 'billing.invalid_input', 'subject is required');
const limit = Math.min(200, positiveInt(req.query.limit || 50, 'limit'));
const limit = Math.min(200, positiveInt(req.query.limit || 50, 'limit'));
@@ -229,40 +229,40 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -229,40 +229,40 @@ function v1Router({ ctx, auth, adapters, limits }) {
if (req.query.cursor) {
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'); }
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))
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 });
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 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;
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));
}, 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}`);
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 });
res.json({ transaction: presentTxn(t), reversed_by: reversals });
}));
}));
// ── Admin (billing.ledger.admin) ─────────────────────────
// ── 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) => {
r.post('/admin/freeze', auth.needs(CAP.admin), wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
if (typeof b.on !== 'boolean') fail(422, 'billing.invalid_input', 'on must be true or false');
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.
// Deliveries stored while frozen are processed now, in arrival order.
const drained = b.on ? null : await providers.processPending(ctx, adapters);
const drained = b.on ? null : await providers.processPending(ctx, adapters);
res.json({ ...state, processed_after_unfreeze: drained });
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);
res.status(200).json(report);
}));
}));
// Stored runs (scheduled and on demand), newest first; ?failed=1 for failed runs only.
// 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 limit = Math.min(200, positiveInt(req.query.limit || 20, 'limit'));
const failed = req.query.failed === '1' || req.query.failed === 'true';
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({
res.json({
runs: rows.map((r) => {
runs: rows.map((r) => {
const rep = JSON.parse(r.report);
const rep = JSON.parse(r.report);
@@ -270,50 +270,50 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -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'
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}`);
if (!row) fail(404, 'billing.reconciliation_not_found', `no reconciliation run ${req.params.id}`);
res.json(JSON.parse(row.report));
res.json(JSON.parse(row.report));
}));
}));
// Provider account → creator (who an EXTERNAL tip on that account belongs to).
// 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).
// 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 b = req.body || {};
const provider = String(b.provider || '').toLowerCase();
const provider = String(b.provider || '').toLowerCase();
if (!adapters[provider]) fail(422, 'billing.invalid_input', `provider must be one of ${Object.keys(adapters).join(', ')}`);
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 });
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 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));
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 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) });
res.json({ events: rows.map(providers.present) });
}));
}));
r.post('/admin/provider-events/:id/reprocess', auth.needs(CAP.admin), notFrozen, wrap(async (req, res) => {
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));
const row = await providers.reprocess(ctx, adapters, Number(req.params.id));
res.json({ event: providers.present(row) });
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;
return r;
}
}
/** Async-safe handler: BillingError → problem+json; anything else → 500. */
/** Async-safe handler: BillingError → problem+json; anything else → 500. */
function wrap(fn) {
function wrap(fn) {
- return (req, res, next) => {
+ return async (req, res, next) => {
try {
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));
if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
} catch (e) { sendError(req, res, e); }
} catch (e) { sendError(req, res, e); }
};
};
@@ -36,8 +36,8 @@ function webhooksRouter({ ctx, adapters }) {
@@ -36,8 +36,8 @@ function webhooksRouter({ ctx, adapters }) {
let parsed;
let parsed;
try { parsed = adapter.parse(req); } catch { return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'unparseable payload', ctx: req.ov }); }
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 });
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);
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 });
return res.status(200).json({ received: true, duplicate, event: after.id, processed: !!after.processed_at, result: after.result || null });
} catch (e) {
} catch (e) {
@@ -33,9 +33,9 @@ const { createMetrics } = require('./metrics');
@@ -33,9 +33,9 @@ const { createMetrics } = require('./metrics');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
-function createApp(opts = {}) {
+async function createApp(opts = {}) {
const config = opts.config || loadConfig();
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 fetchImpl = opts.fetchImpl || globalThis.fetch;
const log = opts.log || console;
const log = opts.log || console;
const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
@@ -63,26 +63,32 @@ function createApp(opts = {}) {
@@ -63,26 +63,32 @@ function createApp(opts = {}) {
require('openvibe-shared/trace').install(app);
require('openvibe-shared/trace').install(app);
app.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
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])),
providers: Object.fromEntries(Object.values(adapters).map((a) => [a.name, a.enabled])),
events_relay: !!config.events.url,
events_relay: !!config.events.url,
}));
}));
// Readiness in the openvibe-shared/ready shape (status, named checks, release): 503 when the ledger
// 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.
// 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({
const readiness = require('openvibe-shared/ready').createReadiness({
service: 'billing', release: release.release,
service: 'billing', release: release.release,
checks: [
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: '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.get('/api/ready', readiness.handler);
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
// Per-person limits on the money-creating routes (api/actor-limits.js); the webhooks above never are.
// 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 }));
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.
// The billing policy is public and indexable; the staff console and the API are not.
@@ -69,6 +69,10 @@ function loadConfig(env = process.env) {
@@ -69,6 +69,10 @@ function loadConfig(env = process.env) {
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
baseUrl,
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
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',
dbPath: env.BILLING_DB_PATH || './data/billing.db',
// 'live' (default, shadow): EXTERNAL PowerChat tips are recorded but not announced, because
// 'live' (default, shadow): EXTERNAL PowerChat tips are recorded but not announced, because
// Live announces them from its own webhook. 'billing': they are announced as
// Live announces them from its own webhook. 'billing': they are announced as
@@ -2,9 +2,9 @@
@@ -2,9 +2,9 @@
/**
/**
* staff_audit: one append-only row per staff action (and per refused one). A done action is also
* 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
* 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
* 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.
* (codes, payout reference, counts), the request id and a keyed hash of the client address.
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
const { iso, prefixedId } = require('../ledger');
const { iso, prefixedId } = require('../ledger');
const { enqueue } = require('../outbox');
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 ms = ctx.now();
const id = prefixedId('sa', ms);
const id = prefixedId('sa', ms);
const row = {
const row = {
@@ -21,10 +21,10 @@ function record(ctx, { actor, action, target, reason, outcome = 'done', detail,
@@ -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 || {}),
reason: reason ? String(reason).slice(0, 500) : null, outcome, detail: JSON.stringify(detail || {}),
request_id: requestId || null, ip_hash: ipHash || null,
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);
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) {
if (outcome === 'done' && row.actor_subject) {
- enqueue(ctx, {
+ await enqueue(ctx, {
event_type: 'billing.staff.action',
event_type: 'billing.staff.action',
subject: { type: 'staff_action', id },
subject: { type: 'staff_action', id },
actor: { type: 'user', id: row.actor_subject },
actor: { type: 'user', id: row.actor_subject },
@@ -39,8 +39,8 @@ function record(ctx, { actor, action, target, reason, outcome = 'done', detail,
@@ -39,8 +39,8 @@ function record(ctx, { actor, action, target, reason, outcome = 'done', detail,
return row;
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 || '{}') }));
.map((r) => ({ ...r, detail: JSON.parse(r.detail || '{}') }));
}
}
@@ -26,12 +26,12 @@
@@ -26,12 +26,12 @@
* and each route names the one capability it needs. Every change goes through the same functions
* 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
* 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
* 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 crypto = require('crypto');
const express = require('express');
const express = require('express');
const { capabilities, staff: staffMap } = require('openvibe-contracts');
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 { A, assertNotFrozen, isFrozen, text } = require('../ops/common');
const cashouts = require('../ops/cashouts');
const cashouts = require('../ops/cashouts');
const admin = require('../ops/admin');
const admin = require('../ops/admin');
@@ -108,11 +108,11 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -108,11 +108,11 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
const requestId = (req) => (req.ov ? req.ov.requestId : null);
const requestId = (req) => (req.ov ? req.ov.requestId : null);
const trace = (req) => (req.ov ? req.ov.traceId : undefined);
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 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,
actor: req.staff ? { subject: req.staff.subject, username: req.staff.username } : fields.actor,
requestId: requestId(req), ipHash: ipHash(req), traceId: trace(req), ...fields,
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 ───────────────────────────────
// ── Every console response ───────────────────────────────
r.use((req, res, next) => {
r.use((req, res, next) => {
@@ -128,14 +128,14 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -128,14 +128,14 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
r.use(express.urlencoded({ extended: false, limit: '16kb', parameterLimit: 50 }));
r.use(express.urlencoded({ extended: false, limit: '16kb', parameterLimit: 50 }));
// ── Session → req.staff ──────────────────────────────────
// ── Session → req.staff ──────────────────────────────────
- r.use((req, res, next) => {
+ r.use(async (req, res, next) => {
req.staff = null;
req.staff = null;
- const row = sessions.read(req);
+ const row = await sessions.read(req);
if (!row) return next();
if (!row) return next();
if (!cc.staffSubjects.includes(row.subject) || !staffMap.can(row.role || 'user', cc.staffCapability)) {
if (!cc.staffSubjects.includes(row.subject) || !staffMap.can(row.role || 'user', cc.staffCapability)) {
- sessions.revoke(row);
+ await sessions.revoke(row);
sessions.clear(res);
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.' }));
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 } };
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 }) {
@@ -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. */
/** Staff only, holding `cap` through the staff mapping. POSTs also need the CSRF token. */
function needs(cap) {
function needs(cap) {
- return (req, res, next) => {
+ return async (req, res, next) => {
if (!req.staff) {
if (!req.staff) {
if (req.method === 'GET') return send(res, 401, pages.signIn({ next: sanitizeNext(req.originalUrl) }));
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.' }));
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)) {
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)) {
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();
return next();
};
};
@@ -166,9 +166,9 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -166,9 +166,9 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
if (origin && origin !== baseOrigin) return false;
if (origin && origin !== baseOrigin) return false;
return sameString((req.body || {})._csrf, req.staff.csrf);
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);
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 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;
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 }) {
@@ -198,63 +198,63 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
}
}
const listed = !!who.subject && cc.staffSubjects.includes(who.subject);
const listed = !!who.subject && cc.staffSubjects.includes(who.subject);
if (!listed || !who.money) {
if (!listed || !who.money) {
- audit.record(ctx, {
+ await audit.record(ctx, {
actor: { subject: who.subject, username: who.username }, action: 'session.sign_in', outcome: 'refused',
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',
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),
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.' }));
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 || '/');
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);
sessions.clear(res);
res.redirect(303, '/');
res.redirect(303, '/');
});
});
// ── Dashboard ────────────────────────────────────────────
// ── Dashboard ────────────────────────────────────────────
- r.get('/', (req, res) => {
+ r.get('/', async (req, res) => {
if (!req.staff) return send(res, 200, pages.signIn({}));
if (!req.staff) return send(res, 200, pages.signIn({}));
return send(res, 200, pages.dashboard({
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 ─────────────────────────────────────────────
// ── 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';
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({
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,
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. */
/** Run a cashout decision through ops; audit once (not on a replay); refusals audited as refused. */
function decide(action, run, validate) {
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 b = req.body || {};
const target = { type: 'cashout', id: c.id };
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) };
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 }) {
@@ -262,11 +262,11 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
const key = actionKey(b.action_key);
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 (!key) throw new BillingError(422, 'console.stale_form', 'the form is missing its action key; reload the page');
if (validate) validate(b);
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) {
if (!o.replay) {
- record(req, {
+ await record(req, {
action, target, reason: action === 'cashout.deny' ? form.reason : null,
action, target, reason: action === 'cashout.deny' ? form.reason : null,
detail: action === 'cashout.approve'
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 }
? { 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 }) {
@@ -274,95 +274,95 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
});
});
}
}
return o;
return o;
- })();
+ });
const done = action === 'cashout.approve' ? (out.replay ? 'already_paid' : 'approved') : (out.replay ? 'already_denied' : 'denied');
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}`);
return res.redirect(303, `/cashouts/${encodeURIComponent(c.id)}?done=${done}`);
} catch (e) {
} catch (e) {
if (!(e instanceof BillingError)) throw 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();
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) => {
}, (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 (!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 (!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');
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) => {
), (b) => {
if (!text(b.reason, 'reason', 300)) throw new BillingError(422, 'console.reason_required', 'a denial needs a reason');
if (!text(b.reason, 'reason', 300)) throw new BillingError(422, 'console.reason_required', 'a denial needs a reason');
})));
})));
// ── Receipts needing review ──────────────────────────────
// ── 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) => {
r.post('/receipts/:id/reprocess', needs(CAP.admin), wrap(async (req, res) => {
const id = Number(req.params.id);
const id = Number(req.params.id);
const target = { type: 'provider_event', id: req.params.id };
const target = { type: 'provider_event', id: req.params.id };
try {
try {
if (!Number.isInteger(id) || id <= 0) throw new BillingError(404, 'billing.event_not_found', `no provider event ${req.params.id}`);
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);
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');
return res.redirect(303, '/receipts?done=reprocessed');
} catch (e) {
} catch (e) {
if (!(e instanceof BillingError)) throw 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 ───────────────────────────────────────
// ── 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;
return rep;
- })();
+ });
res.redirect(303, `/reconciliation/${encodeURIComponent(report.id)}`);
res.redirect(303, `/reconciliation/${encodeURIComponent(report.id)}`);
});
});
// ── Freeze ───────────────────────────────────────────────
// ── 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) => {
r.post('/freeze', needs(CAP.admin), wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const on = b.on === '1';
const on = b.on === '1';
const action = on ? 'economy.freeze' : 'economy.unfreeze';
const action = on ? 'economy.freeze' : 'economy.unfreeze';
const target = { type: 'settings', id: 'freeze' };
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;
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.
// As POST /api/v1/admin/freeze: deliveries stored while frozen are processed now, in order.
const drained = on ? null : await providers.processPending(ctx, adapters);
const drained = on ? null : await providers.processPending(ctx, adapters);
return res.redirect(303, `/freeze?done=${on ? 'frozen' : 'unfrozen'}${drained ? `&processed=${drained.processed || 0}` : ''}`);
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;
return r;
}
}
@@ -8,17 +8,17 @@
@@ -8,17 +8,17 @@
const { iso } = require('../ledger');
const { iso } = require('../ledger');
/** Outstanding balances per account kind, counting only non-test transactions (ADR-012 rule 9). */
/** 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
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 {
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) {
@@ -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;
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;
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) => {
.map((r) => {
const s = summarizeReport(JSON.parse(r.report));
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) };
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
@@ -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
* 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).
* 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();
FROM transactions WHERE json_extract(metadata, '$.review') = 'required' ORDER BY created_at DESC LIMIT 200`).all();
return { review, rejected, unprocessed, transactions };
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();
FROM import_holds ORDER BY resolved_subject IS NOT NULL, live_user_id`).all();
}
}
@@ -93,22 +93,22 @@ const CASHOUT_TABS = {
@@ -93,22 +93,22 @@ const CASHOUT_TABS = {
denied: { label: 'Denied', where: "status = 'denied'", order: 'updated_at DESC' },
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;
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 || '{}') }));
.map((r) => ({ ...r, payout_method: JSON.parse(r.payout_method || '{}') }));
}
}
-function counts(db, nowMs) {
+async function counts(db, nowMs) {
const now = iso(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 {
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'),
};
};
}
}
@@ -79,27 +79,27 @@ function createSessions(ctx, { secret }) {
@@ -79,27 +79,27 @@ function createSessions(ctx, { secret }) {
}
}
// ── Sessions ─────────────────────────────────────────────
// ── Sessions ─────────────────────────────────────────────
- function create(res, { subject, username, role, ip }) {
+ async function create(res, { subject, username, role, ip }) {
const now = ctx.now();
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 id = random(32);
const ttlMs = cc.sessionTtlMin * 60 * 1000;
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));
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 }));
appendCookie(res, cookieHeader(sessionCookie, id, { maxAgeSec: ttlMs / 1000, secure: cc.cookieSecure }));
}
}
/** The live session of this request, or null (unknown, expired or revoked). */
/** The live session of this request, or null (unknown, expired or revoked). */
- function read(req) {
+ async function read(req) {
const id = parseCookies(req)[sessionCookie];
const id = parseCookies(req)[sessionCookie];
if (!id || !/^[A-Za-z0-9_-]{43}$/.test(id)) return null;
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;
if (!row || row.revoked_at || Date.parse(row.expires_at) <= ctx.now()) return null;
return row;
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) {
function clear(res) {
appendCookie(res, cookieHeader(sessionCookie, '', { maxAgeSec: 0, secure: cc.cookieSecure }));
appendCookie(res, cookieHeader(sessionCookie, '', { maxAgeSec: 0, secure: cc.cookieSecure }));
@@ -1,13 +1,13 @@
@@ -1,13 +1,13 @@
'use strict';
'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
* 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
* 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.
* 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)
* 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.
* reconciliation.
*
*
* Operational state that does change (intents, provider events, cashouts, subscriptions,
* Operational state that does change (intents, provider events, cashouts, subscriptions,
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
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',
const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',
'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion'];
'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion'];
@@ -23,305 +23,29 @@ const CURRENCIES = ['vibes-bits', 'usd-cents'];
@@ -23,305 +23,29 @@ const CURRENCIES = ['vibes-bits', 'usd-cents'];
const TXN_TYPES = ['purchase', 'donation', 'subscription', 'subscription_share', 'cashout_request', 'cashout_paid',
const TXN_TYPES = ['purchase', 'donation', 'subscription', 'subscription_share', 'cashout_request', 'cashout_paid',
'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import'];
'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)
-);
-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
-);
-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;
-
-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);
-
-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);
-
-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;
-
-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 };
@@ -34,7 +34,7 @@
@@ -34,7 +34,7 @@
* cutover) only adds what changed. --dry-run does everything inside a transaction that is rolled
* cutover) only adds what changed. --dry-run does everything inside a transaction that is rolled
* back, and stores only the report.
* 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 { A, entry } = require('../ops/common');
const external = require('../ops/external');
const external = require('../ops/external');
@@ -47,16 +47,17 @@ const liveTs = (s) => {
@@ -47,16 +47,17 @@ const liveTs = (s) => {
return Number.isFinite(t) ? new Date(t).toISOString() : null;
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 {}
class DryRun extends Error {}
/** Live's PowerChat connections with the Network subject of their owner (resolved here when `map` lacks it). */
/** 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()) {
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();
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 missing = [...new Set(rows.map((r) => String(r.user_id)).filter((id) => !map.has(id)))];
const more = missing.length ? await resolveLiveUsers(missing) : new Map();
const more = missing.length ? await resolveLiveUsers(missing) : new Map();
@@ -68,15 +69,15 @@ async function readProviderAccounts(live, resolveLiveUsers, map = 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. */
/** 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: [] };
const out = { mapped: 0, unchanged: 0, unmapped: [], kept_admin: [] };
for (const r of rows) {
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 (!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; }
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.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; }
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++;
out.mapped++;
}
}
report.provider_accounts = out;
report.provider_accounts = out;
@@ -91,13 +92,13 @@ async function importProviderAccounts(ctx, { live, resolveLiveUsers, dryRun = fa
@@ -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 report = { run_id: runId, dry_run: !!dryRun, started_at: iso(started), source: 'openvibe-live snapshot (provider accounts only)' };
const rows = await readProviderAccounts(live, resolveLiveUsers);
const rows = await readProviderAccounts(live, resolveLiveUsers);
try {
try {
- db.transaction(() => {
- applyProviderAccounts(ctx, rows, report);
+ await money(db, async () => {
+ await applyProviderAccounts(ctx, rows, report);
if (dryRun) throw new DryRun();
if (dryRun) throw new DryRun();
- })();
+ });
} catch (e) { if (!(e instanceof DryRun)) throw e; }
} catch (e) { if (!(e instanceof DryRun)) throw e; }
report.finished_at = iso(ctx.now());
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));
.run(runId, 'live-accounts', dryRun ? 1 : 0, report.started_at, report.finished_at, JSON.stringify(report));
const a = report.provider_accounts;
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)`);
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
@@ -115,20 +116,20 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
};
};
// ── Read the snapshot ────────────────────────────────────
// ── Read the snapshot ────────────────────────────────────
- const userCols = columns(live, 'users');
+ const userCols = await columns(live, 'users');
const hasCashout = userCols.includes('openvibe_bucks_cashout_balance');
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).
// 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;
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();
if (r && r.value && !Number.isNaN(Date.parse(r.value))) resetAt = new Date(r.value).toISOString();
}
}
report.test_before = resetAt;
report.test_before = resetAt;
@@ -141,7 +142,7 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
@@ -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 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)); }
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.
// 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 map = ids.size ? await resolveLiveUsers([...ids]) : new Map();
const subjectOf = (liveId) => (liveId == null ? null : map.get(String(liveId)) || null);
const subjectOf = (liveId) => (liveId == null ? null : map.get(String(liveId)) || null);
const owner = (liveId) => (liveId == null ? null : subjectOf(liveId) || `hold:live:${liveId}`);
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
@@ -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 };
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 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 ms = ctx.now();
const at = iso(ms);
const at = iso(ms);
const snap = rates.snapshot();
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 ───────────
// ── 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]));
.map((r) => [`${r.provider}|${r.provider_ref}`, r.legacy_order_id]));
let intentsNew = 0; let receiptsNew = 0; let intentsUpdated = 0;
let intentsNew = 0; let receiptsNew = 0; let intentsUpdated = 0;
for (const o of orders) {
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) {
if (existing && !existing.settled_txn) {
// Live moved the order on since an earlier run (credited or failed after the shadow
// 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
// 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
@@ -170,7 +171,7 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
// Billing settled itself (settled_txn) is never rewound.
// Billing settled itself (settled_txn) is never rewound.
const want = o.status === 'credited' ? 'settled' : (o.status === 'failed' && ['created', 'expired'].includes(existing.status) ? 'failed' : null);
const want = o.status === 'credited' ? 'settled' : (o.status === 'failed' && ['created', 'expired'].includes(existing.status) ? 'failed' : null);
if (want && existing.status !== want && existing.status !== 'settled') {
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++;
intentsUpdated++;
}
}
}
}
@@ -199,7 +200,7 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
@@ -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 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');
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 });
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
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),
.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);
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
@@ -207,8 +208,8 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
}
}
if (o.status === 'credited' || o.status === 'paid') {
if (o.status === 'credited' || o.status === 'paid') {
const payload = JSON.stringify(o);
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,
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 }));
JSON.stringify({ effect: 'imported', live_order: o.id, live_status: o.status }));
receiptsNew += r.changes;
receiptsNew += r.changes;
@@ -219,11 +220,11 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
@@ -219,11 +220,11 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
report.counts.receipts_new = receiptsNew;
report.counts.receipts_new = receiptsNew;
// ── 3. history ───────────────────────────────────────
// ── 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 created = liveTs(liveTxn.created_at) || at;
const subject = owner(liveTxn.from_user_id);
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
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,
.run(prefixedId('co', Date.parse(created)), subject, liveTxn.amount, rates.valueCents(liveTxn.amount), status,
JSON.stringify({ type: 'paypal', legacy_message: liveTxn.message || null }),
JSON.stringify({ type: 'paypal', legacy_message: liveTxn.message || null }),
@@ -245,8 +246,8 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
@@ -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');
const moved = t.status === 'completed' || (t.type === 'cashout' && t.status === 'escrow');
if (a <= 0) note = 'non-positive amount';
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 (!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) {
else if (t.type === 'cashout' && from) {
if (t.status === 'escrow') entries = [entry(A.payable(from), -a), entry(A.pending(from), a)];
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))];
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
@@ -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 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 || '-'})`;
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 });
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,
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,
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 },
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) {
if (!r.replay) {
historyNew++;
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;
report.counts.history_new = historyNew;
@@ -276,10 +277,10 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
@@ -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
// 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
// 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.
// 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
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)));
const openingIds = new Set([...ids].filter((i) => byId.has(i)));
for (const i of openingIds) {
for (const i of openingIds) {
const u = byId.get(i);
const u = byId.get(i);
@@ -289,21 +290,21 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
@@ -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.
// A user the Network now knows: move what an earlier run parked on the hold.
if (subject) {
if (subject) {
for (const [acct, name] of [[A.credit, 'user_credit'], [A.payable, 'creator_payable'], [A.pending, 'payouts_pending']]) {
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) {
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]]) {
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);
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 });
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;
const d = target - current;
if (d !== 0) {
if (d !== 0) {
- importTxn({
+ await importTxn({
idempotencyKey: `import:opening:${runId}:${own}:${name}`,
idempotencyKey: `import:opening:${runId}:${own}:${name}`,
entries: [entry(acct(own), d), entry(A.importAdj('vibes-bits'), -d)],
entries: [entry(acct(own), d), entry(A.importAdj('vibes-bits'), -d)],
fromSubject: null, toSubject: subject,
fromSubject: null, toSubject: subject,
@@ -313,14 +314,14 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
@@ -313,14 +314,14 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
}
}
}
}
if (!subject) {
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) });
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.
// Unmapped ids that only appear in money rows (no users row left) are held too.
for (const i of ids) {
for (const i of ids) {
if (!subjectOf(i) && !byId.has(i)) {
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' });
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
@@ -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 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 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 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;
let subId;
if (existing) {
if (existing) {
subId = existing.id;
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;
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) {
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);
.run(status, end, s.auto_renew ? 1 : 0, s.cancel_at_period_end ? 1 : 0, at, subId);
report.subscriptions.updated++;
report.subscriptions.updated++;
}
}
} else {
} 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' });
report.subscriptions.skipped.push({ live_sub: s.id, reason: 'a Billing subscription for this pair already exists' });
continue;
continue;
}
}
subId = prefixedId('sub', Date.parse(liveTs(s.created_at || s.started_at) || at));
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, ?, ?, ?, ?, ?, ?, ?, ?)`)
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,
.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);
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
@@ -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.
// Entitlement for the paid period Live shows, extended when a later snapshot extends it.
if (status !== 'expired' && end && Date.parse(end) > ms) {
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) {
if (!covered || covered < end) {
const start = covered && covered > at ? covered : (liveTs(s.started_at || s.created_at) || at);
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);
VALUES (?, ?, 'channel_subscription', ?, ?, ?, ?, NULL, ?)`).run(prefixedId('ent', ms), subscriber, streamer, subId, start < end ? start : at, end, at);
}
}
}
}
}
}
// ── 6. provider accounts ─────────────────────────────
// ── 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();
if (dryRun) throw new DryRun();
};
};
- try { db.transaction(run)(); }
+ try { await money(db, run); }
catch (e) { if (!(e instanceof DryRun)) throw e; }
catch (e) { if (!(e instanceof DryRun)) throw e; }
report.finished_at = iso(ctx.now());
report.finished_at = iso(ctx.now());
report.usernames_held = report.holds.map((h) => h.username || username.get(String(h.live_user_id)) || null);
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));
.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`);
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;
return report;
@@ -19,47 +19,52 @@ const { createRelay } = require('./outbox');
@@ -19,47 +19,52 @@ const { createRelay } = require('./outbox');
const { isFrozen } = require('./ops/common');
const { isFrozen } = require('./ops/common');
const { runScheduled } = require('./reconcile');
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);
+});
@@ -13,13 +13,13 @@
@@ -13,13 +13,13 @@
* Invariants (enforced here, verified again by reconcile.js):
* Invariants (enforced here, verified again by reconcile.js):
* - every transaction's entries sum to zero per currency;
* - every transaction's entries sum to zero per currency;
* - entries are never updated or deleted (db triggers); corrections are reversing transactions;
* - 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
* - a transaction's idempotency_key is unique: posting the same key again returns the
* original transaction and moves nothing;
* original transaction and moves nothing;
* - every amount and every resulting balance is a safe integer (|n| <= 2^53 - 1): past that JS
* - 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.
* the movement it guards are one atomic unit.
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
@@ -38,18 +38,18 @@ const iso = (ms) => new Date(ms).toISOString();
@@ -38,18 +38,18 @@ const iso = (ms) => new Date(ms).toISOString();
const newTxnId = (ms) => `txn_${ids.ulid(ms)}`;
const newTxnId = (ms) => `txn_${ids.ulid(ms)}`;
const prefixedId = (prefix, ms) => `${prefix}_${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;
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);
return Number(r.lastInsertRowid);
}
}
/** Cached balance of an account (0 when it does not exist yet). */
/** 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;
return row ? row.balance : 0;
}
}
@@ -58,31 +58,31 @@ function parseTxn(row) {
@@ -58,31 +58,31 @@ function parseTxn(row) {
return { ...row, test: !!row.test, actor: JSON.parse(row.actor || '{}'), metadata: JSON.parse(row.metadata || '{}') };
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);
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;
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 }.
* Post one balanced transaction. Returns { txn, replay }.
* Throws BillingError(500, 'ledger.unbalanced') when entries do not sum to zero per currency.
* 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 { db } = ctx;
- const run = () => {
+ const run = async () => {
if (!spec.idempotencyKey) throw new BillingError(500, 'ledger.no_key', 'every transaction needs an idempotency key');
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 };
if (existing) return { txn: existing, replay: true };
const ms = ctx.now();
const ms = ctx.now();
const nowIso = iso(ms);
const nowIso = iso(ms);
@@ -106,7 +106,7 @@ function post(ctx, spec) {
@@ -106,7 +106,7 @@ function post(ctx, spec) {
}
}
const id = newTxnId(ms);
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)
from_subject, to_subject, provider, receipt_ref, source_event_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
id, spec.type, spec.status || 'settled', spec.idempotencyKey, spec.reversesTxn || null, spec.test ? 1 : 0,
id, spec.type, spec.status || 'settled', spec.idempotencyKey, spec.reversesTxn || null, spec.test ? 1 : 0,
@@ -119,24 +119,33 @@ function post(ctx, spec) {
@@ -119,24 +119,33 @@ function post(ctx, spec) {
for (const m of merged.values()) {
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 (!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 (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`);
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()) {
for (const m of merged.values()) {
if (m.amount === 0) continue;
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`. */
/** 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) {
if (have < amount) {
throw new BillingError(409, 'billing.insufficient_funds', `insufficient ${what}: ${have} < ${amount} ${account.currency}`, { available: have, required: 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) {
@@ -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 };
@@ -26,44 +26,44 @@ const { latest } = require('./reconcile');
@@ -26,44 +26,44 @@ const { latest } = require('./reconcile');
function createMetrics({ db, config, now = () => Date.now() }) {
function createMetrics({ db, config, now = () => Date.now() }) {
const registry = createRegistry();
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({
registry.gauge({
name: 'billing_authority', help: 'The configured money authority (BILLING_AUTHORITY); the value is always 1', labelNames: ['authority'],
name: 'billing_authority', help: 'The configured money authority (BILLING_AUTHORITY); the value is always 1', labelNames: ['authority'],
collect: () => [{ labels: { authority: config.authority }, value: 1 }],
collect: () => [{ labels: { authority: config.authority }, value: 1 }],
});
});
registry.gauge({
registry.gauge({
name: 'billing_provider_events', help: 'Stored provider receipts by provider and processing state', labelNames: ['provider', 'state'],
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 processed_at IS NULL THEN 'pending'
WHEN json_extract(result, '$.effect') = 'rejected' THEN 'rejected'
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')
WHEN json_extract(result, '$.effect') IN ('settled', 'external') THEN json_extract(result, '$.effect')
ELSE 'other' END AS state, COUNT(*) AS n
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({
registry.gauge({
name: 'billing_provider_event_oldest_pending_seconds', help: 'Age of the oldest stored-but-unprocessed provider receipt (0 when none)',
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;
return r && r.at ? Math.max(0, Math.round((now() - Date.parse(r.at)) / 1000)) : 0;
},
},
});
});
registry.gauge({
registry.gauge({
name: 'billing_external_receipts', help: 'EXTERNAL receipts (tips on a streamer\'s own provider account) by announcement status', labelNames: ['status'],
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({
registry.gauge({
name: 'billing_cashouts', help: 'Cashouts by status', labelNames: ['status'],
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.
// 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 };
return { registry };
}
}
@@ -37,7 +37,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
@@ -37,7 +37,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
if (config.network.publicKey || timer) return;
if (config.network.publicKey || timer) return;
const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
retry();
retry();
- timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);
+ timer = setInterval(async () => { load(); }, 6 * 60 * 60 * 1000);
timer.unref();
timer.unref();
}
}
function stop() { if (timer) clearInterval(timer); timer = null; }
function stop() { if (timer) clearInterval(timer); timer = null; }
@@ -62,7 +62,7 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -62,7 +62,7 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
body: JSON.stringify(body),
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
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);
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'}`);
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;
return data.results;
@@ -7,22 +7,22 @@
@@ -7,22 +7,22 @@
* mandatory reason — the only way an operator changes a balance (e.g. resolving a chargeback
* 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).
* 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 { enqueue } = require('../outbox');
const { entry, fail, positiveInt, text } = require('./common');
const { entry, fail, positiveInt, text } = require('./common');
const { ACCOUNT_KINDS, CURRENCIES } = require('../db');
const { ACCOUNT_KINDS, CURRENCIES } = require('../db');
const { summary } = require('./purchases');
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 };
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;
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);
.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) {
function account(v, field) {
@@ -33,17 +33,17 @@ function account(v, field) {
@@ -33,17 +33,17 @@ function account(v, field) {
}
}
/** Move `amount` from `from` to `to` (same currency). */
/** Move `amount` from `from` to `to` (same currency). */
-function adjust(ctx, input) {
+async function adjust(ctx, input) {
const from = account(input.from, 'from');
const from = account(input.from, 'from');
const to = account(input.to, 'to');
const to = account(input.to, 'to');
if (from.currency !== to.currency) fail(422, 'billing.invalid_input', 'an adjustment moves value within one currency');
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 amount = positiveInt(input.amount, 'amount', 1_000_000_000);
const reason = text(input.reason, 'reason', 500);
const reason = text(input.reason, 'reason', 500);
if (!reason) fail(422, 'billing.invalid_input', 'an adjustment needs a reason');
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',
type: 'adjustment',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
entries: [entry(from, -amount), entry(to, amount)],
entries: [entry(from, -amount), entry(to, amount)],
@@ -52,9 +52,9 @@ function adjust(ctx, input) {
@@ -52,9 +52,9 @@ function adjust(ctx, input) {
toSubject: to.owner && to.owner.startsWith('usr_') ? to.owner : null,
toSubject: to.owner && to.owner.startsWith('usr_') ? to.owner : null,
metadata: { reason, amount, currency: from.currency, relates_to: input.relatesTo || 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 };
return { txn, replay: false };
- })();
+ });
}
}
module.exports = { freezeState, setFreeze, adjust };
module.exports = { freezeState, setFreeze, adjust };
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* deny() reverses the request: payouts_pending → creator_payable
* deny() reverses the request: payouts_pending → creator_payable
* recycle() creator_payable → user_credit, an explicit journal entry
* 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 { enqueue } = require('../outbox');
const { A, entry, fail, positiveInt, text } = require('./common');
const { A, entry, fail, positiveInt, text } = require('./common');
const { summary } = require('./purchases');
const { summary } = require('./purchases');
@@ -17,7 +17,7 @@ const { summary } = require('./purchases');
@@ -17,7 +17,7 @@ const { summary } = require('./purchases');
function parse(row) {
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;
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) {
function present(c) {
if (!c) return null;
if (!c) return null;
@@ -51,19 +51,19 @@ function payoutMethod(v) {
@@ -51,19 +51,19 @@ function payoutMethod(v) {
return { type, address };
return { type, address };
}
}
-function request(ctx, input) {
+async function request(ctx, input) {
const { db, rates } = ctx;
const { db, rates } = ctx;
const amount = positiveInt(input.amount, 'amount', rates.maxBits);
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`);
if (amount < rates.minCashoutBits) fail(422, 'billing.amount_too_small', `the minimum cashout is ${rates.minCashoutBits} bits`);
const method = payoutMethod(input.payout_method);
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 ms = ctx.now();
const id = prefixedId('co', ms);
const id = prefixedId('co', ms);
const escrowUntil = iso(ms + rates.escrowDays * 86_400_000);
const escrowUntil = iso(ms + rates.escrowDays * 86_400_000);
- const { txn } = post(ctx, {
+ const { txn } = await post(ctx, {
type: 'cashout_request',
type: 'cashout_request',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
entries: [entry(A.payable(input.subject), -amount), entry(A.pending(input.subject), amount)],
entries: [entry(A.payable(input.subject), -amount), entry(A.pending(input.subject), amount)],
@@ -71,27 +71,27 @@ function request(ctx, input) {
@@ -71,27 +71,27 @@ function request(ctx, input) {
fromSubject: input.subject,
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() },
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));
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 };
return { cashout, replay: false };
- })();
+ });
}
}
-function approve(ctx, input) {
+async function approve(ctx, input) {
const { db, rates } = ctx;
const { db, rates } = ctx;
const ref = text(input.payout_reference, 'payout_reference', 200);
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');
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();
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) 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 === '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 (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 });
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 value = c.value_cents;
- const { txn } = post(ctx, {
+ const { txn } = await post(ctx, {
type: 'cashout_paid',
type: 'cashout_paid',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
entries: [
entries: [
@@ -104,23 +104,23 @@ function approve(ctx, input) {
@@ -104,23 +104,23 @@ function approve(ctx, input) {
receiptRef: `${provider}_payouts:${ref}`,
receiptRef: `${provider}_payouts:${ref}`,
metadata: { cashout_id: c.id, amount_bits: c.amount_bits, value_cents: value, payout_reference: ref, rates: rates.snapshot() },
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);
.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 };
return { cashout, replay: false };
- })();
+ });
}
}
-function deny(ctx, input) {
+async function deny(ctx, input) {
const { db } = ctx;
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) fail(404, 'billing.cashout_not_found', `no cashout ${input.id}`);
if (c.status === 'denied') return { cashout: c, replay: true };
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}`);
if (c.status !== 'requested') fail(409, 'billing.cashout_not_pending', `cashout ${c.id} is ${c.status}`);
const reason = text(input.reason, 'reason', 300);
const reason = text(input.reason, 'reason', 300);
- const { txn } = post(ctx, {
+ const { txn } = await post(ctx, {
type: 'cashout_denied',
type: 'cashout_denied',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
reversesTxn: c.request_txn,
reversesTxn: c.request_txn,
@@ -129,23 +129,23 @@ function deny(ctx, input) {
@@ -129,23 +129,23 @@ function deny(ctx, input) {
toSubject: c.subject,
toSubject: c.subject,
metadata: { cashout_id: c.id, amount_bits: c.amount_bits, reason },
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);
.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 };
return { cashout, replay: false };
- })();
+ });
}
}
-function recycle(ctx, input) {
+async function recycle(ctx, input) {
const { db, rates } = ctx;
const { db, rates } = ctx;
const amount = positiveInt(input.amount, 'amount', rates.maxBits);
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',
type: 'recycle',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
entries: [entry(A.payable(input.subject), -amount), entry(A.credit(input.subject), amount)],
entries: [entry(A.payable(input.subject), -amount), entry(A.credit(input.subject), amount)],
@@ -154,18 +154,18 @@ function recycle(ctx, input) {
@@ -154,18 +154,18 @@ function recycle(ctx, input) {
toSubject: input.subject,
toSubject: input.subject,
metadata: { amount_bits: amount },
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 };
return { txn, replay: false };
- })();
+ });
}
}
-function list(db, { status, subject, limit = 100 } = {}) {
+async function list(db, { status, subject, limit = 100 } = {}) {
const where = [];
const where = [];
const args = [];
const args = [];
if (status) { where.push('status = ?'); args.push(status); }
if (status) { where.push('status = ?'); args.push(status); }
if (subject) { where.push('subject = ?'); args.push(subject); }
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 };
module.exports = { request, approve, deny, recycle, find, list, present };
@@ -73,11 +73,11 @@ function text(v, field, max = 300) {
@@ -73,11 +73,11 @@ function text(v, field, max = 300) {
return s || null;
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). */
/** Movement summary for a subject from a transaction's entries (used in API responses). */
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
* }
* }
*
*
* OpenVibe.Tips consumes it (the chat line through Live's /internal/tips/deliveries, the overlay
* 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.
* provider event processed.
*
*
* Announced at most once per payment: external_receipts is keyed by receipt_ref, so a redelivery
* 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');
@@ -35,32 +35,32 @@ const { MAX_RECEIPT_CENTS, fail, userSubject } = require('./common');
const EVENT_TYPE = 'billing.receipt.external';
const EVENT_TYPE = 'billing.receipt.external';
/** The creator a provider account belongs to, by account id, then by (lower-case) username. */
/** 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 !== '') {
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;
if (r) return r.subject;
}
}
const name = String(username || '').trim().toLowerCase();
const name = String(username || '').trim().toLowerCase();
if (!name) return null;
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;
return r ? r.subject : null;
}
}
const USERNAME_RE = /^[a-z0-9_.-]{1,64}$/;
const USERNAME_RE = /^[a-z0-9_.-]{1,64}$/;
/** Add or correct a mapping (operator or importer). Returns the stored row. */
/** 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 { db } = ctx;
const name = String(username || '').trim().toLowerCase();
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, _ . -)');
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 owner = userSubject(subject, 'subject');
const at = iso(ctx.now());
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 (?, ?, ?, ?, ?, ?, ?, ?)
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);
.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
@@ -68,21 +68,21 @@ function mapAccount(ctx, { provider, username, accountId, subject, source, liveU
* sourceEventId, account: { id, username }, streamer?, amountCents, donorName, anonymous, message,
* sourceEventId, account: { id, username }, streamer?, amountCents, donorName, anonymous, message,
* appRef, appPurpose, occurredAt, test }. Returns the provider event result.
* appRef, appPurpose, occurredAt, test }. Returns the provider event result.
*/
*/
-function record(ctx, input) {
+async function record(ctx, input) {
const { db, config, rates } = ctx;
const { db, config, rates } = ctx;
const cents = Number(input.amountCents);
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 (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) {
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` };
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') {
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})` };
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;
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),
// The creator: named by the caller (a direct-subscription intent already verified the account),
// else whoever the receiving account is mapped to.
// 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 reason = null;
let review = false;
let review = false;
if (config.authority !== 'billing') {
if (config.authority !== 'billing') {
@@ -96,7 +96,7 @@ function record(ctx, input) {
@@ -96,7 +96,7 @@ function record(ctx, input) {
let env = null;
let env = null;
if (!reason) {
if (!reason) {
const anonymous = !!input.anonymous;
const anonymous = !!input.anonymous;
- env = enqueue(ctx, {
+ env = await enqueue(ctx, {
event_type: EVENT_TYPE,
event_type: EVENT_TYPE,
subject: { type: 'provider_receipt', id: input.receiptRef },
subject: { type: 'provider_receipt', id: input.receiptRef },
payload: {
payload: {
@@ -123,10 +123,10 @@ function record(ctx, input) {
@@ -123,10 +123,10 @@ function record(ctx, input) {
}
}
const status = env ? 'announced' : 'not_announced';
const status = env ? 'announced' : 'not_announced';
if (prev) {
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);
.run(status, reason, env ? env.event_id : null, streamer, at, input.receiptRef);
} else {
} 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(input.receiptRef, input.provider, input.sourceEventId, username, streamer, cents, input.test ? 1 : 0, status, reason, env ? env.event_id : null, at, at);
.run(input.receiptRef, input.provider, input.sourceEventId, username, streamer, cents, input.test ? 1 : 0, status, reason, env ? env.event_id : null, at, at);
}
}
@@ -15,17 +15,17 @@ function parse(row) {
@@ -15,17 +15,17 @@ function parse(row) {
}
}
/** By id, or by a Live order id for intents imported from payment_orders. */
/** 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 || '');
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 { db, rates } = ctx;
const provider = String(input.provider || '').toLowerCase();
const provider = String(input.provider || '').toLowerCase();
if (!/^[a-z][a-z0-9_-]{1,39}$/.test(provider)) fail(422, 'billing.invalid_input', 'provider is required');
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) {
@@ -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 }) };
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;
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)
auto_renew, status, metadata, created_at, updated_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)`).run(row.id, provider, kind, subject, row.streamer_subject,
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));
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) {
@@ -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) {
function present(i) {
@@ -11,28 +11,28 @@
@@ -11,28 +11,28 @@
* - an explicit `bits` when its value is covered by the payment;
* - 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).
* - 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 { enqueue } = require('../outbox');
const { A, MAX_RECEIPT_CENTS, receiptEntries, fail, positiveInt } = require('./common');
const { A, MAX_RECEIPT_CENTS, receiptEntries, fail, positiveInt } = require('./common');
const intents = require('./intents');
const intents = require('./intents');
-function settle(ctx, input) {
+async function settle(ctx, input) {
const { db, rates } = ctx;
const { db, rates } = ctx;
- return db.transaction(() => {
+ return await money(db, async () => {
const receiptRef = input.receiptRef;
const receiptRef = input.receiptRef;
if (!receiptRef) fail(422, 'billing.invalid_input', 'a provider receipt reference is required');
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);
const paidCents = positiveInt(input.paidCents, 'amount_cents', MAX_RECEIPT_CENTS);
let intent = null;
let intent = null;
if (input.intentId) {
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) 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.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 (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 (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);
intents.refuseIfSettledInLive(intent);
}
}
const subject = intent ? intent.subject : input.subject;
const subject = intent ? intent.subject : input.subject;
@@ -50,7 +50,7 @@ function settle(ctx, input) {
@@ -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 < 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}`);
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',
type: 'purchase',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
entries: receiptEntries(rates, { provider: input.provider, paidCents, bits, target: A.credit(subject) }),
entries: receiptEntries(rates, { provider: input.provider, paidCents, bits, target: A.credit(subject) }),
@@ -65,10 +65,10 @@ function settle(ctx, input) {
@@ -65,10 +65,10 @@ function settle(ctx, input) {
pricing: basis, intent_id: intent ? intent.id : null, rates: rates.snapshot(), ...(input.metadata || {}),
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 };
return { txn, replay: false };
- })();
+ });
}
}
function summary(txn) {
function summary(txn) {
@@ -14,30 +14,30 @@
@@ -14,30 +14,30 @@
*
*
* Partial reversals are supported; the cumulative reversed amount never exceeds what was paid.
* 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 { enqueue } = require('../outbox');
const { A, entry, fail } = require('./common');
const { A, entry, fail } = require('./common');
const { summary } = require('./purchases');
const { summary } = require('./purchases');
const intents = require('./intents');
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?,
* input: { kind: 'refund'|'chargeback', provider, originalReceiptRef, reversalRef, cents?, cumulativeCents?,
* idempotencyKey, sourceEventId, actor, reason }
* idempotencyKey, sourceEventId, actor, reason }
*/
*/
-function reverseReceipt(ctx, input) {
+async function reverseReceipt(ctx, input) {
const { db, rates } = ctx;
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}`);
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 paid = Number(orig.metadata.paid_cents) || 0;
- const done = reversedCents(db, orig.id);
+ const done = await reversedCents(db, orig.id);
const remaining = paid - done;
const remaining = paid - done;
let cents = input.cumulativeCents != null ? Number(input.cumulativeCents) - done : (input.cents != null ? Number(input.cents) : remaining);
let cents = input.cumulativeCents != null ? Number(input.cumulativeCents) - done : (input.cents != null ? Number(input.cents) : remaining);
cents = Math.min(Math.round(cents), remaining);
cents = Math.min(Math.round(cents), remaining);
@@ -54,7 +54,7 @@ function reverseReceipt(ctx, input) {
@@ -54,7 +54,7 @@ function reverseReceipt(ctx, input) {
const B = Number(orig.metadata.bits) || 0;
const B = Number(orig.metadata.bits) || 0;
const b = Math.round((B * (done + cents)) / paid) - Math.round((B * done) / paid);
const b = Math.round((B * (done + cents)) / paid) - Math.round((B * done) / paid);
const owner = orig.to_subject;
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 unrecovered = b - clawed;
const nonFx = cents - rates.valueCents(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));
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) {
@@ -79,7 +79,7 @@ function reverseReceipt(ctx, input) {
}
}
if (review) meta.review = 'required';
if (review) meta.review = 'required';
- const { txn } = post(ctx, {
+ const { txn } = await post(ctx, {
type: kind,
type: kind,
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
reversesTxn: orig.id,
reversesTxn: orig.id,
@@ -93,14 +93,14 @@ function reverseReceipt(ctx, input) {
@@ -93,14 +93,14 @@ function reverseReceipt(ctx, input) {
sourceEventId: input.sourceEventId,
sourceEventId: input.sourceEventId,
metadata: { ...meta, rates: rates.snapshot() },
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 },
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 },
payload: { ...summary(txn), reverses_txn: orig.id, review: review ? 'required' : null, entitlements_revoked: revoked },
});
});
return { txn, replay: false, review };
return { txn, replay: false, review };
- })();
+ });
}
}
module.exports = { reverseReceipt, reversedCents };
module.exports = { reverseReceipt, reversedCents };
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
* Entitlement truth is the entitlements table: one row per granted period; a subject is
* 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.
* 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 { enqueue } = require('../outbox');
const { A, MAX_RECEIPT_CENTS, entry, receiptEntries, fail, positiveInt } = require('./common');
const { A, MAX_RECEIPT_CENTS, entry, receiptEntries, fail, positiveInt } = require('./common');
const { summary } = require('./purchases');
const { summary } = require('./purchases');
@@ -25,9 +25,9 @@ const intents = require('./intents');
@@ -25,9 +25,9 @@ const intents = require('./intents');
const KIND = 'channel_subscription';
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 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) {
function present(s) {
if (!s) return null;
if (!s) return null;
@@ -40,21 +40,21 @@ function present(s) {
@@ -40,21 +40,21 @@ function present(s) {
}
}
/** Entitlement of `subject` to `streamer` at `ms`. */
/** 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 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);
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.
// Chained future periods (a renewal paid early) extend the expiry.
let expires = row && row.ends_at;
let expires = row && row.ends_at;
if (expires) {
if (expires) {
for (;;) {
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;
if (!next) break;
expires = next;
expires = next;
}
}
}
}
- const sub = findPair(db, subject, streamer);
+ const sub = await findPair(db, subject, streamer);
return {
return {
subject: { type: 'user', id: subject }, streamer: { type: 'user', id: streamer }, kind: KIND,
subject: { type: 'user', id: subject }, streamer: { type: 'user', id: streamer }, kind: KIND,
active: !!expires, expires_at: expires || null,
active: !!expires, expires_at: expires || null,
@@ -62,10 +62,10 @@ function entitlement(db, subject, streamer, ms) {
@@ -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);
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) {
@@ -73,22 +73,22 @@ function activeEntitlements(db, subject, ms) {
* receipt?: { provider, receiptRef, paidCents, feeCents, route, providerRef, test, periodEnd },
* receipt?: { provider, receiptRef, paidCents, feeCents, route, providerRef, test, periodEnd },
* intentId?, idempotencyKey, actor, sourceEventId, renewal? }
* intentId?, idempotencyKey, actor, sourceEventId, renewal? }
*/
*/
-function pay(ctx, input) {
+async function pay(ctx, input) {
const { db, rates } = ctx;
const { db, rates } = ctx;
const { subscriber, streamer } = input;
const { subscriber, streamer } = input;
if (subscriber === streamer) fail(422, 'billing.self_dealing', 'you cannot subscribe to yourself');
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;
const r = input.receipt;
if (r) {
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 ms = ctx.now();
const priceCents = input.priceCents != null ? positiveInt(input.priceCents, 'price_cents', 10_000_000) : rates.subPriceCents;
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 entries = [];
let provider = 'credit';
let provider = 'credit';
@@ -97,7 +97,7 @@ function pay(ctx, input) {
@@ -97,7 +97,7 @@ function pay(ctx, input) {
if (input.source === 'credit') {
if (input.source === 'credit') {
const cost = rates.bitsForValueCents(priceCents);
const cost = rates.bitsForValueCents(priceCents);
const share = Math.min(cost, rates.subShareBits(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)];
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 };
meta = { source: 'credit', price_cents: priceCents, cost_bits: cost, share_bits: share, sub_share_pct: rates.subSharePct };
} else if (input.source === 'receipt' && r) {
} else if (input.source === 'receipt' && r) {
@@ -124,7 +124,7 @@ function pay(ctx, input) {
@@ -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 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 renewal = !!(prior && (prior.status === 'active' || input.renewal));
- const { txn } = post(ctx, {
+ const { txn } = await post(ctx, {
type: 'subscription',
type: 'subscription',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
entries,
entries,
@@ -142,34 +142,34 @@ function pay(ctx, input) {
@@ -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));
const autoRenew = input.autoRenew != null ? (input.autoRenew ? 1 : 0) : (prior ? (prior.auto_renew ? 1 : 0) : (input.source === 'credit' ? 1 : 0));
let sub;
let sub;
if (prior) {
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 = ?`)
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);
.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 {
} else {
const id = prefixedId('sub', ms);
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, ?, ?, ?, ?)`)
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);
.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);
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}` },
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) {
@@ -178,7 +178,7 @@ function result(db, txn, ctx, replay) {
*/
*/
async function cancel(ctx, { id, actor }, adapters) {
async function cancel(ctx, { id, actor }, adapters) {
const { db } = ctx;
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) fail(404, 'billing.subscription_not_found', `no subscription ${id}`);
if (sub.status !== 'active') return { subscription: sub, provider_sync: 'not_needed', replay: true };
if (sub.status !== 'active') return { subscription: sub, provider_sync: 'not_needed', replay: true };
let providerSync = 'not_needed';
let providerSync = 'not_needed';
@@ -190,47 +190,47 @@ async function cancel(ctx, { id, actor }, adapters) {
@@ -190,47 +190,47 @@ async function cancel(ctx, { id, actor }, adapters) {
catch (e) { fail(502, 'billing.provider_error', `Stripe refused the cancellation: ${e.message}`); }
catch (e) { fail(502, 'billing.provider_error', `Stripe refused the cancellation: ${e.message}`); }
providerSync = 'stripe_cancel_at_period_end';
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 },
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 { db } = ctx;
- const sub = find(db, id);
+ const sub = await find(db, id);
if (!sub || sub.status === status) return sub;
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);
.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}` },
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. */
/** 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 { 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;
if (!ents.length) return 0;
const at = iso(ctx.now());
const at = iso(ctx.now());
for (const e of ents) {
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) {
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();
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);
.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}` },
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;
return ents.length;
@@ -243,24 +243,24 @@ function revokeForTxn(ctx, txnId, reason) {
@@ -243,24 +243,24 @@ function revokeForTxn(ctx, txnId, reason) {
* - otherwise renew from the subscriber's credit (deterministic key per period), or end it
* - otherwise renew from the subscriber's credit (deterministic key per period), or end it
* when the credit does not cover the price.
* when the credit does not cover the price.
*/
*/
-function sweep(ctx, { limit = 200 } = {}) {
+async function sweep(ctx, { limit = 200 } = {}) {
const { db, rates } = ctx;
const { db, rates } = ctx;
const out = { renewed: [], expired: [], canceled: [], skipped: 0 };
const out = { renewed: [], expired: [], canceled: [], skipped: 0 };
const now = ctx.now();
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) {
for (const sub of due) {
const end = Date.parse(sub.current_period_end);
const end = Date.parse(sub.current_period_end);
if (sub.provider === 'stripe') {
if (sub.provider === 'stripe') {
if (now - end < rates.stripeGraceDays * 86_400_000) { out.skipped++; continue; }
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) {
if (sub.cancel_at_period_end || !sub.auto_renew) {
const st = sub.cancel_at_period_end ? 'canceled' : 'expired';
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 {
try {
- pay(ctx, {
+ await pay(ctx, {
subscriber: sub.subscriber, streamer: sub.streamer, source: 'credit', priceCents: sub.price_cents || rates.subPriceCents,
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}`,
autoRenew: true, renewal: true, idempotencyKey: `renew:${sub.id}:${sub.current_period_end}`,
actor: { principal: 'svc:billing', job: 'renewal-sweep' },
actor: { principal: 'svc:billing', job: 'renewal-sweep' },
@@ -268,19 +268,19 @@ function sweep(ctx, { limit = 200 } = {}) {
@@ -268,19 +268,19 @@ function sweep(ctx, { limit = 200 } = {}) {
out.renewed.push(sub.id);
out.renewed.push(sub.id);
} catch (e) {
} catch (e) {
if (e.code !== 'billing.insufficient_funds') throw 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;
return out;
}
}
-function list(db, { subscriber, streamer, status } = {}) {
+async function list(db, { subscriber, streamer, status } = {}) {
const where = [];
const where = [];
const args = [];
const args = [];
if (subscriber) { where.push('subscriber = ?'); args.push(subscriber); }
if (subscriber) { where.push('subscriber = ?'); args.push(subscriber); }
if (streamer) { where.push('streamer = ?'); args.push(streamer); }
if (streamer) { where.push('streamer = ?'); args.push(streamer); }
if (status) { where.push('status = ?'); args.push(status); }
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 };
module.exports = { pay, cancel, sweep, entitlement, activeEntitlements, revokeForTxn, setStatus, find, findPair, findByProviderRef, list, present, KIND };
@@ -11,14 +11,14 @@
@@ -11,14 +11,14 @@
* their payable (MONEY); the tipper may be unknown.
* their payable (MONEY); the tipper may be unknown.
*/
*/
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { post, getTxn, requireFunds } = require('../ledger');
+const { post, getTxn, requireFunds, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { enqueue } = require('../outbox');
const { A, MAX_RECEIPT_CENTS, entry, receiptEntries, fail, positiveInt, text } = require('./common');
const { A, MAX_RECEIPT_CENTS, entry, receiptEntries, fail, positiveInt, text } = require('./common');
const { summary } = require('./purchases');
const { summary } = require('./purchases');
const KINDS = ['tip', 'donation', 'paid_interaction'];
const KINDS = ['tip', 'donation', 'paid_interaction'];
-function create(ctx, input) {
+async function create(ctx, input) {
const { db, rates } = ctx;
const { db, rates } = ctx;
const amount = positiveInt(input.amount, 'amount', rates.maxBits);
const amount = positiveInt(input.amount, 'amount', rates.maxBits);
const from = input.from;
const from = input.from;
@@ -33,11 +33,11 @@ function create(ctx, input) {
@@ -33,11 +33,11 @@ function create(ctx, input) {
target = input.target;
target = input.target;
}
}
const message = text(input.message, 'message', 500);
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',
type: 'donation',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
entries: [entry(A.credit(from), -amount), entry(A.payable(to), amount)],
entries: [entry(A.credit(from), -amount), entry(A.payable(to), amount)],
@@ -46,25 +46,25 @@ function create(ctx, input) {
@@ -46,25 +46,25 @@ function create(ctx, input) {
toSubject: to,
toSubject: to,
metadata: { kind, amount_bits: amount, target, message, rates: rates.snapshot() },
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 };
return { txn, replay: false };
- })();
+ });
}
}
-function refund(ctx, input) {
+async function refund(ctx, input) {
const { db } = ctx;
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 || 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');
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 remaining = orig.metadata.amount_bits - done;
const amount = input.amount != null ? positiveInt(input.amount, 'amount') : remaining;
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`);
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',
type: 'refund',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
reversesTxn: orig.id,
reversesTxn: orig.id,
@@ -75,20 +75,20 @@ function refund(ctx, input) {
@@ -75,20 +75,20 @@ function refund(ctx, input) {
test: orig.test,
test: orig.test,
metadata: { amount_bits: amount, reason: text(input.reason, 'reason', 300) },
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 };
return { txn, replay: false };
- })();
+ });
}
}
-function fromReceipt(ctx, input) {
+async function fromReceipt(ctx, input) {
const { db, rates } = ctx;
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);
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');
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 bits = rates.bitsForValueCents(paidCents);
- const { txn } = post(ctx, {
+ const { txn } = await post(ctx, {
type: 'donation',
type: 'donation',
idempotencyKey: input.idempotencyKey,
idempotencyKey: input.idempotencyKey,
entries: receiptEntries(rates, { provider: input.provider, paidCents, bits, target: A.payable(input.to) }),
entries: receiptEntries(rates, { provider: input.provider, paidCents, bits, target: A.payable(input.to) }),
@@ -101,9 +101,9 @@ function fromReceipt(ctx, input) {
@@ -101,9 +101,9 @@ function fromReceipt(ctx, input) {
sourceEventId: input.sourceEventId,
sourceEventId: input.sourceEventId,
metadata: { kind: 'tip', route: 'site', paid_cents: paidCents, amount_bits: bits, rates: rates.snapshot(), ...(input.metadata || {}) },
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 };
return { txn, replay: false };
- })();
+ });
}
}
module.exports = { create, refund, fromReceipt };
module.exports = { create, refund, fromReceipt };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'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
* 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
* 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;
* 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');
@@ -13,7 +13,7 @@ const { ids, validate, serviceAuth } = require('openvibe-contracts');
const ACTOR = { type: 'service', id: 'billing' };
const ACTOR = { type: 'service', id: 'billing' };
/** actor defaults to the service itself; staff actions pass the person ({ type: 'user', id }). */
/** 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 ms = ctx.now();
const env = {
const env = {
event_id: ids.newId('event', ms),
event_id: ids.newId('event', ms),
@@ -30,7 +30,7 @@ function enqueue(ctx, { event_type, subject, payload, priority = 'important', tr
@@ -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;
if (traceId && /^[0-9a-f]{32}$/.test(traceId)) env.trace_id = traceId;
const v = validate('events.event-envelope@1', env);
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('; ')}`);
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;
return env;
}
}
@@ -53,7 +53,7 @@ function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, lo
@@ -53,7 +53,7 @@ function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, lo
let sent = 0;
let sent = 0;
try {
try {
for (;;) {
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;
if (!rows.length) break;
const body = { events: rows.map((r) => JSON.parse(r.event)) };
const body = { events: rows.map((r) => JSON.parse(r.event)) };
let res;
let res;
@@ -65,18 +65,18 @@ function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, lo
@@ -65,18 +65,18 @@ function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, lo
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000),
});
});
} catch (e) {
} catch (e) {
- markFailed(rows, e.message);
+ await markFailed(rows, e.message);
break;
break;
}
}
if (res.status === 401) tokens.invalidate();
if (res.status === 401) tokens.invalidate();
if (!res.ok) {
if (!res.ok) {
const text = await res.text().catch(() => '');
const text = await res.text().catch(() => '');
- markFailed(rows, `${res.status} ${text.slice(0, 300)}`);
+ await markFailed(rows, `${res.status} ${text.slice(0, 300)}`);
break;
break;
}
}
const at = new Date().toISOString();
const at = new Date().toISOString();
const mark = db.prepare('UPDATE outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?');
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;
sent += rows.length;
}
}
} finally {
} finally {
@@ -85,9 +85,9 @@ function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, lo
@@ -85,9 +85,9 @@ function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, lo
return { sent };
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 = ?');
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}`);
log.warn(`[Billing] outbox relay: ${rows.length} event(s) not published: ${message}`);
}
}
@@ -55,7 +55,7 @@ function createCcbill(cfg) {
@@ -55,7 +55,7 @@ function createCcbill(cfg) {
};
};
}
}
if (type !== 'NewSaleSuccess') return { effect: 'none', reason: `${type || 'event'} is not handled` };
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 (!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 (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 };
if (!txId) return { effect: 'none', reason: 'sale without a transaction id', review: true };
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* receive() verify → store the delivery in provider_events BEFORE doing anything with it
* receive() verify → store the delivery in provider_events BEFORE doing anything with it
* (UNIQUE(provider, provider_event_id): a redelivery is stored once)
* (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
* 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
* API uses, keyed pe:<provider>:<event id>, and every settling transaction carries its
* provider receipt reference (unique) and source_event_id.
* provider receipt reference (unique) and source_event_id.
* While the economy is frozen events are stored but not processed; processPending() runs them in
* While the economy is frozen events are stored but not processed; processPending() runs them in
@@ -19,13 +19,13 @@
@@ -19,13 +19,13 @@
* none (no money effect: test, echo, not handled; `review` marks ones an operator should see) |
* 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).
* 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.
* of one payment — same delivery id or not, same process or not — settle it once.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-const { iso, BillingError } = require('../ledger');
+const { iso, BillingError, money } = require('../ledger');
const { isFrozen } = require('../ops/common');
const { isFrozen } = require('../ops/common');
const purchases = require('../ops/purchases');
const purchases = require('../ops/purchases');
const transfers = require('../ops/transfers');
const transfers = require('../ops/transfers');
@@ -52,27 +52,27 @@ function createAdapters(config, deps = {}) {
@@ -52,27 +52,27 @@ function createAdapters(config, deps = {}) {
function parseRow(row) { return row ? { ...row, result: row.result ? JSON.parse(row.result) : null } : null; }
function parseRow(row) { return row ? { ...row, result: row.result ? JSON.parse(row.result) : null } : null; }
/** Store a verified delivery. Returns { row, duplicate }. */
/** 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 text = JSON.stringify(payload);
const hash = crypto.createHash('sha256').update(text).digest('hex');
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 };
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 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 });
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) {
switch (plan.effect) {
case 'none': return { effect: 'none', reason: plan.reason, review: plan.review || plan.hold || undefined };
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 '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': {
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 };
if (r.noop) return { effect: 'none', reason: r.noop };
return { ...settled(r), review: r.review || undefined };
return { ...settled(r), review: r.review || undefined };
}
}
@@ -83,50 +83,51 @@ function applyPlan(ctx, plan, row) {
@@ -83,50 +83,51 @@ function applyPlan(ctx, plan, row) {
/** Process one stored event. Returns the updated row (processed or not). */
/** Process one stored event. Returns the updated row (processed or not). */
async function process(ctx, adapters, rowOrId) {
async function process(ctx, adapters, rowOrId) {
const { db } = ctx;
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 (!row || row.processed_at) return row;
- if (isFrozen(db)) return row;
+ if (await isFrozen(db)) return row;
const adapter = adapters[row.provider];
const adapter = adapters[row.provider];
try {
try {
if (!adapter) throw new Error(`no adapter for ${row.provider}`);
if (!adapter) throw new Error(`no adapter for ${row.provider}`);
const plan = await adapter.interpret(ctx, JSON.parse(row.payload), row);
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;
if (fresh.processed_at) return;
let result;
let result;
try {
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) {
} catch (e) {
if (!(e instanceof BillingError) || e.status >= 500) throw e;
if (!(e instanceof BillingError) || e.status >= 500) throw e;
result = { effect: 'rejected', code: e.code, reason: e.detail || e.message };
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);
.run(iso(ctx.now()), JSON.stringify(result), row.id);
- }).immediate();
+ });
} catch (e) {
} 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}`);
(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;
return row;
}
}
/** Process every stored-but-unprocessed event in arrival order. */
/** Process every stored-but-unprocessed event in arrival order. */
async function processPending(ctx, adapters, { limit = 500 } = {}) {
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;
let processed = 0;
for (const r of rows) {
for (const r of rows) {
const after = await process(ctx, adapters, r);
const after = await process(ctx, adapters, r);
if (after && after.processed_at) processed++;
if (after && after.processed_at) processed++;
- if (isFrozen(ctx.db)) break;
+ if (await isFrozen(ctx.db)) break;
}
}
return { processed, pending: rows.length - processed };
return { processed, pending: rows.length - processed };
}
}
/** Reprocess an event (operator): only an unprocessed or rejected one; settled events never re-apply. */
/** Reprocess an event (operator): only an unprocessed or rejected one; settled events never re-apply. */
async function reprocess(ctx, adapters, id) {
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}`);
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;
// 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).
// one that was announced never is (external_receipts would answer duplicate_receipt anyway).
@@ -134,8 +135,8 @@ async function reprocess(ctx, adapters, id) {
@@ -134,8 +135,8 @@ async function reprocess(ctx, adapters, id) {
if (row.processed_at && row.result && !retryable(row.result)) {
if (row.processed_at && row.result && !retryable(row.result)) {
throw new BillingError(409, 'billing.event_settled', `event ${id} already had effect ${row.result.effect}`);
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) {
function present(row) {
@@ -45,7 +45,7 @@ function createNowpayments(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -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' } };
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}` };
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 (!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 };
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) };
const receipt = { provider: 'nowpayments', receiptRef: `nowpayments:${b.payment_id}`, paidCents: Math.round(Number(b.price_amount) * 100) };
@@ -52,8 +52,8 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -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 };
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' };
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;
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' };
if (!paid) return { effect: 'none', reason: 'capture without a USD amount' };
@@ -67,7 +67,7 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -67,7 +67,7 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
const r = event.resource || {};
const r = event.resource || {};
const key = `pe:paypal:${row.provider_event_id}`;
const key = `pe:paypal:${row.provider_event_id}`;
const actor = { principal: 'provider:paypal', provider_event: 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') {
if (event.event_type === 'PAYMENT.CAPTURE.REFUNDED' || event.event_type === 'PAYMENT.CAPTURE.REVERSED') {
const up = (r.links || []).find((l) => l.rel === 'up');
const up = (r.links || []).find((l) => l.rel === 'up');
const captureId = up ? String(up.href).split('/').filter(Boolean).pop() : r.capture_id;
const captureId = up ? String(up.href).split('/').filter(Boolean).pop() : r.capture_id;
@@ -104,7 +104,7 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -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];
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}` };
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 };
return { name: 'paypal', enabled, verify, parse, interpret, createCheckout, capture };
@@ -126,13 +126,13 @@ function createPowerchat(cfg, { network } = {}) {
@@ -126,13 +126,13 @@ function createPowerchat(cfg, { network } = {}) {
let m;
let m;
if ((m = ref.match(/^pcorder:(.+)$/))) {
if ((m = ref.match(/^pcorder:(.+)$/))) {
if (!onSite) return offSite('purchase checkout');
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 (!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' };
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 } };
return { effect: 'purchase', args: { provider: 'powerchat', receiptRef, intentId: intent.id, paidCents: cents, test: isTest, idempotencyKey: key, actor, metadata: meta } };
}
}
if ((m = ref.match(/^pcsub:(.+)$/))) {
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` };
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';
const route = intent.route === 'direct' ? 'direct' : 'site';
if (route === 'site' && !onSite) return offSite('site-routed subscription checkout');
if (route === 'site' && !onSite) return offSite('site-routed subscription checkout');
@@ -50,9 +50,9 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -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 };
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;
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) {
async function interpret(ctx, event, row) {
@@ -61,10 +61,10 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -61,10 +61,10 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
const actor = { principal: 'provider:stripe', provider_event: row.provider_event_id };
const actor = { principal: 'provider:stripe', provider_event: row.provider_event_id };
switch (event.type) {
switch (event.type) {
case 'checkout.session.completed': {
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 (!intent) return { effect: 'none', reason: 'checkout without a Billing intent' };
if (obj.mode === 'subscription') {
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}` };
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 } };
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 } = {}) {
@@ -73,13 +73,13 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
case 'invoice.payment_succeeded': {
case 'invoice.payment_succeeded': {
if (!(obj.amount_paid > 0)) return { effect: 'none', reason: 'zero-amount invoice' };
if (!(obj.amount_paid > 0)) return { effect: 'none', reason: 'zero-amount invoice' };
const stripeSub = obj.subscription;
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;
let subscriber; let streamer; let intentId = null;
if (existing) { subscriber = existing.subscriber; streamer = existing.streamer; }
if (existing) { subscriber = existing.subscriber; streamer = existing.streamer; }
else {
else {
const meta = (obj.subscription_details && obj.subscription_details.metadata) || obj.metadata || {};
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}` };
if (!intent) return { effect: 'none', reason: `invoice for unknown Stripe subscription ${stripeSub}` };
subscriber = intent.subject; streamer = intent.streamer_subject; intentId = intent.id;
subscriber = intent.subject; streamer = intent.streamer_subject; intentId = intent.id;
}
}
@@ -112,9 +112,9 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -112,9 +112,9 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
},
},
};
};
case 'customer.subscription.deleted': {
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' };
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:
default:
return { effect: 'none', reason: `${event.type} is not handled` };
return { effect: 'none', reason: `${event.type} is not handled` };
@@ -144,7 +144,7 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -144,7 +144,7 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
async function cancelAtPeriodEnd(stripeSubscriptionId) {
async function cancelAtPeriodEnd(stripeSubscriptionId) {
const p = new URLSearchParams({ cancel_at_period_end: 'true' });
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 };
return { name: 'stripe', enabled, verify, parse, interpret, createCheckout, cancelAtPeriodEnd };
@@ -35,28 +35,28 @@ const { isFrozen } = require('./ops/common');
@@ -35,28 +35,28 @@ const { isFrozen } = require('./ops/common');
const SETTLING = ['purchase', 'donation', 'subscription'];
const SETTLING = ['purchase', 'donation', 'subscription'];
const REVERSING = ['refund', 'chargeback'];
const REVERSING = ['refund', 'chargeback'];
-function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
+async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
const { db } = ctx;
const { db } = ctx;
const started = ctx.now();
const started = ctx.now();
const checks = [];
const checks = [];
const check = (id, ok, detail) => checks.push({ id, ok, ...(detail ? { detail } : {}) });
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 })) });
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();
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);
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();
FROM accounts a LEFT JOIN account_balances b ON b.account_id = a.id`).all();
const mismatches = cache.filter((r) => r.cached !== r.derived);
const mismatches = cache.filter((r) => r.cached !== r.derived);
check('balances.cache', !mismatches.length, mismatches.length ? { mismatches } : { accounts: cache.length });
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
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();
WHERE a.kind = 'fx_conversion' GROUP BY t.id`).all();
const fxBad = fxRows.filter((r) => {
const fxBad = fxRows.filter((r) => {
@@ -66,25 +66,25 @@ function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -66,25 +66,25 @@ function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
});
});
check('fx.mirror', !fxBad.length, fxBad.length ? { offenders: fxBad } : { transactions: fxRows.length });
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
(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();
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));
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 });
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);
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);
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,
(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
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();
FROM transactions t JOIN provider_events p ON p.id = t.source_event_id`).all();
const rcptBad = rcpt.filter((r) => {
const rcptBad = rcpt.filter((r) => {
if (r.provider !== r.event_provider) return true;
if (r.provider !== r.event_provider) return true;
@@ -94,59 +94,59 @@ function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -94,59 +94,59 @@ function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
});
});
check('receipts.ledger', !rcptBad.length, rcptBad.length ? { offenders: rcptBad } : { transactions: rcpt.length });
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();
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();
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' })),
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' }))];
...extTwice.map((r) => ({ ...r, problem: 'announced more than once' }))];
check('receipts.external', !extOffenders.length, extOffenders.length ? { offenders: extOffenders }
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 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));
.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 } : {}) });
check('receipts.stale', !stale.length, stale.length ? { offenders: stale, older_than_min: staleMin } : { older_than_min: staleMin, ...(frozen ? { frozen: true } : {}) });
const warnings = {
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(),
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) })),
.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
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 = {
const totals = {
excludes_test_transactions: true,
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 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 };
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) {
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));
.run(report.id, report.started_at, report.finished_at, report.ok ? 1 : 0, JSON.stringify(report));
}
}
return report;
return report;
@@ -157,10 +157,10 @@ function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -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
* 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.
* 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) {
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));
.run(iso(ctx.now() - keepDays * 86_400_000));
}
}
const log = ctx.log || console;
const log = ctx.log || console;
@@ -169,8 +169,8 @@ function runScheduled(ctx, { keepDays = 30 } = {}) {
@@ -169,8 +169,8 @@ function runScheduled(ctx, { keepDays = 30 } = {}) {
}
}
/** The most recent stored run (any trigger), parsed; null when none. */
/** 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;
return row ? JSON.parse(row.report) : null;
}
}
@@ -20,20 +20,20 @@ const { boot, fund, check, done } = require('./helpers/app');
@@ -20,20 +20,20 @@ const { boot, fund, check, done } = require('./helpers/app');
const creator = t.user(33);
const creator = t.user(33);
const second = t.user(34);
const second = t.user(34);
console.log('actor limits');
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 });
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 () => {
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}`);
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' });
const r = await checkout(fan, { key: 'limits-retry-key-1' });
assert.strictEqual(r.status, 429, r.text);
assert.strictEqual(r.status, 429, r.text);
assert.strictEqual(r.headers.get('retry-after'), '45');
assert.strictEqual(r.headers.get('retry-after'), '45');
assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
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.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.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 () => {
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');
@@ -83,10 +83,10 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(tipSecond.status, 201, tipSecond.text);
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]' } } });
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}`);
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);
const r = await cashout(creator);
assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
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);
const ok = await cashout(second);
assert.strictEqual(ok.status, 201, ok.text);
assert.strictEqual(ok.status, 201, ok.text);
});
});
@@ -120,7 +120,7 @@ const { boot, fund, check, done } = require('./helpers/app');
@@ -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]]) {
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}`);
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();
await t.close();
@@ -42,7 +42,7 @@ const { startEvents } = require('./helpers/stubs');
@@ -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);
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]' } } });
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);
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);
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 type of ['billing.transaction.settled', 'billing.cashout.requested', 'billing.entitlement.changed']) assert.ok(types.includes(type), `${type} in ${types}`);
for (const e of rows) {
for (const e of rows) {
@@ -51,21 +51,21 @@ const { startEvents } = require('./helpers/stubs');
@@ -51,21 +51,21 @@ const { startEvents } = require('./helpers/stubs');
assert.deepStrictEqual([e.source, e.actor.type, e.actor.id], ['billing', 'service', 'billing']);
assert.deepStrictEqual([e.source, e.actor.type, e.actor.id], ['billing', 'service', 'billing']);
assert.ok(['transaction', 'entitlement', 'cashout', 'subscription'].includes(e.subject.type));
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 } });
const refused = await t.call('POST', '/api/v1/transfers', { body: { from: a, to: b, amount: 999999 } });
assert.strictEqual(refused.status, 409);
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 () => {
await check('the relay publishes unsent events to OpenVibe.Events with an events.event.publish token', async () => {
const events = await startEvents();
const events = await startEvents();
const { createRelay } = require('../server/outbox');
const { createRelay } = require('../server/outbox');
const relay = createRelay({ db: t.db, config: { ...t.config, events: { url: events.url, intervalMs: 1000 } }, log: { warn() {} } });
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();
const r = await relay.flush();
assert.strictEqual(r.sent, n);
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 token = events.tokens[0].slice(7);
const v = serviceAuth.verifyServiceToken(token, { publicKey: t.network.publicPem, audience: 'openvibe.events' });
const v = serviceAuth.verifyServiceToken(token, { publicKey: t.network.publicPem, audience: 'openvibe.events' });
assert.ok(v.ok, v.reason);
assert.ok(v.ok, v.reason);
@@ -80,12 +80,12 @@ const { startEvents } = require('./helpers/stubs');
@@ -80,12 +80,12 @@ const { startEvents } = require('./helpers/stubs');
assert.strictEqual(ok.status, 200, ok.text);
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.ok, true, JSON.stringify(ok.json.checks.filter((c) => !c.ok)));
assert.strictEqual(ok.json.totals.excludes_test_transactions, true);
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'] });
const bad = await t.call('GET', '/api/v1/admin/reconcile', { cap: ['billing.ledger.admin'] });
assert.strictEqual(bad.json.ok, false);
assert.strictEqual(bad.json.ok, false);
const c = bad.json.checks.find((x) => x.id === 'balances.cache');
const c = bad.json.checks.find((x) => x.id === 'balances.cache');
assert.strictEqual(c.detail.mismatches[0].owner, b.id);
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();
await t.close();
@@ -53,8 +53,8 @@ const DAY = 86_400_000;
@@ -53,8 +53,8 @@ const DAY = 86_400_000;
}
}
const csrfOf = (page) => (page.text.match(/name="_csrf" value="([^"]+)"/) || [])[1];
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 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) {
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]' } } });
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);
assert.strictEqual(r.status, 201, r.text);
@@ -94,7 +94,7 @@ const DAY = 86_400_000;
@@ -94,7 +94,7 @@ const DAY = 86_400_000;
assert.strictEqual((await get(`/auth/callback?code=${code}&state=wrong`, flow)).status, 400, 'wrong state');
assert.strictEqual((await get(`/auth/callback?code=${code}&state=wrong`, flow)).status, 400, 'wrong state');
const [body] = flow.slice('ovb_flow='.length).split('.');
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((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 () => {
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;
@@ -116,10 +116,10 @@ const DAY = 86_400_000;
assert.strictEqual(b.cookie, null);
assert.strictEqual(b.cookie, null);
const c = await signIn(listedNonAdmin, { role: 'admin', is_owner: false });
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(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.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 () => {
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;
@@ -132,10 +132,10 @@ const DAY = 86_400_000;
const maxAge = Number((r.setCookie.match(/Max-Age=(\d+)/) || [])[1]);
const maxAge = Number((r.setCookie.match(/Max-Age=(\d+)/) || [])[1]);
assert.ok(maxAge > 0 && maxAge <= 3600, `max-age ${maxAge}`);
assert.ok(maxAge > 0 && maxAge <= 3600, `max-age ${maxAge}`);
s = r.cookie;
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.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 () => {
await check('pages: staff only, server-rendered, no script, noindex, no-store, strict CSP', async () => {
@@ -175,9 +175,9 @@ const DAY = 86_400_000;
@@ -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(`/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('/freeze', s, { on: '1', reason: 'csrf test' })).status, 403);
assert.strictEqual((await post('/auth/logout', s, {})).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');
assert.strictEqual((await get('/cashouts', s)).status, 200, 'the session survived');
});
});
@@ -194,10 +194,10 @@ const DAY = 86_400_000;
@@ -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' });
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.strictEqual(early.status, 409);
assert.ok(early.text.includes('billing.escrow_active'));
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;
let co2;
@@ -216,25 +216,25 @@ const DAY = 86_400_000;
@@ -216,25 +216,25 @@ const DAY = 86_400_000;
const again = await post(`/cashouts/${co1}/approve`, s, form);
const again = await post(`/cashouts/${co1}/approve`, s, form);
assert.strictEqual(again.status, 303);
assert.strictEqual(again.status, 303);
assert.strictEqual(again.headers.get('location'), `/cashouts/${co1}?done=already_paid`);
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.status, 'paid');
assert.strictEqual(c.payout_reference, 'PAYOUT-7Q2');
assert.strictEqual(c.payout_reference, 'PAYOUT-7Q2');
assert.strictEqual(JSON.parse(c.decided_by).principal, staff);
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.length, 1);
assert.strictEqual(rows[0].actor_subject, staff);
assert.strictEqual(rows[0].actor_subject, staff);
assert.strictEqual(rows[0].target_id, co1);
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.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');
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.strictEqual(ev.length, 1);
assert.deepStrictEqual(ev[0].actor, { type: 'user', id: staff });
assert.deepStrictEqual(ev[0].actor, { type: 'user', id: staff });
assert.strictEqual(ev[0].visibility, 'internal');
assert.strictEqual(ev[0].visibility, 'internal');
assert.deepStrictEqual(ev[0].payload.target, { type: 'cashout', id: co1 });
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);
const shown = await get(`/cashouts/${co1}?done=approved`, s);
assert.ok(shown.text.includes('Payout recorded') && shown.text.includes('PAYOUT-7Q2'));
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 () => {
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;
@@ -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' });
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(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((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);
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.length, 1);
assert.strictEqual(rows[0].reason, 'payout address bounced');
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' });
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');
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 () => {
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;
@@ -264,7 +264,7 @@ const DAY = 86_400_000;
assert.strictEqual((await post('/freeze', s, { _csrf: csrf, on: '1', reason: '' })).status, 422);
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' });
const on = await post('/freeze', s, { _csrf: csrf, on: '1', reason: 'cutover rehearsal' });
assert.strictEqual(on.status, 303);
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');
assert.strictEqual((await post('/freeze', s, { _csrf: csrf, on: '1', reason: 'again' })).status, 409, 'no double toggle');
const co3 = await newCashout(500).catch(() => null);
const co3 = await newCashout(500).catch(() => null);
assert.strictEqual(co3, null, 'the API refuses writes while frozen');
assert.strictEqual(co3, null, 'the API refuses writes while frozen');
@@ -278,14 +278,14 @@ const DAY = 86_400_000;
@@ -278,14 +278,14 @@ const DAY = 86_400_000;
const off = await post('/freeze', s, { _csrf: csrf, on: '0', reason: 'rehearsal done' });
const off = await post('/freeze', s, { _csrf: csrf, on: '0', reason: 'rehearsal done' });
assert.strictEqual(off.status, 303);
assert.strictEqual(off.status, 303);
assert.match(off.headers.get('location'), /done=unfrozen&processed=1/);
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 () => {
await check('a money action is refused while frozen (same guard as the API)', async () => {
@@ -296,7 +296,7 @@ const DAY = 86_400_000;
@@ -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' });
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.strictEqual(r.status, 503);
assert.ok(r.text.includes('billing.frozen'));
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' });
await post('/freeze', s, { _csrf: csrf, on: '0', reason: 'guard test done' });
});
});
@@ -311,8 +311,8 @@ const DAY = 86_400_000;
@@ -311,8 +311,8 @@ const DAY = 86_400_000;
assert.ok(run.text.includes('journal.zero_sum'));
assert.ok(run.text.includes('journal.zero_sum'));
assert.ok((await get('/reconciliation', s)).text.includes(id));
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.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 () => {
await check('receipts needing review are listed without payloads; the console shows no secrets', async () => {
@@ -328,16 +328,16 @@ const DAY = 86_400_000;
@@ -328,16 +328,16 @@ const DAY = 86_400_000;
const csrf = csrfOf(page);
const csrf = csrfOf(page);
const re = await post(`/receipts/${rejected.json.event}/reprocess`, s, { _csrf: csrf });
const re = await post(`/receipts/${rejected.json.event}/reprocess`, s, { _csrf: csrf });
assert.strictEqual(re.status, 303, re.text);
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((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"'];
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']) {
for (const p of ['/', '/receipts', '/cashouts', `/cashouts/${co1}`, '/reconciliation', '/freeze', '/audit', '/import-holds']) {
const body = (await get(p, s)).text;
const body = (await get(p, s)).text;
for (const x of secrets) assert.ok(!body.includes(x), `${p} shows ${x}`);
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 csrf2 = csrfOf(await get('/reconciliation', s));
const rid = (await post('/reconciliation', s, { _csrf: csrf2 })).headers.get('location');
const rid = (await post('/reconciliation', s, { _csrf: csrf2 })).headers.get('location');
const rep = (await get(rid, s)).text;
const rep = (await get(rid, s)).text;
@@ -345,8 +345,8 @@ const DAY = 86_400_000;
@@ -345,8 +345,8 @@ const DAY = 86_400_000;
});
});
await check('the audit log is append-only and shown to staff', async () => {
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);
const page = await get('/audit', s);
assert.ok(page.text.includes('cashout.approve') && page.text.includes('economy.freeze'));
assert.ok(page.text.includes('cashout.approve') && page.text.includes('economy.freeze'));
});
});
@@ -368,7 +368,7 @@ const DAY = 86_400_000;
@@ -368,7 +368,7 @@ const DAY = 86_400_000;
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
assert.ok(r.cookies.some((c) => c.startsWith('__Host-ovb_staff=;') && c.includes('Max-Age=0')));
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((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');
assert.ok(network.revoked.length >= 3, 'every Network refresh token handed to Billing was revoked, never kept');
});
});
@@ -13,7 +13,7 @@ const path = require('path');
@@ -13,7 +13,7 @@ const path = require('path');
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
-function fabricateLive(file) {
+async function fabricateLive(file) {
const live = new Database(file);
const live = new Database(file);
const recent = new Date(Date.now() - 30 * 60_000).toISOString().replace('T', ' ').slice(0, 19);
const recent = new Date(Date.now() - 30 * 60_000).toISOString().replace('T', ' ').slice(0, 19);
live.exec(`
live.exec(`
@@ -30,13 +30,13 @@ function fabricateLive(file) {
@@ -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 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}');
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', ?),
(1, 1, 'powerchat', NULL, 'bucks', 700, 500, NULL, 'credited', ?),
(2, 3, 'powerchat', NULL, 'bucks', 700, 500, NULL, 'pending', ?),
(2, 3, 'powerchat', NULL, 'bucks', 700, 500, NULL, 'pending', ?),
(3, 1, 'powerchat', 'site:fee=50', 'subscription', 549, 0, 2, 'credited', ?),
(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);
(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);
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);
VALUES (1, 1, 2, 'powerchat', 549, 'active', ?, ?)`).run(end, recent);
live.close();
live.close();
}
}
@@ -47,7 +47,7 @@ function fabricateLive(file) {
@@ -47,7 +47,7 @@ function fabricateLive(file) {
const bob = t.network.addUser(2);
const bob = t.network.addUser(2);
const carol = t.network.addUser(3);
const carol = t.network.addUser(3);
const file = path.join(t.dir, 'live-snapshot.db');
const file = path.join(t.dir, 'live-snapshot.db');
- fabricateLive(file);
+ await fabricateLive(file);
const { importLive } = require('../server/importer/live');
const { importLive } = require('../server/importer/live');
const { createIdentity } = require('../server/network');
const { createIdentity } = require('../server/network');
const identity = createIdentity(t.config);
const identity = createIdentity(t.config);
@@ -55,31 +55,31 @@ function fabricateLive(file) {
@@ -55,31 +55,31 @@ function fabricateLive(file) {
const live = new Database(file, { readonly: true, fileMustExist: true });
const live = new Database(file, { readonly: true, fileMustExist: true });
try { return await importLive(t.ctx, { live, resolveLiveUsers: identity.resolveLiveUsers, log: { log() {} } }); } finally { live.close(); }
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;
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 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');
console.log('cutover guards');
await run();
await run();
await check('an order Live credited is imported as settled-in-Live', async () => {
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 () => {
await check('its PowerChat delivery reaching Billing is refused (rejected, for review), never credited twice', async () => {
const r = await tip('pcorder:1', 700);
const r = await tip('pcorder:1', 700);
assert.strictEqual(r.status, 200, JSON.stringify(r.json));
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.effect, 'rejected');
assert.strictEqual(res.code, 'billing.intent_settled_in_live');
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');
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);
const shown = queue.rejected.find((e) => e.id === r.json.event);
assert.ok(shown, 'listed in the staff console review queue');
assert.ok(shown, 'listed in the staff console review queue');
assert.strictEqual(shown.result.code, 'billing.intent_settled_in_live');
assert.strictEqual(shown.result.code, 'billing.intent_settled_in_live');
@@ -87,15 +87,15 @@ function fabricateLive(file) {
@@ -87,15 +87,15 @@ function fabricateLive(file) {
});
});
await check('a subscription order Live credited is not paid (or granted) again either', async () => {
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);
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 () => {
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);
const live = new Database(file);
live.exec(`UPDATE payment_orders SET status = 'credited' WHERE id = 4;
live.exec(`UPDATE payment_orders SET status = 'credited' WHERE id = 4;
UPDATE users SET openvibe_bucks_balance = 1000 WHERE id = 3;
UPDATE users SET openvibe_bucks_balance = 1000 WHERE id = 3;
@@ -103,37 +103,34 @@ function fabricateLive(file) {
@@ -103,37 +103,34 @@ function fabricateLive(file) {
live.close();
live.close();
const report = await run();
const report = await run();
assert.ok(report.counts.intents_updated >= 1, JSON.stringify(report.counts));
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);
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 () => {
await check('a legacy order Live never credited (a checkout in flight) settles exactly once', async () => {
const r = await tip('pcorder:2', 700);
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 } });
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();
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.strictEqual(report.new_transactions, 0);
assert.deepStrictEqual(report.adjustments, [], 'no adjustment against a Billing-native settlement');
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 () => {
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 } });
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');
assert.strictEqual(w.status, 503); assert.strictEqual(w.json.code, 'billing.frozen');
const live = new Database(file);
const live = new Database(file);
@@ -142,12 +139,12 @@ function fabricateLive(file) {
@@ -142,12 +139,12 @@ function fabricateLive(file) {
await run(); // the final import, while Billing is frozen
await run(); // the final import, while Billing is frozen
const held = await tip('pcorder:5', 700);
const held = await tip('pcorder:5', 700);
assert.strictEqual(held.status, 202, JSON.stringify(held.json));
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
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();
await t.close();
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/app');
const { startEvents } = require('./helpers/stubs');
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 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 () => {
(async () => {
console.log('external receipts');
console.log('external receipts');
@@ -33,17 +33,17 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
@@ -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 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 () => {
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' }));
const r = await t.powerchat(donation({ eventId: 'ext-L1', amountUsdCents: 500, donorName: 'Fan', message: 'hi' }));
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.json.result.effect, 'external');
assert.strictEqual(r.json.result.effect, 'external');
assert.strictEqual(r.json.result.announced, false);
assert.strictEqual(r.json.result.announced, false);
assert.match(r.json.result.reason, /BILLING_AUTHORITY=live/);
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.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_receipts, 1);
assert.strictEqual(rec.totals.external_announced, 0);
assert.strictEqual(rec.totals.external_announced, 0);
});
});
@@ -76,7 +76,7 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
@@ -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' });
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.strictEqual(r.status, 200);
assert.deepStrictEqual([r.json.result.effect, r.json.result.announced, r.json.result.streamer], ['external', true, streamer.id]);
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);
assert.strictEqual(evs.length, 1);
first = evs[0];
first = evs[0];
assert.ok(validate('events.event-envelope@1', first).valid);
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
@@ -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.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.receiving_account, { provider: 'powerchat', id: 'pc-77', username: 'streamerpc' });
assert.deepStrictEqual([p.classification, p.app_purpose, p.test], ['EXTERNAL', 'goal:12', false]);
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 () => {
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
@@ -97,20 +97,20 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
assert.strictEqual(same.json.duplicate, true);
assert.strictEqual(same.json.duplicate, true);
const again = await t.powerchat(donation({ eventId: 'ext-1', amountUsdCents: 1234 }), { deliveryId: 'dlv-ext-1-retry' });
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(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]) {
for (const id of [firstEvent, again.json.event]) {
const refused = await t.call('POST', `/api/v1/admin/provider-events/${id}/reprocess`, { key: null });
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(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 () => {
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' }));
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);
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]);
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
@@ -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' }));
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.deepStrictEqual([r.json.result.effect, r.json.result.announced, r.json.result.review], ['external', false, true]);
assert.match(r.json.result.reason, /otherpc/);
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));
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 } });
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 });
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.strictEqual(re.status, 200, re.text);
assert.deepStrictEqual([re.json.event.result.effect, re.json.event.result.announced], ['external', true]);
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 () => {
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
@@ -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 () => {
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 }));
const zero = await t.powerchat(donation({ amountUsdCents: 0 }));
assert.strictEqual(zero.json.result.effect, 'none');
assert.strictEqual(zero.json.result.effect, 'none');
const test = await t.powerchat(donation({ amountUsdCents: 500, isTest: true }));
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
@@ -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]);
assert.deepStrictEqual([huge.json.result.effect, huge.json.result.review], ['none', true]);
const inf = await t.powerchat(donation({ amountUsdCents: '1e400' }));
const inf = await t.powerchat(donation({ amountUsdCents: '1e400' }));
assert.strictEqual(inf.json.result.effect, 'none');
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 () => {
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
@@ -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() {} } });
const relay = createRelay({ db: t.db, config: { ...t.config, events: { url: events.url, intervalMs: 1000 } }, log: { warn() {} } });
await relay.flush();
await relay.flush();
const sent = events.batches.flatMap((b) => b.events).filter((e) => e.event_type === 'billing.receipt.external');
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);
assert.strictEqual(sent[0].event_id, first.event_id);
await events.close();
await events.close();
});
});
@@ -179,21 +179,21 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
@@ -179,21 +179,21 @@ const externalEvents = (db) => db.prepare("SELECT event FROM outbox WHERE json_e
const ro = new Database(file, { readonly: true });
const ro = new Database(file, { readonly: true });
const dry = await importProviderAccounts(t.ctx, { live: ro, resolveLiveUsers: t.ctx.network.resolveLiveUsers, dryRun: true, log: {} });
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(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 rep = await importProviderAccounts(t.ctx, { live: ro, resolveLiveUsers: t.ctx.network.resolveLiveUsers, log: {} });
const a = rep.provider_accounts;
const a = rep.provider_accounts;
assert.strictEqual(a.mapped, 1);
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.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']);
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.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: {} });
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');
assert.strictEqual(full.provider_accounts.unchanged, 1, 'the full import runs the same step; a re-run changes nothing');
ro.close();
ro.close();
const r = await t.powerchat(donation({ eventId: 'ext-newbie', amountUsdCents: 100 }, { id: 'pc-50', username: 'NewbiePC' }));
const r = await t.powerchat(donation({ eventId: 'ext-newbie', amountUsdCents: 100 }, { id: 'pc-50', username: 'NewbiePC' }));
assert.strictEqual(r.json.result.streamer, newbie);
assert.strictEqual(r.json.result.streamer, newbie);
- t.assertReconciled('after the accounts import');
+ await t.assertReconciled('after the accounts import');
});
});
await t.close();
await t.close();
@@ -36,7 +36,12 @@ async function boot(opts = {}) {
@@ -36,7 +36,12 @@ async function boot(opts = {}) {
const logs = [];
const logs = [];
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
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).
// 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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -75,9 +80,9 @@ async function boot(opts = {}) {
@@ -75,9 +80,9 @@ async function boot(opts = {}) {
}
}
/** Reconciliation must pass (journal balanced, caches right, events settled once). */
/** Reconciliation must pass (journal balanced, caches right, events settled once). */
- function assertReconciled(label = '') {
+ async function assertReconciled(label = '') {
const { reconcile } = require('../../server/reconcile');
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);
const failed = r.checks.filter((c) => !c.ok);
assert.deepStrictEqual(failed, [], `reconciliation failed ${label}: ${JSON.stringify(failed)}`);
assert.deepStrictEqual(failed, [], `reconciliation failed ${label}: ${JSON.stringify(failed)}`);
return r;
return r;
@@ -97,7 +102,8 @@ async function boot(opts = {}) {
@@ -97,7 +102,8 @@ async function boot(opts = {}) {
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
if (!opts.network) await network.close();
if (!opts.network) await network.close();
if (stripe) await stripe.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 });
fs.rmSync(dir, { recursive: true, force: true });
},
},
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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 };
@@ -24,13 +24,13 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
@@ -24,13 +24,13 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
return r.json.intent;
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');
const providers = require('../server/providers');
console.log('hygiene');
console.log('hygiene');
// ── Overflow ─────────────────────────────────────────────
// ── Overflow ─────────────────────────────────────────────
await check('huge provider amounts are rejected for review, never booked', async () => {
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 });
const i = await intent({ kind: 'purchase', subject: buyer, bits: 100 });
for (const cents of [1e20, 2 ** 53, 9007199254740993, 100_000_001, -500]) {
for (const cents of [1e20, 2 ** 53, 9007199254740993, 100_000_001, -500]) {
const r = await t.powerchat(donation({ amountUsdCents: cents, appExternalRef: i.checkout_ref }));
const r = await t.powerchat(donation({ amountUsdCents: cents, appExternalRef: i.checkout_ref }));
@@ -40,8 +40,8 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
@@ -40,8 +40,8 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
const tip = await t.powerchat(donation({ amountUsdCents: 1e17, appExternalRef: `pcdon:${streamerLiveId}:0` }));
const tip = await t.powerchat(donation({ amountUsdCents: 1e17, appExternalRef: `pcdon:${streamerLiveId}:0` }));
assert.strictEqual(tip.json.result.effect, 'rejected');
assert.strictEqual(tip.json.result.effect, 'rejected');
assert.strictEqual(tip.json.result.code, 'billing.invalid_amount');
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 () => {
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',
@@ -59,15 +59,15 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
const { A, entry } = require('../server/ops/common');
const { A, entry } = require('../server/ops/common');
const acct = A.credit(buyer.id);
const acct = A.credit(buyer.id);
const big = Number.MAX_SAFE_INTEGER - 10;
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 () => {
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',
@@ -78,7 +78,7 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
assert.strictEqual(rf.json.result.effect, 'settled', JSON.stringify(rf.json));
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;
const tx = (await t.call('GET', `/api/v1/transactions/${rf.json.result.txn_id}`)).json.transaction;
assert.strictEqual(tx.metadata.cents, 700);
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 ────────
// ── Concurrent settlement of one provider payment ────────
@@ -88,10 +88,10 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
@@ -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 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' })));
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.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);
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 () => {
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',
@@ -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 === 'settled').length, 1, effects.join(','));
assert.strictEqual(effects.filter((e) => e === 'duplicate_receipt').length, 19);
assert.strictEqual(effects.filter((e) => e === 'duplicate_receipt').length, 19);
assert.strictEqual((await t.balances(siteStreamer)).payable, credit + 250);
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 });
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' } });
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' });
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);
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;
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.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);
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 () => {
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',
@@ -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),
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);
assert.strictEqual((await t.balances(siteStreamer)).payable, payable + 300);
});
});
@@ -167,31 +146,31 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
@@ -167,31 +146,31 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
let heldEvent;
let heldEvent;
await check('the Network down while a site tip resolves its creator: stored, 200 unprocessed, nothing booked, retried later', async () => {
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;
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' });
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.status, 200, 'acknowledged: the receipt is stored, PowerChat must not retry forever');
assert.strictEqual(r.json.processed, false);
assert.strictEqual(r.json.processed, false);
heldEvent = r.json.event;
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.strictEqual(row.processed_at, null);
assert.ok(row.last_error, 'the failure is recorded');
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);
const pending = await providers.processPending(t.ctx, t.app.locals.adapters);
assert.strictEqual(pending.processed, 0, 'still down: still pending');
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);
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 () => {
await check('a receipt stuck past BILLING_RECONCILE_STALE_RECEIPT_MIN fails reconciliation (not while frozen)', async () => {
const { reconcile } = require('../server/reconcile');
const { reconcile } = require('../server/reconcile');
t.clock.offset = 61 * 60_000;
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');
const stale = r.checks.find((c) => c.id === 'receipts.stale');
assert.strictEqual(stale.ok, false);
assert.strictEqual(stale.ok, false);
assert.deepStrictEqual(stale.detail.offenders.map((o) => o.id), [heldEvent]);
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());
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)/);
assert.match(m, /billing_provider_event_oldest_pending_seconds (36[6-9]\d|3[7-9]\d\d)/);
t.clock.offset = 0;
t.clock.offset = 0;
@@ -204,47 +183,47 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
@@ -204,47 +183,47 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
assert.strictEqual(out.processed, 1);
assert.strictEqual(out.processed, 1);
await providers.processPending(t.ctx, t.app.locals.adapters);
await providers.processPending(t.ctx, t.app.locals.adapters);
assert.strictEqual((await t.balances(siteStreamer)).payable, payable + 400);
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(JSON.parse(row.result).effect, 'settled');
assert.strictEqual(row.last_error, null);
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 () => {
await check('a failure inside the settlement transaction rolls everything back; the retry settles once', async () => {
const intents = require('../server/ops/intents');
const intents = require('../server/ops/intents');
const i = await intent({ kind: 'purchase', subject: buyer, bits: 100 });
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 credit = (await t.balances(buyer.id)).credit;
const real = intents.markSettled;
const real = intents.markSettled;
let fails = 1;
let fails = 1;
// Marking the intent settled comes after the journal posting, in the same transaction: failing
// 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.
// 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;
let r;
try { r = await t.powerchat(donation({ eventId: 'don-crash', amountUsdCents: 150, appExternalRef: i.checkout_ref })); }
try { r = await t.powerchat(donation({ eventId: 'don-crash', amountUsdCents: 150, appExternalRef: i.checkout_ref })); }
finally { intents.markSettled = real; }
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(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.balances(buyer.id)).credit, credit);
assert.strictEqual((await t.call('GET', `/api/v1/intents/${i.id}`)).json.intent.status, 'created');
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);
await providers.processPending(t.ctx, t.app.locals.adapters);
assert.strictEqual((await t.balances(buyer.id)).credit, credit + 100);
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 () => {
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 { createRelay } = require('../server/outbox');
const log = { warn() {} };
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 });
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);
await fund(t, buyer, 200);
const r = await down.flush();
const r = await down.flush();
assert.strictEqual(r.sent, 0);
assert.strictEqual(r.sent, 0);
- const waiting = unsent();
+ const waiting = await unsent();
assert.ok(waiting > 0);
assert.ok(waiting > 0);
const m = await fetch(`${t.base}/metrics`).then((x) => x.text());
const m = await fetch(`${t.base}/metrics`).then((x) => x.text());
assert.match(m, new RegExp(`billing_outbox_pending ${waiting}\\b`));
assert.match(m, new RegExp(`billing_outbox_pending ${waiting}\\b`));
@@ -252,7 +231,7 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
@@ -252,7 +231,7 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
const events = await startEvents();
const events = await startEvents();
const up = createRelay({ db: t.db, config: { ...t.config, events: { url: events.url, intervalMs: 1000 } }, log });
const up = createRelay({ db: t.db, config: { ...t.config, events: { url: events.url, intervalMs: 1000 } }, log });
await up.flush();
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);
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');
assert.strictEqual(new Set(ids).size, ids.length, 'each event published once');
await events.close();
await events.close();
@@ -261,12 +240,12 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
@@ -261,12 +240,12 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
// ── Scheduled reconciliation, exposed ────────────────────
// ── Scheduled reconciliation, exposed ────────────────────
await check('the scheduled run is stored with its trigger, listed by the API and old passing runs are pruned', async () => {
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 { 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);
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.strictEqual(rep.trigger, 'scheduled');
assert.ok(rep.checks.some((c) => c.id === 'receipts.ledger' && c.ok));
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'] });
const list = await t.call('GET', '/api/v1/admin/reconciliations?limit=5', { cap: ['billing.ledger.admin'] });
assert.strictEqual(list.status, 200, list.text);
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]);
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',
@@ -282,20 +261,20 @@ const donation = (data, streamer = 'openvibe') => ({ type: 'donation.completed',
// A settlement whose clearing entry disagrees with the receipt it names.
// A settlement whose clearing entry disagrees with the receipt it names.
const { post } = require('../server/ledger');
const { post } = require('../server/ledger');
const { A, entry } = require('../server/ops/common');
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,
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)],
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 } },
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);
assert.strictEqual(rep.ok, false);
const c = rep.checks.find((x) => x.id === 'receipts.ledger');
const c = rep.checks.find((x) => x.id === 'receipts.ledger');
assert.strictEqual(c.ok, false);
assert.strictEqual(c.ok, false);
assert.deepStrictEqual(c.detail.offenders.map((o) => [o.receipt_ref, o.clearing, o.paid_cents]), [['powerchat:forged', -100, 900]]);
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());
const m = await fetch(`${t.base}/metrics`).then((x) => x.text());
assert.match(m, /^billing_reconciliation_ok 0$/m);
assert.match(m, /^billing_reconciliation_ok 0$/m);
assert.match(m, /^billing_reconciliation_failed_checks 1$/m);
assert.match(m, /^billing_reconciliation_failed_checks 1$/m);
@@ -10,7 +10,7 @@ const path = require('path');
@@ -10,7 +10,7 @@ const path = require('path');
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
-function fabricateLive(file) {
+async function fabricateLive(file) {
const live = new Database(file);
const live = new Database(file);
live.exec(`
live.exec(`
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, openvibe_bucks_balance REAL DEFAULT 0.00,
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) {
@@ -48,7 +48,7 @@ function fabricateLive(file) {
(5, 3, 'powerchat', NULL, 'bucks', 325, 250, NULL, 'paid', '2026-07-06 09:00:00');
(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);
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);
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();
live.close();
return new Database(file, { readonly: true, fileMustExist: true });
return new Database(file, { readonly: true, fileMustExist: true });
@@ -60,32 +60,32 @@ function fabricateLive(file) {
@@ -60,32 +60,32 @@ function fabricateLive(file) {
const bob = t.network.addUser(2);
const bob = t.network.addUser(2);
const dave = t.network.addUser(4);
const dave = t.network.addUser(4);
t.network.addUser(5);
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 { importLive } = require('../server/importer/live');
const { createIdentity } = require('../server/network');
const { createIdentity } = require('../server/network');
const identity = createIdentity(t.config);
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');
console.log('import from live.db');
await check('a dry run reports everything and keeps nothing but its report', async () => {
await check('a dry run reports everything and keeps nothing but its report', async () => {
const r = await run({ dryRun: true });
const r = await run({ dryRun: true });
assert.strictEqual(r.dry_run, true);
assert.strictEqual(r.dry_run, true);
assert.ok(r.new_transactions > 0);
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;
let report;
await check('opening balances equal the Live columns', async () => {
await check('opening balances equal the Live columns', async () => {
report = await run();
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'));
assert.ok(t.network.resolveCalls.every((c) => c.system === 'live' && c.type === 'user'));
});
});
@@ -93,35 +93,35 @@ function fabricateLive(file) {
@@ -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]);
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.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.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 () => {
await check('unmapped users are held, never dropped', async () => {
assert.deepStrictEqual(report.holds.map((h) => [h.live_user_id, h.credit_bits]), [[3, 250]]);
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);
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');
assert.strictEqual(i.subject, 'hold:live:3');
});
});
await check('history rows become import transactions; test-era rows are flagged test', async () => {
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));
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]]);
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 () => {
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.deepStrictEqual(report.duplicate_provider_refs.map((d) => [d.order, d.kept_on_order]), [[3, 2]]);
assert.strictEqual(report.route_markers, 1);
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.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.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 () => {
await check('subscriptions become entitlements', async () => {
@@ -134,28 +134,28 @@ function fabricateLive(file) {
@@ -134,28 +134,28 @@ function fabricateLive(file) {
});
});
await check('the journal reconciles after import', async () => {
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.strictEqual(rec.warnings.import_holds.length, 1);
assert.ok(rec.totals.test_transactions >= 1);
assert.ok(rec.totals.test_transactions >= 1);
});
});
await check('a re-run over the same snapshot changes nothing', async () => {
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();
const again = await run();
assert.strictEqual(again.new_transactions, 0);
assert.strictEqual(again.new_transactions, 0);
assert.deepStrictEqual(again.adjustments, []);
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 () => {
await check('a hold is released to the subject once the Network maps the user', async () => {
const carol = t.network.addUser(3);
const carol = t.network.addUser(3);
const r = await run();
const r = await run();
assert.deepStrictEqual(r.holds, []);
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);
assert.strictEqual((await run()).new_transactions, 0);
});
});
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/** The journal itself: balance invariant, append-only, idempotent posting, cached balances. */
/** The journal itself: balance invariant, append-only, idempotent posting, cached balances. */
const assert = require('assert');
const assert = require('assert');
-const { openDb } = require('../server/db');
+const { testDb } = require('./helpers/db');
const { loadConfig } = require('../server/config');
const { loadConfig } = require('../server/config');
const { createRates } = require('../server/rates');
const { createRates } = require('../server/rates');
const { post, balance, BillingError } = require('../server/ledger');
const { post, balance, BillingError } = require('../server/ledger');
@@ -9,56 +9,57 @@ const { A, entry } = require('../server/ops/common');
@@ -9,56 +9,57 @@ const { A, entry } = require('../server/ops/common');
const { reconcile } = require('../server/reconcile');
const { reconcile } = require('../server/reconcile');
const { check, done } = require('./helpers/app');
const { check, done } = require('./helpers/app');
-const db = openDb(':memory:');
const config = loadConfig({ NODE_ENV: 'test' });
const config = loadConfig({ NODE_ENV: 'test' });
-const ctx = { db, config, rates: createRates(config.rates), now: () => Date.now() };
+let db, ctx;
const U = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';
const U = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';
(async () => {
(async () => {
+ db = await testDb();
+ ctx = { db, config, rates: createRates(config.rates), now: () => Date.now() };
console.log('ledger');
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');
(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');
(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(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.replay, true);
assert.strictEqual(r.txn.id, first);
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;
const r = ctx.rates;
assert.strictEqual(r.priceCents(100), 150);
assert.strictEqual(r.priceCents(100), 150);
assert.strictEqual(r.priceCents(1000), 1300);
assert.strictEqual(r.priceCents(1000), 1300);
@@ -70,15 +71,15 @@ const U = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';
@@ -70,15 +71,15 @@ const U = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';
assert.strictEqual(r.siteFeeCents(499), 50); // Live: round(499 * 10 / 100)
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);
assert.strictEqual(r.ok, false);
const c = r.checks.find((x) => x.id === 'balances.cache');
const c = r.checks.find((x) => x.id === 'balances.cache');
assert.strictEqual(c.ok, false);
assert.strictEqual(c.ok, false);
assert.strictEqual(c.detail.mismatches[0].cached - c.detail.mismatches[0].derived, 1);
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();
done();
@@ -37,7 +37,7 @@ const { boot, fund, check, done } = require('./helpers/app');
@@ -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.revenue_cents, 300);
assert.strictEqual(r.json.transaction.metadata.rates.bits_per_usd, 100);
assert.strictEqual(r.json.transaction.metadata.rates.bits_per_usd, 100);
assert.strictEqual((await t.balances(viewer.id)).credit, 1000);
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 () => {
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');
@@ -59,7 +59,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.deepStrictEqual(donation.metadata.target, { service: 'live', type: 'stream', id: '77' });
assert.deepStrictEqual(donation.metadata.target, { service: 'live', type: 'stream', id: '77' });
assert.strictEqual(r.json.balance.credit, 600);
assert.strictEqual(r.json.balance.credit, 600);
assert.strictEqual((await t.balances(streamer.id)).payable, 400);
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 () => {
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');
@@ -115,7 +115,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(over.json.code, 'billing.already_reversed');
assert.strictEqual(over.json.code, 'billing.already_reversed');
const tx = await t.call('GET', `/api/v1/transactions/${donation.id}`);
const tx = await t.call('GET', `/api/v1/transactions/${donation.id}`);
assert.deepStrictEqual(tx.json.reversed_by, [r.json.transaction.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 () => {
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');
@@ -123,7 +123,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json.transaction.type, 'recycle');
assert.strictEqual(r.json.transaction.type, 'recycle');
assert.deepStrictEqual([r.json.balance.credit, r.json.balance.payable], [50, 250]);
assert.deepStrictEqual([r.json.balance.credit, r.json.balance.payable], [50, 250]);
- t.assertReconciled('after recycle');
+ await t.assertReconciled('after recycle');
});
});
let cashout;
let cashout;
@@ -140,9 +140,9 @@ const { boot, fund, check, done } = require('./helpers/app');
@@ -140,9 +140,9 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(cashout.value_cents, 1000);
assert.strictEqual(cashout.value_cents, 1000);
const b = await t.balances(streamer.id);
const b = await t.balances(streamer.id);
assert.deepStrictEqual([b.payable, b.pending_payouts], [250, 1000]);
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).
// 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('billing.cashout.requested'), 'the requested event is queued');
assert.ok(!outboxText.includes('[email protected]'), 'no payout address in any cashout event');
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');
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');
@@ -163,7 +163,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(ok.json.cashout.status, 'paid');
assert.strictEqual(ok.json.cashout.status, 'paid');
assert.strictEqual(ok.json.cashout.payout_reference, 'PAYPAL-BATCH-1');
assert.strictEqual(ok.json.cashout.payout_reference, 'PAYPAL-BATCH-1');
assert.strictEqual((await t.balances(streamer.id)).pending_payouts, 0);
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);
assert.strictEqual(r.checks.find((c) => c.id === 'payouts.references').ok, true);
});
});
@@ -181,7 +181,7 @@ const { boot, fund, check, done } = require('./helpers/app');
@@ -181,7 +181,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(tx.json.transaction.reverses_txn, r.json.cashout.request_txn);
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'] });
const list = await t.call('GET', '/api/v1/cashouts?status=denied', { cap: ['billing.cashout.manage'] });
assert.strictEqual(list.json.cashouts.length, 1);
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 () => {
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');
@@ -193,7 +193,7 @@ const { boot, fund, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json.transaction.type, 'adjustment');
assert.strictEqual(r.json.transaction.type, 'adjustment');
assert.strictEqual(r.json.transaction.metadata.reason, 'goodwill after outage');
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: {} });
const denied = await t.call('POST', '/api/v1/admin/adjustments', { cap: ['billing.transfer.create'], body: {} });
assert.strictEqual(denied.status, 403);
assert.strictEqual(denied.status, 403);
});
});
@@ -64,14 +64,14 @@ async function startProviderStub() {
@@ -64,14 +64,14 @@ async function startProviderStub() {
assert.strictEqual(noPrice.json.result.review, true);
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' });
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(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' });
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(again.json.duplicate, true);
assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
const cb = await post('secret=cc-secret&eventType=Chargeback', { transactionId: 'T1', amount: '13.00' });
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(cb.json.result.effect, 'settled', JSON.stringify(cb.json));
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
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 () => {
await check('NOWPayments: HMAC-SHA512 IPN, several statuses settle one payment once, refunded reverses', async () => {
@@ -92,7 +92,7 @@ async function startProviderStub() {
@@ -92,7 +92,7 @@ async function startProviderStub() {
const r = await ipn({ ...body, payment_status: 'refunded' });
const r = await ipn({ ...body, payment_status: 'refunded' });
assert.strictEqual(r.json.result.effect, 'settled');
assert.strictEqual(r.json.result.effect, 'settled');
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
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 () => {
await check('PayPal: verified webhooks, capture after return settles once, reversal is a chargeback', async () => {
@@ -115,7 +115,7 @@ async function startProviderStub() {
@@ -115,7 +115,7 @@ async function startProviderStub() {
const tx = (await t.call('GET', `/api/v1/transactions/${rev.json.result.txn_id}`)).json.transaction;
const tx = (await t.call('GET', `/api/v1/transactions/${rev.json.result.txn_id}`)).json.transaction;
assert.strictEqual(tx.type, 'chargeback');
assert.strictEqual(tx.type, 'chargeback');
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
- t.assertReconciled('after paypal');
+ await t.assertReconciled('after paypal');
});
});
await t.close();
await t.close();
@@ -64,7 +64,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -64,7 +64,7 @@ const { boot, check, done } = require('./helpers/app');
const r = await tipTo('openvibe', { amountUsdCents: 250, appExternalRef: `pcdon:${attackerLiveId}:0` });
const r = await tipTo('openvibe', { amountUsdCents: 250, appExternalRef: `pcdon:${attackerLiveId}:0` });
assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
assert.strictEqual((await t.balances(attacker.id)).payable, 250);
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 () => {
await check('without POWERCHAT_SITE_USERNAME site-routed refs are held, never credited', async () => {
@@ -67,7 +67,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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}`);
const e = await t.call('GET', `/api/v1/entitlements/${buyer.id}?streamer=${streamer.id}`);
assert.strictEqual(e.json.active, true);
assert.strictEqual(e.json.active, true);
assert.strictEqual(Date.parse(e.json.expires_at), (end1 + 30 * 86400) * 1000);
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 () => {
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');
@@ -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}`);
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.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);
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 () => {
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');
@@ -97,7 +97,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
assert.strictEqual((await t.balances(buyer.id)).credit, 0);
const orig = (await t.call('GET', `/api/v1/transactions/${tx.reverses_txn}`)).json;
const orig = (await t.call('GET', `/api/v1/transactions/${tx.reverses_txn}`)).json;
assert.strictEqual(orig.reversed_by.length, 2);
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 () => {
await check('cancel is sent to Stripe (cancel_at_period_end) before Billing records it', async () => {
@@ -31,7 +31,7 @@ const DAY = 86_400_000;
@@ -31,7 +31,7 @@ const DAY = 86_400_000;
assert.strictEqual(rev.amount, 150);
assert.strictEqual(rev.amount, 150);
assert.strictEqual(r.json.entitlement.active, true);
assert.strictEqual(r.json.entitlement.active, true);
assert.ok(Math.abs(Date.parse(r.json.entitlement.expires_at) - (Date.now() + 31 * DAY)) < 60_000);
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 () => {
await check('an early renewal credits the share again and extends the entitlement', async () => {
@@ -42,7 +42,7 @@ const DAY = 86_400_000;
@@ -42,7 +42,7 @@ const DAY = 86_400_000;
assert.strictEqual((await t.balances(streamer.id)).payable, 698);
assert.strictEqual((await t.balances(streamer.id)).payable, 698);
const after = Date.parse(r.json.entitlement.expires_at);
const after = Date.parse(r.json.entitlement.expires_at);
assert.strictEqual(after - before, 31 * DAY);
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 () => {
await check('insufficient credit refuses a subscription and changes nothing', async () => {
@@ -74,7 +74,7 @@ const DAY = 86_400_000;
@@ -74,7 +74,7 @@ const DAY = 86_400_000;
assert.strictEqual(e.json.active, false);
assert.strictEqual(e.json.active, false);
assert.strictEqual(e.json.subscription.status, 'expired');
assert.strictEqual(e.json.subscription.status, 'expired');
t.clock.offset = 0;
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 () => {
await check('cancel keeps access until the period ends, then the sweep ends it', async () => {
@@ -20,14 +20,14 @@ const { boot, check, done } = require('./helpers/app');
@@ -20,14 +20,14 @@ const { boot, check, done } = require('./helpers/app');
return r.json.intent;
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 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 () => {
await check('an unsigned or stale delivery is refused and not stored', async () => {
const bad = await t.powerchat(donation({ amountUsdCents: 100 }), { secret: 'wrong' });
const bad = await t.powerchat(donation({ amountUsdCents: 100 }), { secret: 'wrong' });
assert.strictEqual(bad.status, 401);
assert.strictEqual(bad.status, 401);
const stale = await t.powerchat(donation({ amountUsdCents: 100 }), { ts: Date.now() - 16 * 60 * 1000 });
const stale = await t.powerchat(donation({ amountUsdCents: 100 }), { ts: Date.now() - 16 * 60 * 1000 });
assert.strictEqual(stale.status, 401);
assert.strictEqual(stale.status, 401);
- assert.strictEqual(eventCount(), 0);
+ assert.strictEqual(await eventCount(), 0);
});
});
let buyIntent;
let buyIntent;
@@ -41,7 +41,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -41,7 +41,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
const i = await t.call('GET', `/api/v1/intents/${buyIntent.id}`);
const i = await t.call('GET', `/api/v1/intents/${buyIntent.id}`);
assert.strictEqual(i.json.intent.status, 'settled');
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 () => {
await check('a duplicated delivery produces exactly one accounting effect', async () => {
@@ -51,8 +51,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -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' });
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(redelivered.json.result.effect, 'duplicate_receipt');
assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
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 () => {
await check('test deliveries and app echoes move no money', async () => {
@@ -72,10 +72,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -72,10 +72,10 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.balances(streamer.id)).payable, 349);
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'] });
const e = await t.call('GET', `/api/v1/entitlements/${buyer.id}?streamer=${streamer.id}`, { cap: ['billing.entitlement.check'] });
assert.strictEqual(e.json.active, true);
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);
const m = JSON.parse(tx.metadata);
assert.deepStrictEqual([m.paid_cents, m.fee_cents, m.share_bits], [549, 50, 349]);
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 () => {
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');
@@ -116,7 +116,7 @@ const { boot, check, done } = require('./helpers/app');
assert.match(ext.json.result.reason, /EXTERNAL/);
assert.match(ext.json.result.reason, /EXTERNAL/);
const site = await t.powerchat(donation({ amountUsdCents: 1000 }, 'openvibe'));
const site = await t.powerchat(donation({ amountUsdCents: 1000 }, 'openvibe'));
assert.strictEqual(site.json.result.review, true);
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);
assert.ok(rec.warnings.events_for_review.length >= 2);
});
});
@@ -135,7 +135,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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}`);
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.deepStrictEqual(orig.json.reversed_by, [tx.json.transaction.id]);
assert.strictEqual(orig.json.transaction.entries.length, 5, 'the original stays as it was');
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 () => {
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');
@@ -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']);
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');
const loss = tx.entries.find((e) => e.account.kind === 'chargeback_loss');
assert.strictEqual(loss.amount, -1100, 'unrecovered 800 + spread 300');
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.ok(rec.warnings.transactions_for_review.some((x) => x.id === tx.id));
assert.strictEqual(rec.totals.chargeback_loss_cents, -1100);
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' } });
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');
@@ -191,12 +191,12 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(q.status, 202);
assert.strictEqual(q.status, 202);
assert.strictEqual(q.json.queued, true);
assert.strictEqual(q.json.queued, true);
assert.strictEqual((await t.balances(buyer.id)).credit, credit);
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 } });
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.frozen, false);
assert.strictEqual(off.json.processed_after_unfreeze.processed, 1);
assert.strictEqual(off.json.processed_after_unfreeze.processed, 1);
assert.strictEqual((await t.balances(buyer.id)).credit, credit + 100);
assert.strictEqual((await t.balances(buyer.id)).credit, credit + 100);
- t.assertReconciled('after unfreeze');
+ await t.assertReconciled('after unfreeze');
});
});
await t.close();
await t.close();