openvibers/openvibe.billing Commits
Vibes Pay (D12): app charges, the pay sheet, the project wallet and signed webhooks (#191)
Changes
@@@ -137,3 +137,18 @@ POWERCHAT_SITE_USERNAME=# Account export and deletion (ADR-033): the signing secret of Billing's own Events subscriptions (32+ characters;# comma-separated during a rotation). Without it, the account subscriptions are not created and /internal/events answers 503.#BILLING_EVENTS_SECRET=++++# ── Vibes Pay (D12): developer app charges, the pay sheet and sandbox test Vibes ──++# These are the pre-D11 names of the billing.vibes namespace keys; while set they pin the owner's value.++#BILLING_VIBES_PAY=0++#BILLING_VIBES_MAX_CHARGE=0++#BILLING_VIBES_DAILY_APP_SPEND=0++# Test grants and sandbox charges, worth nothing (one switch, like BILLING_CREDITS_SANDBOX).++#BILLING_VIBES_SANDBOX=0++# Charge lifetime and the openvibe.network pay-sheet origin.++#BILLING_VIBES_CHARGE_TTL_SECONDS=1800++#BILLING_VIBES_PAY_BASE_URL=https://openvibe.network++# The webhook delivery job (0 = off; tests call deliverDue directly).++#BILLING_VIBES_WEBHOOK_INTERVAL_MS=5000++# Development only: allow a private webhook address. Production always applies the egress guard.++#BILLING_VIBES_ALLOW_PRIVATE_WEBHOOKS=0@@@ -51,6 +51,8 @@ promo write changes both in one transaction.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)++- Vibes Pay (D12): app charges, the project's `project_payable` Vibes wallet, its signed webhooks and sandbox test++ Vibes ([docs/vibes-pay.md](docs/vibes-pay.md))## Does not own@@@ -64,7 +66,7 @@ promo write changes both in one transaction.- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)- OpenVibe.Events (the SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are set)- the payment providers whose secrets are configured (none in production today)--- `openvibe-contracts` v0.222.0, `openvibe-sdk` v0.82.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`), `openvibe-shared` v4.109.2, pinned by release tarball++- `openvibe-contracts` v0.289.1, `openvibe-sdk` v0.84.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.110.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)@@@ -73,10 +75,11 @@ promo write changes both in one transaction.Implemented here (the service manifest's `capabilities`, audience `openvibe.billing`; routes under[API](#api)): `billing.intent.create`, `billing.transfer.create`, `billing.balance.read`,`billing.cashout.request`, `billing.cashout.manage`, `billing.subscription.manage`,--`billing.entitlement.check`, `billing.ledger.admin` and `billing.usage.record` (usage readings; released in--Contracts v0.85.0). Callers today are Live (after the cutover),--Tips (`billing.intent.create`, `billing.transfer.create`) and VIP (`billing.intent.create`,--`billing.subscription.manage`, `billing.entitlement.check`).++`billing.entitlement.check`, `billing.ledger.admin`, `billing.usage.record` (usage readings; released in++Contracts v0.85.0), and the public `billing.vibes.charge` and `billing.vibes.read` for developer apps (D12).++Callers today are Live (after the cutover),++Tips (`billing.intent.create`, `billing.transfer.create`), VIP (`billing.intent.create`,++`billing.subscription.manage`, `billing.entitlement.check`) and a developer app with its own app token for Vibes Pay.Called elsewhere, as the service principal `billing`: `events.event.publish` (Events) and`identity.subject.resolve` (Network, the Live import).@@@ -107,6 +110,13 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unitsame transaction and verified by reconciliation. Funds checks run inside the transaction that movesthe money, and every money transaction is SERIALIZABLE (`ledger.js` `money()`, retried on aserialization failure), so two movements that each passed a check cannot both commit.++- **Vibes Pay** (`server/ops/vibes.js`, `server/ops/vibes-webhooks.js`, [docs/vibes-pay.md](docs/vibes-pay.md)):++ an app creates a charge (whole Vibes, idempotent on its reference), the person pays it on the pay sheet, and the++ Vibes move once from `user_credit` to `project_payable:project:<prj_id>`; a project owner cashes out through the++ existing cashout path. Each paid/refunded charge publishes `billing.vibes.charge.paid@1`/`.refunded@1` to Events++ and queues a signed HMAC-SHA256 webhook in `vibes_webhook_outbox`, retried with backoff. Sandbox apps use++ `vibes_credit_sandbox`/`project_payable_sandbox` and test transactions only. The owner's `billing.vibes` limits++ and the economy freeze are checked inside the money transaction.- **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`.@@@ -119,7 +129,22 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unitsettles once whatever event, retry or API call carries it.- **Freeze** (ADR-012 rule 11): `settings.freeze`; while on, every mutating endpoint answers`503 billing.frozen`, reads work, webhooks are stored (202) and processed in arrival order after-- unfreeze. Jobs pause.++ unfreeze. Jobs pause. The owner's `money_writes_frozen` switch keeps its own audited path++ (`/api/admin/money`); it is not configuration and no rollback may move it.++- **Owner settings (D11)** ([server/config-store.js](server/config-store.js)): the namespace++ `billing.credits` holds the Balance switch, the sandbox grants, the top-up switch, its amounts and++ fee, and the hold lease, through openvibe-shared/config. Each key keeps the environment variable it++ had as an `x-env` pin: while that variable is set its value wins and a write is `409++ config.pinned_in_env`; otherwise the owner sets it from Network's AI tab and it applies live. The++ routes are `/internal/config` (capability `billing.config.manage`, Network's service token), with the++ owner's `usr_` id in `X-OV-Subject` and `X-OV-Fresh: 1` for the danger keys (`credits`, `topup`,++ `topup_amounts_cents`, `topup_fee_bps`). Every activation writes a `staff_audit` row with the keys,++ actor and revision, never a value. With `OV_CONFIG_KEKS` set the store seals secrets and reseals old++ plaintext rows at boot; this namespace has no secret-class keys, so it reports `sealed` but exposes no++ value. `billing.vibes` holds the Vibes Pay switch and limit, the daily app spend per person and the++ `from_balance` switch (`vibes_pay` and `from_balance` are danger keys); `GET /api/me/balance`, the transfer++ and the Vibes purchase are in [docs/balance.md](docs/balance.md); `GET /api/me/activity` is the person's own++ movement list for Network's wallet.- **Outbox**: openvibe-sdk/events stores pending envelopes in `service_outbox` and relays them with Billing's service token. The legacy `outbox` table stays through the N-1 rollback window. Events include `billing.transaction.settled|reversed`, `billing.entitlement.changed`,`billing.subscription.canceled`, `billing.cashout.requested|paid|denied`, `billing.staff.action`,`billing.receipt.external`, written in the same transaction as the effect (events.event-envelope@1,@@@ -228,6 +253,7 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30| `POST /api/v1/admin/sweep`, `GET /api/v1/admin/import-holds` | `billing.ledger.admin` | renewal sweep now; unmapped import users || `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` || `GET /metrics` | direct loopback caller | Prometheus text (openvibe-shared/metrics): HTTP golden signals, process, `release_info`, and `billing_*` gauges — freeze, authority, receipts by state, oldest pending receipt, EXTERNAL receipts, outbox backlog, cashouts, the latest reconciliation. 404 to anything a proxy relayed; nginx answers 404 on the public host |++| `GET\|POST /internal/config[/:namespace[/history\|rollback]]`, `PUT\|DELETE …/secrets/:key` | `billing.config.manage` | owner settings (D11): namespace summaries with the schema, values, pins and `sealed`; apply a change (danger keys need `X-OV-Fresh: 1`); history and rollback. Network's AI tab is the only caller; nginx 404s `/internal/` on the public host |The existing capabilities and service manifest are released in openvibe-contracts (v0.8.0;`billing.staff.action` since v0.17.0). `billing.usage.record` is released in Contracts v0.85.0: input is@@@ -487,7 +513,7 @@ PowerChat purchase settling end to end after the cutover.Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.222.0--- openvibe-sdk: v0.82.0--- openvibe-shared: v4.109.2++- openvibe-contracts: v0.289.1++- openvibe-sdk: v0.84.0++- openvibe-shared: v4.110.0<!-- versions:end -->@@@ -8,9 +8,9 @@"authoritative": true,"deployed": true,"deployment": "The money ledger since the cutover of 2026-10-17 04:50-04:56 UTC (BILLING_AUTHORITY=billing in billing.env and live.env): deployed 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), a person's own Vibes (/api/me/vibes, CORS for OpenVibe's sites), /api/health, /api/ready, /release.json and /webhooks/*. PowerChat deliveries reach /webhooks/powerchat through openvibe.live's /api/powerchat/webhook (proxied by Live's vhost, OpenVibe.Live#172).",-- "contracts": "openvibe-contracts v0.222.0",-- "sdk": "openvibe-sdk v0.82.0",-- "shared": "openvibe-shared v4.109.2",++ "contracts": "openvibe-contracts v0.289.1",++ "sdk": "openvibe-sdk v0.84.0",++ "shared": "openvibe-shared v4.110.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, EXTERNAL PowerChat receipts (billing.receipt.external, announced for OpenVibe.Tips), overflow guards and serializable settlement (PostgreSQL, ADR-035). The cutover (README runbook, docs/live-cutover.md) ran on 2026-10-17: final import 0 new rows, 0 holds, 0 anomalies; reconciliation OK before and after; Live's money tables are read-only behind a database tripwire. OpenVibe.Tips and OpenVibe.VIP launched right after (VIP checkout open). Still due: a small real PowerChat purchase settling end to end after the cutover. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.","features": ["append-only double-entry journal in bits and cents, balanced per currency",@@@ -32,8 +32,15 @@"usage rating: a rate card -> the subject's promo allowance -> one usage transaction per reading, under hard budgets (BILLING_RATING_INTERVAL_MS off by default; nothing charged without a loaded card, key usage:<reading key>)","promo allowance ledger","a person's own Vibes for every site's account menu (/api/me/vibes)",++ "Balance held by a person (usd_credit: user:<usr_id>) or a project (project:<prj_id>), with personal and project holds",++ "GET /api/me/balance (and the /api/me/credits alias): the person's Balance plus every project they administer, read from Network with Billing's service token",++ "moving personal Balance to a project (atomic, idempotent, admin only) and getting Vibes with Balance at the pack value rate",++ "GET /api/me/activity: the person's own Balance and Vibes movements with plain labels, two rows for a two-currency transaction, and cursor pagination",++ "owner settings: billing.vibes (Vibes Pay, the largest charge, the daily app spend per person and from_balance)","network-wide money counts with no person in them, for products' public stats (billing.stats.read)",-- "account export and deletion (ADR-033)"++ "account export and deletion (ADR-033)",++ "owner settings: billing.credits (Balance, sandbox, top-up switch/amounts/fee and the hold lease) and billing.vibes (Vibes Pay limits, Get Vibes with Balance) through openvibe-shared/config, with env pins and live changes (D11/D12)",++ "Vibes Pay (D12): app-token charges with the app's reference as the idempotency key, the person's pay-sheet routes, the project's Vibes wallet (project_payable) with the project owner's cashout through the existing path, HMAC-SHA256 webhooks from vibes_webhook_outbox retried with backoff, sandbox Vibes gated by BILLING_VIBES_SANDBOX, and the billing.vibes limits and frozen-economy checks"],-- "updated": "2026-11-03"++ "updated": "2026-11-04"}@@@ -59,11 +59,53 @@ server {location = /api/health { proxy_pass http://127.0.0.1:4600; }# A signed-in person's own Vibes, read by the shared navbar on every OpenVibe site (server/api/me.js: a user token,-- # CORS for OpenVibe's own origins). The only other public /api path.++ # CORS for OpenVibe's own origins).location = /api/me/vibes {limit_req zone=ovbilling_console burst=20 nodelay;proxy_pass http://127.0.0.1:4600;}++ # A person's Balance: their own and the projects they administer (docs/balance.md; 404 while BILLING_CREDITS is off).++ location = /api/me/balance {++ limit_req zone=ovbilling_console burst=20 nodelay;++ proxy_pass http://127.0.0.1:4600;++ }++ # Moving personal Balance to a project the person administers (docs/balance.md).++ location = /api/me/balance/transfers {++ limit_req zone=ovbilling_console burst=10 nodelay;++ proxy_pass http://127.0.0.1:4600;++ }++ # The person's own Balance and Vibes movements for Network's wallet (docs/balance.md).++ location = /api/me/activity {++ limit_req zone=ovbilling_console burst=20 nodelay;++ proxy_pass http://127.0.0.1:4600;++ }++ # The old wire path stays an alias of /api/me/balance (and ?project_id= for one project's statement).++ location = /api/me/credits {++ limit_req zone=ovbilling_console burst=20 nodelay;++ proxy_pass http://127.0.0.1:4600;++ }++ # Starting a Balance top-up (docs/balance.md; one of the owner's amounts, behind BILLING_CREDITS_TOPUP). The same++ # public /api/me surface, with its own burst so a checkout cannot be used to flood the provider.++ location = /api/me/credits/topups {++ limit_req zone=ovbilling_console burst=5 nodelay;++ proxy_pass http://127.0.0.1:4600;++ }++ # Getting Vibes with Balance (docs/balance.md; behind billing.vibes.from_balance).++ location = /api/me/vibes/from-balance {++ limit_req zone=ovbilling_console burst=10 nodelay;++ proxy_pass http://127.0.0.1:4600;++ }++ # Vibes Pay's pay sheet charge: the app, item, price and the person's Vibes (docs/vibes-pay.md). A charge id is++ # vch_ + 26 Crockford characters; anything else stays a 404 at the catch-all below.++ location ~ ^/api/me/vibes/charges/vch_[0-9A-HJKMNP-TV-Z]{26}$ {++ limit_req zone=ovbilling_console burst=20 nodelay;++ proxy_pass http://127.0.0.1:4600;++ }++ # Pay or cancel that charge; both are POSTs with the person's own bearer token.++ location ~ ^/api/me/vibes/charges/vch_[0-9A-HJKMNP-TV-Z]{26}/(pay|cancel)$ {++ limit_req zone=ovbilling_console burst=10 nodelay;++ proxy_pass http://127.0.0.1:4600;++ }# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4600/metrics). Node also# answers 404 to any request a proxy relayed.nnew file mode 100644@@@ -0,0 +1,214 @@++# Balance: the prepaid USD balance for metered services++++OpenVibe has two currencies (AI Exchange D9, owner 2026-11-03):++++| Currency | For | Precision |++|---|---|---|++| **Vibes** | Social and consumer use: tips and bits on Live, creators, cashout, consumer apps priced in whole Vibes. | A whole Vibes bit, 100 per USD. |++| **Balance** | A topped-up USD balance that metered developer and platform services spend: the AI API first, then compute. One for one with the dollar. | nano-USD (1e-9 USD) |++++Nothing about Vibes changes. Balance is built on the same double-entry journal, in its own currency (`nano-usd`)++and its own accounts, and never goes through `chargeFor`.++++Everything is behind `BILLING_CREDITS` (off). Sandbox grants are also behind `BILLING_CREDITS_SANDBOX` (off).++++**Who holds a Balance (D12).**++- A person's Balance is `usd_credit` owned by `user:<usr_id>`.++- A project's Balance is `usd_credit` owned by `project:<prj_id>`.++- A hold with a `project_id` spends the project's Balance; a hold without one spends the person's.++- Sandbox balances follow the same split under `usd_credit_sandbox`.++++**Sandbox Balance is kept apart (D10).**++- A grant lands in the project's `usd_credit_sandbox` account (`project:<prj_id>`), never in `usd_credit`.++- Only a hold with `sandbox: true` spends it, and a real hold never sees it: granted test credit can never pay for++ real supply.++- A sandbox charge returns to the sandbox issuer, not to `platform_revenue`, and its journal transaction is marked++ test.++- The statement shows the sandbox Balance apart (`sandbox`).++++Every internal request body is checked against its `openvibe-contracts` schema first; another shape is a 422++`billing.invalid_request`, before anything is held.++++## How a request is paid++++1. **Hold.** The service (OpenVibe AI) asks for a hold of the worst case before it does anything paid, with++ `POST /internal/credits/holds` and `{ idempotency_key, subject, project_id, service, max_nano_usd, rate_version,++ request_fingerprint, sandbox? }`.++ - The account row is locked (`FOR UPDATE`), and the hold is created only if `available = balance − active holds`++ covers it. Otherwise the answer is 402 and nothing runs.++2. **Extend.** While a stream grows, the service extends the hold in steps (`/holds/:id/extend`), each one checked the++ same way.++3. **Settle, once.** At the end the service sends the four token counts, the retail card it used (version and rates),++ the supply cost, `byok` and the attempts (`/holds/:id/settle`).++ - Billing computes the charge itself: for each token class, `ceil(tokens × rate_nano_per_m / 1,000,000)`, plus the++ per-request fee.++ - It moves that amount from the account the hold locked (the person's or the project's `usd_credit`) to++ `platform_revenue` (a sandbox hold: from `usd_credit_sandbox` back to the issuer), and releases the rest of++ the hold.++ - In the same transaction it announces `billing.credit.settled@1`, for aggregates (OpenVibe.Report). The event++ carries the answering deployment, its `provider_access`, the four token counts, what was collected, the supply++ cost and the shortfall. It never carries the subject, the project or the account: a person's charges are read++ from their statement.++4. **Release.** A request that never ran releases its hold with nothing charged (`/holds/:id/release`). A hold that is++ forgotten is released by the lease sweeper.++++## The invariants, and the tests that hold them (`test/credits.test.js`)++++| Invariant | How it is kept | Test |++|---|---|---|++| **No overspend.** Holds plus charges never exceed the funded balance, under any concurrency. | Every hold, extend and settle locks the account row and re-reads `available` inside the transaction. | 100 concurrent holds on a balance that fits 7: exactly 7 succeed, the balance is untouched, the holds sum to the balance. |++| **Exactly one charge per request.** | The settle's idempotency key is unique. A replay returns the first settlement. Two concurrent settles serialize on the hold lock, and the second replays. A key reused for another hold is a 409. | settle replay: one journal transaction, the same settlement. |++| **One hold per key.** | The key is unique and re-checked after the account lock. The same key with another fingerprint is a 409. | idempotent create; 409 on a fingerprint change. |++| **Only what was used is charged.** | The settle computes the charge from usage and releases the rest. An aborted stream settles its partial usage. | an aborted stream: a partial charge, the rest released. |++| **No silent loss.** | Usage beyond the hold is taken from the free balance. What cannot be collected (never below zero) is recorded as `shortfall_nano_usd` for reconciliation. | usage beyond the hold: the free balance is taken, the shortfall recorded, the balance never below zero. |++| **Sandbox Balance never pays for real supply.** | A grant credits `usd_credit_sandbox`; only a `sandbox: true` hold draws on it, and its charge goes back to the issuer. | a real hold on granted credit is 402; a sandbox hold settles, `platform_revenue` unchanged. |++| **BYOK is never billed twice.** | `byok: true` charges 0; the provider bills the person directly. | BYOK settle: charge 0. |++| **The journal balances.** | Every movement is a balanced journal entry, in its own currency. Reconciliation sums per currency as text, so no total exceeds JavaScript's safe range. | the journal balances at the end. |++| **Rounding is once per token class per request.** | `computeCharge`. | the 4-class rounding test, with a worked example. |++++**The old amplification, gone.** `chargeFor` rounds every usage reading up to one whole Vibes bit ($0.01). One token at++$1 per billion tokens is charged a full cent there, a ten-million-fold over-charge. Balance charges it 1 nano-USD.++(test: "the old chargeFor amplification for tiny usage does not affect the subledger")++++## Reading it++++`GET /api/me/balance` (a person token, the same CORS as `/api/me/vibes`) answers:++++```json++{++ "personal": { "owner": { "type": "user", "id": "usr_…" }, "balance_nano_usd": "…", "held_nano_usd": "…", "available_nano_usd": "…" },++ "projects": [ { "owner": { "type": "project", "id": "prj_…" }, "balance_nano_usd": "…", "held_nano_usd": "…", "available_nano_usd": "…" } ]++}++```++++The `projects` are the ones the person administers: Billing asks Network with its own service token (`network.project.member.read`), and a failed read is an error, never an empty list. `GET /api/me/credits` is an alias of the same answer; with `?project_id=prj_…` it still answers that one project's statement (after Network confirms the person administers it):++- the balance, what is held and what is available;++- the last 50 charges: service, model, token counts, charge, shortfall, BYOK, rate version.++++There is never any prompt or answer: Billing never receives them.++++## Activity++++`GET /api/me/activity?limit=50&before=<transaction id>` (the same person token and CORS as `/api/me/balance`,++rate limited) is the person's own movement list for Network's wallet:++++- one row per currency part of a transaction: `{ id, at, kind, label, detail, amount }`;++- `kind` is `balance` (signed nano-USD decimal string) or `vibes` (signed whole integer);++- a transaction that moves both (Get Vibes with Balance) has two rows with the same `id`, so the client groups them;++- labels are plain words: Added money (the provider in detail), AI usage (the model and request count from the++ settlement), Got Vibes, Tipped @name (the creator's public name only), Paid <app> (Vibes Pay), Moved to <project>,++ Refund;++- only Balance (`usd_credit user:<usr_id>`) and spendable Vibes (`user_credit`) are read: never sandbox, another++ person's account, a project secret or an email.++++The answer is `{ activity: [...], next_before: <transaction id>|null }`; pass `next_before` back as `before`.++++## Moving Balance++++`POST /api/me/balance/transfers` with `{ to_project_id, amount_nano_usd, idempotency_key }` moves money from the++person's Balance to a project they administer (admin or owner, unarchived). One serializable transaction locks both++accounts; the source spends available Balance only (never money under an active hold), so it can never go below zero.++A replay with the same key returns the same transfer and moves nothing; a different request under that key is a 409.++++`POST /api/me/vibes/from-balance` with `{ vibes, idempotency_key }` buys Vibes at the pack value rate:++++ `price_nano_usd = ceil(vibes × 1,000,000,000 / bits_per_usd)`++++with `bits_per_usd` = `BILLING_BITS_PER_USD` (default 100). The journal moves the exact nano-USD from the person's++`usd_credit` to `platform_revenue:nano-usd` and mints the Vibes from `fx_conversion:vibes-bits` into the person's++Vibes account exactly as a pack purchase does. It is behind `billing.vibes.from_balance` (danger, off) and++`BILLING_CREDITS`.++++## Top-up: a person adds money to a Balance++++Behind `BILLING_CREDITS_TOPUP` (off, and only meaningful with `BILLING_CREDITS=1`). The owner sets every amount and++the fee; the code has no default amount and no default fee. **One function reads the terms**:++`topupTerms(ctx)` in `server/ops/credits.js`. Since D11 they are the `billing.credits` namespace of Billing's++owner settings (`server/config-store.js`), set from Network's AI tab and applied live:++++- `topup_amounts_cents` (env pin `BILLING_CREDITS_TOPUP_AMOUNTS_CENTS`): whole USD cents. These are the only++ amounts a person may buy. The env pin still takes a comma-separated list; the owner writes a JSON array.++- `topup_fee_bps` (env pin `BILLING_CREDITS_TOPUP_FEE_BPS`): the fee in integer basis points, charged on top of++ the amount. `0` is a real, configured fee (free); leaving it unset is not.++++The same namespace holds `credits` (`BILLING_CREDITS`), `sandbox` (`BILLING_CREDITS_SANDBOX`), `topup`++(`BILLING_CREDITS_TOPUP`) and `hold_lease_seconds` (`BILLING_INFERENCE_LEASE_SECONDS`). While an env pin is set++it wins and the owner's write is `409 config.pinned_in_env`; nothing changes until the owner sets a value.++`money_writes_frozen` is **not** in the namespace: it keeps its own audited switch at `/api/admin/money`.++++Both are required. With either unset or malformed, `POST /api/me/credits/topups` refuses with++`503 billing.topup_unconfigured` before creating an intent or opening a provider checkout.++++**Every provider carries a Balance top-up intent** through the same multi-purpose pipeline as a Vibes purchase++(`server/providers/index.js` receive → store → process → `applyPlan`):++++| Provider | How the person pays | The adapter maps its verified delivery to |++|---|---|---|++| Stripe | hosted checkout (`checkout.session.completed`) | the shared `credits_topup` plan |++| PayPal | hosted checkout, then capture (`PAYMENT.CAPTURE.COMPLETED`) | the shared `credits_topup` plan |++| CCBill | hosted FlexForm (`NewSaleSuccess`) | the shared `credits_topup` plan |++| NOWPayments | hosted invoice (`payment_status` finished/confirmed/sending) | the shared `credits_topup` plan |++| PowerChat | a site-account tip carrying `app_ref=pccredit:<intent>` (`donation.completed`) | the shared `credits_topup` plan |++++There is one plan effect (`credits_topup`) and one settle function: adapters only map their own verified delivery to++it, exactly as they map to `purchase`. PowerChat has no hosted checkout URL; Billing returns the intent's++`checkout_ref` (`pccredit:<intent>`) for the product to put in the tip's `app_ref`. No provider is silently skipped:++an adapter that is not enabled is refused at start (`409 billing.provider_disabled`), and one without a mapping is++listed here before it is offered.++++1. **Start.** A signed-in person (`Authorization: Bearer <user token>`) calls++ `POST /api/me/credits/topups` with `{ provider, project_id?, amount_cents }`. A project is optional: without one++the top-up adds to the person's own Balance.++ - The amount must be one the owner listed, otherwise `422 billing.amount_not_allowed`.++ - The fee is `ceil(amount_cents × fee_bps / 10000)` cents, rounded once; the provider charges amount + fee.++ - Billing creates a `payment_intents` row with `kind='credits_topup'` (the project, amount and fee in its++ metadata) and opens the provider's existing checkout with the same `intent_id` metadata as a purchase.++2. **Paid.** The provider's paid webhook for that intent is mapped to `credits_topup` and settles through the one++ shared plan. It credits the intent's account — `usd_credit:user:<usr_id>` without a project, or++ `usd_credit:project:<prj_id>` with one — with the intent's amount at `1 cent = 10,000,000 nano-USD`, and books++ the fee (and any overpayment) to `platform_revenue`. The journal, all in `nano-usd`:++ - `provider_clearing:<provider>` −(what was paid);++ - `usd_credit:user:<usr_id>` (or `usd_credit:project:<prj_id>`) +amount;++ - `platform_revenue` +fee.++3. **Once.** The provider event is stored once by its event id, and the settled transaction's receipt reference is++ unique. A redelivery returns `duplicate_receipt`; a second event for the same payment credits nothing further. The++ intent is marked `settled` in the same transaction.++4. **Statement.** `GET /api/me/credits?project_id=prj_…` lists `topups` beside `charges`: amount, fee, what was paid,++ the reversal and any debt.++5. **Refund or chargeback** (the provider's receipt reversal, `ops/reversals.js`):++ - the credited part the refund covers is debited from `usd_credit` by what the account still holds, never below++ zero;++ - the part already spent is a debt of the person: a `credit_topup_debts` row, the reversal transaction is flagged++ for review, and reconciliation lists it under `warnings.credit_debts` and totals it as++ `credit_topup_debt_nano_usd`. It is never written off silently;++ - the fee portion is taken back from `platform_revenue`; the reversal's clearing entry is the refunded cents in++ nano-USD, so the journal stays balanced.++6. **Off.** `BILLING_CREDITS_TOPUP=0` answers `404 billing.topup_disabled`, creates no intent and opens no checkout. A++ paid event that arrives while the feature is off or unconfigured is recorded for review and moves no money.++++## Launch runbook (each step needs the owner's go-ahead)++++1. **Canary.** In a staging copy: `BILLING_CREDITS=1` and `BILLING_CREDITS_SANDBOX=1`.++ - Fund a staff account with a test top-up booked straight through the ledger (a one-off script, the way++ `fund()` in `test/credits.test.js` books one; sandbox grants cannot pay real holds). Then run OpenVibe AI with++ `AI_CREDITS=1` against an own-deployment model, and settle about a hundred requests.++ - The reconciliation job must report zero problems (`journal.zero_sum`, no negative `usd_credit`, no unbalanced++ transaction).++2. **Production, dark.**++ - Deploy with `BILLING_CREDITS=0`. Migrations `0011` (the subledger) and `0012` (the top-up intent kind and its++ debt table) are expand-only: they widen checks (`NOT VALID`, then validated) and add new tables.++ - Turn the flag on for staff projects only. Funding is still off, so real balances are zero: staff try the flow++ with sandbox tokens on sandbox credit, which pays for nothing real.++3. **Funding.** Add the checkout top-up into `usd_credit`; this is a separate change, and the owner sets the prices++ and terms in `billing.credits` from Network's AI tab (or through the old env pins++ `BILLING_CREDITS_TOPUP`, `BILLING_CREDITS_TOPUP_AMOUNTS_CENTS` and `BILLING_CREDITS_TOPUP_FEE_BPS` while the++ deployment still sets them; an env pin wins and the owner's write is 409). Turn the top-up on only after the++ owner sets the amounts and the fee (no default exists), and confirm `POST /api/me/credits/topups` is reachable++ through the vhost. Reconcile before and after the first real payment.++4. **Kill switch.** `BILLING_CREDITS=0` refuses every hold (503). OpenVibe AI then refuses paid requests with a clear++ error, while BYOK, the person's own machine and sandbox keep working. `money_writes_frozen` freezes the whole++ economy, Balance included.++5. **Rollback.** Turn the flag off. The tables stay, unused: N-1 code never reads them.++6. **Reconcile daily.**++ - The reconciliation job covers `nano-usd` like every other currency.++ - Shortfalls are listed for review, never written off silently.nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "billing.config.manage",++ "version": "1.0",++ "owner": "billing",++ "status": "active",++ "visibility": "internal",++ "description": "Manage the billing.credits namespace of OpenVibe.Billing through openvibe-shared/config (D11): GET /internal/config lists the namespace summaries, GET /internal/config/:namespace returns one as common.config-namespace@1, POST applies common.config-change@1, and PUT /internal/config/:namespace/secrets/:key sets a secret with common.config-secret-put@1 (write-only; the answer is the marker) while DELETE removes it. Only Network's admin proxy, acting for the owner, holds this capability; it is never grantable to apps. Each request carries the owner as actor, and the service audits every write with that actor and key, never a secret value.",++ "permissions": [++ "config:manage"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "config-manage",++ "events": [],++ "implementedBy": [++ "GET /internal/config",++ "GET|POST /internal/config/:namespace",++ "PUT|DELETE /internal/config/:namespace/secrets/:key"++ ],++ "inputSchema": "common.config-change@1",++ "outputSchema": "common.config-namespace@1"++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "billing.credit.hold.create",++ "version": "1.0",++ "owner": "billing",++ "status": "active",++ "visibility": "internal",++ "description": "Create an inference credit hold (D2): POST /internal/credits/holds. Service-authenticated; reserves up to max_nano_usd against a (subject, project_id) account.",++ "inputSchema": "billing.credit-hold-create-request@1",++ "outputSchema": "billing.credit-hold-create-result@1",++ "permissions": [++ "billing:credit:hold"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "billing-credit",++ "events": [],++ "implementedBy": [++ "POST /internal/credits/holds"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "billing.credit.hold.extend",++ "version": "1.0",++ "owner": "billing",++ "status": "active",++ "visibility": "internal",++ "description": "Extend an active inference hold (D2): POST /internal/credits/holds/:id/extend. Grows the held amount mid-stream; returns 402 billing.insufficient_funds if the balance is short.",++ "inputSchema": "billing.credit-hold-extend-request@1",++ "outputSchema": "billing.credit-hold-create-result@1",++ "permissions": [++ "billing:credit:hold"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "billing-credit",++ "events": [],++ "implementedBy": [++ "POST /internal/credits/holds/:id/extend"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "billing.credit.hold.release",++ "version": "1.0",++ "owner": "billing",++ "status": "active",++ "visibility": "internal",++ "description": "Release a credit hold (D2): POST /internal/credits/holds/:id/release. Releases the remaining held amount with nothing charged.",++ "inputSchema": "common.no-body@1",++ "outputSchema": "billing.credit-hold-release-result@1",++ "permissions": [++ "billing:credit:hold"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "billing-credit",++ "events": [],++ "implementedBy": [++ "POST /internal/credits/holds/:id/release"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "billing.credit.hold.settle",++ "version": "1.0",++ "owner": "billing",++ "status": "active",++ "visibility": "internal",++ "description": "Settle a credit hold (D2): POST /internal/credits/holds/:id/settle. Records measured usage and the immutable retail rate version, charges the project account and releases the unused hold. Idempotent: replays return the same settlement.",++ "inputSchema": "billing.credit-hold-settle-request@1",++ "outputSchema": "billing.credit-hold-settle-result@1",++ "permissions": [++ "billing:credit:settle"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "billing-credit",++ "events": [],++ "implementedBy": [++ "POST /internal/credits/holds/:id/settle"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "billing.credit.sandbox.grant",++ "version": "1.0",++ "owner": "billing",++ "status": "active",++ "visibility": "internal",++ "description": "Grant synthetic credit to a (subject, project) for tests and staging (D9): POST /internal/credits/sandbox/grants. Refused unless BILLING_CREDITS_SANDBOX=1; a test transaction, never money.",++ "inputSchema": "billing.credit-sandbox-grant-request@1",++ "outputSchema": "billing.credit-sandbox-grant-result@1",++ "permissions": [++ "billing:credit:sandbox"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "billing-credit",++ "events": [],++ "implementedBy": [++ "POST /internal/credits/sandbox/grants"++ ]++}nnew file mode 100644@@@ -0,0 +1,27 @@++{++ "id": "billing.vibes.charge",++ "version": "1.0",++ "owner": "billing",++ "status": "active",++ "visibility": "public",++ "sensitive": true,++ "description": "Sell something for Vibes as a developer app (Vibes Pay, D12). POST /api/v1/vibes/charges creates a charge from the app's token (billing.vibes-charge-create-request@1; idempotent on the app's reference) and answers billing.vibes-charge@1 with a pay_url; GET /api/v1/vibes/charges/:id reads it; POST /api/v1/vibes/charges/:id/refund returns the Vibes from the project's Vibes wallet to the person (billing.vibes-charge-refund-request@1). The person pays on OpenVibe's pay sheet; Billing moves the Vibes atomically and once to the app's project Vibes wallet, then POSTs a signed webhook (HMAC-SHA256 with the app's webhook secret, retried with backoff) whose body is billing.vibes.charge.paid@1 or billing.vibes.charge.refunded@1 — no person id or email, only an opaque payer_ref stable per app. A sandbox app charges sandbox Vibes only (test grants, worth nothing). The owner's billing.vibes settings cap the amount, the daily per-person spend and whether Vibes Pay is on.",++ "permissions": [++ "billing:vibes:charge"++ ],++ "resourceConstraints": [++ "project"++ ],++ "quotaClass": "billing-vibes-write",++ "events": [++ "billing.vibes.charge.paid",++ "billing.vibes.charge.refunded"++ ],++ "implementedBy": [++ "POST /api/v1/vibes/charges",++ "GET /api/v1/vibes/charges/:id",++ "POST /api/v1/vibes/charges/:id/refund"++ ],++ "inputSchema": "billing.vibes-charge-create-request@1",++ "outputSchema": "billing.vibes-charge@1"++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "billing.vibes.read",++ "version": "1.0",++ "owner": "billing",++ "status": "active",++ "visibility": "public",++ "description": "Read a person's own Vibes as an app the person approved (D12): GET /api/v1/vibes/me with a person-delegated app token answers billing.vibes-wallet@1 — the Vibes to spend, the creator payable that can be cashed out (never into Balance) and today's bits_per_usd. The app sees only the person who approved it, never another person's Vibes.",++ "permissions": [++ "billing:vibes:read"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "billing-vibes-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/vibes/me"++ ],++ "inputSchema": "common.no-body@1",++ "outputSchema": "billing.vibes-wallet@1"++}@@@ -22,7 +22,11 @@"billing.subscription.manage","billing.entitlement.check","billing.ledger.admin",-- "billing.usage.record"++ "billing.usage.record",++ "billing.credit.hold",++ "billing.credit.sandbox.grant",++ "billing.vibes.charge",++ "billing.vibes.read"],"eventsProduced": ["billing.transaction.settled",@@@ -33,11 +37,14 @@"billing.cashout.paid","billing.cashout.denied","billing.staff.action",-- "billing.receipt.external"++ "billing.receipt.external",++ "billing.credit.settled",++ "billing.vibes.charge.paid",++ "billing.vibes.charge.refunded"],"eventsConsumed": [],"namespacesOwned": ["billing.*"],-- "notes": "The isolated money ledger (ADR-012): balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts, and a staff console (Network SSO, staff only) for payouts, review, reconciliation and the freeze. Proposed manifest: adds billing.staff.action (internal; released in contracts v0.17.0) and billing.receipt.external (internal: an EXTERNAL tip on a streamer's own PowerChat — subject provider_receipt <provider>:<payment id>; payload streamer SubjectRef, amount_cents, currency usd-cents, value_bits, donor_name (null when anonymous), anonymous, message, provider, provider_event_id, delivery_id, receipt_ref, receiving_account, app_ref, app_purpose, occurred_at, test, rates — sent only while BILLING_AUTHORITY=billing, consumed by OpenVibe.Tips) to the released 0.2.0."++ "notes": "The isolated money ledger (ADR-012): balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts, and a staff console (Network SSO, staff only) for payouts, review, reconciliation and the freeze. Proposed manifest: adds billing.staff.action (internal; released in contracts v0.17.0) and billing.receipt.external (internal: an EXTERNAL tip on a streamer's own PowerChat \u2014 subject provider_receipt <provider>:<payment id>; payload streamer SubjectRef, amount_cents, currency usd-cents, value_bits, donor_name (null when anonymous), anonymous, message, provider, provider_event_id, delivery_id, receipt_ref, receiving_account, app_ref, app_purpose, occurred_at, test, rates \u2014 sent only while BILLING_AUTHORITY=billing, consumed by OpenVibe.Tips) to the released 0.2.0."}nnew file mode 100644@@@ -0,0 +1,145 @@++# Vibes Pay: sell things for Vibes++++Vibes Pay (D12 step 4) lets any OpenVibe app sell an item for whole Vibes, on its own site or inside++OpenVibe. Billing moves the Vibes once from the person to the app's project; the app learns by a signed++webhook and by `GET /api/v1/vibes/charges/:id`.++++Money is whole Vibes (integers). A charge is never priced in Balance, cents or nano-USD.++++## The app's routes++++An app authenticates with its own Network app token (capability `billing.vibes.charge`), never with a++service token. The token names the app, its project and its environment (`production` or `sandbox`).++++```++POST /api/v1/vibes/charges++{ "amount": 50, "description": "Sticker pack: Neon cats", "reference": "order_123",++ "return_url": "https://myapp.example/thanks" }++→ { "id": "vch_…", "pay_url": "https://openvibe.network/pay/vch_…", "status": "pending",++ "expires_at": "…", … } 201 (200 on a replay)++++GET /api/v1/vibes/charges/:id the charge, as billing.vibes-charge@1 (the app's own only)++POST /api/v1/vibes/charges/:id/refund { "reason": "…" } → the refunded charge; once++```++++`reference` is the app's idempotency key: the same reference and body returns the same charge, a different++body is `409 billing.reference_reused`. A charge expires 30 minutes after creation++(`BILLING_VIBES_CHARGE_TTL_SECONDS`, 1–86 400).++++The app's webhook settings are its own, in Billing's D11 store (a `billing.app.<app_id>` namespace; the++secret is a secret-class value sealed with the service's KEK when one is configured):++++```++GET /api/v1/vibes/webhook → { url, secret_set, secret_hint, sealed, … }++PUT /api/v1/vibes/webhook { url, secret?, rotate_secret? }++ → the settings; a generated secret is returned once++DELETE /api/v1/vibes/webhook → { configured: false }++```++++The webhook URL is https in production and must name a public address; deliveries are checked against the++egress guard and do not follow redirects.++++## The person's routes++++Signed in with their own account token; the same CORS and `Cache-Control: private, no-store` as++`/api/me/vibes`.++++```++GET /api/me/vibes/charges/:id the pay sheet: charge, app { id, name, icon }, vibes_credit,++ shortfall_vibes, can_pay, expired, daily_remaining_vibes++POST /api/me/vibes/charges/:id/pay { idempotency_key } → { charge, vibes_credit } (200)++POST /api/me/vibes/charges/:id/cancel → { charge }++```++++The person's Vibes never go below zero. Short of Vibes is `402 billing.insufficient_vibes` with++`available` and `required`; the sheet offers "Get Vibes". A paid charge cannot be paid again: the same++person gets a replay (and one journal transaction), another person gets `409 billing.charge_not_pending`.++An expired charge is `409 billing.charge_expired`. A double refund returns the same refunded charge.++++The app's name and icon come from Network's app registry (`GET /internal/projects/:project_id`, the++`dev_apps` entry). When Billing's service token cannot read that project view, the charge still answers:++the app id is shown and Network's pay sheet fills the name from its own `dev_apps` row.++++## The project's wallet and cashout++++Paid Vibes land in **`project_payable`, owner `project:<prj_id>`** — a kind of its own, not++`creator_payable`, because its owner is a project: a personal cashout, a tip refund or an account export++must never touch a project's money.++++The project's owner cashes it out through the existing cashout path:++`POST /api/v1/cashouts { subject, amount, payout_method, project_id }` with `billing.cashout.request`.++Billing checks with Network that the person is the project's **owner**, spends `project_payable`, and the++normal escrow, approve and deny rules apply. A deny returns the Vibes to the owner's `creator_payable`++(payouts are for the owner).++++## Webhooks++++Each paid or refunded charge queues one row in `vibes_webhook_outbox` in the same transaction as the++money. The delivery is:++++```++POST <the app's webhook url>++Content-Type: application/json++X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">++X-OpenVibe-Timestamp: <unix seconds>++X-OpenVibe-Delivery-Id: <stable across retries>++X-OpenVibe-Delivery-Attempt: <1, 2, …>++X-OpenVibe-Event: billing.vibes.charge.paid | billing.vibes.charge.refunded++```++++The body is `billing.vibes.charge.paid@1` or `billing.vibes.charge.refunded@1`. It carries the charge++fields, `status`, the whole `amount`, `sandbox` and an opaque `payer_ref` that is random and stable per++(app, person). It never carries a person id, username or email. A 2xx is delivered; anything else is++retried after 15 s, 30 s, 1 min, 2 min, 5 min, 15 min, 1 h and 6 h, then abandoned. Every attempt signs++again with a fresh `t`; `openvibe-sdk` v0.84.0 verifies the header with++`webhooks.verify(rawBody, signature, secret)` against the raw body and the ±300 s window.++++The same payload is published to OpenVibe.Events as `billing.vibes.charge.paid` / `.refunded`, so++first-party products can consume it without a webhook.++++## Sandbox++++A sandbox app (`env: sandbox` in its app token) charges **sandbox Vibes only**, worth nothing. Its test++grant route mints them for a person for that app:++++```++POST /api/v1/vibes/sandbox/grants { "subject": { "type": "user", "id": "usr_…" }, "amount": 500 }++```++++Sandbox payments move `vibes_credit_sandbox` → `project_payable_sandbox`, and their journal transaction++is marked test. They never read or write a real Vibes account, never count toward the real daily limit,++and are available only while `BILLING_VIBES_SANDBOX=1`. A sandbox app cannot read real Vibes through++`GET /api/v1/vibes/me`.++++For a sandbox app to get an app token for this audience, Network must list `openvibe.billing` in++`DEV_SANDBOX_AUDIENCES` (a Network production setting, the same requirement AI's sandbox has). Its++`billing.vibes.charge` grant is approved inside the project's allowance as usual.++++## Limits and the frozen economy++++The owner sets these in the `billing.vibes` namespace (D11; env pins win while set):++++| Key | What it does |++|---|---|++| `vibes_pay` | On/off for real app charges. Off is `403 billing.vibes_pay_disabled`. |++| `max_charge_vibes` | The largest single real charge. 0 closes new charges. |++| `daily_app_spend_vibes_per_person` | The most one person may pay in apps in a day (paid charges in the last 24 h). 0 closes spending. |++++A refusal is `403 billing.vibes_limit_exceeded` with the limit and the amount in `details`. Sandbox charges++have no owner limit; they move test Vibes only. Every write (create, pay, refund, cancel, sandbox grant,++webhook setting) is refused with `503 billing.frozen` while the economy is frozen; reads keep working.++++Real payments also require `BILLING_AUTHORITY=billing`: while Live is the authority, a real pay or refund++answers `409 billing.not_authoritative`, because Billing's Vibes numbers are a shadow then.++++## Environment++++- `BILLING_VIBES_PAY`, `BILLING_VIBES_MAX_CHARGE`, `BILLING_VIBES_DAILY_APP_SPEND` — the pre-D11 names of the++ `billing.vibes` keys (still env pins while set).++- `BILLING_VIBES_SANDBOX=1` — sandbox test grants and charges.++- `BILLING_VIBES_CHARGE_TTL_SECONDS` — charge lifetime (default 1800; 1–86 400).++- `BILLING_VIBES_PAY_BASE_URL` — the pay-sheet origin (default `https://openvibe.network`).++- `BILLING_VIBES_WEBHOOK_INTERVAL_MS` — the delivery job interval (default 5000; 0 disables the timer; tests++ call `deliverDue` directly).++- `BILLING_VIBES_ALLOW_PRIVATE_WEBHOOKS=1` — development only: allow a private webhook address. Production++ always applies the egress guard.nnew file mode 100644@@@ -0,0 +1,83 @@++-- phase: expand++-- Credits: the prepaid USD balance for metered services (AI Exchange D1, D2): per-(subject, project_id) nano-USD accounts,++-- reservation holds, final settlements and sandbox grants. Integer nano-USD (1e-9 USD) stored as BIGINT,++-- computed with BigInt arithmetic in JS. Nothing here touches chargeFor, Vibes bits, creator payables,++-- cashout or promo. The subledger is off until BILLING_CREDITS=1; sandbox grants need++-- BILLING_CREDITS_SANDBOX=1.++++-- Widen the existing account-kind and currency enumerations. The (kind, owner_subject, currency) unique++-- key stays; inference accounts use kind='usd_credit', currency='nano-usd' and owner_subject++-- '<subject>:<project_id>' so the ledger remains balanced without a second journal.++ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;++ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',++ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve', 'usd_credit', 'usd_credit_sandbox')) NOT VALID;++ALTER TABLE accounts VALIDATE CONSTRAINT accounts_kind_check;++ALTER TABLE accounts DROP CONSTRAINT accounts_currency_check;++ALTER TABLE accounts ADD CONSTRAINT accounts_currency_check CHECK (currency IN ('vibes-bits', 'usd-cents', 'nano-usd')) NOT VALID;++ALTER TABLE accounts VALIDATE CONSTRAINT accounts_currency_check;++++-- Inference charges are a distinct transaction type. They never go through 'usage' or chargeFor.++ALTER TABLE transactions DROP CONSTRAINT transactions_type_check;++ALTER TABLE transactions ADD CONSTRAINT transactions_type_check CHECK (type IN ('purchase', 'donation', 'subscription', 'subscription_share',++ 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage', 'credit')) NOT VALID;++ALTER TABLE transactions VALIDATE CONSTRAINT transactions_type_check;++++-- Holds: an active reservation against an inference account. held_nano_usd reduces the available balance++-- while the hold is active. The hold is created, extended, settled or released exactly once.++CREATE TABLE credit_holds (++ id text COLLATE "C" PRIMARY KEY,++ idempotency_key text COLLATE "C" NOT NULL,++ subject text COLLATE "C" NOT NULL,++ project_id text COLLATE "C" NOT NULL,++ credential_id text COLLATE "C",++ service text COLLATE "C" NOT NULL DEFAULT 'ai' CHECK (service ~ '^[a-z][a-z0-9-]{0,39}$'), -- which metered service spends it (ai, run, ...)++ account_id bigint NOT NULL REFERENCES accounts (id),++ held_nano_usd bigint NOT NULL CHECK (held_nano_usd >= 0),++ max_nano_usd bigint NOT NULL CHECK (max_nano_usd >= 0),++ rate_version text COLLATE "C" NOT NULL,++ request_fingerprint text COLLATE "C" NOT NULL,++ lease_expires_at timestamptz NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'extended', 'settled', 'released', 'expired')),++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL,++ UNIQUE (idempotency_key)++);++CREATE INDEX credit_holds_account_active ON credit_holds (account_id, status) WHERE status IN ('active', 'extended');++CREATE INDEX credit_holds_lease ON credit_holds (lease_expires_at) WHERE status IN ('active', 'extended');++++-- Settlements: the final charge for one hold. The retail rate version and usage are kept exactly as++-- supplied so the charge is reproducible. supply_cost_nano_usd is recorded for margin analytics; it is++-- not a ledger movement until a supplier is actually paid.++CREATE TABLE credit_settlements (++ id text COLLATE "C" PRIMARY KEY,++ hold_id text COLLATE "C" NOT NULL UNIQUE REFERENCES credit_holds (id),++ idempotency_key text COLLATE "C" NOT NULL,++ subject text COLLATE "C" NOT NULL,++ project_id text COLLATE "C" NOT NULL,++ account_id bigint NOT NULL REFERENCES accounts (id),++ charge_nano_usd bigint NOT NULL CHECK (charge_nano_usd >= 0),++ released_nano_usd bigint NOT NULL CHECK (released_nano_usd >= 0),++ shortfall_nano_usd bigint NOT NULL DEFAULT 0 CHECK (shortfall_nano_usd >= 0), -- usage beyond hold + balance: owed, not collected++ journal_id text COLLATE "C" NOT NULL REFERENCES transactions (id),++ usage jsonb NOT NULL,++ retail jsonb NOT NULL,++ supply_cost_nano_usd bigint NOT NULL CHECK (supply_cost_nano_usd >= 0),++ byok boolean NOT NULL DEFAULT false,++ attempts jsonb NOT NULL DEFAULT '[]',++ created_at timestamptz NOT NULL,++ UNIQUE (idempotency_key)++);++CREATE INDEX credit_settlements_account ON credit_settlements (account_id);++++-- Sandbox grants: synthetic inference credit, available only when BILLING_CREDITS_SANDBOX=1.++-- Each grant is one inference transaction usd_credit_sandbox -> usd_credit, marked test.++CREATE TABLE credit_sandbox_grants (++ id text COLLATE "C" PRIMARY KEY,++ subject text COLLATE "C" NOT NULL,++ project_id text COLLATE "C" NOT NULL,++ account_id bigint NOT NULL REFERENCES accounts (id),++ amount_nano_usd bigint NOT NULL CHECK (amount_nano_usd > 0),++ journal_id text COLLATE "C" NOT NULL REFERENCES transactions (id),++ reason text COLLATE "C" NOT NULL DEFAULT '',++ created_at timestamptz NOT NULL++);nnew file mode 100644@@@ -0,0 +1,34 @@++-- phase: expand++-- Credits top-up (D9): a person pays through the existing checkout provider, the paid webhook credits their++-- (subject, project_id) usd_credit account with the allowed amount in nano-USD, and the fee the owner set goes to++-- platform_revenue. Everything is behind BILLING_CREDITS_TOPUP and the owner's amounts/fee configuration.++--++-- A refund or chargeback debits the account by what remains (never below zero). The part already spent is a debt of++-- the person, never written off: one row here per reversal, listed by reconciliation. The debt also has its own++-- ledger account kind so the reversal transaction balances (credit_topup_debt: negative = owed to the platform).++++ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;++ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',++ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve', 'usd_credit', 'usd_credit_sandbox',++ 'credit_topup_debt')) NOT VALID;++ALTER TABLE accounts VALIDATE CONSTRAINT accounts_kind_check;++++-- A top-up is its own checkout intent kind: it is created by the person's Credits route, never by /api/v1, and its++-- paid webhook credits usd_credit instead of Vibes.++ALTER TABLE payment_intents DROP CONSTRAINT payment_intents_kind_check;++ALTER TABLE payment_intents ADD CONSTRAINT payment_intents_kind_check CHECK (kind IN ('purchase', 'subscription', 'credits_topup')) NOT VALID;++ALTER TABLE payment_intents VALIDATE CONSTRAINT payment_intents_kind_check;++++CREATE TABLE credit_topup_debts (++ id text COLLATE "C" PRIMARY KEY,++ topup_txn_id text COLLATE "C" NOT NULL REFERENCES transactions (id),++ transaction_id text COLLATE "C" NOT NULL UNIQUE REFERENCES transactions (id), -- the refund/chargeback that left the debt++ reversal_ref text COLLATE "C" NOT NULL,++ subject text COLLATE "C" NOT NULL,++ project_id text COLLATE "C" NOT NULL,++ provider text COLLATE "C" NOT NULL,++ amount_nano_usd bigint NOT NULL CHECK (amount_nano_usd > 0), -- the spent part of the top-up that was refunded++ reason text COLLATE "C" NOT NULL DEFAULT '',++ created_at timestamptz NOT NULL++);++CREATE INDEX credit_topup_debts_subject ON credit_topup_debts (subject, project_id);nnew file mode 100644@@@ -0,0 +1,32 @@++-- phase: expand++-- D11 owner settings. The revisioned-configuration journal of openvibe-shared/config on PostgreSQL++-- (configSchema() in that package's config-pg.js): Billing's namespace billing.credits keeps the Credits++-- switches, the top-up terms and the hold lease, with history and rollback. The runtime role creates no++-- tables, so a service's migrations own them (Network's 0006_config.sql is the pattern).++++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL,++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL,++ created_at text COLLATE "C" NOT NULL++);nnew file mode 100644@@@ -0,0 +1,53 @@++-- phase: expand++-- D12: Balance and Vibes.++--++-- Balance accounts are usd_credit owned by user:<usr_id> (a person) or project:<prj_id> (a project); sandbox++-- Balance follows the same split under usd_credit_sandbox. The old '<subject>:<project>' owner format is gone++-- (nothing was in production), so a personal hold has no project_id and a project hold spends the project's++-- account. A hold's subject may now be an app acting for a project, so its type is stored beside its id.++++ALTER TABLE credit_holds ADD COLUMN subject_type text COLLATE "C" NOT NULL DEFAULT 'user'++ CHECK (subject_type IN ('user', 'app'));++ALTER TABLE credit_holds ALTER COLUMN project_id DROP NOT NULL;++++ALTER TABLE credit_settlements ADD COLUMN subject_type text COLLATE "C" NOT NULL DEFAULT 'user'++ CHECK (subject_type IN ('user', 'app'));++ALTER TABLE credit_settlements ALTER COLUMN project_id DROP NOT NULL;++++ALTER TABLE credit_sandbox_grants ALTER COLUMN project_id DROP NOT NULL;++ALTER TABLE credit_topup_debts ALTER COLUMN project_id DROP NOT NULL;++++-- A move from a person's Balance to a project's Balance: one balanced journal transaction, recorded here so the++-- idempotency key names one transfer and a replay can answer with the same result.++CREATE TABLE balance_transfers (++ id text COLLATE "C" PRIMARY KEY,++ idempotency_key text COLLATE "C" NOT NULL UNIQUE,++ subject text COLLATE "C" NOT NULL,++ to_project_id text COLLATE "C" NOT NULL,++ amount_nano_usd bigint NOT NULL CHECK (amount_nano_usd > 0),++ journal_id text COLLATE "C" NOT NULL REFERENCES transactions (id),++ created_at timestamptz NOT NULL++);++CREATE INDEX balance_transfers_subject ON balance_transfers (subject, created_at);++CREATE INDEX balance_transfers_project ON balance_transfers (to_project_id, created_at);++++-- Buying Vibes with Balance: the USD moves to platform_revenue in nano-USD and the Vibes are minted into the person's++-- Vibes account exactly as a pack purchase mints them. One row per purchase so the idempotency key is exact.++CREATE TABLE vibes_from_balance (++ id text COLLATE "C" PRIMARY KEY,++ idempotency_key text COLLATE "C" NOT NULL UNIQUE,++ subject text COLLATE "C" NOT NULL,++ vibes bigint NOT NULL CHECK (vibes > 0),++ price_nano_usd bigint NOT NULL CHECK (price_nano_usd > 0),++ bits_per_usd integer NOT NULL CHECK (bits_per_usd > 0),++ journal_id text COLLATE "C" NOT NULL REFERENCES transactions (id),++ created_at timestamptz NOT NULL++);++CREATE INDEX vibes_from_balance_subject ON vibes_from_balance (subject, created_at);++++-- A Balance transfer is its own transaction type; it is not a provider receipt and never goes through chargeFor.++ALTER TABLE transactions DROP CONSTRAINT transactions_type_check;++ALTER TABLE transactions ADD CONSTRAINT transactions_type_check CHECK (type IN ('purchase', 'donation', 'subscription', 'subscription_share',++ 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage', 'credit',++ 'balance_transfer')) NOT VALID;++ALTER TABLE transactions VALIDATE CONSTRAINT transactions_type_check;nnew file mode 100644@@@ -0,0 +1,78 @@++-- phase: expand++-- Vibes Pay (D12 step 4): a developer app creates a charge, a person pays it on OpenVibe's pay sheet, and the++-- Vibes move once from the person to the app's project. Amounts are whole Vibes (bigint, never a fraction).++--++-- vibes_charges one row per charge; (app_id, reference) is the app's idempotency key++-- vibes_payers the opaque payer_ref of a person, stable per app (never a person id in a webhook)++-- vibes_webhook_outbox durable signed deliveries, retried with backoff++--++-- The project's Vibes wallet is a new account kind (project_payable), and sandbox pay uses its own kinds and++-- test transactions, so a sandbox charge can never touch real Vibes. A project cashout spends project_payable++-- through the existing cashout path (POST /api/v1/cashouts with project_id), checked against Network's owner role.++++ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;++ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',++ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve', 'usd_credit', 'usd_credit_sandbox',++ 'credit_topup_debt', 'project_payable', 'vibes_credit_sandbox', 'project_payable_sandbox', 'vibes_sandbox')) NOT VALID;++ALTER TABLE accounts VALIDATE CONSTRAINT accounts_kind_check;++++ALTER TABLE transactions DROP CONSTRAINT transactions_type_check;++ALTER TABLE transactions ADD CONSTRAINT transactions_type_check CHECK (type IN ('purchase', 'donation', 'subscription', 'subscription_share',++ 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage', 'credit',++ 'balance_transfer', 'vibes_charge', 'vibes_refund', 'vibes_sandbox_grant')) NOT VALID;++ALTER TABLE transactions VALIDATE CONSTRAINT transactions_type_check;++++-- A project's received Vibes are cashed out by its owner; record which project a cashout belongs to.++ALTER TABLE cashouts ADD COLUMN project_id text COLLATE "C";++++CREATE TABLE vibes_charges (++ id text COLLATE "C" PRIMARY KEY,++ app_id text COLLATE "C" NOT NULL,++ project_id text COLLATE "C" NOT NULL,++ amount bigint NOT NULL CHECK (amount > 0),++ description text COLLATE "C" NOT NULL,++ reference text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL CHECK (status IN ('pending', 'paid', 'refunded', 'expired', 'cancelled')),++ pay_url text COLLATE "C" NOT NULL,++ return_url text COLLATE "C",++ sandbox bigint NOT NULL DEFAULT 0 CHECK (sandbox IN (0, 1)),++ payer_subject text COLLATE "C",++ payer_ref text COLLATE "C",++ pay_idempotency_key text COLLATE "C",++ refund_reason text COLLATE "C",++ expires_at timestamptz NOT NULL,++ created_at timestamptz NOT NULL,++ paid_at timestamptz,++ refunded_at timestamptz,++ cancelled_at timestamptz,++ UNIQUE (app_id, reference)++);++CREATE INDEX vibes_charges_app_status ON vibes_charges (app_id, status, created_at);++CREATE INDEX vibes_charges_payer ON vibes_charges (payer_subject, paid_at) WHERE payer_subject IS NOT NULL;++++-- payer_ref is opaque and stable per app: the webhook carries it instead of a person id or email. A random value,++-- never a hash of the subject (a dump of this table plus a guessable subject space must not link the two).++CREATE TABLE vibes_payers (++ app_id text COLLATE "C" NOT NULL,++ subject text COLLATE "C" NOT NULL,++ payer_ref text COLLATE "C" NOT NULL UNIQUE,++ created_at timestamptz NOT NULL,++ PRIMARY KEY (app_id, subject)++);++++CREATE TABLE vibes_webhook_outbox (++ id text COLLATE "C" PRIMARY KEY,++ charge_id text COLLATE "C" NOT NULL REFERENCES vibes_charges (id),++ app_id text COLLATE "C" NOT NULL,++ event_type text COLLATE "C" NOT NULL CHECK (event_type IN ('billing.vibes.charge.paid', 'billing.vibes.charge.refunded')),++ payload text COLLATE "C" NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at timestamptz NOT NULL,++ delivered_at timestamptz,++ abandoned_at timestamptz,++ last_status integer,++ last_error text COLLATE "C",++ created_at timestamptz NOT NULL++);++CREATE INDEX vibes_webhook_due ON vibes_webhook_outbox (next_attempt_at, id)++ WHERE delivered_at IS NULL AND abandoned_at IS NULL;@@@ -12,9 +12,9 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.0"},"devDependencies": {@@@ -1103,9 +1103,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.222.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",++ "version": "0.289.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1119,9 +1119,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.82.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",++ "version": "0.84.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",++ "integrity": "sha512-OLngno3URAIUOqvEtQ9e/dvg3mhZrndGOEBEIgA7yn/uEDtYykgpc6HKajiyeub4UvZ8Dn2Vqn0WdqIYr/8/Uw==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1147,9 +1147,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.109.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",++ "version": "4.110.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -2,7 +2,7 @@"name": "openvibe-billing","version": "0.1.0","private": true,-- "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).",++ "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).","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -26,9 +26,9 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.0"},"engines": {@@@ -6,7 +6,9 @@** export the person's accounts with their balances, every transaction they sent or received, their checkouts,* cash-outs, subscriptions (as a member, and as a creator without the members' identities), entitlements,-- * tips on their own PowerChat, usage readings, promo allowances, tier prices and connected payment accounts++ * tips on their own PowerChat, usage readings, promo allowances, Balance holds, settlements, sandbox++ * grants and refunded-top-up debts, Balance transfers and Vibes bought with Balance, tier prices and++ * connected payment accounts* erase money records stay (the ledger, receipts, checkouts, cash-outs, subscriptions, entitlements, usage and* promo rows are records the law and the reconciliation need): counted as retained. What is only settings* goes: tier prices, connected payment accounts (the attribution of a provider username to the person),@@@ -20,7 +22,7 @@ const TABLES = [{ table: 'transactions', subject: 'from_subject', file: 'transactions-sent.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger } },{ table: 'transactions', subject: 'to_subject', file: 'transactions-received.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger }, kind: 'transactions_received' },{ table: 'payment_intents', subject: 'subject', file: 'checkouts.json', columns: ['id', 'provider', 'kind', 'streamer_subject', 'amount_cents', 'fee_cents', 'bits', 'route', 'auto_renew', 'status', 'settled_txn', 'created_at', 'updated_at'], erase: { keep: ledger } },-- { table: 'cashouts', subject: 'subject', file: 'cashouts.json', columns: ['id', 'amount_bits', 'value_cents', 'status', 'payout_method', 'escrow_until', 'payout_provider', 'payout_reference', 'reason', 'created_at', 'updated_at'], erase: { keep: ledger } },++ { table: 'cashouts', subject: 'subject', file: 'cashouts.json', columns: ['id', 'project_id', 'amount_bits', 'value_cents', 'status', 'payout_method', 'escrow_until', 'payout_provider', 'payout_reference', 'reason', 'created_at', 'updated_at'], erase: { keep: ledger } },{ table: 'subscriptions', subject: 'subscriber', file: 'subscriptions.json', columns: ['id', 'streamer', 'tier', 'provider', 'status', 'auto_renew', 'cancel_at_period_end', 'price_cents', 'current_period_end', 'created_at', 'updated_at'], erase: { keep: ledger } },// As a creator: their subscriptions without who the members are (that is the members' data).{ table: 'subscriptions', subject: 'streamer', file: 'subscriptions-to-you.json', columns: ['id', 'tier', 'status', 'price_cents', 'current_period_end', 'created_at'], erase: { keep: ledger }, kind: 'subscriptions_to_you' },@@@ -34,12 +36,25 @@ const TABLES = [// Import holds and usage budgets are part of the books: the import, its credits and the spend must still reconcile.{ table: 'import_holds', subject: 'owner', file: 'import-holds.json', columns: ['live_user_id', 'owner', 'reason', 'credit_bits', 'payable_bits', 'resolved_subject', 'first_seen_at', 'updated_at'], erase: { keep: ledger } },{ table: 'usage_budgets', subject: 'subject', file: 'usage-budgets.json', erase: { keep: ledger } },++ // The credits ledger (AI Exchange D2): holds, settlements and sandbox grants are money records too.++ { table: 'credit_holds', subject: 'subject', file: 'inference-holds.json', columns: ['id', 'project_id', 'held_nano_usd', 'max_nano_usd', 'rate_version', 'status', 'lease_expires_at', 'created_at', 'updated_at'], erase: { keep: ledger } },++ { table: 'credit_settlements', subject: 'subject', file: 'inference-charges.json', columns: ['id', 'hold_id', 'project_id', 'charge_nano_usd', 'released_nano_usd', 'journal_id', 'usage', 'retail', 'byok', 'created_at'], erase: { keep: ledger } },++ { table: 'credit_sandbox_grants', subject: 'subject', file: 'inference-sandbox-grants.json', columns: ['id', 'project_id', 'amount_nano_usd', 'reason', 'created_at'], erase: { keep: ledger } },++ // A refunded top-up whose credit was already spent: a money record of the person (and the debt they owe).++ { table: 'credit_topup_debts', subject: 'subject', file: 'credit-debts.json', columns: ['id', 'topup_txn_id', 'transaction_id', 'reversal_ref', 'project_id', 'provider', 'amount_nano_usd', 'reason', 'created_at'], erase: { keep: ledger } },++ // D12: moving Balance to a project and buying Vibes with Balance are money records too.++ { table: 'balance_transfers', subject: 'subject', file: 'balance-transfers.json', columns: ['id', 'idempotency_key', 'to_project_id', 'amount_nano_usd', 'journal_id', 'created_at'], erase: { keep: ledger } },++ { table: 'vibes_from_balance', subject: 'subject', file: 'vibes-from-balance.json', columns: ['id', 'idempotency_key', 'vibes', 'price_nano_usd', 'bits_per_usd', 'journal_id', 'created_at'], erase: { keep: ledger } },++ // D12 Vibes Pay: the charges this person paid in an app, and the opaque reference that app knows them by. A++ // charge they never paid has no payer and is not their data; the app's own settings are not people's records.++ { table: 'vibes_charges', subject: 'payer_subject', file: 'vibes-pay-charges.json', columns: ['id', 'app_id', 'project_id', 'amount', 'description', 'reference', 'status', 'sandbox', 'paid_at', 'refunded_at', 'created_at'], erase: { keep: ledger } },++ { table: 'vibes_payers', subject: 'subject', file: 'vibes-pay-payers.json', columns: ['app_id', 'payer_ref', 'created_at'], erase: { keep: ledger } },];/** Accounts with their balances (a balance is the account's cached sum of ledger entries). */async function balances(db, subject) {return await db.prepare(`SELECT a.kind, a.currency, COALESCE(b.balance, 0) AS balance, a.created_at FROM accounts a-- LEFT JOIN account_balances b ON b.account_id = a.id WHERE a.owner_subject = ? ORDER BY a.id`).all(subject);++ LEFT JOIN account_balances b ON b.account_id = a.id WHERE a.owner_subject IN (?, ?) ORDER BY a.id`).all(subject, `user:${subject}`);}function create({ db, log = console } = {}) {@@@ -50,7 +65,7 @@ function create({ db, log = console } = {}) {// Accounts stay (the ledger points at them): counted as retained with the other money records.extraErase: async (t, subjects, counts) => {for (const subject of subjects) {-- counts.add(counts.retained, 'accounts', Number(await t.value('SELECT COUNT(*)::int FROM accounts WHERE owner_subject = $1', [subject])) || 0);++ counts.add(counts.retained, 'accounts', Number(await t.value('SELECT COUNT(*)::int FROM accounts WHERE owner_subject IN ($1, $2)', [subject, `user:${subject}`])) || 0);}},});@@@ -30,6 +30,13 @@ const BUDGETS = {// Someone buying Vibes or subscribing starts one, and perhaps retries it once or twice; dozens are card// testing. Tips' supporters are also capped at 20 unpaid checkouts a day there.'billing.intent.create': { minute: 5, hour: 30 },++ // Starting a Balance top-up (POST /api/me/credits/topups) opens a provider checkout for real money; the same++ // budget as a purchase checkout, counted per signed-in person.++ 'billing.credits.topup.create': { minute: 5, hour: 30 },++ // Moving Balance to a project (POST /api/me/balance/transfers) is a wallet write, not a checkout.++ 'billing.balance.transfer': { minute: 10, hour: 100 },++ // Getting Vibes with Balance (POST /api/me/vibes/from-balance): a few buys at a time.++ 'billing.vibes.from_balance': { minute: 5, hour: 30 },// A tip, donation or paid interaction from a person's credit: one a second at most, above Tips' own// 20 a minute per supporter, so no real supporter meets it.'billing.transfer.create': { minute: 60, hour: 1200 },@@@ -44,6 +51,14 @@ const BUDGETS = {'billing.recycle': { minute: 10, hour: 60 },// A subscription period paid from credit: a person subscribes to a few channels at a time.'billing.subscription.create': { minute: 10, hour: 60 },++ // Vibes Pay (D12): an app creates charges and refunds them from its server; a person pays and cancels on the pay++ // sheet. The owner's billing.vibes limits (amount, daily spend, on/off) are separate and checked by ops/vibes.js.++ 'billing.vibes.charge.create': { minute: 60, hour: 1200 },++ 'billing.vibes.charge.refund': { minute: 20, hour: 200 },++ 'billing.vibes.pay': { minute: 30, hour: 300 },++ 'billing.vibes.cancel': { minute: 30, hour: 300 },++ 'billing.vibes.sandbox.grant': { minute: 30, hour: 300 },++ 'billing.vibes.webhook': { minute: 10, hour: 60 },};/** The bare usr_… id from a SubjectRef or a bare id, else null. */@@@ -15,19 +15,73 @@ const { verifyServiceToken } = require('openvibe-sdk/auth');const { capabilities, http, validate } = contracts;++const ULID = '[0-9A-HJKMNP-TV-Z]{26}';++const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);++const PROJECT_RE = new RegExp(`^prj_(${ULID})$`);++const ENVS = ['sandbox', 'production'];++++/** True for a Network app token: sub app:app_<ULID> and actor_type app. */++function isAppClaims(claims) {++ return Boolean(claims && claims.actor_type === 'app' && APP_SUB_RE.test(String(claims.sub)));++}++++/** The app a verified token belongs to, or null for anything that is not a usable app token. */++function appPrincipal(claims) {++ const m = APP_SUB_RE.exec(String(claims && claims.sub));++ if (!m) return null;++ if (!PROJECT_RE.test(String(claims.project_id || ''))) return null;++ const env = claims.env === undefined ? 'production' : claims.env;++ if (!ENVS.includes(env)) return null;++ return {++ sub: claims.sub, appId: `app_${m[1]}`, projectId: claims.project_id, env,++ onBehalfOf: typeof claims.on_behalf_of === 'string' ? claims.on_behalf_of : null,++ };++}++function createAuth({ config, keys }) {++ /** The verified claims, or { error: { status, code, detail } } (the token was not verified). */++ async function verify(req, { acceptSandbox = false } = {}) {++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return { error: { status: 401, code: 'token.missing', detail: 'a service token is required' } };++ const r = await verifyServiceToken(header.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, audience: config.audience, contracts, acceptSandbox });++ if (!r.ok) {++ if (r.code === 'token.unavailable') return { error: { status: 503, code: 'auth.unavailable', detail: 'the Network key is not loaded yet' } };++ return { error: { status: 401, code: r.code, detail: r.reason } };++ }++ return { claims: r.claims };++ }++/** true when req.principal is set; otherwise the problem has been answered. */async function authenticate(req, res) {if (req.principal) return true;-- const header = String(req.headers.authorization || '');-- if (!header.startsWith('Bearer ')) { http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: req.ov }); return false; }-- const r = await verifyServiceToken(header.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, audience: config.audience, contracts });-- if (!r.ok && r.code === 'token.unavailable') { http.sendProblem(res, 503, 'auth.unavailable', { detail: 'the Network key is not loaded yet', ctx: req.ov }); return false; }-- if (!r.ok) { http.sendProblem(res, 401, r.code, { detail: r.reason, ctx: req.ov }); return false; }-- req.principal = { sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti };++ const out = await verify(req);++ if (out.error) { http.sendProblem(res, out.error.status, out.error.code, { detail: out.error.detail, ctx: req.ov }); return false; }++ req.principal = { sub: out.claims.sub, cap: out.claims.cap || [], jti: out.claims.jti };return true;}++ /**++ * The guard for the routes a developer app may use (D12 Vibes Pay; billing.vibes.charge / billing.vibes.read).++ * Only an app token is accepted: no capability on a service token can name the app whose Vibes are charged.++ * A sandbox app token is verified here; the routes decide whether the operation is sandbox-aware.++ */++ function appOnly(cap) {++ return async function appOnlyGuard(req, res, next) {++ const out = await verify(req, { acceptSandbox: true });++ if (out.error) return http.sendProblem(res, out.error.status, out.error.code, { detail: out.error.detail, ctx: req.ov });++ if (!isAppClaims(out.claims)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'an app token is required', ctx: req.ov });++ const app = appPrincipal(out.claims);++ if (!app) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: 'the app token names no project or a bad environment', ctx: req.ov });++ const decision = capabilities.check(out.claims, cap);++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: decision.reason, ctx: req.ov });++ req.principal = {++ kind: 'app', sub: out.claims.sub, appId: app.appId, projectId: app.projectId, env: app.env,++ onBehalfOf: app.onBehalfOf, cap: out.claims.cap || [], jti: out.claims.jti, claims: out.claims,++ };++ return next();++ };++ }++/** needs('billing.x.y') or needs(['a', 'b']) — any one of the listed capabilities. */function needs(caps) {const list = Array.isArray(caps) ? caps : [caps];@@@ -42,7 +96,7 @@ function createAuth({ config, keys }) {};}-- return { needs, authenticate };++ return { needs, authenticate, verify, appOnly };}/** Audit actor for a request: the calling principal, plus the person it acts for when given. */@@@ -53,4 +107,4 @@ function actorOf(req) {return actor;}--module.exports = { createAuth, actorOf };++module.exports = { createAuth, actorOf, isAppClaims, appPrincipal };nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++++/**++ * Internal credits ledger API (AI Exchange D2). Service-token authenticated (audience openvibe.billing),++ * mounted under /internal/credits always, each handler refusing while the `credits` owner setting is off.++ * This is the authority surface++ * OpenVibe.AI calls to reserve, extend, settle and release inference credit.++ */++const express = require('express');++const { http, validate } = require('openvibe-contracts');++const { BillingError } = require('../ledger');++const { isFrozen } = require('../ops/common');++const { actorOf } = require('./auth');++const inference = require('../ops/credits');++++// One capability per operation, as openvibe-contracts v0.284.0 defines them (D9).++const CAP = {++ create: 'billing.credit.hold.create',++ extend: 'billing.credit.hold.extend',++ settle: 'billing.credit.hold.settle',++ release: 'billing.credit.hold.release',++ sandbox: 'billing.credit.sandbox.grant',++};++++/**++ * The request body must be the contract's (openvibe-contracts v0.289.1, D9/D10/D12): a caller that sends another shape++ * gets 422 before anything is held or charged, and the ledger code only ever sees validated input. D12's request++ * schema makes project_id optional (a personal hold); an app subject still always names its project.++ */++function body(schemaId) {++ return (req, res, next) => {++ const v = validate(schemaId, req.body == null ? {} : req.body);++ if (v.valid) return next();++ const detail = v.errors.slice(0, 5).map((e) => `${e.path || '/'} ${e.message}`).join('; ');++ return http.sendProblem(res, 422, 'billing.invalid_request', { detail: `not a ${schemaId}: ${detail}`, ctx: req.ov });++ };++}++++function internalRouter({ ctx, auth }) {++ const r = express.Router();++ const notFrozen = async (req, res, next) => (await isFrozen(ctx.db)++ ? http.sendProblem(res, 503, 'billing.frozen', { detail: 'the economy is frozen', ctx: req.ov })++ : next());++++ r.post('/holds', auth.needs(CAP.create), body('billing.credit-hold-create-request@1'), notFrozen, wrap(async (req, res) => {++ const out = await inference.createHold(ctx, { ...req.body, actor: actorOf(req) });++ res.status(out.replay ? 200 : 201).json(out.hold);++ }));++++ r.post('/holds/:id/extend', auth.needs(CAP.extend), body('billing.credit-hold-extend-request@1'), notFrozen, wrap(async (req, res) => {++ const out = await inference.extendHold(ctx, { hold_id: req.params.id, add_nano_usd: req.body.add_nano_usd, actor: actorOf(req) });++ res.json(out.hold);++ }));++++ r.post('/holds/:id/settle', auth.needs(CAP.settle), body('billing.credit-hold-settle-request@1'), notFrozen, wrap(async (req, res) => {++ const out = await inference.settleHold(ctx, { hold_id: req.params.id, ...req.body, actor: actorOf(req) });++ res.status(out.replay ? 200 : 201).json(out.settlement);++ }));++++ r.post('/holds/:id/release', auth.needs(CAP.release), notFrozen, wrap(async (req, res) => {++ const out = await inference.releaseHold(ctx, { hold_id: req.params.id, actor: actorOf(req) });++ res.json(out);++ }));++++ r.post('/sandbox/grants', auth.needs(CAP.sandbox), body('billing.credit-sandbox-grant-request@1'), notFrozen, wrap(async (req, res) => {++ const out = await inference.sandboxGrant(ctx, { ...req.body, actor: actorOf(req) });++ res.status(201).json(out);++ }));++++ return r;++}++++function wrap(fn) {++ return async (req, res, next) => {++ try {++ 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); }++ };++}++++function sendError(req, res, e) {++ if (res.headersSent) return;++ if (e instanceof BillingError) {++ return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });++ }++ console.error('[Billing] unexpected internal error:', e);++ return http.sendProblem(res, 500, 'billing.internal', { detail: 'internal error', ctx: req.ov });++}++++module.exports = { internalRouter, CAP };@@@ -1,8 +1,8 @@'use strict';/**-- * The signed-in person's own Vibes, for every OpenVibe site (owner 2026-10-08: "make vibes cross site too like opencoins";-- * plan T5 step 2). The shared navbar shows them next to OpenCoins.++ * The signed-in person's own wallet for every OpenVibe site: Vibes (T5) and Balance (D12). The shared navbar shows++ * Vibes next to OpenCoins; the wallet shows Balance, the projects the person administers, and the moves between them.** GET /api/me/vibes Authorization: Bearer <the person's Network user JWT>* → 200 { subject, currency: 'vibes-bits', credit, payable, bits_per_usd }@@@ -11,15 +11,26 @@* numbers are a shadow then, so the chip stays hidden rather than show them* → 401 without a person's token (service, app, module and typed tokens are refused)*++ * GET /api/me/balance the person's Balance plus the Balance of every project they administer++ * GET /api/me/credits the same answer (the old wire path stays an alias)++ * GET /api/me/activity the person's own Balance and Vibes movements, newest first (before=<txn id>)++ * POST /api/me/balance/transfers { to_project_id, amount_nano_usd, idempotency_key }++ * POST /api/me/vibes/from-balance { vibes, idempotency_key }++ ** CORS: OpenVibe's own sites only (exact https origins from the Contracts service manifests' domains, as Network does;* never a wildcard, so a tenant subdomain of a hosting product is never allowed), Authorization allowed, no cookies.* The answer is private and never cached.*/const express = require('express');const contracts = require('openvibe-contracts');++const { validate } = contracts;const { verifyUserToken } = require('openvibe-sdk/auth');const { balance } = require('../ledger');const { A } = require('../ops/common');++const topups = require('../ops/topups');++const balances = require('../ops/balance');++const vibesPay = require('../ops/vibes');++const activity = require('../ops/activity');const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;let origins = null;@@@ -36,7 +47,7 @@ function firstPartyOrigins() {return out;}--function meRouter({ ctx, config, keys, limits }) {++function meRouter({ ctx, config, keys, limits, adapters }) {const r = express.Router();const problem = (res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail });@@@ -44,7 +55,7 @@ function meRouter({ ctx, config, keys, limits }) {const origin = req.get('origin');res.set({ 'Cache-Control': 'private, no-store', Vary: 'Origin' });if (origin && firstPartyOrigins().has(origin)) {-- res.set({ 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Headers': 'Authorization', 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Max-Age': '600' });++ res.set({ 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Max-Age': '600' });}if (req.method === 'OPTIONS') return res.status(204).end();return next();@@@ -52,7 +63,7 @@ function meRouter({ ctx, config, keys, limits }) {async function person(req, res, next) {const header = String(req.get('authorization') || '');-- if (!header.startsWith('Bearer ')) return problem(res, 401, 'token.missing', 'sign in to see your Vibes');++ if (!header.startsWith('Bearer ')) return problem(res, 401, 'token.missing', 'sign in to see your wallet');try {const claims = await verifyUserToken(header.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, now: ctx.now() });const subject = claims.subject_id;@@@ -61,7 +72,7 @@ function meRouter({ ctx, config, keys, limits }) {return next();} catch (e) {if (e && e.code === 'token.unavailable') return problem(res, 503, 'auth.unavailable', 'the Network key is not loaded yet');-- return problem(res, 401, (e && e.code) || 'token.invalid', 'sign in again to see your Vibes');++ return problem(res, 401, (e && e.code) || 'token.invalid', 'sign in again to see your wallet');}}@@@ -74,7 +85,142 @@ function meRouter({ ctx, config, keys, limits }) {});});++ // A signed-in person's Balance (D12): their own and every project they administer. Off with the ledger.++ const readBalance = async (req, res) => {++ if (!config.credits || !config.credits.enabled) return problem(res, 404, 'billing.not_enabled', 'Balance is not open yet');++ try {++ return res.json(await balances.readBalance(ctx, req.person.subject));++ } catch (e) {++ return failure(req, res, e, 'the project list is unavailable');++ }++ };++ r.get('/balance', person, limits.selfRead, readBalance);++++ // The old wire path stays an alias of /api/me/balance. With ?project_id it answers that one project's statement,++ // as before, after Network confirms the person administers it.++ r.get('/credits', person, limits.selfRead, async (req, res) => {++ if (!config.credits || !config.credits.enabled) return problem(res, 404, 'billing.not_enabled', 'Balance is not open yet');++ if (req.query.project_id == null) return await readBalance(req, res);++ const projectId = String(req.query.project_id || '');++ if (!/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(projectId)) return problem(res, 422, 'billing.invalid_input', 'project_id is a project id');++ try {++ await balances.assertProjectAdmin(ctx, req.person.subject, projectId);++ return res.json(await require('../ops/credits').statement(ctx, req.person.subject, projectId));++ } catch (e) {++ return failure(req, res, e, 'the project is unavailable');++ }++ });++++ // The person's own movements over Balance and Vibes (D12, Network's /wallet). Their token, their accounts only.++ r.get('/activity', person, limits.selfRead, async (req, res) => {++ try {++ return res.json(await activity.activity(ctx, req.person.subject, { limit: req.query.limit, before: req.query.before }));++ } catch (e) {++ return failure(req, res, e, 'the activity is unavailable');++ }++ });++++ // Move money from the person's Balance to a project they administer (D12): atomic, idempotent, never below zero.++ r.post('/balance/transfers', express.json({ limit: '16kb' }), person, body('billing.balance-transfer-request@1'),++ limits.budget('billing.balance.transfer', (req) => req.person && req.person.subject), async (req, res) => {++ try {++ const body = req.body || {};++ const out = await balances.transfer(ctx, {++ subject: req.person.subject, toProjectId: body.to_project_id,++ amountNanoUsd: body.amount_nano_usd, idempotencyKey: body.idempotency_key,++ });++ return res.status(out.replay ? 200 : 201).json(out);++ } catch (e) {++ return failure(req, res, e, 'the transfer is unavailable');++ }++ });++++ // Get Vibes with Balance (D12): the pack value rate, exact to the nano-USD, minted as a pack is. Behind++ // billing.vibes.from_balance (danger, off by default).++ r.post('/vibes/from-balance', express.json({ limit: '16kb' }), person, body('billing.vibes-purchase-from-balance-request@1'),++ limits.budget('billing.vibes.from_balance', (req) => req.person && req.person.subject), async (req, res) => {++ try {++ const body = req.body || {};++ const out = await balances.buyVibes(ctx, { subject: req.person.subject, vibes: body.vibes, idempotencyKey: body.idempotency_key });++ return res.status(out.replay ? 200 : 201).json(out);++ } catch (e) {++ return failure(req, res, e, 'the purchase is unavailable');++ }++ });++++ // ── Vibes Pay (D12): the pay sheet's charge ───────────────++ // What the sheet shows: the app (name and icon from Network's registry, the id when it cannot be read), the item,++ // the price, the person's Vibes and whether they can pay now. Paying is atomic and once; a double pay is a replay++ // for the same person and 409 for anyone else. Cancelling a pending charge moves no money.++ r.get('/vibes/charges/:id', person, limits.selfRead, async (req, res) => {++ try {++ return res.json(await vibesPay.personCharge(ctx, req.person.subject, req.params.id));++ } catch (e) {++ return failure(req, res, e, 'the payment link is unavailable');++ }++ });++++ r.post('/vibes/charges/:id/pay', express.json({ limit: '16kb' }), person,++ limits.budget('billing.vibes.pay', (req) => req.person && req.person.subject), async (req, res) => {++ try {++ const out = await vibesPay.payCharge(ctx, req.person.subject, req.params.id, req.body || {});++ return res.status(200).json({ charge: out.charge, vibes_credit: out.vibes_credit, replay: out.replay });++ } catch (e) {++ return failure(req, res, e, 'the payment could not be completed');++ }++ });++++ r.post('/vibes/charges/:id/cancel', express.json({ limit: '16kb' }), person,++ limits.budget('billing.vibes.cancel', (req) => req.person && req.person.subject), async (req, res) => {++ try {++ const out = await vibesPay.cancelCharge(ctx, req.person.subject, req.params.id);++ return res.status(200).json({ charge: out.charge, replay: out.replay, ...(out.expired ? { expired: true } : {}) });++ } catch (e) {++ return failure(req, res, e, 'the payment could not be cancelled');++ }++ });++++ // Start a Balance top-up (D9) with the person's own token: one of the owner's configured amounts, the owner's fee,++ // and the existing provider checkout. With the feature or its configuration off it refuses before any intent.++ r.post('/credits/topups', express.json({ limit: '16kb' }), person,++ limits.budget('billing.credits.topup.create', (req) => req.person && req.person.subject), async (req, res) => {++ if (!config.credits || !config.credits.enabled) return problem(res, 404, 'billing.not_enabled', 'Balance is not open yet');++ try {++ const out = await topups.startTopup(ctx, adapters, { ...(req.body || {}), subject: req.person.subject });++ return res.status(201).json(out);++ } catch (e) {++ if (e && e.status && e.code) {++ return contracts.http.sendProblem(res, e.status, e.code, {++ detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined,++ });++ }++ (ctx.log || console).error('[Billing] /api/me/credits/topups error:', e);++ return problem(res, 500, 'billing.internal', 'internal error');++ }++ });++return r;}++/** A D12 request body must be the contract's, before any money moves. */++function body(schemaId) {++ return (req, res, next) => {++ const v = validate(schemaId, req.body == null ? {} : req.body);++ if (v.valid) return next();++ const detail = v.errors.slice(0, 5).map((e) => `${e.path || '/'} ${e.message}`).join('; ');++ return contracts.http.sendProblem(res, 422, 'billing.invalid_request', { detail: `not a ${schemaId}: ${detail}`, ctx: req.ov });++ };++}++++/** A route's failures as problems: an expected BillingError keeps its code; anything else is a 502 upstream. */++function failure(req, res, e, detail) {++ if (e && e.status && e.code) {++ return contracts.http.sendProblem(res, e.status, e.code, {++ detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined,++ });++ }++ (req.app.locals && req.app.locals.ctx ? req.app.locals.ctx.log : console).error('[Billing] /api/me error:', e);++ return contracts.http.sendProblem(res, 502, 'billing.upstream_unavailable', { detail, ctx: req.ov });++}++module.exports = { meRouter, firstPartyOrigins };@@@ -31,6 +31,7 @@ const promo = require('../ops/promo');const rating = require('../ops/rating');const tiers = require('../ops/tiers');const { stats } = require('../ops/stats');++const vibes = require('../ops/vibes');const CAP = {intent: 'billing.intent.create',@@@ -44,6 +45,8 @@ const CAP = {stats: 'billing.stats.read',admin: 'billing.ledger.admin',usage: 'billing.usage.record',++ vibesCharge: 'billing.vibes.charge',++ vibesRead: 'billing.vibes.read',};function v1Router({ ctx, auth, adapters, limits }) {@@@ -157,7 +160,10 @@ function v1Router({ ctx, auth, adapters, limits }) {// ── Cashouts ─────────────────────────────────────────────r.post('/cashouts', ...write(CAP.cashoutRequest, async (req, res) => {const b = req.body || {};-- const out = await 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,++ projectId: b.project_id, 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, async (req, res) => {@@@ -297,6 +303,53 @@ function v1Router({ ctx, auth, adapters, limits }) {}));r.get('/usage', ...read(CAP.admin, async (req, res) => res.json(await usage.list(db, req.query))));++ // ── Vibes Pay (D12): a developer app sells an item for whole Vibes ──++ // The app authenticates with its own Network app token (billing.vibes.charge); the person pays on the pay sheet++ // through /api/me/vibes/charges/:id. A charge is idempotent on the app's reference, and only its own app may read++ // or refund it.++ const appWrite = (handler) => [auth.appOnly(CAP.vibesCharge), notFrozen,++ limits.budget('billing.vibes.charge.create', () => null), wrap(handler)];++ r.post('/vibes/charges', ...appWrite(async (req, res) => {++ const out = await vibes.createCharge(ctx, req.principal, req.body || {});++ res.status(out.replay ? 200 : 201).json(out.charge);++ }));++ r.get('/vibes/charges/:id', auth.appOnly(CAP.vibesCharge), wrap(async (req, res) => {++ res.json((await vibes.getChargeForApp(ctx, req.principal, req.params.id)).charge);++ }));++ r.post('/vibes/charges/:id/refund', auth.appOnly(CAP.vibesCharge), notFrozen,++ limits.budget('billing.vibes.charge.refund', () => null), wrap(async (req, res) => {++ const out = await vibes.refundCharge(ctx, req.principal, req.params.id, req.body || {});++ res.status(out.replay ? 200 : 201).json(out.charge);++ }));++ // The app's own test grant: sandbox Vibes for a person, never real ones.++ r.post('/vibes/sandbox/grants', auth.appOnly(CAP.vibesCharge), notFrozen,++ limits.budget('billing.vibes.sandbox.grant', () => null), wrap(async (req, res) => {++ res.status(201).json(await vibes.sandboxGrant(ctx, req.principal, req.body || {}));++ }));++ // The person who delegated this app reads their own Vibes (billing.vibes.read; no first-party route is widened).++ r.get('/vibes/me', auth.appOnly(CAP.vibesRead), wrap(async (req, res) => {++ res.json(await vibes.walletForApp(ctx, req.principal));++ }));++ // Webhook settings for the app's own charges. The secret is write-only: PUT answers it once when generated.++ r.get('/vibes/webhook', auth.appOnly(CAP.vibesCharge), wrap(async (req, res) => {++ const out = await ctx.vibesWebhooks.get(req.principal.appId);++ res.json({++ app_id: req.principal.appId, url: out.url, configured: Boolean(out.url && out.marker),++ secret_set: Boolean(out.marker), secret_hint: out.marker ? out.marker.hint : null,++ sealed: out.sealed, updated_at: out.marker ? out.marker.set_at : null,++ });++ }));++ r.put('/vibes/webhook', auth.appOnly(CAP.vibesCharge), notFrozen,++ limits.budget('billing.vibes.webhook', () => null), wrap(async (req, res) => {++ const b = req.body || {};++ const url = ctx.vibesWebhooks.safeUrl(b.url, ctx.config);++ res.json(await ctx.vibesWebhooks.configure(req.principal.appId, { url, secret: b.secret, rotate: b.rotate_secret === true }));++ }));++ r.delete('/vibes/webhook', auth.appOnly(CAP.vibesCharge), notFrozen,++ limits.budget('billing.vibes.webhook', () => null), wrap(async (req, res) => {++ res.json(await ctx.vibesWebhooks.remove(req.principal.appId));++ }));++// ── Admin (billing.ledger.admin) ─────────────────────────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) => {@@@ -11,8 +11,8 @@* /webhooks/<provider> provider receipts (see api/webhooks.js)* /, /auth/*, /cashouts, … staff console (Network SSO, server-rendered; see console/index.js)*-- * createApp({ config, db, keys, identity, adapters, now, fetchImpl, log, limitsNow }) — everything injectable-- * (limitsNow: the per-person limiter's clock, tests; default the wall clock).++ * createApp({ config, db, env, keys, identity, adapters, now, fetchImpl, log, limitsNow }) — everything injectable++ * (env: the raw environment, default process.env; limitsNow: the per-person limiter's clock, tests; default the wall clock).*/const path = require('path');const express = require('express');@@@ -22,9 +22,13 @@ const { createRelease } = require('openvibe-shared/release');const { loadConfig } = require('./config');const { openDb } = require('./db');const { createRates } = require('./rates');--const { createKeyProvider, createIdentity } = require('./network');++const { createKeyProvider, createIdentity, createProjects, createApps } = require('./network');const { createAuth } = require('./api/auth');const { v1Router } = require('./api/v1');++const { internalRouter } = require('./api/internal');++const { createBillingConfig, createVibesConfig, applyValues, applyVibesValues } = require('./config-store');++const { internalConfigRouter } = require('./internal-config');++const { createVibesWebhooks } = require('./ops/vibes-webhooks');const { createActorLimits } = require('./api/actor-limits');const { webhooksRouter } = require('./api/webhooks');const { consoleRouter } = require('./console');@@@ -51,7 +55,23 @@ async function createApp(opts = {}) {validate: (env) => contracts.validate('events.event-envelope@1', env),...(opts.fetchImpl ? { fetch: fetchImpl } : {}),});-- const ctx = { db, config, rates: createRates(config.rates), now: opts.now || (() => Date.now()), network: identity, log, outbox };++ // ctx.env is the raw environment the owner-settings store reads its x-env pins from; the service reads the++ // settings themselves through ctx.configStore (D11). Tests pass their own env through here.++ const ctx = { db, config, env: opts.env || process.env, rates: createRates(config.rates), now: opts.now || (() => Date.now()), network: identity, log, outbox };++ // D11 owner settings: `billing.credits` is the source of the Balance switches, the top-up terms and the++ // hold lease; `billing.vibes` holds the Vibes Pay limits and the Get Vibes with Balance switch. The env pins++ // keep today's variables winning while they are set; a change applies live.++ ctx.configStore = await createBillingConfig({ db, env: ctx.env, log, onActivate: (values) => applyValues(config, values) });++ applyValues(config, ctx.configStore.get());++ ctx.vibesConfigStore = await createVibesConfig({ db, env: ctx.env, log, onActivate: (values) => applyVibesValues(config, values) });++ applyVibesValues(config, ctx.vibesConfigStore.get());++ // Network's project-membership reads (D12): the projects a person administers and one role check.++ ctx.projects = opts.projects || createProjects(config, { fetchImpl });++ // Network's app registry (D12 Vibes Pay): the app name/icon on the pay sheet. Best effort; Network's own++ // pay sheet fills the name from dev_apps when this read is not permitted.++ ctx.apps = opts.apps || createApps(config, { fetchImpl, log });++ // Per-app Vibes Pay webhook settings (D12): a D11 namespace each, sealed with the service KEK when present.++ ctx.vibesWebhooks = createVibesWebhooks({ db, config, env: ctx.env, log });const adapters = opts.adapters || providers.createAdapters(config, { fetchImpl, network: identity });const auth = createAuth({ config, keys });@@@ -99,6 +119,8 @@ async function createApp(opts = {}) {});app.get('/api/ready', readiness.handler);++ // Owner settings (D11): Network's service token, capability billing.config.manage. Before every public route.++ app.use(internalConfigRouter({ ctx, auth }));app.use('/webhooks', webhooksRouter({ ctx, adapters }));// Account export and deletion (ADR-033, server/account-data.js): OpenVibe.Events delivers network.account.* to// 127.0.0.1:4600 directly (the consumer refuses anything that came through a proxy; nginx 404s /internal/ too), and@@@ -110,11 +132,14 @@ async function createApp(opts = {}) {app.post('/internal/events', accountData.consumer({ secrets: config.events.webhookSecrets, send: accountSend }));app.locals.accountData = accountData;app.locals.accountSend = accountSend;++ // Inference subledger (AI Exchange D2): mounted always, each handler refuses with 503 billing.not_enabled++ // while the `credits` setting is off, so the owner can open it live from Network's AI tab.++ app.use('/internal/credits', express.json({ limit: '64kb' }), internalRouter({ ctx, auth }));// 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, valkey });app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));// A signed-in person's own Vibes, for the shared navbar on every OpenVibe site (api/me.js; user tokens, CORS for owned sites).-- app.use('/api/me', require('./api/me').meRouter({ ctx, config, keys, limits }));++ app.use('/api/me', require('./api/me').meRouter({ ctx, config, keys, limits, adapters }));// The billing policy is public and indexable; the staff console and the API are not.app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));nnew file mode 100644@@@ -0,0 +1,247 @@++'use strict';++++/**++ * Billing's owner settings (D11): the namespaces `billing.credits` and `billing.vibes`, read through++ * openvibe-shared/config.++ *++ * Every key keeps the environment variable that configured it before D11 as an `x-env` pin: while that++ * variable is set, its value is effective and read-only (the owner's value is refused with++ * 409 config.pinned_in_env). Nothing changes until the owner sets a value. `topup` and the two terms stay++ * without defaults: unset means `topupTerms()` reports `configured: false` and the person's route answers++ * 503 billing.topup_unconfigured. `hold_lease_seconds` is an owner setting with the old++ * BILLING_INFERENCE_LEASE_SECONDS as its pin; when neither is set the loader's 300 s default applies.++ *++ * `credits`, `topup` and the two top-up terms are danger keys (they open or move money). There are no secret-class++ * keys in this namespace: Billing's provider credentials stay in the service's environment, so the++ * write-only `/secrets/:key` route refuses every key here with 422 config.not_secret.++ */++const config = require('openvibe-shared/config');++const { MAX_RECEIPT_CENTS } = require('./ops/common');++const { iso, prefixedId } = require('./ledger');++++const SERVICE = 'billing';++const NAMESPACE = 'billing.credits';++const GROUP = 'Balance';++const VIBES_NAMESPACE = 'billing.vibes';++const VIBES_GROUP = 'Vibes';++++const SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Billing Balance',++ description: 'Balance ledger and the owner terms of the checkout top-up.',++ type: 'object',++ additionalProperties: false,++ properties: {++ credits: {++ type: 'boolean', default: false,++ title: 'Balance ledger', description: 'Open the Balance ledger: the internal hold/settle API and the person\'s Balance routes.',++ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS', 'x-danger': true,++ },++ sandbox: {++ type: 'boolean', default: false,++ title: 'Sandbox credit grants', description: 'Allow synthetic sandbox credit grants, for development and integration tests only.',++ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS_SANDBOX',++ },++ topup: {++ type: 'boolean', default: false,++ title: 'Balance top-up', description: 'Offer checkout top-ups of Balance through the payment providers.',++ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP', 'x-danger': true,++ },++ topup_amounts_cents: {++ type: 'array', items: { type: 'integer', minimum: 1, maximum: MAX_RECEIPT_CENTS }, minItems: 1, uniqueItems: true,++ title: 'Top-up amounts (cents)', description: 'The only amounts a person may top up, in whole USD cents.',++ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP_AMOUNTS_CENTS', 'x-danger': true,++ },++ topup_fee_bps: {++ type: 'integer', minimum: 0, maximum: 10_000,++ title: 'Top-up fee (bps)', description: 'Fee in basis points charged on top of the top-up at checkout; 0 is a real, configured fee.',++ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP_FEE_BPS', 'x-danger': true,++ },++ // The unit is seconds; Shared 4.110.0's x-unit only accepts nano_usd, ms and bps, so it is in the title++ // rather than an x-unit that would make createConfigStore throw.++ hold_lease_seconds: {++ type: 'integer', minimum: 1, maximum: 86_400, default: 300,++ title: 'Hold lease (seconds)', description: 'How long a Balance hold lasts before it expires.',++ 'x-group': GROUP, 'x-env': 'BILLING_INFERENCE_LEASE_SECONDS',++ },++ },++};++++const VIBES_SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Billing Vibes',++ description: 'Vibes Pay limits and the switch that lets a person get Vibes with Balance.',++ type: 'object',++ additionalProperties: false,++ properties: {++ vibes_pay: {++ type: 'boolean', default: false,++ title: 'Vibes Pay', description: 'Let apps charge Vibes on the pay sheet. Turning it on enables spending real Vibes.',++ 'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_PAY', 'x-danger': true,++ },++ max_charge_vibes: {++ type: 'integer', minimum: 0, maximum: 10_000_000, default: 0,++ title: 'Largest single charge (Vibes)', description: 'The most Vibes one app charge may ask for; 0 closes app charges.',++ 'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_MAX_CHARGE',++ },++ daily_app_spend_vibes_per_person: {++ type: 'integer', minimum: 0, maximum: 10_000_000, default: 0,++ title: 'Daily app spend per person (Vibes)', description: 'The most Vibes one person may spend in apps in a day; 0 closes app spending.',++ 'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_DAILY_APP_SPEND',++ },++ from_balance: {++ type: 'boolean', default: false,++ title: 'Get Vibes with Balance', description: 'Let a person buy Vibes with their Balance, at the pack value rate.',++ 'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_FROM_BALANCE', 'x-danger': true,++ },++ },++};++++const AMOUNT_RE = /^[0-9]+$/;++++/** A top-up amount list from a JSON array, a JSON string, or today's comma-separated env value. */++function parseAmounts(raw) {++ const bad = [];++ let parts;++ if (Array.isArray(raw)) parts = raw;++ else {++ const text = String(raw == null ? '' : raw).trim();++ if (!text) return { values: [], ok: false, empty: true, invalid: [] };++ if (text.startsWith('[')) {++ try { parts = JSON.parse(text); } catch { return { values: [], ok: false, invalid: [text] }; }++ if (!Array.isArray(parts)) return { values: [], ok: false, invalid: [text] };++ } else parts = text.split(',').map((s) => s.trim());++ }++ const values = [];++ for (const part of parts) {++ const s = String(part);++ const n = Number(s);++ if (!AMOUNT_RE.test(s) || !Number.isSafeInteger(n) || n <= 0 || n > MAX_RECEIPT_CENTS) { bad.push(s); continue; }++ values.push(n);++ }++ return { values: [...new Set(values)].sort((a, b) => a - b), ok: bad.length === 0, invalid: bad };++}++++const PINNED = ['BILLING_CREDITS', 'BILLING_CREDITS_SANDBOX', 'BILLING_CREDITS_TOPUP', 'BILLING_CREDITS_TOPUP_AMOUNTS_CENTS',++ 'BILLING_CREDITS_TOPUP_FEE_BPS', 'BILLING_INFERENCE_LEASE_SECONDS'];++const VIBES_PINNED = ['BILLING_VIBES_PAY', 'BILLING_VIBES_MAX_CHARGE', 'BILLING_VIBES_DAILY_APP_SPEND', 'BILLING_VIBES_FROM_BALANCE'];++++/**++ * The environment as the store should read it. An empty variable means unset (as `loadConfig` already reads++ * it), a boolean keeps every form `loadConfig` accepted (`1`, `true`, `yes`, `on`), and a comma-separated++ * amount list becomes the JSON array its array pin needs.++ */++function storeEnv(env) {++ const out = { ...(env || {}) };++ for (const name of PINNED) if (out[name] != null && String(out[name]).trim() === '') delete out[name];++ for (const name of ['BILLING_CREDITS', 'BILLING_CREDITS_SANDBOX', 'BILLING_CREDITS_TOPUP']) {++ if (out[name] == null) continue;++ out[name] = ['1', 'true', 'yes', 'on'].includes(String(out[name]).toLowerCase()) ? '1' : '0';++ }++ const raw = out.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS;++ if (raw != null) {++ const parsed = parseAmounts(raw);++ if (parsed.ok) out.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS = JSON.stringify(parsed.values);++ }++ return out;++}++++/** The values the namespace would have from the environment alone (the pre-D11 reader; unit tests and `topupTerms` fallback). */++function valuesFromEnv(env) {++ const on = (name) => ['1', 'true', 'yes', 'on'].includes(String(env && env[name] != null ? env[name] : '').toLowerCase());++ return {++ credits: on('BILLING_CREDITS'),++ sandbox: on('BILLING_CREDITS_SANDBOX'),++ topup: on('BILLING_CREDITS_TOPUP'),++ ...(env && env.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS != null && String(env.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS).trim() !== ''++ ? { topup_amounts_cents: env.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS } : {}),++ ...(env && env.BILLING_CREDITS_TOPUP_FEE_BPS != null && String(env.BILLING_CREDITS_TOPUP_FEE_BPS).trim() !== ''++ ? { topup_fee_bps: Number(env.BILLING_CREDITS_TOPUP_FEE_BPS) } : {}),++ ...(env && env.BILLING_INFERENCE_LEASE_SECONDS != null && String(env.BILLING_INFERENCE_LEASE_SECONDS).trim() !== ''++ ? { hold_lease_seconds: Number(env.BILLING_INFERENCE_LEASE_SECONDS) } : {}),++ };++}++++/** The Vibes namespace's environment: empty means unset and a switch keeps every form loadConfig accepted. */++function vibesStoreEnv(env) {++ const out = { ...(env || {}) };++ for (const name of VIBES_PINNED) if (out[name] != null && String(out[name]).trim() === '') delete out[name];++ for (const name of ['BILLING_VIBES_PAY', 'BILLING_VIBES_FROM_BALANCE']) {++ if (out[name] == null) continue;++ out[name] = ['1', 'true', 'yes', 'on'].includes(String(out[name]).toLowerCase()) ? '1' : '0';++ }++ return out;++}++++/** Apply the store's effective values to the config object the rest of the service already reads (live). */++function applyValues(config, values) {++ const credits = config.credits || (config.credits = {});++ credits.enabled = values.credits === true;++ credits.sandbox = values.sandbox === true;++ const lease = Number(values.hold_lease_seconds);++ credits.leaseSeconds = Number.isFinite(lease) ? Math.max(1, Math.min(86_400, Math.trunc(lease))) : 300;++}++++function applyVibesValues(config, values) {++ const vibes = config.vibes || (config.vibes = {});++ vibes.pay = values.vibes_pay === true;++ vibes.fromBalance = values.from_balance === true;++ const max = Number(values.max_charge_vibes);++ const daily = Number(values.daily_app_spend_vibes_per_person);++ vibes.maxChargeVibes = Number.isSafeInteger(max) && max >= 0 ? max : 0;++ vibes.dailyAppSpendVibesPerPerson = Number.isSafeInteger(daily) && daily >= 0 ? daily : 0;++}++++/** One append-only staff_audit row per activation: service, namespace, keys, actor, revision; never a value. */++async function auditChange(db, { namespace, service, keys, actor, revision }, log) {++ try {++ const at = iso(Date.now());++ await db.prepare(`INSERT INTO staff_audit (id, at, actor_subject, action, target_type, target_id, reason, outcome, detail)++ VALUES (?, ?, ?, 'config.change', 'config_namespace', ?, 'configuration activated', 'done', ?)`)++ .run(prefixedId('sa', Date.now()), at, actor && actor.type === 'user' ? actor.id : null, namespace,++ JSON.stringify({ service, namespace, keys: [...(keys || [])], revision }));++ } catch (err) {++ // The audit must never refuse a revision: log the failure without the values.++ (log || console).warn(`[config] audit line failed: ${err && err.message ? err.message.split('\n')[0] : 'unknown error'}`);++ }++}++++/** Create the store, apply its values and (when a KEK is configured) reseal old plaintext rows once at boot. */++async function createBillingConfig({ db, env, log = console, schema = SCHEMA, onActivate = null }) {++ const store = await config.createConfigStore({++ db, service: SERVICE, namespace: NAMESPACE, schema,++ env: storeEnv(env),++ classify: () => 'internal',++ onActivate,++ onChange: async (change) => await auditChange(db, { ...change, namespace: NAMESPACE, service: SERVICE }, log),++ log,++ });++ if (store.sealed) {++ try {++ const report = await store.reseal();++ if (report.plaintext || report.resealed || report.errors.length) {++ log.info(`[config] ${NAMESPACE} reseal at boot: ${report.resealed} resealed, ${report.plaintext} plaintext, ${report.errors.length} error(s)`);++ }++ } catch (err) {++ log.warn(`[config] ${NAMESPACE} reseal at boot failed: ${err && err.message ? err.message.split('\n')[0] : 'unknown error'}`);++ }++ }++ return store;++}++++/** The D11 `billing.vibes` namespace (Vibes Pay limits and Get Vibes with Balance). */++async function createVibesConfig({ db, env, log = console, schema = VIBES_SCHEMA, onActivate = null }) {++ return await config.createConfigStore({++ db, service: SERVICE, namespace: VIBES_NAMESPACE, schema,++ env: vibesStoreEnv(env),++ classify: () => 'internal',++ onActivate,++ onChange: async (change) => await auditChange(db, { ...change, namespace: VIBES_NAMESPACE, service: SERVICE }, log),++ log,++ });++}++++module.exports = {++ SERVICE, NAMESPACE, VIBES_NAMESPACE, SCHEMA, VIBES_SCHEMA, parseAmounts, storeEnv, valuesFromEnv, applyValues,++ vibesStoreEnv, applyVibesValues, createBillingConfig, createVibesConfig, auditChange,++};@@@ -167,6 +167,8 @@ function loadConfig(env = process.env) {enabled: env.BILLING_JOBS !== 'off',sweepIntervalMs: int(env.BILLING_SWEEP_INTERVAL_MS, 60 * 60 * 1000),webhookRetryMs: int(env.BILLING_WEBHOOK_RETRY_MS, 60 * 1000),++ // Vibes Pay app webhooks (D12): due deliveries from vibes_webhook_outbox, retried with backoff.++ vibesWebhookIntervalMs: Math.max(0, int(env.BILLING_VIBES_WEBHOOK_INTERVAL_MS, 5000)),// Scheduled reconciliation (0 turns it off): stored like any other run; passing scheduled// runs older than reconcileKeepDays are pruned, failed ones are kept.reconcileIntervalMs: int(env.BILLING_RECONCILE_INTERVAL_MS, 60 * 60 * 1000),@@@ -187,6 +189,32 @@ function loadConfig(env = process.env) {// fails reconciliation: the provider was paid and the ledger does not show it yet.staleReceiptMin: int(env.BILLING_RECONCILE_STALE_RECEIPT_MIN, 60),},++ credits: {++ // AI Exchange D2/D12: high-precision Balance ledger. Off by default; no inference money moves without it.++ enabled: bool(env.BILLING_CREDITS),++ // Synthetic inference credit grants, for development and integration tests only.++ sandbox: bool(env.BILLING_CREDITS_SANDBOX),++ // Default hold lease duration in seconds; extended holds get at least one extra minute.++ leaseSeconds: Math.max(1, Math.min(86400, int(env.BILLING_INFERENCE_LEASE_SECONDS, 300))),++ // Background sweeper interval for expired holds (0 = off, default off until the subledger is enabled).++ sweepIntervalMs: int(env.BILLING_INFERENCE_SWEEP_MS, 0),++ },++++ // D12 `billing.vibes`: Vibes Pay limits and Get Vibes with Balance. The defaults are closed; the store in++ // server/config-store.js applies the owner's values live, with these env names as x-env pins.++ vibes: {++ pay: bool(env.BILLING_VIBES_PAY),++ maxChargeVibes: Math.max(0, int(env.BILLING_VIBES_MAX_CHARGE, 0)),++ dailyAppSpendVibesPerPerson: Math.max(0, int(env.BILLING_VIBES_DAILY_APP_SPEND, 0)),++ fromBalance: bool(env.BILLING_VIBES_FROM_BALANCE),++ // D12 step 4: the pay sheet lives on openvibe.network; a charge's expires_at is this far after creation.++ payBaseUrl: trim(env.BILLING_VIBES_PAY_BASE_URL || 'https://openvibe.network'),++ chargeTtlSeconds: Math.max(1, Math.min(86_400, int(env.BILLING_VIBES_CHARGE_TTL_SECONDS, 1800))),++ // Test grants and sandbox charges (like BILLING_CREDITS_SANDBOX): off by default, worth nothing.++ sandbox: bool(env.BILLING_VIBES_SANDBOX),++ // Development only: let a webhook point at a private address. Production always applies the egress guard.++ allowPrivateWebhooks: bool(env.BILLING_VIBES_ALLOW_PRIVATE_WEBHOOKS),++ },// Staff console (server/console): Network SSO (authorization code + PKCE S256, OAuth// client `billing`), only for Network admins listed in BILLING_STAFF_SUBJECTS.@@@ -15,6 +15,7 @@ async function outstanding(db) {return {credit_bits: await sum('user_credit', 'vibes-bits'),payable_bits: await sum('creator_payable', 'vibes-bits'),++ project_payable_bits: await sum('project_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'),@@@ -18,12 +18,15 @@ const path = require('path');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', 'promo_credit', 'promo_reserve'];++ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve',++ 'usd_credit', 'usd_credit_sandbox', 'credit_topup_debt', 'project_payable', 'vibes_credit_sandbox',++ 'project_payable_sandbox', 'vibes_sandbox'];// The promo ledger's kinds (migrations/0003): their entries never share a transaction with any other kind.const PROMO_KINDS = ['promo_credit', 'promo_reserve'];--const CURRENCIES = ['vibes-bits', 'usd-cents'];++const CURRENCIES = ['vibes-bits', 'usd-cents', 'nano-usd'];const TXN_TYPES = ['purchase', 'donation', 'subscription', 'subscription_share', 'cashout_request', 'cashout_paid',-- 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage'];++ 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage', 'credit', 'inference',++ 'balance_transfer', 'vibes_charge', 'vibes_refund', 'vibes_sandbox_grant'];const MIGRATIONS = path.join(__dirname, '..', 'migrations');@@@ -18,6 +18,8 @@ const { createApp } = require('./app');const providers = require('./providers');const subscriptions = require('./ops/subscriptions');const rating = require('./ops/rating');++const inference = require('./ops/credits');++const vibesWebhooks = require('./ops/vibes-webhooks');const { isFrozen } = require('./ops/common');const { runScheduled } = require('./reconcile');const { gracefulStop, telemetry } = require('openvibe-sdk/service');@@@ -53,6 +55,21 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkif (out.rated || out.skipped || out.failed) console.log(`[Billing] rating: ${out.rated} rated, ${out.skipped} left unrated, ${out.failed} failed`);});}++ if (config.jobs.vibesWebhookIntervalMs > 0) {++ every(config.jobs.vibesWebhookIntervalMs, async () => {++ // Vibes Pay app webhooks (D12): due rows in vibes_webhook_outbox, signed and retried with backoff.++ const out = await vibesWebhooks.deliverDue(ctx);++ if (out.delivered || out.failed || out.abandoned) console.log(`[Billing] Vibes Pay webhooks: ${out.delivered} delivered, ${out.failed} retried, ${out.abandoned} abandoned`);++ });++ }++ if (config.credits.sweepIntervalMs > 0) {++ every(config.credits.sweepIntervalMs, async () => {++ // The owner may open the ledger live; the sweep follows the current setting.++ if (!ctx.config.credits.enabled) return;++ const out = await inference.sweepExpired(ctx);++ if (out.released) console.log(`[Billing] inference lease sweep: ${out.released} expired holds released`);++ });++ }relay.start();}nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * The owner-settings surface (D11): openvibe-shared/config's adminRoutes mounted at /internal/config.++ *++ * Only Network's service token holds `billing.config.manage`; every route is guarded by it (server/api/auth.js).++ * The acting subject is the owner's usr_ id in X-OV-Subject, and only for a caller that holds the capability;++ * X-OV-Fresh: 1 is required for a write to an x-danger key, so a stale session can never turn Balance or the++ * top-up switch on. Both namespaces (`billing.credits`, `billing.vibes`) are served here; their onChange writes++ * Billing's audit line (server/config-store.js).++ *++ * The paths are full paths (the router is mounted at the app root) so the capability declaration's++ * implementedBy list points at real routes.++ */++const express = require('express');++const { capabilities } = require('openvibe-contracts');++const { adminRoutes } = require('openvibe-shared/config');++++const CAP = 'billing.config.manage';++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++function internalConfigRouter({ ctx, auth }) {++ const r = express.Router();++ // The store is the only caller of the config routes, so its body parser needs to run only on these paths.++ r.use('/internal/config', express.json({ limit: '64kb' }));++ const routes = adminRoutes([ctx.configStore, ctx.vibesConfigStore], {++ requireAdmin: auth.needs(CAP),++ basePath: '/internal/config',++ actor: (req) => {++ // Only a caller holding the capability may name an owner (the guard above already ran).++ if (!req.principal || !capabilities.grants(req.principal.cap, CAP)) return null;++ const id = String(req.headers['x-ov-subject'] || '');++ return USER_SUBJECT_RE.test(id) ? { type: 'user', id } : null;++ },++ requireFresh: (req) => req.headers['x-ov-fresh'] === '1',++ log: ctx.log,++ });++ r.get('/internal/config', routes.list);++ r.get('/internal/config/:namespace', routes.get);++ r.get('/internal/config/:namespace/history', routes.history);++ r.post('/internal/config/:namespace', routes.apply);++ r.post('/internal/config/:namespace/rollback', routes.rollback);++ r.put('/internal/config/:namespace/secrets/:key', routes.secretPut);++ r.delete('/internal/config/:namespace/secrets/:key', routes.secretDelete);++ return r;++}++++module.exports = { internalConfigRouter, CAP, USER_SUBJECT_RE };@@@ -24,6 +24,20 @@*/const { ids } = require('openvibe-contracts');++const NANO_USD = 'nano-usd';++const isBigCurrency = (currency) => currency === NANO_USD;++const PG_BIGINT_MAX = 9223372036854775807n;++++/** Parse a bigint column value (returned as text via ::text) into the right JS type for its currency. */++function parseMoney(value, currency) {++ if (value == null) return isBigCurrency(currency) ? 0n : 0;++ if (isBigCurrency(currency)) return BigInt(String(value));++ return Number(value);++}++++/** Zero value matching the currency's numeric type. */++function zeroFor(currency) { return isBigCurrency(currency) ? 0n : 0; }++class BillingError extends Error {constructor(status, code, detail, extra) {super(detail || code);@@@ -46,11 +60,11 @@ async function accountId(db, { kind, owner = null, currency }, nowIso) {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). Returns BigInt for nano-usd, Number otherwise. */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++ const row = await db.prepare(`SELECT b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.idWHERE a.kind = ? AND a.owner_subject IS NOT DISTINCT FROM ? AND a.currency = ?`).get(kind, owner, currency);-- return row ? row.balance : 0;++ return parseMoney(row ? row.balance : null, currency);}function parseTxn(row) {@@@ -59,8 +73,9 @@ function parseTxn(row) {}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++ const rows = await db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, e.amount::text AS 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);++ return rows.map((r) => ({ kind: r.kind, owner: r.owner, currency: r.currency, amount: parseMoney(r.amount, r.currency) }));}async function getTxn(db, id) {@@@ -91,18 +106,31 @@ async function post(ctx, spec) {const merged = new Map();const sums = new Map();for (const e of spec.entries || []) {-- if (!Number.isInteger(e.amount)) throw new BillingError(500, 'ledger.bad_amount', `non-integer amount ${e.amount}`);-- if (!Number.isSafeInteger(e.amount)) throw new BillingError(422, 'billing.amount_overflow', `amount ${e.amount} is beyond the exact integer range`);-- if (e.amount === 0) continue;++ const isBig = typeof e.amount === 'bigint';++ if (isBigCurrency(e.currency)) {++ if (!isBig) throw new BillingError(500, 'ledger.bad_amount', `${e.currency} amounts must be BigInt`);++ if (e.amount > PG_BIGINT_MAX || e.amount < -PG_BIGINT_MAX) throw new BillingError(422, 'billing.amount_overflow', `amount ${e.amount} ${e.currency} does not fit in the ledger column`);++ } else {++ if (isBig) throw new BillingError(500, 'ledger.bad_amount', `${e.currency} amounts must be Number`);++ if (!Number.isInteger(e.amount)) throw new BillingError(500, 'ledger.bad_amount', `non-integer amount ${e.amount}`);++ if (!Number.isSafeInteger(e.amount)) throw new BillingError(422, 'billing.amount_overflow', `amount ${e.amount} is beyond the exact integer range`);++ }++ if (e.amount == 0) continue; // loose: 0n == 0const key = `${e.kind}|${e.owner == null ? '' : e.owner}|${e.currency}`;-- const m = merged.get(key) || { kind: e.kind, owner: e.owner == null ? null : e.owner, currency: e.currency, amount: 0 };++ const m = merged.get(key) || { kind: e.kind, owner: e.owner == null ? null : e.owner, currency: e.currency, amount: zeroFor(e.currency) };m.amount += e.amount;merged.set(key, m);-- sums.set(e.currency, (sums.get(e.currency) || 0) + e.amount);++ const curSum = sums.get(e.currency) ?? zeroFor(e.currency);++ sums.set(e.currency, curSum + e.amount);}for (const [currency, sum] of sums) {-- if (!Number.isSafeInteger(sum)) throw new BillingError(422, 'billing.amount_overflow', `entries in ${currency} overflow the exact integer range`);-- if (sum !== 0) throw new BillingError(500, 'ledger.unbalanced', `entries sum to ${sum} ${currency}, not zero`, { entries: spec.entries });++ if (isBigCurrency(currency)) {++ if (typeof sum !== 'bigint') throw new BillingError(500, 'ledger.unbalanced', `entries in ${currency} are not all BigInt`, { entries: spec.entries });++ if (sum !== 0n) throw new BillingError(500, 'ledger.unbalanced', `entries sum to ${sum} ${currency}, not zero`, { entries: spec.entries });++ } else {++ if (!Number.isSafeInteger(sum)) throw new BillingError(422, 'billing.amount_overflow', `entries in ${currency} overflow the exact integer range`);++ if (sum !== 0) throw new BillingError(500, 'ledger.unbalanced', `entries sum to ${sum} ${currency}, not zero`, { entries: spec.entries });++ }}const id = newTxnId(ms);@@@ -117,14 +145,19 @@ async function post(ctx, spec) {const insEntry = db.prepare('INSERT INTO ledger_entries (txn_id, account_id, amount) VALUES (?, ?, ?)');const bump = db.prepare('UPDATE account_balances SET balance = balance + ?, updated_at = ? WHERE account_id = ?');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(await balance(db, m) + m.amount)) {++ const isBig = isBigCurrency(m.currency);++ if (!isBig && !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;++ const have = await balance(db, m);++ const would = have + m.amount;++ if (isBig) {++ if (would > PG_BIGINT_MAX || would < -PG_BIGINT_MAX) throw new BillingError(422, 'billing.amount_overflow', `${m.kind}${m.owner ? `:${m.owner}` : ''} would leave the bigint range`);++ } else if (!Number.isSafeInteger(would)) {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;++ if (m.amount == 0) continue;const acct = await accountId(db, m, nowIso);await insEntry.run(id, acct, m.amount);await bump.run(m.amount, nowIso, acct);@@@ -147,7 +180,8 @@ async function money(db, fn) {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 });++ const extra = isBigCurrency(account.currency) ? { available: String(have), required: String(amount) } : { available: have, required: amount };++ throw new BillingError(409, 'billing.insufficient_funds', `insufficient ${what}: ${have} < ${amount} ${account.currency}`, extra);}return have;}@@@ -161,7 +195,7 @@ function present(txn) {from_subject: txn.from_subject || null, to_subject: txn.to_subject || null,provider: txn.provider || null, receipt_ref: txn.receipt_ref || null,actor: txn.actor, metadata: txn.metadata, created_at: txn.created_at,-- entries: (txn.entries || []).map((e) => ({ account: { kind: e.kind, owner: e.owner, currency: e.currency }, amount: e.amount })),++ entries: (txn.entries || []).map((e) => ({ account: { kind: e.kind, owner: e.owner, currency: e.currency }, amount: isBigCurrency(e.currency) ? String(e.amount) : e.amount })),};}@@@ -6,9 +6,12 @@* keys the Network's RS256 signing keys: openvibe-sdk/auth createNetworkKeys (OV_NETWORK_PUBLIC_KEY pinned,* else the JWKS, retried every 30 s until it loads, refreshed every 15 minutes, a rotation followed on* an unknown kid) — verifies service tokens and the console's sign-in-- * identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch-- * { system: 'live', type: 'user', ids } with a client-credentials token++ * identity Live user ids → canonical subjects, and subject ids → their public names, via++ * POST /internal/identity/resolve-batch with a client-credentials token* (audience openvibe.network, capability identity.subject.resolve)++ * projects the projects a person administers (GET /internal/projects?subject=usr_…, capability++ * network.project.member.read) and one membership (GET /internal/projects/:id/members/:subject),++ * with Billing's own service token*/const { serviceAuth } = require('openvibe-contracts');const { createNetworkKeys } = require('openvibe-sdk/auth');@@@ -44,6 +47,23 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {return data.results;}++ /** Subject ids → Map(subject id → { username, display_name } | null), for public names only. */++ async function profiles(subjectIds) {++ const out = new Map();++ const list = [...new Set(subjectIds.map(String))];++ for (let i = 0; i < list.length; i += BATCH) {++ const chunk = list.slice(i, i + BATCH);++ const results = await post({ subject_ids: chunk });++ for (const k of chunk) {++ const p = results[k];++ out.set(k, p && p.subject && p.subject.type === 'user'++ ? { username: typeof p.username === 'string' ? p.username : null, display_name: typeof p.display_name === 'string' ? p.display_name : null }++ : null);++ }++ }++ return out;++ }++/** Live user ids → Map(String(id) → subject id | null). */async function resolveLiveUsers(liveIds) {const out = new Map();@@@ -58,7 +78,107 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {}return out;}-- return { resolveLiveUsers };++ return { resolveLiveUsers, profiles };++}++++/**++ * Network's project-membership reads, with Billing's service token. A person's own token never authorises them:++ * Network states the role, and Billing only lists or moves money for admin/owner memberships of unarchived projects.++ */++const PROJECT_ADMINS = new Set(['admin', 'owner']);++function createProjects(config, { fetchImpl = globalThis.fetch } = {}) {++ const base = config.network.internalUrl;++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network',++ scope: 'network.project.member.read',++ fetchImpl,++ });++++ async function get(path, { retried = false } = {}) {++ const res = await fetchImpl(`${base}${path}`, {++ method: 'GET',++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },++ signal: AbortSignal.timeout(10000),++ });++ if (res.status === 401 && !retried) { tokens.invalidate(); return await get(path, { retried: true }); }++ const data = await res.json().catch(() => null);++ if (res.status === 404) return { status: 404, data: null };++ if (!res.ok || data == null) throw new Error(`network project read ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);++ return { status: res.status, data };++ }++++ /** The person's memberships, filtered to the projects they administer (admin or owner, not archived). */++ async function administered(subject) {++ const out = await get(`/internal/projects?subject=${encodeURIComponent(subject)}`);++ if (!Array.isArray(out.data.projects)) throw new Error('network project list: bad response');++ return out.data.projects.filter((membership) => !membership.archived && PROJECT_ADMINS.has(membership.role));++ }++++ /** One membership; null when the person is not a member (Network answers 404 for a non-member and an unknown project alike). */++ async function member(projectId, subject) {++ const out = await get(`/internal/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(subject)}`);++ return out.status === 404 ? null : out.data;++ }++++ return { administered, member };++}++++/**++ * Network's app registry: the name (and icon, when Network carries one) of a developer app, for the pay sheet.++ * One read of the app's project (GET /internal/projects/:project_id, network.project.read). Best effort: a++ * Network hiccup must not hide a charge, and Network's own pay sheet fills the name from dev_apps anyway.++ */++function createApps(config, { fetchImpl = globalThis.fetch, log = console } = {}) {++ const base = config.network.internalUrl;++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network',++ scope: 'network.project.read',++ fetchImpl,++ });++ const cache = new Map();++ const TTL_MS = 60 * 1000;++ let warned = false;++++ async function get(path, { retried = false } = {}) {++ const res = await fetchImpl(`${base}${path}`, {++ method: 'GET',++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },++ signal: AbortSignal.timeout(10000),++ });++ if (res.status === 401 && !retried) { tokens.invalidate(); return await get(path, { retried: true }); }++ const data = await res.json().catch(() => null);++ if (!res.ok || data == null) throw new Error(`network app read ${res.status}`);++ return data;++ }++++ /** { id, name, icon } for one app; name/icon are null when Network does not know it (or the read failed). */++ async function app(projectId, appId) {++ const key = `${projectId}:${appId}`;++ const hit = cache.get(key);++ if (hit && Date.now() - hit.at < TTL_MS) return hit.value;++ let value = { id: appId, name: null, icon: null };++ try {++ const out = await get(`/internal/projects/${encodeURIComponent(projectId)}`);++ const found = (out.apps || []).find((a) => a && a.id === appId);++ if (found) value = { id: appId, name: found.name || null, icon: found.icon || found.icon_url || null };++ } catch (err) {++ if (!warned) {++ warned = true;++ // The error names neither a person nor a secret; it is a service permission or a Network outage.++ if (log && log.warn) log.warn(`[Billing] app registry read failed (${err && err.message ? err.message : 'network'}); the pay sheet falls back to the app id`);++ }++ }++ cache.set(key, { at: Date.now(), value });++ return value;++ }++++ return { app };}--module.exports = { createKeyProvider, createIdentity };++module.exports = { createKeyProvider, createIdentity, createProjects, createApps };nnew file mode 100644@@@ -0,0 +1,151 @@++'use strict';++++/**++ * A person's own activity (D12, Network's /wallet): one row per currency part of every journal transaction that++ * touched their Balance (`usd_credit`, user:<usr_id>) or their spendable Vibes (`user_credit`). A transaction that++ * moved both (Get Vibes with Balance) has two rows with the same id, so the wallet can group them.++ *++ * Labels are the public words: Added money, AI usage, Got Vibes, Tipped @name, Paid <app>, Moved to <project>,++ * Refund. Only a creator's public name is read from Network; no other subject id, email or project secret is in a++ * row. Balance amounts are signed nano-USD decimal strings; Vibes are signed whole integers.++ */++const { fail, userSubject } = require('./common');++++const DEFAULT_LIMIT = 50;++const MAX_LIMIT = 100;++const PROVIDERS = { stripe: 'Stripe', paypal: 'PayPal', ccbill: 'CCBill', nowpayments: 'NOWPayments', powerchat: 'PowerChat' };++const APP_NAME_MAX = 80;++++function parseJson(v) {++ if (v == null) return {};++ if (typeof v === 'object') return v;++ try { return JSON.parse(v) || {}; } catch { return {}; }++}++++function titleCase(s) {++ return String(s || '').replace(/[_-]+/g, ' ').replace(/\b[a-z]/g, (c) => c.toUpperCase()).trim();++}++++/** The model in a settlement attempt's deployment_id: "<provider>:<model>". */++function modelOf(attempt) {++ if (!attempt || typeof attempt.deployment_id !== 'string') return null;++ const i = attempt.deployment_id.indexOf(':');++ const model = i >= 0 ? attempt.deployment_id.slice(i + 1) : '';++ return model ? model.slice(0, 120) : null;++}++++function labelFor(t, { settlement, projectName, creatorName }) {++ const m = parseJson(t.metadata);++ if (t.type === 'credit' && m.credit === 'settle') {++ const attempts = settlement && Array.isArray(settlement.attempts) ? settlement.attempts : [];++ const model = modelOf(attempts[attempts.length - 1]);++ const requests = attempts.length || 1;++ return { label: 'AI usage', detail: [model, `${requests} request${requests === 1 ? '' : 's'}`].filter(Boolean).join(' · ') };++ }++ if (t.type === 'credit' && m.credit === 'topup') {++ const provider = PROVIDERS[String(t.provider || m.provider || '').toLowerCase()] || titleCase(t.provider || m.provider || '');++ return { label: 'Added money', detail: provider };++ }++ if (t.type === 'purchase' && m.balance === 'vibes_from_balance') {++ const n = Number(m.vibes || 0);++ return { label: 'Got Vibes', detail: n ? `${n} Vibes` : '' };++ }++ if ((t.type === 'purchase' && (m.vibes_charge || m.vibes_charge_id || m.charge === 'vibes_pay' || m.app_name)) || t.type === 'vibes_charge') {++ const app = m.app_name || (m.app && typeof m.app === 'object' ? m.app.name : null) || (m.app && typeof m.app !== 'object' ? m.app : null);++ const name = app ? String(app).slice(0, APP_NAME_MAX) : null;++ return { label: name ? `Paid ${name}` : 'Paid an app', detail: m.description ? String(m.description).slice(0, 120) : '' };++ }++ if (t.type === 'purchase') {++ const n = Number(m.bits || m.vibes || m.amount_bits || 0);++ return { label: 'Got Vibes', detail: n ? `${n} Vibes` : '' };++ }++ if (t.type === 'balance_transfer') {++ return { label: projectName ? `Moved to ${projectName}` : 'Moved to a project', detail: '' };++ }++ if (t.type === 'donation') {++ return { label: creatorName ? `Tipped @${creatorName}` : 'Tipped a creator', detail: '' };++ }++ if (t.type === 'refund' || t.type === 'vibes_refund') return { label: 'Refund', detail: 'Returned' };++ if (t.type === 'cashout_request' || t.type === 'cashout_paid') return { label: 'Cash out', detail: 'Payable' };++ return { label: titleCase(t.type) || 'Activity', detail: '' };++}++++/**++ * GET /api/me/activity. `before` is the id of the last transaction on the previous page; the answer carries rows++ * (newest first) and `next_before` when more exist.++ */++async function activity(ctx, rawSubject, { limit = DEFAULT_LIMIT, before = null } = {}) {++ const db = ctx.db;++ const subject = userSubject(rawSubject, 'subject');++ const n = Number(limit);++ const take = Number.isSafeInteger(n) && n > 0 ? Math.min(n, MAX_LIMIT) : DEFAULT_LIMIT;++++ let cursor = null;++ if (before != null && before !== '') {++ const id = String(before);++ if (!/^txn_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) fail(422, 'billing.invalid_cursor', 'before must be a transaction id from this list');++ cursor = await db.prepare('SELECT id, created_at FROM transactions WHERE id = ?').get(id);++ if (!cursor) fail(422, 'billing.invalid_cursor', 'before names no transaction');++ }++++ const touch = `((a.kind = 'usd_credit' AND a.currency = 'nano-usd' AND a.owner_subject = ?)++ OR (a.kind = 'user_credit' AND a.currency = 'vibes-bits' AND a.owner_subject = ?))`;++ const args = [`user:${subject}`, subject];++ let where = `WHERE ${touch}`;++ if (cursor) {++ where += ' AND (t.created_at::timestamptz, t.id) < (?::timestamptz, ?)';++ args.push(cursor.created_at, cursor.id);++ }++ const txns = await db.prepare(`SELECT DISTINCT t.id, t.created_at FROM transactions t++ JOIN ledger_entries e ON e.txn_id = t.id JOIN accounts a ON a.id = e.account_id++ ${where} ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...args, take + 1);++ const page = txns.slice(0, take);++ const more = txns.length > take;++ if (!page.length) return { activity: [], next_before: null };++++ const ids = page.map((t) => t.id);++ const holes = ids.map(() => '?').join(',');++ const rows = await db.prepare(`SELECT t.id, t.type, t.created_at, t.metadata, t.provider, t.to_subject,++ a.kind AS account_kind, e.amount::text AS amount++ FROM transactions t JOIN ledger_entries e ON e.txn_id = t.id JOIN accounts a ON a.id = e.account_id++ WHERE t.id IN (${holes}) AND ${touch}++ ORDER BY t.created_at DESC, t.id DESC, a.kind`).all(...ids, `user:${subject}`, subject);++ const settlements = await db.prepare(`SELECT journal_id, attempts FROM credit_settlements WHERE journal_id IN (${holes})`).all(...ids);++ const settlementOf = new Map(settlements.map((s) => [s.journal_id, { attempts: parseJson(s.attempts) }]));++++ // Public names for the creators a tip names, and the person's project names for a transfer. Both are best-effort:++ // a Network hiccup must not hide the person's own movements.++ const donationTargets = [...new Set(rows.filter((r) => r.type === 'donation' && r.to_subject).map((r) => r.to_subject))];++ const names = new Map();++ if (donationTargets.length && ctx.network && typeof ctx.network.profiles === 'function') {++ try { for (const [k, v] of await ctx.network.profiles(donationTargets)) names.set(k, v); } catch { /* fall back to "a creator" */ }++ }++ const projects = new Map();++ if (rows.some((r) => r.type === 'balance_transfer') && ctx.projects && typeof ctx.projects.administered === 'function') {++ try { for (const p of await ctx.projects.administered(subject)) projects.set(p.project_id, p.name); } catch { /* fall back to "a project" */ }++ }++++ const byTxn = new Map();++ for (const r of rows) {++ const cur = byTxn.get(r.id) || { txn: r, nano: 0n, vibes: 0 };++ if (r.account_kind === 'usd_credit') cur.nano += BigInt(String(r.amount));++ else cur.vibes += Number(r.amount);++ byTxn.set(r.id, cur);++ }++++ const out = [];++ for (const t of page) {++ const cur = byTxn.get(t.id);++ if (!cur) continue;++ const meta = parseJson(cur.txn.metadata);++ let projectName = null;++ if (cur.txn.type === 'balance_transfer') projectName = projects.get(meta.to_project_id) || null;++ const creator = names.get(cur.txn.to_subject);++ const d = labelFor(cur.txn, { settlement: settlementOf.get(t.id), projectName, creatorName: creator && creator.username ? creator.username : null });++ if (cur.nano !== 0n) out.push({ id: t.id, at: t.created_at, kind: 'balance', label: d.label, detail: d.detail, amount: cur.nano.toString() });++ if (cur.vibes !== 0) out.push({ id: t.id, at: t.created_at, kind: 'vibes', label: d.label, detail: d.detail, amount: cur.vibes });++ }++ return { activity: out, next_before: more ? page[page.length - 1].id : null };++}++++module.exports = { activity, labelFor };nnew file mode 100644@@@ -0,0 +1,234 @@++'use strict';++++/**++ * Balance (D12): a person's or a project's prepaid USD, and the two ways a person moves it.++ *++ * GET /api/me/balance personal Balance plus the projects the person administers++ * POST /api/me/balance/transfers personal Balance -> a project's Balance (atomic, idempotent, admin only)++ * POST /api/me/vibes/from-balance personal Balance -> Vibes, at the pack value rate (atomic, idempotent)++ *++ * A person's Balance is usd_credit owned by user:<usr_id>; a project's is usd_credit owned by project:<prj_id>.++ * Everything is integer nano-USD as BigInt. The two writes are one balanced journal transaction each and are++ * idempotent by their idempotency key; a replay never moves money twice and a different request under the same++ * key is refused. The source only ever spends available Balance (balance minus active holds), so a hold can never++ * be spent out from under a transfer or a Vibes purchase.++ */++const { money, post, balance, iso, prefixedId } = require('../ledger');++const { A, entry, fail, userSubject, positiveInt, isFrozen } = require('./common');++const { A_INF, nano, heldOnAccount, ownerOf } = require('./credits');++++const KEY_RE = /^[A-Za-z0-9._:-]{8,128}$/;++const NANO_PER_USD = 1_000_000_000n;++++function assertEnabled(config) {++ if (!config.credits || !config.credits.enabled) fail(404, 'billing.not_enabled', 'Balance is not open yet');++}++++async function assertNotFrozen(db) {++ if (await isFrozen(db)) fail(503, 'billing.frozen', 'the economy is frozen: Balance writes are refused');++}++++function idempotencyKey(v) {++ const s = String(v == null ? '' : v).trim();++ if (!KEY_RE.test(s)) fail(422, 'billing.invalid_input', 'idempotency_key must be 8-128 characters of letters, digits, dot, colon, underscore or dash');++ return s;++}++++/** One side of a Balance read. `projectId` null is the person's own Balance. */++async function presentBalance(ctx, subject, projectId = null) {++ const { db } = ctx;++ const owner = ownerOf(subject, projectId);++ const ownerRef = projectId ? { type: 'project', id: projectId } : { type: 'user', id: subject };++ const row = await db.prepare(`SELECT a.id, b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id++ WHERE a.kind = 'usd_credit' AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(owner);++ let bal = 0n;++ let held = 0n;++ if (row) {++ bal = BigInt(String(row.balance));++ held = await heldOnAccount(db, row.id);++ }++ return {++ owner: ownerRef,++ balance_nano_usd: String(bal),++ held_nano_usd: String(held),++ available_nano_usd: String(bal - held < 0n ? 0n : bal - held),++ };++}++++/**++ * The person's Balance plus the Balance of every project they administer (role admin or owner, unarchived).++ * Network answers the membership list with Billing's service token; a failed read is an error, never an empty++ * list, because an empty list would hide a project from its admin.++ */++async function readBalance(ctx, subject) {++ assertEnabled(ctx.config);++ const memberships = await ctx.projects.administered(subject);++ const projects = [];++ for (const m of memberships) projects.push(await presentBalance(ctx, null, m.project_id));++ projects.sort((a, b) => (a.owner.id < b.owner.id ? -1 : a.owner.id > b.owner.id ? 1 : 0));++ return { personal: await presentBalance(ctx, subject), projects };++}++++/** The person must be an admin (or owner) of an unarchived project before its Balance is touched. */++async function assertProjectAdmin(ctx, subject, projectId) {++ const m = await ctx.projects.member(projectId, subject);++ if (!m || m.archived || !['admin', 'owner'].includes(m.role)) {++ fail(403, 'billing.not_project_admin', 'you are not an admin of that project');++ }++ return m;++}++++/**++ * Move money from the person's Balance to a project's Balance. Both accounts are locked inside one serializable++ * transaction; the source spends available (never held) Balance only, so it can never go below zero.++ */++async function transfer(ctx, { subject: rawSubject, toProjectId, amountNanoUsd, idempotencyKey: rawKey }) {++ const { db, config } = ctx;++ assertEnabled(config);++ await assertNotFrozen(db);++ const subject = userSubject(rawSubject, 'subject');++ const projectId = String(toProjectId == null ? '' : toProjectId);++ if (!/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(projectId)) fail(422, 'billing.invalid_input', 'to_project_id is a project id');++ const amount = nano(amountNanoUsd, 'amount_nano_usd');++ if (amount === 0n) fail(422, 'billing.invalid_amount', 'amount_nano_usd must be positive');++ const key = idempotencyKey(rawKey);++ await assertProjectAdmin(ctx, subject, projectId);++++ return await money(db, async () => {++ const now = ctx.now();++ const nowIso = iso(now);++ const check = async () => {++ const row = await db.prepare('SELECT *, amount_nano_usd::text AS amount_text FROM balance_transfers WHERE idempotency_key = ?').get(key);++ if (!row) return null;++ if (row.subject !== subject || row.to_project_id !== projectId || BigInt(row.amount_text) !== amount) {++ fail(409, 'billing.idempotency_key_reused', 'the same idempotency_key was used for another transfer');++ }++ return row;++ };++ const first = await check();++ if (first) return await transferResult(ctx, first, subject, projectId, amount, true);++++ const fromId = await requireBalanceAccount(db, subject, null, nowIso);++ // The account lock serializes two transfers with the same key, so the loser sees the winner's row.++ const raced = await check();++ if (raced) return await transferResult(ctx, raced, subject, projectId, amount, true);++++ const available = BigInt(String(await availableOf(db, fromId)));++ if (available < amount) fail(402, 'billing.insufficient_funds', `insufficient Balance: ${available} < ${amount} nano-USD`, { available: String(available), required: String(amount) });++ await requireBalanceAccount(db, null, projectId, nowIso);++ const { txn } = await post(ctx, {++ type: 'balance_transfer', idempotencyKey: `balance:transfer:${key}`, actor: { type: 'user', id: subject },++ fromSubject: subject, toSubject: null,++ entries: [entry(A_INF.credit(subject, null), -amount), entry(A_INF.credit(null, projectId), amount)],++ metadata: { balance: 'transfer', idempotency_key: key, to_project_id: projectId, amount_nano_usd: String(amount) },++ });++ const row = await db.prepare(`INSERT INTO balance_transfers (id, idempotency_key, subject, to_project_id, amount_nano_usd, journal_id, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?::timestamptz) RETURNING *, amount_nano_usd::text AS amount_text`)++ .get(prefixedId('btr', now), key, subject, projectId, amount, txn.id, nowIso);++ return await transferResult(ctx, row, subject, projectId, amount, false);++ });++}++++async function transferResult(ctx, row, subject, projectId, amount, replay) {++ return {++ transfer_id: row.id,++ transaction_id: row.journal_id,++ replay,++ amount_nano_usd: String(amount),++ from: await presentBalance(ctx, subject),++ to: await presentBalance(ctx, null, projectId),++ };++}++++/** Lock (creating if needed) a real usd_credit account. `projectId` null is the person's own Balance. */++async function requireBalanceAccount(db, subject, projectId, nowIso) {++ const owner = ownerOf(subject, projectId);++ const existing = await db.prepare(`SELECT id FROM accounts WHERE kind = 'usd_credit' AND owner_subject = ? AND currency = 'nano-usd'`).get(owner);++ if (!existing) {++ await db.prepare(`INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES ('usd_credit', ?, 'nano-usd', ?)++ ON CONFLICT DO NOTHING`).run(owner, nowIso);++ const made = await db.prepare(`SELECT id FROM accounts WHERE kind = 'usd_credit' AND owner_subject = ? AND currency = 'nano-usd'`).get(owner);++ await db.prepare('INSERT INTO account_balances (account_id, balance, updated_at) VALUES (?, 0, ?) ON CONFLICT DO NOTHING').run(made.id, nowIso);++ }++ const row = await db.prepare(`SELECT id FROM accounts WHERE kind = 'usd_credit' AND owner_subject = ? AND currency = 'nano-usd'`).get(owner);++ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(row.id);++ return row.id;++}++++/** Balance minus active holds (the only money a write may spend). */++async function availableOf(db, accountId) {++ const row = await db.prepare('SELECT balance::text AS balance FROM account_balances WHERE account_id = ?').get(accountId);++ const bal = BigInt(String(row ? row.balance : '0'));++ return bal - await heldOnAccount(db, accountId);++}++++/**++ * Buy Vibes with Balance at the pack value rate: price_nano = ceil(vibes * 1e9 / bits_per_usd), exact to the++ * nano-USD. The USD moves to platform_revenue:nano-usd; the Vibes are minted from fx_conversion in vibes-bits into++ * the person's Vibes account, exactly as purchases.js mints a pack.++ */++async function buyVibes(ctx, { subject: rawSubject, vibes: rawVibes, idempotencyKey: rawKey }) {++ const { db, config, rates } = ctx;++ assertEnabled(config);++ if (!config.vibes || !config.vibes.fromBalance) fail(404, 'billing.vibes_from_balance_disabled', 'Get Vibes with Balance is not open');++ await assertNotFrozen(db);++ const subject = userSubject(rawSubject, 'subject');++ const vibes = positiveInt(rawVibes, 'vibes', rates.maxBits);++ if (vibes < rates.minPurchaseBits) fail(422, 'billing.amount_too_small', `the minimum is ${rates.minPurchaseBits} Vibes`);++ const bitsPerUsd = Number(rates.bitsPerUsd);++ if (!Number.isInteger(bitsPerUsd) || bitsPerUsd <= 0) fail(503, 'billing.rates_unavailable', 'the Vibes rate is not configured');++ const price = (BigInt(vibes) * NANO_PER_USD + BigInt(bitsPerUsd) - 1n) / BigInt(bitsPerUsd);++ const key = idempotencyKey(rawKey);++++ return await money(db, async () => {++ const now = ctx.now();++ const nowIso = iso(now);++ const check = async () => {++ const row = await db.prepare('SELECT *, vibes::text AS vibes_text, price_nano_usd::text AS price_text FROM vibes_from_balance WHERE idempotency_key = ?').get(key);++ if (!row) return null;++ if (row.subject !== subject || BigInt(row.vibes_text) !== BigInt(vibes)) {++ fail(409, 'billing.idempotency_key_reused', 'the same idempotency_key was used for another Vibes purchase');++ }++ return row;++ };++ const first = await check();++ if (first) return await vibesResult(ctx, first, subject, vibes, BigInt(first.price_text), BigInt(first.bits_per_usd), true);++++ const fromId = await requireBalanceAccount(db, subject, null, nowIso);++ const raced = await check();++ if (raced) return await vibesResult(ctx, raced, subject, vibes, BigInt(raced.price_text), BigInt(raced.bits_per_usd), true);++++ const available = BigInt(String(await availableOf(db, fromId)));++ if (available < price) fail(402, 'billing.insufficient_funds', `insufficient Balance: ${available} < ${price} nano-USD for ${vibes} Vibes`, { available: String(available), required: String(price) });++ const { txn } = await post(ctx, {++ type: 'purchase', idempotencyKey: `balance:vibes:${key}`, actor: { type: 'user', id: subject },++ fromSubject: subject, toSubject: subject, provider: 'balance',++ entries: [++ entry(A_INF.credit(subject, null), -price),++ entry(A_INF.revenue(), price),++ entry(A.fxBits(), -vibes),++ entry(A.credit(subject), vibes),++ ],++ metadata: { balance: 'vibes_from_balance', idempotency_key: key, vibes, price_nano_usd: String(price), bits_per_usd: bitsPerUsd },++ });++ const row = await db.prepare(`INSERT INTO vibes_from_balance (id, idempotency_key, subject, vibes, price_nano_usd, bits_per_usd, journal_id, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?::timestamptz) RETURNING *, vibes::text AS vibes_text, price_nano_usd::text AS price_text`)++ .get(prefixedId('vfb', now), key, subject, vibes, price, bitsPerUsd, txn.id, nowIso);++ return await vibesResult(ctx, row, subject, vibes, price, BigInt(bitsPerUsd), false);++ });++}++++async function vibesResult(ctx, row, subject, vibes, price, bitsPerUsd, replay) {++ return {++ purchase_id: row.id,++ transaction_id: row.journal_id,++ replay,++ vibes: Number(vibes),++ price_nano_usd: String(price),++ bits_per_usd: Number(bitsPerUsd),++ balance: await presentBalance(ctx, subject),++ vibes_credit: await balance(ctx.db, A.credit(subject)),++ };++}++++module.exports = { readBalance, presentBalance, transfer, buyVibes, assertProjectAdmin, idempotencyKey, availableOf };@@@ -4,6 +4,8 @@* Cashouts (MONEY leaving the platform) and recycling (MONEY → CREDIT, ADR-012 rule 4).** request() creator_payable → payouts_pending; escrow_until = now + escrowDays++ * with project_id: project_payable:<prj> → payouts_pending:<the project's owner> (D12 Vibes Pay). The++ * owner role is checked with Network's membership read; deny returns to the owner's creator_payable.* approve() payouts_pending → out through the payout provider; needs a payout reference from* the provider and is refused while the escrow period runs (rule 10)* deny() reverses the request: payouts_pending → creator_payable@@@ -22,7 +24,7 @@ async function find(db, id) { return parse(await db.prepare('SELECT * FROM cashofunction present(c) {if (!c) return null;return {-- id: c.id, subject: { type: 'user', id: c.subject }, amount_bits: c.amount_bits, value_cents: c.value_cents,++ id: c.id, subject: { type: 'user', id: c.subject }, project_id: c.project_id || null, amount_bits: c.amount_bits, value_cents: c.value_cents,status: c.status, payout_method: c.payout_method, escrow_until: c.escrow_until,request_txn: c.request_txn, settle_txn: c.settle_txn || null,payout_provider: c.payout_provider || null, payout_reference: c.payout_reference || null,@@@ -56,23 +58,42 @@ async function request(ctx, input) {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);++ const projectId = input.projectId == null || input.projectId === '' ? null : String(input.projectId);++ if (projectId && !/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(projectId)) fail(422, 'billing.invalid_input', 'project_id is a project id');++ if (projectId) {++ // D12: only the project's owner cashes out its Vibes, under the same escrow and payout rules as a creator.++ const m = await ctx.projects.member(projectId, input.subject);++ if (!m || m.archived || m.role !== 'owner') fail(403, 'billing.not_project_owner', "only the project's owner may cash out its Vibes");++ }++ const source = projectId ? A.projectPayable(projectId) : A.payable(input.subject);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 existing = await db.prepare('SELECT * FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);++ if (existing) {++ const meta = JSON.parse(existing.metadata || '{}');++ if (existing.from_subject !== input.subject || Number(meta.amount_bits) !== amount || (meta.project_id || null) !== projectId) {++ fail(409, 'billing.idempotency_key_reused', 'the same idempotency_key was used for another cashout');++ }++ return { cashout: parse(await db.prepare('SELECT * FROM cashouts WHERE request_txn = ?').get(existing.id)), replay: true };++ }++ await requireFunds(db, source, amount, projectId ? 'the project\'s Vibes' : '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 entries = projectId++ // The project's Vibes become the owner's payable in the same request, then the normal cashout escrow++ // applies. A deny returns them to the owner's creator_payable (payouts are for the owner).++ ? [entry(source, -amount), entry(A.payable(input.subject), amount), entry(A.payable(input.subject), -amount), entry(A.pending(input.subject), amount)]++ : [entry(source, -amount), entry(A.pending(input.subject), amount)];const { txn } = await post(ctx, {type: 'cashout_request',idempotencyKey: input.idempotencyKey,-- entries: [entry(A.payable(input.subject), -amount), entry(A.pending(input.subject), amount)],++ entries,actor: input.actor,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(), ...(projectId ? { project_id: projectId } : {}) },});-- 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));++ await db.prepare(`INSERT INTO cashouts (id, subject, project_id, amount_bits, value_cents, status, payout_method, escrow_until, request_txn, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, 'requested', ?, ?, ?, ?, ?)`).run(id, input.subject, projectId, amount, rates.valueCents(amount), JSON.stringify(method), escrowUntil, txn.id, iso(ms), iso(ms));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 };@@@ -20,6 +20,15 @@ const A = {promo: (s) => ({ kind: 'promo_credit', owner: s, currency: BITS }),promoReserve: () => ({ kind: 'promo_reserve', owner: null, currency: BITS }),payable: (s) => ({ kind: 'creator_payable', owner: s, currency: BITS }),++ // D12 Vibes Pay: a project's received Vibes wait here until its owner cashes them out through the existing++ // cashout path. A separate kind from creator_payable because the owner is a project, not a person: a personal++ // cashout, account export or tip-refund must never see a project's money.++ projectPayable: (projectId) => ({ kind: 'project_payable', owner: `project:${projectId}`, currency: BITS }),++ // Sandbox Vibes (D12): the app's test pocket for a person, the app's test project wallet, and the issuer the++ // grants come from. All three are test-only and never mixed with the real kinds above.++ vibesSandbox: (appId, subject) => ({ kind: 'vibes_credit_sandbox', owner: `sandbox:${appId}:user:${subject}`, currency: BITS }),++ projectPayableSandbox: (appId, projectId) => ({ kind: 'project_payable_sandbox', owner: `sandbox:${appId}:project:${projectId}`, currency: BITS }),++ vibesSandboxIssuer: () => ({ kind: 'vibes_sandbox', owner: null, currency: BITS }),pending: (s) => ({ kind: 'payouts_pending', owner: s, currency: BITS }),clearing: (provider) => ({ kind: 'provider_clearing', owner: provider, currency: CENTS }),revenue: (currency) => ({ kind: 'platform_revenue', owner: null, currency }),nnew file mode 100644@@@ -0,0 +1,584 @@++'use strict';++++/**++ * Balance: the prepaid USD balance for metered services (AI Exchange D12). All amounts are integer nano-USD (1e-9 USD),++ * represented as JS BigInt. A person's Balance is kind='usd_credit', currency='nano-usd', owner_subject='user:<usr_id>';++ * a project's Balance is the same kind and currency owned by 'project:<prj_id>'. Holds are reservations; settlement++ * moves the actual charge to platform_revenue:nano-usd and releases the unused reservation. Sandbox balances follow the++ * same split under usd_credit_sandbox and are only available when BILLING_CREDITS_SANDBOX=1.++ *++ * Nothing here goes through chargeFor or the Vibes bits ledger.++ */++const { ids } = require('openvibe-contracts');++const { enqueue } = require('../outbox');++const { post, balance, requireFunds, money, iso, prefixedId } = require('../ledger');++const { A, entry, fail, userSubject, positiveInt, text, isFrozen, MAX_RECEIPT_CENTS } = require('./common');++const { valuesFromEnv, parseAmounts } = require('../config-store');++++const CURRENCY = 'nano-usd';++// D9: 1 cent = 10,000,000 nano-USD. The checkout top-up is priced in whole cents at the edge.++const NANO_PER_CENT = 10_000_000n;++const ACTOR = { type: 'service', id: 'billing' };++const ACTIVE_STATUSES = ['active', 'extended'];++const TERMINAL_STATUSES = ['settled', 'released', 'expired'];++++/**++ * The owner's Balance top-up terms: the only reader of the amounts, the fee and the top-up switch.++ *++ * D11: they come from Billing's own config namespace `billing.credits`, through the store on ctx, so the++ * owner sets them from Network's AI tab and a change applies live. There is no default amount and no++ * default fee: with either unset or invalid the result is `configured: false` and the person's route++ * refuses (503 billing.topup_unconfigured). A context without a store (a unit test) reads the old env++ * names, which are also the namespace's x-env pins while they are set.++ *++ * Returns { enabled, amountsCents: number[], feeBps: number|null, configured, problem }.++ */++function topupTerms(ctx) {++ const values = ctx && ctx.configStore ? ctx.configStore.get() : valuesFromEnv(ctx && ctx.env);++ const problems = [];++ const enabled = values.topup === true;++ let amountsCents = [];++ const raw = values.topup_amounts_cents;++ if (raw == null || (Array.isArray(raw) && !raw.length)) {++ problems.push('topup_amounts_cents is not set');++ } else {++ const parsed = parseAmounts(raw);++ if (parsed.empty) problems.push('topup_amounts_cents is not set');++ else for (const s of parsed.invalid) problems.push(`topup_amounts_cents has an invalid amount "${s}"`);++ amountsCents = parsed.values;++ }++ let feeBps = null;++ const rawFee = values.topup_fee_bps == null ? '' : String(values.topup_fee_bps).trim();++ if (!rawFee) {++ problems.push('topup_fee_bps is not set');++ } else if (!/^[0-9]+$/.test(rawFee) || !Number.isSafeInteger(Number(rawFee)) || Number(rawFee) > 10_000) {++ problems.push(`topup_fee_bps must be an integer from 0 to 10000 (got "${rawFee}")`);++ } else {++ feeBps = Number(rawFee);++ }++ return { enabled, amountsCents, feeBps, configured: problems.length === 0, problem: problems.length ? problems.join('; ') : null };++}++++/** The one owner format (D12): a person's Balance is user:<usr_id>; a project's is project:<prj_id>. */++const ownerOf = (subject, projectId) => (projectId ? `project:${projectId}` : `user:${subject}`);++++const A_INF = {++ credit: (subject, projectId) => ({ kind: 'usd_credit', owner: ownerOf(subject, projectId), currency: CURRENCY }),++ // A person's or project's sandbox Balance (D10/D12): its own account, spent only by a hold marked sandbox, so a++ // grant never becomes spending power on real supply.++ sandboxCredit: (subject, projectId) => ({ kind: 'usd_credit_sandbox', owner: ownerOf(subject, projectId), currency: CURRENCY }),++ // The issuer the grants come from and sandbox charges return to (owner null).++ sandbox: () => ({ kind: 'usd_credit_sandbox', owner: null, currency: CURRENCY }),++ revenue: () => ({ kind: 'platform_revenue', owner: null, currency: CURRENCY }),++ // A top-up's money arrives from the checkout provider in nano-USD (D9), not through the Vibes usd-cents clearing.++ clearing: (provider) => ({ kind: 'provider_clearing', owner: provider, currency: CURRENCY }),++ // The spent part of a refunded top-up: a debt of the person, reported by reconciliation, never written off.++ debt: () => ({ kind: 'credit_topup_debt', owner: null, currency: CURRENCY }),++};++++const nano = (v, field) => {++ if (typeof v === 'bigint') {++ if (v < 0n) fail(422, 'billing.invalid_amount', `${field} must be non-negative`);++ return v;++ }++ if (typeof v === 'number') {++ if (!Number.isSafeInteger(v) || v < 0) fail(422, 'billing.invalid_amount', `${field} must be a safe integer`);++ return BigInt(v);++ }++ if (typeof v === 'string' && /^[0-9]+$/.test(v)) {++ return BigInt(v);++ }++ fail(422, 'billing.invalid_amount', `${field} must be integer nano-USD`);++};++++const nanoNonNeg = nano;++++const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** A hold's SubjectRef: a person, or (with a project) an app acting for that project. */++function parseSubject(v) {++ if (v && typeof v === 'object' && v.type === 'app') {++ if (typeof v.id !== 'string' || !APP_ID_RE.test(v.id)) fail(422, 'billing.invalid_subject', 'an app subject must be { type: "app", id: "app_…" }');++ return { type: 'app', id: v.id };++ }++ if (v && typeof v === 'object' && v.type === 'user') return { type: 'user', id: userSubject(v) };++ if (typeof v === 'string' && v.startsWith('user:')) return { type: 'user', id: v.slice(5) };++ return { type: 'user', id: userSubject(v) };++}++++function assertEnabled(config) {++ if (!config.credits || !config.credits.enabled) fail(503, 'billing.not_enabled', 'BILLING_CREDITS is off');++}++++function assertSandbox(config) {++ if (!config.credits || !config.credits.sandbox) fail(503, 'billing.sandbox_disabled', 'BILLING_CREDITS_SANDBOX is off');++}++++async function assertNotFrozen(db) {++ if (await isFrozen(db)) fail(503, 'billing.frozen', 'the economy is frozen: inference writes are refused');++}++++const KINDS = new Set(['usd_credit', 'usd_credit_sandbox']);++++/**++ * The account row id for a (subject, project_id), selected FOR UPDATE. Creates the account and balance row if absent.++ * `kind` is usd_credit (real Balance) or usd_credit_sandbox (that person's sandbox credit for the project).++ */++async function lockAccount(db, subject, projectId, nowIso, kind = 'usd_credit') {++ if (!KINDS.has(kind)) throw new Error(`lockAccount: unknown kind ${kind}`);++ const owner = ownerOf(subject, projectId);++ let row = await db.prepare(`SELECT a.id FROM accounts a WHERE a.kind = ? AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(kind, owner);++ if (!row) {++ // Two first holds at once: one inserts, the other finds it (the account's unique key), never a 500.++ await db.prepare(`INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES (?, ?, 'nano-usd', ?)++ ON CONFLICT DO NOTHING`).run(kind, owner, nowIso);++ row = await db.prepare(`SELECT a.id FROM accounts a WHERE a.kind = ? AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(kind, owner);++ await db.prepare('INSERT INTO account_balances (account_id, balance, updated_at) VALUES (?, 0, ?) ON CONFLICT DO NOTHING').run(row.id, nowIso);++ }++ // Re-select FOR UPDATE to serialize concurrent holds on the same account.++ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(row.id);++ return row.id;++}++++/** Sum of active holds for an account. */++async function heldOnAccount(db, accountId) {++ const r = await db.prepare(`SELECT COALESCE(SUM(held_nano_usd), 0)::text AS held FROM credit_holds++ WHERE account_id = ? AND status IN ('active', 'extended')`).get(accountId);++ return BigInt(String(r.held));++}++++/** Available balance = credit balance - active holds. */++async function available(db, accountId) {++ const row = await db.prepare(`SELECT b.balance::text AS balance FROM account_balances b WHERE b.account_id = ?`).get(accountId);++ const bal = BigInt(String(row.balance));++ return bal - await heldOnAccount(db, accountId);++}++++function presentHold(row, availableAfter = null) {++ return {++ hold_id: row.id,++ subject: { type: row.subject_type || 'user', id: row.subject },++ project_id: row.project_id,++ credential_id: row.credential_id || null,++ service: row.service || 'ai',++ held_nano_usd: String(row.held_nano_usd),++ // What the account has left to hold after this one (null when not read with the hold).++ available_nano_usd: availableAfter == null ? null : String(availableAfter < 0n ? 0n : availableAfter),++ max_nano_usd: row.max_nano_usd == null ? null : String(row.max_nano_usd),++ rate_version: row.rate_version,++ request_fingerprint: row.request_fingerprint,++ lease_expires_at: row.lease_expires_at,++ status: row.status,++ created_at: row.created_at,++ };++}++++function presentSettlement(row) {++ return {++ settlement_id: row.id,++ hold_id: row.hold_id,++ subject: { type: row.subject_type || 'user', id: row.subject },++ project_id: row.project_id,++ charge_nano_usd: String(row.charge_nano_usd),++ released_nano_usd: String(row.released_nano_usd),++ shortfall_nano_usd: String(row.shortfall_nano_usd == null ? 0 : row.shortfall_nano_usd),++ journal_id: row.journal_id,++ usage: row.usage,++ retail: row.retail,++ supply_cost_nano_usd: String(row.supply_cost_nano_usd),++ byok: !!row.byok,++ attempts: row.attempts,++ created_at: row.created_at,++ };++}++++/**++ * Create or replay a hold. Idempotent by idempotency_key + request_fingerprint.++ * Returns { hold, replay }.++ */++async function createHold(ctx, input) {++ const { db, config } = ctx;++ assertEnabled(config);++ await assertNotFrozen(db);++ const subject = parseSubject(input.subject);++ const projectId = text(input.project_id, 'project_id');++ if (projectId && !PROJECT_ID_RE.test(projectId)) fail(422, 'billing.invalid_input', 'project_id must be a project ULID');++ // A personal hold (no project) always spends the person's own Balance. A project hold spends the project's.++ if (!projectId && subject.type !== 'user') fail(422, 'billing.invalid_input', 'an app subject needs a project_id');++ const maxNano = nanoNonNeg(input.max_nano_usd, 'max_nano_usd');++ const rateVersion = text(input.rate_version, 'rate_version', 200) || fail(422, 'billing.invalid_input', 'rate_version is required');++ const fingerprint = text(input.request_fingerprint, 'request_fingerprint', 1024) || fail(422, 'billing.invalid_input', 'request_fingerprint is required');++ const idempotencyKey = text(input.idempotency_key, 'idempotency_key', 128) || fail(422, 'billing.invalid_input', 'idempotency_key is required');++ const credentialId = input.credential_id ? text(input.credential_id, 'credential_id') : null;++ // Which metered service spends the credit (D9): AI inference today; compute and others later.++ const service = String(input.service == null ? 'ai' : input.service);++ if (!/^[a-z][a-z0-9-]{0,39}$/.test(service)) fail(422, 'billing.invalid_input', 'service is a lowercase service name (ai, run, ...)');++ const leaseSeconds = Math.max(1, Math.min(config.credits.leaseSeconds || 300, 86400));++ // D10: a sandbox caller's hold draws on its sandbox credit only; a real hold never sees sandbox credit.++ const sandbox = input.sandbox === true;++ if (sandbox) assertSandbox(config);++++ return await money(db, async () => {++ const now = ctx.now();++ const nowIso = iso(now);++ const existing = await db.prepare('SELECT * FROM credit_holds WHERE idempotency_key = ?').get(idempotencyKey);++ if (existing) {++ if (existing.request_fingerprint !== fingerprint) fail(409, 'billing.hold_key_reused', 'the same idempotency_key with a different fingerprint is already used');++ return { hold: presentHold(existing, await available(db, existing.account_id)), replay: true };++ }++ const accountId = await lockAccount(db, subject.id, projectId, nowIso, sandbox ? 'usd_credit_sandbox' : 'usd_credit');++ // The same key at the same moment: the account lock serializes them, so the second sees the first's hold here.++ const raced = await db.prepare('SELECT * FROM credit_holds WHERE idempotency_key = ?').get(idempotencyKey);++ if (raced) {++ if (raced.request_fingerprint !== fingerprint) fail(409, 'billing.hold_key_reused', 'the same idempotency_key with a different fingerprint is already used');++ return { hold: presentHold(raced, await available(db, raced.account_id)), replay: true };++ }++ const avail = await available(db, accountId);++ if (avail < maxNano) fail(402, 'billing.insufficient_funds', `insufficient ${sandbox ? 'sandbox ' : ''}Balance: ${avail} < ${maxNano} nano-USD`, { available: String(avail), required: String(maxNano) });++++ const holdId = prefixedId('hld', now);++ const leaseExpires = iso(now + leaseSeconds * 1000);++ const row = await db.prepare(`INSERT INTO credit_holds++ (id, idempotency_key, subject, subject_type, project_id, credential_id, service, account_id, held_nano_usd, max_nano_usd, rate_version, request_fingerprint, lease_expires_at, status, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::timestamptz, 'active', ?::timestamptz, ?::timestamptz) RETURNING *`).get(++ holdId, idempotencyKey, subject.id, subject.type, projectId, credentialId, service, accountId, maxNano, maxNano, rateVersion, fingerprint, leaseExpires, nowIso, nowIso);++ return { hold: presentHold(row, avail - maxNano), replay: false };++ });++}++++/**++ * Extend an active hold. Grows held_nano_usd by add_nano_usd; answers 402 if the account has no room.++ */++async function extendHold(ctx, input) {++ const { db, config } = ctx;++ assertEnabled(config);++ await assertNotFrozen(db);++ const holdId = text(input.hold_id, 'hold_id', 100) || fail(422, 'billing.invalid_input', 'hold_id is required');++ const addNano = nanoNonNeg(input.add_nano_usd, 'add_nano_usd');++++ return await money(db, async () => {++ const now = ctx.now();++ const nowIso = iso(now);++ const hold = await db.prepare('SELECT * FROM credit_holds WHERE id = ? FOR UPDATE').get(holdId);++ if (!hold) fail(404, 'billing.hold_not_found', `no hold ${holdId}`);++ // Past its lease but not yet swept, a hold still counts against the balance, so it may still grow: the sweeper alone++ // decides expiry (a settle that arrives late must not lose billable usage).++ if (TERMINAL_STATUSES.includes(hold.status)) fail(409, 'billing.hold_terminal', `hold ${holdId} is already ${hold.status}`);++++ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(hold.account_id);++ const avail = await available(db, hold.account_id);++ if (avail < addNano) fail(402, 'billing.insufficient_funds', `cannot extend hold: ${avail} < ${addNano} nano-USD available`, { available: String(avail), required: String(addNano) });++++ const newHeld = BigInt(String(hold.held_nano_usd)) + addNano;++ const row = await db.prepare(`UPDATE credit_holds SET held_nano_usd = ?, max_nano_usd = ?, status = 'extended', lease_expires_at = GREATEST(lease_expires_at, ?::timestamptz), updated_at = ?::timestamptz WHERE id = ? RETURNING *`).get(++ newHeld, newHeld, iso(now + 60 * 1000), nowIso, holdId);++ return { hold: presentHold(row) };++ });++}++++/** Compute retail charge from usage and rate (ceil once per component). */++function computeCharge(usage, retail) {++ const perM = retail.nano_usd_per_m;++ const components = [++ [BigInt(usage.input_tokens || 0), nano(perM.input, 'retail.nano_usd_per_m.input')],++ [BigInt(usage.cached_read_tokens || 0), nano(perM.cached_read, 'retail.nano_usd_per_m.cached_read')],++ [BigInt(usage.cache_write_tokens || 0), nano(perM.cache_write, 'retail.nano_usd_per_m.cache_write')],++ [BigInt(usage.output_tokens || 0), nano(perM.output, 'retail.nano_usd_per_m.output')],++ ];++ let charge = 0n;++ for (const [tokens, rate] of components) {++ if (tokens === 0n || rate === 0n) continue;++ // ceil(tokens * rate / 1_000_000)++ const num = tokens * rate;++ const div = num / 1_000_000n;++ charge += (num % 1_000_000n === 0n ? div : div + 1n);++ }++ charge += nano(retail.per_request_nano_usd, 'retail.per_request_nano_usd');++ return charge;++}++++/**++ * Settle a hold. Idempotent by idempotency_key. Charges the computed retail amount, releases the rest.++ * Returns { settlement, replay }.++ */++async function settleHold(ctx, input) {++ const { db, config } = ctx;++ assertEnabled(config);++ await assertNotFrozen(db);++ const holdId = text(input.hold_id, 'hold_id', 100) || fail(422, 'billing.invalid_input', 'hold_id is required');++ const idempotencyKey = text(input.idempotency_key, 'idempotency_key', 128) || fail(422, 'billing.invalid_input', 'idempotency_key is required');++ const usage = input.usage || {};++ const retail = input.retail || {};++ const byok = !!input.byok;++ const supplyCost = nano(input.supply_cost_nano_usd || '0', 'supply_cost_nano_usd');++ const attempts = Array.isArray(input.attempts) ? input.attempts : [];++++ const tokenOk = (v) => Number.isSafeInteger(v) && v >= 0;++ if (!tokenOk(usage.input_tokens) || !tokenOk(usage.cached_read_tokens) || !tokenOk(usage.cache_write_tokens) || !tokenOk(usage.output_tokens)) {++ fail(422, 'billing.invalid_input', 'usage must have non-negative integer token counts');++ }++ if (!retail.version || typeof retail.nano_usd_per_m !== 'object' || typeof retail.per_request_nano_usd === 'undefined') {++ fail(422, 'billing.invalid_input', 'retail must have version, nano_usd_per_m and per_request_nano_usd');++ }++++ const charge = byok ? 0n : computeCharge(usage, retail);++++ return await money(db, async () => {++ const now = ctx.now();++ const nowIso = iso(now);++++ const replayOf = (row) => {++ // A key names one settlement of one hold: the same key for another hold is a conflict, never its answer.++ if (row.hold_id !== holdId) fail(409, 'billing.settle_key_reused', 'this idempotency_key already settled another hold');++ return { settlement: presentSettlement(row), replay: true };++ };++ const existing = await db.prepare('SELECT * FROM credit_settlements WHERE idempotency_key = ?').get(idempotencyKey);++ if (existing) return replayOf(existing);++++ const hold = await db.prepare('SELECT * FROM credit_holds WHERE id = ? FOR UPDATE').get(holdId);++ if (!hold) fail(404, 'billing.hold_not_found', `no hold ${holdId}`);++ // Two settles at once with one key: the hold lock serializes them; the second sees the first's settlement.++ const raced = await db.prepare('SELECT * FROM credit_settlements WHERE idempotency_key = ?').get(idempotencyKey);++ if (raced) return replayOf(raced);++ if (TERMINAL_STATUSES.includes(hold.status)) fail(409, 'billing.hold_terminal', `hold ${holdId} is already ${hold.status}`);++++ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(hold.account_id);++ const held = BigInt(String(hold.held_nano_usd));++ // Usage beyond the hold (a stream longer than its estimate that was not extended) is taken from what the account++ // still has, never below zero; what cannot be collected is recorded as a shortfall for reconciliation, so++ // billable usage is never silently lost and the balance never goes negative.++ let collected = charge;++ let shortfall = 0n;++ if (charge > held) {++ const room = held + await available(db, hold.account_id); // the account's other room (this hold excluded)++ if (charge > room) { collected = room; shortfall = charge - room; }++ }++ const released = collected >= held ? 0n : held - collected;++++ const sandbox = (await kindOf(db, hold.account_id)) === 'usd_credit_sandbox';++ const { txn } = await post(ctx, {++ type: 'credit', idempotencyKey: `credit:settle:${idempotencyKey}`, actor: input.actor || ACTOR,++ fromSubject: hold.subject, toSubject: null, ...(sandbox ? { test: true } : {}),++ entries: sandbox++ ? [entry(A_INF.sandboxCredit(hold.subject, hold.project_id), -collected), entry(A_INF.sandbox(), collected)]++ : [entry(A_INF.credit(hold.subject, hold.project_id), -collected), entry(A_INF.revenue(), collected)],++ metadata: {++ credit: 'settle', hold_id: holdId, project_id: hold.project_id, credential_id: hold.credential_id,++ usage, retail, byok, supply_cost_nano_usd: String(supplyCost), attempts, released_nano_usd: String(released),++ charge_nano_usd: String(charge), shortfall_nano_usd: String(shortfall),++ },++ });++++ await db.prepare(`UPDATE credit_holds SET status = 'settled', held_nano_usd = 0, updated_at = ?::timestamptz WHERE id = ?`).run(nowIso, holdId);++ const row = await db.prepare(`INSERT INTO credit_settlements++ (id, hold_id, idempotency_key, subject, subject_type, project_id, account_id, charge_nano_usd, released_nano_usd, shortfall_nano_usd, journal_id, usage, retail, supply_cost_nano_usd, byok, attempts, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?, ?::jsonb, ?::timestamptz) RETURNING *`).get(++ prefixedId('stl', now), holdId, idempotencyKey, hold.subject, hold.subject_type || 'user', hold.project_id, hold.account_id,++ collected, released, shortfall, txn.id, JSON.stringify(usage), JSON.stringify(retail), supplyCost, byok, JSON.stringify(attempts), nowIso);++ // billing.credit.settled@1, in the same transaction: for aggregates only (no subject, project or account).++ const answered = attempts.filter((a) => a && !a.error_code).pop() || attempts[attempts.length - 1] || null;++ await enqueue(ctx, {++ event_type: 'billing.credit.settled', subject: { type: 'credit-settlement', id: row.id },++ payload: {++ settlement_id: row.id, hold_id: holdId, service: hold.service || 'ai', settled_at: nowIso,++ deployment_id: answered && answered.deployment_id ? String(answered.deployment_id) : null,++ provider_access: answered && answered.provider_access ? answered.provider_access : null,++ rate_version: String(retail.version),++ usage: { input_tokens: usage.input_tokens, cached_read_tokens: usage.cached_read_tokens, cache_write_tokens: usage.cache_write_tokens, output_tokens: usage.output_tokens },++ charge_nano_usd: String(collected), supply_cost_nano_usd: String(supplyCost), shortfall_nano_usd: String(shortfall),++ released_nano_usd: String(released), byok, sandbox,++ },++ });++ return { settlement: presentSettlement(row), replay: false };++ });++}++++/**++ * Release a hold with nothing charged. Idempotent: a replay returns the same result.++ */++async function releaseHold(ctx, input) {++ const { db, config } = ctx;++ assertEnabled(config);++ await assertNotFrozen(db);++ const holdId = text(input.hold_id, 'hold_id', 100) || fail(422, 'billing.invalid_input', 'hold_id is required');++++ return await money(db, async () => {++ const now = ctx.now();++ const nowIso = iso(now);++ const hold = await db.prepare('SELECT * FROM credit_holds WHERE id = ? FOR UPDATE').get(holdId);++ if (!hold) fail(404, 'billing.hold_not_found', `no hold ${holdId}`);++ if (hold.status === 'released') return { hold_id: holdId, released_nano_usd: String(hold.max_nano_usd) };++ if (hold.status === 'settled') fail(409, 'billing.hold_terminal', `hold ${holdId} is already settled`);++ if (TERMINAL_STATUSES.includes(hold.status)) fail(409, 'billing.hold_terminal', `hold ${holdId} is already ${hold.status}`);++++ const released = BigInt(String(hold.held_nano_usd));++ await db.prepare(`UPDATE credit_holds SET status = 'released', held_nano_usd = 0, updated_at = ?::timestamptz WHERE id = ?`).run(nowIso, holdId);++ return { hold_id: holdId, released_nano_usd: String(released) };++ });++}++++/**++ * Release holds whose lease has expired. Called by the scheduled sweeper.++ */++async function sweepExpired(ctx) {++ const { db, config } = ctx;++ if (!config.credits || !config.credits.enabled) return { released: 0 };++ if (await isFrozen(db)) return { released: 0, frozen: true };++ const nowIso = iso(ctx.now());++ const due = await db.prepare(`SELECT id FROM credit_holds WHERE status IN ('active', 'extended') AND lease_expires_at <= ?::timestamptz ORDER BY lease_expires_at, id FOR UPDATE`).all(nowIso);++ let released = 0;++ for (const { id } of due) {++ await db.prepare(`UPDATE credit_holds SET status = 'expired', held_nano_usd = 0, updated_at = ?::timestamptz WHERE id = ? AND status IN ('active', 'extended')`).run(nowIso, id);++ released++;++ }++ return { released };++}++++/**++ * Sandbox grant: add synthetic inference credit to an account. Only when BILLING_CREDITS_SANDBOX=1.++ */++async function sandboxGrant(ctx, input) {++ const { db, config } = ctx;++ assertEnabled(config);++ assertSandbox(config);++ await assertNotFrozen(db);++ const subject = parseSubject(input.subject);++ const projectId = text(input.project_id, 'project_id');++ if (projectId && !PROJECT_ID_RE.test(projectId)) fail(422, 'billing.invalid_input', 'project_id must be a project ULID');++ // A personal hold (no project) always spends the person's own Balance. A project hold spends the project's.++ if (!projectId && subject.type !== 'user') fail(422, 'billing.invalid_input', 'an app subject needs a project_id');++ const amount = nanoNonNeg(input.amount_nano_usd, 'amount_nano_usd');++ if (amount === 0n) fail(422, 'billing.invalid_amount', 'amount_nano_usd must be positive');++ const reason = text(input.reason, 'reason', 500) || 'sandbox grant';++++ return await money(db, async () => {++ const now = ctx.now();++ const nowIso = iso(now);++ const accountId = await lockAccount(db, subject.id, projectId, nowIso, 'usd_credit_sandbox');++ const grantId = prefixedId('igr', now);++ const { txn } = await post(ctx, {++ type: 'credit', idempotencyKey: `credit:sandbox:${grantId}`, actor: input.actor || ACTOR, toSubject: subject.id,++ test: true,++ entries: [entry(A_INF.sandbox(), -amount), entry(A_INF.sandboxCredit(subject.id, projectId), amount)],++ metadata: { credit: 'sandbox_grant', grant_id: grantId, project_id: projectId, reason, amount_nano_usd: String(amount) },++ });++ await db.prepare(`INSERT INTO credit_sandbox_grants (id, subject, project_id, account_id, amount_nano_usd, journal_id, reason, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?::timestamptz)`).run(grantId, subject.id, projectId, accountId, amount, txn.id, reason, nowIso);++ return { grant_id: grantId, amount_nano_usd: String(amount), balance_nano_usd: String(await balance(db, A_INF.sandboxCredit(subject.id, projectId))) };++ });++}++++/**++ * A person's Balance statement (GET /api/me/credits): the balance, what is held now, what is available, the last 50++ * top-ups beside the last 50 charges (no prompt or answer is ever here: only counts, prices and ids). With a++ * projectId it reads that project's Balance instead of the person's. A top-up's reversal shows what was clawed back++ * and what remains a debt.++ */++async function statement(ctx, subject, projectId = null, { limit = 50 } = {}) {++ const { db, config } = ctx;++ assertEnabled(config);++ const owner = ownerOf(subject, projectId);++ const acct = await db.prepare(`SELECT a.id, b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id++ WHERE a.kind = 'usd_credit' AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(owner);++ // Sandbox credit is shown apart (D10): it never pays for a real request, so it is never part of the balance.++ const box = await db.prepare(`SELECT a.id, b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id++ WHERE a.kind = 'usd_credit_sandbox' AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(owner);++ let sandbox = null;++ if (box) {++ const boxHeld = await heldOnAccount(db, box.id);++ const boxBal = BigInt(String(box.balance));++ sandbox = { balance_nano_usd: String(boxBal), held_nano_usd: String(boxHeld), available_nano_usd: String(boxBal - boxHeld < 0n ? 0n : boxBal - boxHeld) };++ }++ const ownerRef = projectId ? { type: 'project', id: projectId } : { type: 'user', id: subject };++ if (!acct) return { owner: ownerRef, project_id: projectId || null, balance_nano_usd: '0', held_nano_usd: '0', available_nano_usd: '0', sandbox, topups: [], charges: [] };++ const held = await heldOnAccount(db, acct.id);++ const rows = await db.prepare(`SELECT s.*, h.service FROM credit_settlements s JOIN credit_holds h ON h.id = s.hold_id++ WHERE s.account_id = ? ORDER BY s.created_at DESC, s.id DESC LIMIT ?`).all(acct.id, Math.max(1, Math.min(200, limit)));++ const tops = await db.prepare(`SELECT t.id, t.created_at, t.provider, t.receipt_ref, t.metadata FROM transactions t++ WHERE json_extract(t.metadata, '$.credit') = 'topup'++ AND EXISTS (SELECT 1 FROM ledger_entries e WHERE e.txn_id = t.id AND e.account_id = ?)++ ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(acct.id, Math.max(1, Math.min(200, limit)));++ const topIds = tops.map((r) => r.id);++ const revs = topIds.length ? await db.prepare(`SELECT r.reverses_txn, r.type, r.created_at,++ json_extract(r.metadata, '$.cents') AS cents, json_extract(r.metadata, '$.clawed_nano_usd') AS clawed,++ json_extract(r.metadata, '$.debt_nano_usd') AS debt++ FROM transactions r WHERE r.reverses_txn IN (${topIds.map(() => '?').join(',')}) ORDER BY r.created_at, r.id`).all(...topIds) : [];++ const reversed = new Map();++ for (const r of revs) {++ const o = reversed.get(r.reverses_txn) || { cents: 0, clawed: 0n, debt: 0n };++ o.cents += Number(r.cents || 0);++ o.clawed += BigInt(r.clawed || 0);++ o.debt += BigInt(r.debt || 0);++ reversed.set(r.reverses_txn, o);++ }++ const bal = BigInt(String(acct.balance));++ return {++ owner: ownerRef,++ project_id: projectId || null,++ balance_nano_usd: String(bal), held_nano_usd: String(held), available_nano_usd: String(bal - held < 0n ? 0n : bal - held),++ sandbox,++ topups: tops.map((r) => {++ const m = parseJson(r.metadata);++ const rev = reversed.get(r.id) || { cents: 0, clawed: 0n, debt: 0n };++ const paid = Number(m.paid_cents || 0);++ return {++ at: r.created_at, transaction_id: r.id, provider: r.provider, receipt_ref: r.receipt_ref || null,++ amount_nano_usd: String(m.amount_nano_usd || '0'), fee_nano_usd: String(m.fee_nano_usd || '0'),++ paid_cents: paid, fee_bps: Number(m.fee_bps || 0),++ reversed_cents: rev.cents, refunded_nano_usd: String(rev.clawed), debt_nano_usd: String(rev.debt),++ status: rev.cents <= 0 ? 'settled' : (rev.cents >= paid ? (rev.debt > 0n ? 'refunded_with_debt' : 'refunded') : 'partially_refunded'),++ };++ }),++ charges: rows.map((r) => {++ const attempts = Array.isArray(r.attempts) ? r.attempts : [];++ const last = attempts.length ? attempts[attempts.length - 1] : null;++ return {++ at: r.created_at, settlement_id: r.id, service: r.service || 'ai', model: modelOfAttempt(last),++ charge_nano_usd: String(r.charge_nano_usd), shortfall_nano_usd: String(r.shortfall_nano_usd || 0), byok: !!r.byok,++ usage: r.usage, rate_version: r.retail && r.retail.version ? String(r.retail.version) : null,++ };++ }),++ };++}++++/** JSON from a text column, never a throw. */++function parseJson(v) {++ if (v == null) return {};++ if (typeof v === 'object') return v;++ try { return JSON.parse(v) || {}; } catch { return {}; }++}++++/** The model of a settle attempt: the contract's deployment_id is "<provider>:<model>" (AI's form). */++function modelOfAttempt(a) {++ if (!a || typeof a.deployment_id !== 'string') return null;++ const i = a.deployment_id.indexOf(':');++ const model = i >= 0 ? a.deployment_id.slice(i + 1) : '';++ return model ? model.slice(0, 160) : null;++}++++/** An account's kind (usd_credit or usd_credit_sandbox for a hold's account). */++async function kindOf(db, accountId) {++ const row = await db.prepare('SELECT kind FROM accounts WHERE id = ?').get(accountId);++ return row ? row.kind : null;++}++++/** Read a hold by id. */++async function findHold(db, id) {++ const row = await db.prepare('SELECT * FROM credit_holds WHERE id = ?').get(id);++ return row ? presentHold(row) : null;++}++++/** Read a settlement by hold id or id. */++async function findSettlement(db, { holdId, id } = {}) {++ const row = holdId++ ? await db.prepare('SELECT * FROM credit_settlements WHERE hold_id = ?').get(holdId)++ : await db.prepare('SELECT * FROM credit_settlements WHERE id = ?').get(id);++ return row ? presentSettlement(row) : null;++}++++module.exports = {++ createHold, extendHold, settleHold, releaseHold, sweepExpired, sandboxGrant, statement,++ findHold, findSettlement, computeCharge, available, topupTerms, A_INF, CURRENCY, NANO_PER_CENT,++ ownerOf, parseSubject, PROJECT_ID_RE, nano, heldOnAccount,++};@@@ -11,6 +11,7 @@*/const { prefixedId, iso } = require('../ledger');const { fail, positiveInt, userSubject } = require('./common');++const { NANO_PER_CENT } = require('./credits');const tiers = require('./tiers');function parse(row) {@@@ -84,6 +85,23 @@ async function create(ctx, input) {return await find(db, row.id);}++/**++ * A Balance top-up intent (D9). Created only by the signed-in person's own route (ops/topups.js), never by++ * /api/v1: the amount and the fee were validated against the owner's configured list first. amount_cents is++ * what the provider charges (the top-up plus the fee); the top-up itself is metadata.topup_amount_cents.++ */++async function createCreditsTopup(ctx, { provider, subject, projectId, amountCents, feeCents, feeBps }) {++ const ms = ctx.now();++ const nowIso = iso(ms);++ const id = prefixedId('pi', ms);++ const metadata = { project_id: projectId || null, topup_amount_cents: amountCents, topup_fee_cents: feeCents, topup_fee_bps: feeBps };++ await ctx.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, 'credits_topup', ?, NULL, ?, ?, 0, NULL, 0, 'created', ?, ?, ?)`)++ .run(id, provider, subject, amountCents + feeCents, feeCents, JSON.stringify(metadata), nowIso, nowIso);++ return await find(ctx.db, id);++}++/*** An intent imported from a Live payment order that Live had already credited before the cutover.* Its money effect lives in the imported history/opening balances, not in a Billing settlement, so@@@ -115,15 +133,26 @@ async function markSettled(ctx, id, txnId) {function present(i) {if (!i) return null;++ const m = i.metadata || {};return {id: i.id, provider: i.provider, provider_ref: i.provider_ref, kind: i.kind,subject: { type: 'user', id: i.subject }, streamer: i.streamer_subject ? { type: 'user', id: i.streamer_subject } : null,amount_cents: i.amount_cents, fee_cents: i.fee_cents, bits: i.bits, route: i.route, auto_renew: i.auto_renew,status: i.status, settled_txn: i.settled_txn || null, created_at: i.created_at,...(i.kind === 'subscription' ? { tier: Number((i.metadata && i.metadata.tier) || 1) } : {}),-- // PowerChat checkouts carry this in app_ref; the webhook echoes it back.-- checkout_ref: i.provider === 'powerchat' ? `${i.kind === 'purchase' ? 'pcorder' : 'pcsub'}:${i.id}` : undefined,++ // A Balance top-up: the project it funds (null for the person's own Balance), the amount added and the fee.++ ...(i.kind === 'credits_topup' ? {++ project_id: m.project_id || null,++ topup: {++ amount_cents: Number(m.topup_amount_cents || 0), fee_cents: Number(m.topup_fee_cents || 0),++ fee_bps: Number(m.topup_fee_bps || 0), amount_nano_usd: String(BigInt(m.topup_amount_cents || 0) * NANO_PER_CENT),++ },++ } : {}),++ // PowerChat checkouts carry this in app_ref; the webhook echoes it back. A Balance top-up uses pccredit.++ checkout_ref: i.provider === 'powerchat'++ ? `${i.kind === 'purchase' ? 'pcorder' : i.kind === 'subscription' ? 'pcsub' : 'pccredit'}:${i.id}`++ : undefined,};}--module.exports = { find, findByProviderRef, create, setProviderRef, mergeMetadata, setStatus, markSettled, present, parse, settledInLive, refuseIfSettledInLive };++module.exports = { find, findByProviderRef, create, createCreditsTopup, setProviderRef, mergeMetadata, setStatus, markSettled, present, parse, settledInLive, refuseIfSettledInLive };@@@ -19,6 +19,7 @@const { post, getTxn, balance, money } = require('../ledger');const { enqueue } = require('../outbox');const { A, entry, fail, text } = require('./common');++const { A_INF, NANO_PER_CENT } = require('./credits');const { summary } = require('./purchases');const intents = require('./intents');@@@ -68,6 +69,32 @@ async function reverseReceipt(ctx, input) {}Object.assign(meta, { bits_reversed: b, bits_clawed_back: clawed, unrecovered_bits: unrecovered });review = unrecovered > 0;++ } else if (orig.type === 'credit' && orig.metadata.credit === 'topup') {++ // A Balance top-up (D9/D12) is reversed in nano-USD: the part of the credited amount this refund covers is++ // debited from usd_credit by what the account still holds (never below zero). The spent part is the++ // person's debt, in its own ledger account and a row reconciliation reports; it is never written off.++ const projectId = orig.metadata.project_id == null || orig.metadata.project_id === '' ? null : String(orig.metadata.project_id);++ const amountNano = BigInt(orig.metadata.amount_nano_usd || 0);++ if (amountNano <= 0n) fail(422, 'billing.not_reversible', `top-up transaction ${orig.id} has no Balance amount`);++ const totalPaid = Number(orig.metadata.paid_cents) || paid;++ const cumBefore = (amountNano * BigInt(done)) / BigInt(totalPaid);++ const cumAfter = (amountNano * BigInt(done + cents)) / BigInt(totalPaid);++ const creditPart = cumAfter - cumBefore;++ const held = await balance(db, A_INF.credit(orig.to_subject, projectId));++ const clawed = creditPart <= 0n || held <= 0n ? 0n : (held < creditPart ? held : creditPart);++ const debt = creditPart - clawed;++ const centsNano = BigInt(cents) * NANO_PER_CENT;++ const revenuePart = centsNano - creditPart;++ entries.push(entry(A_INF.clearing(orig.provider), centsNano));++ if (clawed > 0n) entries.push(entry(A_INF.credit(orig.to_subject, projectId), -clawed));++ if (revenuePart > 0n) entries.push(entry(A_INF.revenue(), -revenuePart));++ if (debt > 0n) entries.push(entry(A_INF.debt(), -debt));++ Object.assign(meta, {++ credit: 'topup_refund', credit_topup: true, project_id: projectId, amount_nano_usd: String(amountNano), paid_nano_usd: String(centsNano),++ credit_revoked_nano_usd: String(creditPart), clawed_nano_usd: String(clawed), debt_nano_usd: String(debt),++ revenue_reversed_nano_usd: String(revenuePart),++ });++ review = debt > 0n;} else if (orig.type === 'subscription' || orig.type === 'donation') {if (orig.entries.length) {entries.push(entry(A.clearing(orig.provider), cents), entry(lossOrRefund, -cents));@@@ -95,6 +122,12 @@ async function reverseReceipt(ctx, input) {sourceEventId: input.sourceEventId,metadata: { ...meta, rates: rates.snapshot() },});++ if (orig.type === 'credit' && orig.metadata.credit === 'topup' && BigInt(meta.debt_nano_usd || 0) > 0n) {++ await require('./topups').recordDebt(ctx, {++ topupTxnId: orig.id, transactionId: txn.id, reversalRef: input.reversalRef || '', subject: orig.to_subject,++ projectId: meta.project_id, provider: orig.provider, amountNano: BigInt(meta.debt_nano_usd), reason: kind,++ });++ }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, {nnew file mode 100644@@@ -0,0 +1,160 @@++'use strict';++++/**++ * Balance top-up (D9/D12): a person pays USD through the existing checkout provider and the paid webhook credits++ * their usd_credit account, at nano-USD precision (1 cent = 10,000,000 nano-USD). Without a project_id it credits the++ * person's own Balance (owner user:<usr_id>); with one it credits that project's Balance (owner project:<prj_id>). The++ * fee the owner configured is charged on top and booked to platform_revenue.++ *++ * Everything is behind BILLING_CREDITS_TOPUP (off). The owner sets every allowed amount and the fee through++ * topupTerms(ctx) in ops/credits.js, the one reader of the terms; there is no default amount and no default fee.++ * With either unset or invalid the person's route answers 503 billing.topup_unconfigured, and a paid event that++ * arrives while the feature is off/unconfigured is recorded for review and moves no money.++ *++ * Every adapter maps its own verified delivery to the one `credits_topup` plan (providers/index.js applyPlan), so a++ * PowerChat tip, a Stripe checkout, a PayPal capture, a CCBill sale and a NOWPayments payment all settle through++ * settle() below. No adapter carries Balance logic beyond that mapping.++ *++ * Journal on a paid top-up (nano-usd, all balanced):++ * provider_clearing:<provider> -(what was paid)++ * usd_credit:user:<usr_id> (or usd_credit:project:<prj_id>) +amount++ * platform_revenue +fee (and any overpayment)++ *++ * The receipt reference is unique in the journal and the intent is marked settled in the same transaction, so a++ * provider redelivery (same event id or a new one for the same payment) credits exactly once. Refunds and++ * chargebacks go through ops/reversals.js.++ */++const { post, getTxn, money, prefixedId, iso } = require('../ledger');++const { enqueue } = require('../outbox');++const { MAX_RECEIPT_CENTS, entry, fail, positiveInt, userSubject, isFrozen } = require('./common');++const { A_INF, NANO_PER_CENT, topupTerms } = require('./credits');++const intents = require('./intents');++const { summary } = require('./purchases');++++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const ACTOR = { type: 'service', id: 'billing' };++++/** The fee in whole cents the person pays on top of the top-up: ceil(amount × bps / 10000). 0 bps is 0. */++function feeCents(amountCents, feeBps) {++ return Number((BigInt(amountCents) * BigInt(feeBps) + 9999n) / 10000n);++}++++function assertStartable(ctx) {++ if (!ctx.config.credits || !ctx.config.credits.enabled) fail(404, 'billing.not_enabled', 'Balance is not open yet');++ const t = topupTerms(ctx);++ if (!t.enabled) fail(404, 'billing.topup_disabled', 'Balance top-up is not open');++ if (!t.configured) fail(503, 'billing.topup_unconfigured', `the Balance top-up is not configured: ${t.problem}`);++ return t;++}++++/**++ * Start a top-up: validate the person's choice against the owner's terms, create a credits_topup intent and open the++ * provider's existing checkout. PowerChat has no hosted checkout: its intent carries `checkout_ref` (pccredit:<id>)++ * for the product to put in the tip's app_ref, exactly as a PowerChat purchase intent does.++ */++async function startTopup(ctx, adapters, input) {++ const { config } = ctx;++ const t = assertStartable(ctx);++ // The freeze stops money writes; opening a checkout that would take money is one, as on /api/v1/intents.++ if (await isFrozen(ctx.db)) fail(503, 'billing.frozen', 'the economy is frozen: top-ups are refused');++ const subject = userSubject(input.subject, 'subject');++ // A top-up with no project adds to the person's own Balance (D12); with one it adds to that project's Balance.++ const projectId = input.project_id == null || input.project_id === '' ? null : String(input.project_id);++ if (projectId != null && !PROJECT_RE.test(projectId)) fail(422, 'billing.invalid_input', 'project_id is a project id');++ const amountCents = positiveInt(input.amount_cents, 'amount_cents', MAX_RECEIPT_CENTS);++ if (!t.amountsCents.includes(amountCents)) fail(422, 'billing.amount_not_allowed', 'this top-up amount is not offered');++ const provider = String(input.provider || '').toLowerCase();++ const adapter = adapters[provider];++ if (!adapter) fail(422, 'billing.invalid_input', `unknown provider ${input.provider}`);++ if (!adapter.enabled) fail(409, 'billing.provider_disabled', `${adapter.name} is not enabled on Billing`);++++ const fee = feeCents(amountCents, t.feeBps);++ if (amountCents + fee > MAX_RECEIPT_CENTS) fail(422, 'billing.invalid_amount', 'the top-up plus its fee is above the maximum');++ const intent = await intents.createCreditsTopup(ctx, { provider, subject, projectId, amountCents, feeCents: fee, feeBps: t.feeBps });++ let checkoutUrl = null;++ if (adapter.createCheckout) {++ try {++ const out = await adapter.createCheckout(ctx, intent, {++ name: 'OpenVibe Balance',++ successUrl: `${config.baseUrl}/checkout/success`, cancelUrl: `${config.baseUrl}/checkout/cancel`,++ ipnUrl: `${config.baseUrl}/webhooks/${adapter.name}`,++ });++ if (out.providerRef) await intents.setProviderRef(ctx, intent.id, out.providerRef);++ checkoutUrl = out.url || null;++ } catch (e) {++ await intents.setStatus(ctx, intent.id, 'failed');++ fail(502, 'billing.provider_error', `${adapter.name}: ${e.message}`);++ }++ }++ return { intent: intents.present(await intents.find(ctx.db, intent.id)), checkout_url: checkoutUrl };++}++++/**++ * Settle a paid top-up exactly once, for every provider. When the feature is off or unconfigured the event is++ * recorded for review and moves nothing (money received but not credited is visible to an operator, never silent).++ * Idempotent on the provider event key and on the receipt reference (a second event for the same payment returns the++ * first transaction). Adds exactly the intent's amount to the Balance; the fee (and any overpayment) is platform++ * revenue. Marks++ * the intent settled in the same transaction.++ */++async function settle(ctx, input) {++ const { db } = ctx;++ const t = topupTerms(ctx);++ if (!ctx.config.credits || !ctx.config.credits.enabled || !t.enabled) return { skipped: 'Balance top-up is off' };++ if (!t.configured) return { skipped: `Balance top-up is not configured: ${t.problem}` };++ return await money(db, async () => {++ const receiptRef = input.receiptRef;++ if (!receiptRef) fail(422, 'billing.invalid_input', 'a provider receipt reference is required');++ if (!input.idempotencyKey) fail(422, 'billing.invalid_input', 'an idempotency key is required');++ const dup = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ? OR (receipt_ref IS NOT NULL AND receipt_ref = ?)').get(input.idempotencyKey, receiptRef);++ if (dup) return { txn: await getTxn(db, dup.id), replay: true, duplicateReceipt: true };++++ if (!input.intentId) fail(422, 'billing.invalid_input', 'a top-up needs its intent');++ const intent = await intents.find(db, input.intentId);++ if (!intent) fail(404, 'billing.intent_not_found', `no payment intent ${input.intentId}`);++ if (intent.kind !== 'credits_topup') fail(422, 'billing.intent_mismatch', `intent ${intent.id} is a ${intent.kind}, not a credits_topup`);++ if (intent.provider !== input.provider) fail(422, 'billing.intent_mismatch', `intent ${intent.id} belongs to ${intent.provider}, not ${input.provider}`);++ if (intent.status === 'settled' && intent.settled_txn) return { txn: await getTxn(db, intent.settled_txn), replay: true };++ intents.refuseIfSettledInLive(intent);++++ const paidCents = positiveInt(input.paidCents, 'amount_cents', MAX_RECEIPT_CENTS);++ if (paidCents < intent.amount_cents) fail(422, 'billing.underpaid', `the payment of ${paidCents} cents does not cover the top-up of ${intent.amount_cents} cents`);++ const amountCents = Number(intent.metadata.topup_amount_cents);++ const feeBps = Number(intent.metadata.topup_fee_bps);++ const rawProject = intent.metadata.project_id;++ const projectId = rawProject == null || rawProject === '' ? null : String(rawProject);++ if (!Number.isSafeInteger(amountCents) || amountCents <= 0 || (projectId != null && !PROJECT_RE.test(projectId))) fail(422, 'billing.intent_mismatch', `intent ${intent.id} is not a valid Balance top-up`);++++ const amountNano = BigInt(amountCents) * NANO_PER_CENT;++ const paidNano = BigInt(paidCents) * NANO_PER_CENT;++ const revenueNano = paidNano - amountNano;++ const { txn } = await post(ctx, {++ type: 'credit', idempotencyKey: input.idempotencyKey, actor: input.actor || ACTOR, toSubject: intent.subject,++ provider: input.provider, receiptRef, sourceEventId: input.sourceEventId, test: !!input.test,++ entries: [++ entry(A_INF.clearing(input.provider), -paidNano),++ entry(A_INF.credit(intent.subject, projectId), amountNano),++ entry(A_INF.revenue(), revenueNano),++ ],++ metadata: {++ credit: 'topup', project_id: projectId, intent_id: intent.id,++ amount_nano_usd: String(amountNano), fee_nano_usd: String(revenueNano), paid_nano_usd: String(paidNano),++ paid_cents: paidCents, topup_amount_cents: amountCents, fee_cents: paidCents - amountCents, fee_bps: feeBps,++ },++ });++ 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) });++ return { txn, replay: false };++ });++}++++/** Record the spent part of a refunded top-up: a debt of the person, reported by reconciliation. */++async function recordDebt(ctx, { topupTxnId, transactionId, reversalRef, subject, projectId, provider, amountNano, reason }) {++ const nowIso = iso(ctx.now());++ await ctx.db.prepare(`INSERT INTO credit_topup_debts++ (id, topup_txn_id, transaction_id, reversal_ref, subject, project_id, provider, amount_nano_usd, reason, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?::timestamptz)`)++ .run(prefixedId('cdt', ctx.now()), topupTxnId, transactionId, reversalRef, subject, projectId, provider, amountNano, String(reason || '').slice(0, 300), nowIso);++}++++module.exports = { startTopup, settle, recordDebt, feeCents };nnew file mode 100644@@@ -0,0 +1,259 @@++'use strict';++++/**++ * The app side of Vibes Pay: the per-app webhook URL and secret, and the durable outbox that delivers the signed++ * billing.vibes.charge.paid@1 / billing.vibes.charge.refunded@1 bodies with backoff.++ *++ * Network's developer apps have no webhook settings of their own, so Billing keeps them in its D11 store: one++ * namespace `billing.app.<app_id>` per app, with `webhook_url` internal and `webhook_secret` secret-class. The++ * secret is sealed with the service's KEK whenever one is configured (OV_CONFIG_KEKS); summaries and logs only++ * ever carry its marker. The app sets both from its own token; no other app can read or change them.++ *++ * Delivery header (the SDK's verifyWebhook): X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of++ * "<t>.<raw body>">. X-OpenVibe-Delivery-Id is stable across retries. A 2xx is delivered; anything else is++ * retried on a 15 s → 30 s → … → 6 h schedule, eight attempts, then abandoned. Production deliveries go through the++ * egress guard (https, a public address, no redirects); tests and development may name a private address.++ */++const crypto = require('crypto');++const net = require('net');++const http = require('http');++const https = require('https');++const { createConfigStore } = require('openvibe-shared/config');++const { assertPublicUrl, safeLookup, isInternalName, isPublicAddress } = require('openvibe-shared/egress');++const { iso, prefixedId, BillingError } = require('../ledger');++const { fail } = require('./common');++++const SERVICE = 'billing';++const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;++const RETRY_DELAYS_MS = [15_000, 30_000, 60_000, 120_000, 300_000, 900_000, 3_600_000, 21_600_000];++const MAX_ATTEMPTS = RETRY_DELAYS_MS.length;++const DELIVER_TIMEOUT_MS = 10_000;++const CLAIM_MS = 120_000;++++const SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Vibes Pay app webhook',++ description: 'Where OpenVibe.Billing posts this app\'s Vibes Pay events, and the secret that signs them.',++ type: 'object',++ additionalProperties: false,++ properties: {++ webhook_url: {++ type: 'string', maxLength: 2000,++ title: 'Webhook URL', description: 'The https endpoint this app\'s Vibes Pay events are posted to.',++ },++ webhook_secret: {++ type: 'string', minLength: 16, maxLength: 200,++ title: 'Webhook secret', description: 'The signing secret (write-only; the answer is a marker).',++ },++ },++};++++/** Parse and validate a webhook URL; in production it must be https and name a public host. */++function safeUrl(value, config) {++ const raw = String(value == null ? '' : value).trim();++ if (!raw || raw.length > 2000) fail(422, 'billing.invalid_webhook_url', 'webhook url is required (at most 2000 characters)');++ let u;++ try { u = new URL(raw); } catch { fail(422, 'billing.invalid_webhook_url', 'webhook url must be an http(s) URL'); }++ if (u.username || u.password || u.hash) fail(422, 'billing.invalid_webhook_url', 'webhook url carries no credentials or fragment');++ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && config.nodeEnv !== 'production')) {++ fail(422, 'billing.invalid_webhook_url', 'webhook url must be https');++ }++ if (config.isProduction) {++ if (isInternalName(u.hostname)) fail(422, 'billing.invalid_webhook_url', 'webhook url must name a public host');++ if (net.isIP(u.hostname) && !isPublicAddress(u.hostname)) fail(422, 'billing.invalid_webhook_url', 'webhook url must name a public address');++ }++ return u.toString();++}++++function secretValue(v) {++ if (typeof v !== 'string' || v.length < 16 || v.length > 200) fail(422, 'billing.invalid_webhook_secret', 'webhook secret must be 16-200 characters');++ return v;++}++++/** The D11 per-app webhook store, created on first use and cached: one process, one store instance per app. */++function createVibesWebhooks({ db, config, env = process.env, log = console }) {++ const stores = new Map();++ async function storeFor(appId) {++ const id = String(appId == null ? '' : appId);++ if (!APP_ID_RE.test(id)) throw new BillingError(500, 'billing.bad_app', 'a webhook store needs an app id');++ if (stores.has(id)) return stores.get(id);++ const store = await createConfigStore({++ db, service: SERVICE, namespace: `billing.app.${id.toLowerCase()}`, schema: SCHEMA, env,++ classify: (key) => (key === 'webhook_secret' ? 'secret' : 'internal'), log,++ });++ stores.set(id, store);++ return store;++ }++++ async function get(appId) {++ const store = await storeFor(appId);++ const values = store.get();++ const marker = await store.secretMarker('webhook_secret');++ return {++ store,++ url: typeof values.webhook_url === 'string' ? values.webhook_url : null,++ secret: typeof values.webhook_secret === 'string' ? values.webhook_secret : null,++ marker,++ sealed: store.sealed,++ };++ }++++ /** Set the URL and, when given or rotated, the secret. A generated secret is returned once. */++ async function configure(appId, { url, secret = null, rotate = false }) {++ const store = await storeFor(appId);++ const current = store.get();++ const actor = { type: 'app', id: appId };++ if (current.webhook_url !== url) {++ await store.apply({ webhook_url: url }, { merge: true, actor, reason: 'Vibes Pay webhook URL' });++ }++ const before = await store.secretMarker('webhook_secret');++ let generated = null;++ let value = secret;++ if (value == null && (rotate || !before)) {++ generated = crypto.randomBytes(32).toString('base64url');++ value = generated;++ }++ if (value != null) {++ await store.setSecret('webhook_secret', secretValue(value), { actor, reason: generated ? 'Vibes Pay webhook secret created' : 'Vibes Pay webhook secret set' });++ }++ const marker = await store.secretMarker('webhook_secret');++ return {++ app_id: appId, url, secret_set: Boolean(marker), secret_hint: marker ? marker.hint : null,++ sealed: store.sealed, updated_at: marker ? marker.set_at : null,++ ...(generated ? { secret: generated, shown_once: true } : {}),++ };++ }++++ async function remove(appId) {++ const store = await storeFor(appId);++ const values = store.get();++ const actor = { type: 'app', id: appId };++ if (values.webhook_url != null) {++ await store.apply({}, { merge: true, unset: ['webhook_url'], actor, reason: 'Vibes Pay webhook URL removed' });++ }++ if (await store.secretMarker('webhook_secret')) {++ await store.deleteSecret('webhook_secret', { actor, reason: 'Vibes Pay webhook secret removed' });++ }++ return { app_id: appId, configured: false };++ }++++ return { get, configure, remove, storeFor, safeUrl };++}++++/** Queue one signed delivery in the same transaction as the charge state change. */++async function enqueueWebhook(ctx, charge, eventType, payload) {++ const nowIso = iso(ctx.now());++ await ctx.db.prepare(`INSERT INTO vibes_webhook_outbox++ (id, charge_id, app_id, event_type, payload, attempts, next_attempt_at, created_at)++ VALUES (?, ?, ?, ?, ?, 0, ?::timestamptz, ?::timestamptz)`).run(++ prefixedId('vwh', ctx.now()), charge.id, charge.app_id, eventType, JSON.stringify(payload), nowIso, nowIso);++}++++function retryDelay(attempts) {++ return RETRY_DELAYS_MS[Math.min(Math.max(attempts - 1, 0), RETRY_DELAYS_MS.length - 1)];++}++++function shortError(e) {++ if (e && e.code === 'EGRESS_DENIED') return 'the webhook URL is not a public address';++ return 'the delivery failed';++}++++function canReachPrivate(config) {++ return !config.isProduction && (config.nodeEnv === 'test' || (config.vibes && config.vibes.allowPrivateWebhooks === true));++}++++async function deliverOne(ctx, row) {++ const db = ctx.db;++ const now = ctx.now();++ const nowIso = iso(now);++ const claim = await db.prepare(`UPDATE vibes_webhook_outbox SET next_attempt_at = ?::timestamptz++ WHERE id = ? AND delivered_at IS NULL AND abandoned_at IS NULL AND next_attempt_at <= ?::timestamptz++ RETURNING *`).get(iso(now + CLAIM_MS), row.id, nowIso);++ if (!claim) return { skipped: true };++ const settings = await ctx.vibesWebhooks.get(row.app_id);++ if (!settings.url || !settings.secret) {++ await db.prepare(`UPDATE vibes_webhook_outbox SET abandoned_at = ?::timestamptz, last_error = ?++ WHERE id = ?`).run(nowIso, 'no webhook configured for this app', row.id);++ return { abandoned: true };++ }++ try {++ if (!canReachPrivate(ctx.config)) await assertPublicUrl(settings.url, { protocols: ctx.config.isProduction ? ['https:'] : ['https:', 'http:'] });++ } catch (e) {++ await failAttempt(ctx, row, { error: shortError(e) });++ return { failed: true };++ }++++ const ts = Math.floor(now / 1000);++ const signature = crypto.createHmac('sha256', settings.secret).update(`${ts}.`).update(row.payload).digest('hex');++ let response;++ try {++ response = await postWebhook(settings.url, row.payload, {++ 'Content-Type': 'application/json', Accept: 'application/json',++ 'User-Agent': 'OpenVibe.Billing Vibes Pay', 'X-OpenVibe-Delivery-Id': row.id,++ 'X-OpenVibe-Delivery-Attempt': String(Number(row.attempts) + 1), 'X-OpenVibe-Event': row.event_type,++ 'X-OpenVibe-Timestamp': String(ts), 'X-OpenVibe-Signature-V2': `t=${ts},v2=${signature}`,++ }, { pinAddress: !canReachPrivate(ctx.config) });++ } catch (e) {++ await failAttempt(ctx, row, { error: shortError(e) });++ return { failed: true };++ }++ if (response.status >= 200 && response.status < 300) {++ await db.prepare('UPDATE vibes_webhook_outbox SET delivered_at = ?::timestamptz, attempts = attempts + 1, last_status = ? WHERE id = ?')++ .run(iso(ctx.now()), response.status, row.id);++ return { delivered: true };++ }++ await failAttempt(ctx, row, { status: response.status, error: `HTTP ${response.status}` });++ return { failed: true };++}++++/**++ * POST the raw body without following redirects. In production the socket uses the egress guard's safeLookup, so a++ * DNS answer that was public at assertPublicUrl time cannot rebind to a private address before the connection.++ */++function postWebhook(url, raw, headers, { pinAddress = true, timeoutMs = DELIVER_TIMEOUT_MS } = {}) {++ return new Promise((resolve, reject) => {++ let u;++ try { u = new URL(url); } catch { reject(new Error('bad url')); return; }++ const mod = u.protocol === 'https:' ? https : http;++ const body = Buffer.from(String(raw), 'utf8');++ const req = mod.request({++ protocol: u.protocol, hostname: u.hostname, port: u.port || undefined,++ path: `${u.pathname}${u.search}`, method: 'POST',++ headers: { ...headers, 'Content-Length': body.length },++ ...(pinAddress ? { lookup: safeLookup } : {}),++ }, (res) => {++ res.resume();++ resolve({ status: res.statusCode });++ });++ req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')));++ req.on('error', reject);++ req.end(body);++ });++}++++async function failAttempt(ctx, row, { status = null, error = null }) {++ const attempts = Number(row.attempts) + 1;++ const abandoned = attempts >= MAX_ATTEMPTS;++ await ctx.db.prepare(`UPDATE vibes_webhook_outbox SET attempts = ?, last_status = ?, last_error = ?,++ next_attempt_at = ?::timestamptz, abandoned_at = ?::timestamptz WHERE id = ?`).run(++ attempts, status, String(error || 'delivery failed').slice(0, 500),++ iso(ctx.now() + retryDelay(attempts)), abandoned ? iso(ctx.now()) : null, row.id);++}++++/** Deliver every due webhook once: the job timer in server/index.js and the tests call this. */++async function deliverDue(ctx, { limit = 20 } = {}) {++ const rows = await ctx.db.prepare(`SELECT * FROM vibes_webhook_outbox++ WHERE delivered_at IS NULL AND abandoned_at IS NULL AND next_attempt_at <= ?::timestamptz++ ORDER BY next_attempt_at, id LIMIT ?`).all(iso(ctx.now()), Math.max(1, Math.min(100, limit)));++ const out = { delivered: 0, failed: 0, abandoned: 0, skipped: 0 };++ for (const row of rows) {++ const r = await deliverOne(ctx, row);++ if (r.delivered) out.delivered++;++ else if (r.abandoned) out.abandoned++;++ else if (r.failed) out.failed++;++ else out.skipped++;++ }++ return out;++}++++module.exports = { createVibesWebhooks, enqueueWebhook, deliverDue, safeUrl, SCHEMA, RETRY_DELAYS_MS, MAX_ATTEMPTS };nnew file mode 100644@@@ -0,0 +1,424 @@++'use strict';++++/**++ * Vibes Pay (D12 step 4): a developer app sells an item for whole Vibes.++ *++ * POST /api/v1/vibes/charges app token, billing.vibes.charge; idempotent on the app's reference++ * GET /api/v1/vibes/charges/:id app token; its own charge only++ * POST /api/v1/vibes/charges/:id/refund app token; paid -> refunded, once++ * POST /api/v1/vibes/sandbox/grants sandbox app token; test Vibes for the app's testing++ * GET /api/v1/vibes/me app token delegated by a person; billing.vibes-wallet@1++ *++ * GET /api/me/vibes/charges/:id the pay sheet: the app, the item, the price, the person's Vibes++ * POST /api/me/vibes/charges/:id/pay the person pays (atomic, once, never below zero)++ * POST /api/me/vibes/charges/:id/cancel the person closes a pending charge++ *++ * A real payment moves whole Vibes from user_credit:<person> to project_payable:project:<prj_id> in one balanced++ * journal transaction. A sandbox payment moves test Vibes between vibes_credit_sandbox and++ * project_payable_sandbox and is marked test, so it never touches a real balance. The charge row is locked and its++ * status checked before any movement; the journal's idempotency key makes a replay move nothing.++ *++ * An app webhook is enqueued in vibes_webhook_outbox in the same transaction (src/ops/vibes-webhooks.js delivers++ * it, signed and retried). The same payload goes to OpenVibe.Events as billing.vibes.charge.paid@1/refunded@1; it++ * carries an opaque payer_ref and no person id or email.++ */++const crypto = require('crypto');++const { validate } = require('openvibe-contracts');++const { balance, post, money, accountId, iso, prefixedId, BillingError } = require('../ledger');++const { A, entry, fail, userSubject, positiveInt, text, assertNotFrozen } = require('./common');++const { enqueue } = require('../outbox');++const { enqueueWebhook } = require('./vibes-webhooks');++++const CHARGE_ID_RE = /^vch_[0-9A-HJKMNP-TV-Z]{26}$/;++const KEY_RE = /^[A-Za-z0-9._:-]{8,128}$/;++const DAY_MS = 86_400_000;++const MAX_VIBES = 10_000_000;++++const isoOf = (v) => {++ if (v == null) return null;++ const d = v instanceof Date ? v : new Date(v);++ return Number.isFinite(d.getTime()) ? d.toISOString() : String(v);++};++const num = (v) => Number(String(v == null ? 0 : v));++const isSandbox = (row) => row.sandbox === true || row.sandbox === 1 || row.sandbox === '1';++++function requireApp(principal) {++ if (!principal || principal.kind !== 'app' || !principal.appId) fail(403, 'capability.denied', 'an app token is required');++ return principal;++}++++function assertVibesPay(config) {++ if (!config.vibes || config.vibes.pay !== true) fail(403, 'billing.vibes_pay_disabled', 'Vibes Pay is off');++}++function assertVibesSandbox(config) {++ if (!config.vibes || config.vibes.sandbox !== true) fail(403, 'billing.vibes_sandbox_disabled', 'sandbox Vibes are off');++}++function assertAuthority(config) {++ if (config.authority !== 'billing') fail(409, 'billing.not_authoritative', 'OpenVibe.Live keeps the Vibes ledger until Billing is authoritative');++}++++function chargeId(v) {++ const id = String(v == null ? '' : v);++ if (!CHARGE_ID_RE.test(id)) fail(404, 'billing.charge_not_found', 'no such payment link');++ return id;++}++function idempotencyKey(v) {++ const s = String(v == null ? '' : v).trim();++ if (!KEY_RE.test(s)) fail(422, 'billing.invalid_input', 'idempotency_key must be 8-128 characters of letters, digits, dot, colon, underscore or dash');++ return s;++}++++/** A return URL is https in production (http only in development/tests), never a fragment or credentials. */++function returnUrlOf(v, config) {++ if (v == null || v === '') return null;++ let u;++ try { u = new URL(String(v)); } catch { fail(422, 'billing.invalid_input', 'return_url must be an http(s) URL'); }++ if (u.username || u.password || u.hash) fail(422, 'billing.invalid_input', 'return_url carries no credentials or fragment');++ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && config.nodeEnv !== 'production')) fail(422, 'billing.invalid_input', 'return_url must be https');++ if (u.href.length > 1000) fail(422, 'billing.invalid_input', 'return_url is too long');++ return u.href;++}++++/** The pay sheet's public shape (billing.vibes-charge@1): the app id as a string, whole Vibes, ISO times. */++function presentCharge(row) {++ if (!row) return null;++ return {++ id: row.id,++ app: row.app_id,++ project_id: row.project_id,++ amount: row.amount_text != null ? num(row.amount_text) : num(row.amount),++ description: row.description,++ reference: row.reference,++ status: row.status,++ pay_url: row.pay_url,++ return_url: row.return_url || null,++ created_at: isoOf(row.created_at),++ paid_at: isoOf(row.paid_at),++ refunded_at: isoOf(row.refunded_at),++ expires_at: isoOf(row.expires_at),++ sandbox: isSandbox(row),++ };++}++++/** The webhook and Events body (billing.vibes.charge.paid@1 / .refunded@1): the charge plus an opaque payer_ref. */++function webhookPayload(row, status) {++ const p = {++ id: row.id,++ app: row.app_id,++ project_id: row.project_id,++ amount: row.amount_text != null ? num(row.amount_text) : num(row.amount),++ description: row.description,++ reference: row.reference,++ status,++ created_at: isoOf(row.created_at),++ paid_at: isoOf(row.paid_at),++ refunded_at: status === 'refunded' ? isoOf(row.refunded_at) : null,++ expires_at: isoOf(row.expires_at),++ sandbox: isSandbox(row),++ payer_ref: row.payer_ref,++ };++ const v = validate(`billing.vibes.charge.${status}@1`, p);++ if (!v.valid) throw new BillingError(500, 'billing.webhook_invalid', `the ${status} webhook does not match its contract: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);++ return p;++}++++/** The account a charge's payer spends from: a person's real Vibes, or that app's test pocket. */++function payerAccount(sandbox, appId, subject) {++ return sandbox ? A.vibesSandbox(appId, subject) : A.credit(subject);++}++/** The account a paid charge lands in: the project's real Vibes wallet, or its test wallet. */++function projectAccount(sandbox, appId, projectId) {++ return sandbox ? A.projectPayableSandbox(appId, projectId) : A.projectPayable(projectId);++}++++/** Create or replay a charge. (app_id, reference) is the app's idempotency key; a different body is a conflict. */++async function createCharge(ctx, principal, body) {++ const app = requireApp(principal);++ const b = body == null ? {} : body;++ const v = validate('billing.vibes-charge-create-request@1', b);++ if (!v.valid) fail(422, 'billing.invalid_request', `not a billing.vibes-charge-create-request@1: ${v.errors.slice(0, 5).map((e) => `${e.path || '/'} ${e.message}`).join('; ')}`);++ const amount = positiveInt(b.amount, 'amount', MAX_VIBES);++ const sandbox = app.env === 'sandbox';++ if (sandbox) assertVibesSandbox(ctx.config);++ else {++ assertVibesPay(ctx.config);++ const max = Number(ctx.config.vibes.maxChargeVibes) || 0;++ if (max <= 0) fail(403, 'billing.vibes_limit_exceeded', 'the largest single charge is closed by the owner');++ if (amount > max) fail(403, 'billing.vibes_limit_exceeded', `the largest single charge is ${max} Vibes`, { max_charge_vibes: max, amount });++ }++ await assertNotFrozen(ctx);++ const returnUrl = returnUrlOf(b.return_url, ctx.config);++ const description = String(b.description).trim();++ const reference = String(b.reference).trim();++++ return await money(ctx.db, async () => {++ const db = ctx.db;++ const now = ctx.now();++ const nowIso = iso(now);++ const find = async () => await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE app_id = ? AND reference = ?').get(app.appId, reference);++ const replay = (row) => {++ const same = num(row.amount_text) === amount && row.description === description && (row.return_url || null) === returnUrl;++ if (!same) fail(409, 'billing.reference_reused', 'the same reference was used for a different charge');++ return { charge: presentCharge(row), replay: true };++ };++ const existing = await find();++ if (existing) return replay(existing);++ const id = prefixedId('vch', now);++ const row = await db.prepare(`INSERT INTO vibes_charges++ (id, app_id, project_id, amount, description, reference, status, pay_url, return_url, sandbox, expires_at, created_at)++ VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?::timestamptz, ?::timestamptz)++ ON CONFLICT (app_id, reference) DO NOTHING++ RETURNING *, amount::text AS amount_text`).get(++ id, app.appId, app.projectId, amount, description, reference,++ `${String(ctx.config.vibes.payBaseUrl).replace(/\/+$/, '')}/pay/${id}`, returnUrl, sandbox ? 1 : 0,++ iso(now + ctx.config.vibes.chargeTtlSeconds * 1000), nowIso);++ if (!row) {++ const raced = await find();++ if (!raced) throw new BillingError(409, 'billing.reference_reused', 'this reference is already being used');++ return replay(raced);++ }++ return { charge: presentCharge(row), replay: false };++ });++}++++async function findCharge(db, id) {++ return await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE id = ?').get(id);++}++++async function getChargeForApp(ctx, principal, id) {++ const app = requireApp(principal);++ const row = await findCharge(ctx.db, chargeId(id));++ if (!row || row.app_id !== app.appId) fail(404, 'billing.charge_not_found', 'no such payment link');++ return { charge: presentCharge(row) };++}++++/** Refund a paid charge whole, once, from the project's Vibes wallet back to the person who paid it. */++async function refundCharge(ctx, principal, id, body) {++ const app = requireApp(principal);++ const cid = chargeId(id);++ const v = validate('billing.vibes-charge-refund-request@1', body == null ? {} : body);++ if (!v.valid) fail(422, 'billing.invalid_request', `not a billing.vibes-charge-refund-request@1: ${v.errors.slice(0, 5).map((e) => `${e.path || '/'} ${e.message}`).join('; ')}`);++ const reason = text(body && body.reason, 'reason', 300);++ await assertNotFrozen(ctx);++++ return await money(ctx.db, async () => {++ const db = ctx.db;++ const row = await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE id = ? FOR UPDATE').get(cid);++ if (!row || row.app_id !== app.appId) fail(404, 'billing.charge_not_found', 'no such payment link');++ if (row.status === 'refunded') return { charge: presentCharge(row), replay: true };++ if (row.status !== 'paid') fail(409, 'billing.charge_not_paid', `this charge is ${row.status}, not paid`);++ const sandbox = isSandbox(row);++ if (!row.payer_subject) fail(409, 'billing.charge_not_paid', 'this charge has no payer to refund');++ if (!sandbox) assertAuthority(ctx.config);++ const amount = num(row.amount_text);++ const from = projectAccount(sandbox, row.app_id, row.project_id);++ const to = payerAccount(sandbox, row.app_id, row.payer_subject);++ const have = await balance(db, from);++ if (have < amount) fail(402, 'billing.insufficient_vibes', `the project's Vibes wallet holds ${have}, not the ${amount} to refund`, { available: have, required: amount });++ const now = ctx.now();++ const nowIso = iso(now);++ const { txn } = await post(ctx, {++ type: 'vibes_refund', idempotencyKey: `vibes:refund:${row.id}`, test: sandbox,++ actor: { type: 'app', id: row.app_id }, fromSubject: null, toSubject: row.payer_subject,++ entries: [entry(from, -amount), entry(to, amount)],++ metadata: { vibes_charge: 'refund', charge_id: row.id, app_id: row.app_id, project_id: row.project_id, amount },++ });++ const updated = await db.prepare(`UPDATE vibes_charges SET status = 'refunded', refunded_at = ?::timestamptz, refund_reason = ?++ WHERE id = ? RETURNING *, amount::text AS amount_text`).get(nowIso, reason, row.id);++ const payload = webhookPayload(updated, 'refunded');++ await enqueueWebhook(ctx, updated, 'billing.vibes.charge.refunded', payload);++ await enqueue(ctx, { event_type: 'billing.vibes.charge.refunded', subject: { type: 'vibes-charge', id: row.id }, payload });++ return { charge: presentCharge(updated), replay: false, transaction_id: txn.id };++ });++}++++/** The opaque payer_ref: random, stable per (app, person), never derived from the person's id. */++async function payerRef(ctx, appId, subject) {++ const db = ctx.db;++ const existing = await db.prepare('SELECT payer_ref FROM vibes_payers WHERE app_id = ? AND subject = ?').get(appId, subject);++ if (existing) return existing.payer_ref;++ const ref = `pr_${crypto.randomBytes(16).toString('hex')}`;++ await db.prepare(`INSERT INTO vibes_payers (app_id, subject, payer_ref, created_at) VALUES (?, ?, ?, ?::timestamptz)++ ON CONFLICT (app_id, subject) DO NOTHING`).run(appId, subject, ref, iso(ctx.now()));++ const row = await db.prepare('SELECT payer_ref FROM vibes_payers WHERE app_id = ? AND subject = ?').get(appId, subject);++ if (!row) throw new BillingError(500, 'billing.payer_ref', 'could not record the payer reference');++ return row.payer_ref;++}++++/** How many Vibes the person has already paid in apps in the last 24 h (refunded charges do not count). */++async function spentToday(db, subject, now) {++ const row = await db.prepare(`SELECT COALESCE(SUM(amount), 0)::text AS n FROM vibes_charges++ WHERE payer_subject = ? AND status = 'paid' AND paid_at > ?::timestamptz`).get(subject, iso(now - DAY_MS));++ return num(row.n);++}++++/** Pay a pending charge: locked, status-checked, once, never below zero. Returns the person's view of it. */++async function payCharge(ctx, subject, id, body) {++ const person = userSubject(subject, 'subject');++ const cid = chargeId(id);++ const key = idempotencyKey(body && body.idempotency_key);++ await assertNotFrozen(ctx);++++ const out = await money(ctx.db, async () => {++ const db = ctx.db;++ const now = ctx.now();++ const row = await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE id = ? FOR UPDATE').get(cid);++ if (!row) fail(404, 'billing.charge_not_found', 'no such payment link');++ if (row.status === 'paid' && row.payer_subject === person) {++ return { charge: presentCharge(row), vibes_credit: await balance(db, payerAccount(isSandbox(row), row.app_id, person)), replay: true };++ }++ if (row.status === 'paid') fail(409, 'billing.charge_not_pending', 'this charge was already paid by another person');++ if (row.status !== 'pending') fail(409, 'billing.charge_not_pending', `this charge is ${row.status}`);++ if (new Date(row.expires_at).getTime() <= now) {++ // Commit the expiry (it is a state change, not a refusal side effect); answer the 409 outside the transaction.++ const expired = await db.prepare(`UPDATE vibes_charges SET status = 'expired' WHERE id = ? AND status = 'pending' RETURNING *, amount::text AS amount_text`).get(row.id);++ return { expired: true, expires_at: isoOf(expired ? expired.expires_at : row.expires_at) };++ }++ const sandbox = isSandbox(row);++ const amount = num(row.amount_text);++ if (sandbox) assertVibesSandbox(ctx.config);++ else {++ assertVibesPay(ctx.config);++ assertAuthority(ctx.config);++ const daily = Number(ctx.config.vibes.dailyAppSpendVibesPerPerson) || 0;++ if (daily <= 0) fail(403, 'billing.vibes_limit_exceeded', 'the daily app spend is closed by the owner');++ const spent = await spentToday(db, person, now);++ if (spent + amount > daily) {++ fail(403, 'billing.vibes_limit_exceeded', `this payment would pass the daily app spend of ${daily} Vibes`, { daily_limit_vibes: daily, spent_vibes: spent, amount });++ }++ }++ const from = payerAccount(sandbox, row.app_id, person);++ const to = projectAccount(sandbox, row.app_id, row.project_id);++ const have = await balance(db, from);++ if (have < amount) fail(402, 'billing.insufficient_vibes', `you have ${have} Vibes, not the ${amount} this charge asks`, { available: have, required: amount });++ const ref = await payerRef(ctx, row.app_id, person);++ const { txn } = await post(ctx, {++ type: 'vibes_charge', idempotencyKey: `vibes:charge:${row.id}`, test: sandbox,++ actor: { type: 'user', id: person }, fromSubject: person, toSubject: null,++ entries: [entry(from, -amount), entry(to, amount)],++ metadata: {++ vibes_charge: 'pay', charge_id: row.id, app_id: row.app_id, project_id: row.project_id,++ amount, description: row.description, payer_ref: ref,++ },++ });++ const updated = await db.prepare(`UPDATE vibes_charges SET status = 'paid', paid_at = ?::timestamptz,++ payer_subject = ?, payer_ref = ?, pay_idempotency_key = ? WHERE id = ? RETURNING *, amount::text AS amount_text`).get(++ iso(now), person, ref, key, row.id);++ const payload = webhookPayload(updated, 'paid');++ await enqueueWebhook(ctx, updated, 'billing.vibes.charge.paid', payload);++ await enqueue(ctx, { event_type: 'billing.vibes.charge.paid', subject: { type: 'vibes-charge', id: row.id }, payload });++ return { charge: presentCharge(updated), vibes_credit: await balance(db, from), replay: false, transaction_id: txn.id };++ });++ if (out.expired) fail(409, 'billing.charge_expired', 'this payment link has expired', { expires_at: out.expires_at });++ return out;++}++++/** The pay sheet's read: the charge and app, the item, the person's Vibes and whether they can pay it now. */++async function personCharge(ctx, subject, id) {++ const person = userSubject(subject, 'subject');++ const row = await findCharge(ctx.db, chargeId(id));++ if (!row) fail(404, 'billing.charge_not_found', 'no such payment link');++ const sandbox = isSandbox(row);++ const amount = num(row.amount_text);++ const credit = await balance(ctx.db, payerAccount(sandbox, row.app_id, person));++ const now = ctx.now();++ const expired = row.status === 'pending' && new Date(row.expires_at).getTime() <= now;++ let dailyRemaining = null;++ let canPay = row.status === 'pending' && !expired;++ if (sandbox) canPay = canPay && ctx.config.vibes.sandbox === true;++ else {++ canPay = canPay && ctx.config.vibes.pay === true && ctx.config.authority === 'billing';++ const daily = Number(ctx.config.vibes.dailyAppSpendVibesPerPerson) || 0;++ if (daily <= 0) canPay = false;++ else {++ const spent = await spentToday(ctx.db, person, now);++ dailyRemaining = Math.max(0, daily - spent);++ canPay = canPay && dailyRemaining >= amount;++ }++ }++ canPay = canPay && credit >= amount;++ let app = { id: row.app_id, name: null, icon: null };++ try { app = await ctx.apps.app(row.project_id, row.app_id); } catch { /* the app id is still enough for the sheet */ }++ return {++ charge: presentCharge(row),++ app,++ vibes_credit: credit,++ shortfall_vibes: Math.max(0, amount - credit),++ can_pay: Boolean(canPay),++ expired,++ daily_remaining_vibes: dailyRemaining,++ };++}++++/** Cancel a pending charge (a close, no money movement). Paid or refunded charges are never cancelled. */++async function cancelCharge(ctx, subject, id) {++ const person = userSubject(subject, 'subject');++ const cid = chargeId(id);++ await assertNotFrozen(ctx);++ return await money(ctx.db, async () => {++ const db = ctx.db;++ const row = await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE id = ? FOR UPDATE').get(cid);++ if (!row) fail(404, 'billing.charge_not_found', 'no such payment link');++ if (row.status === 'cancelled') return { charge: presentCharge(row), replay: true };++ if (row.status !== 'pending') fail(409, 'billing.charge_not_pending', `this charge is ${row.status}`);++ const now = ctx.now();++ if (new Date(row.expires_at).getTime() <= now) {++ const expired = await db.prepare(`UPDATE vibes_charges SET status = 'expired' WHERE id = ? AND status = 'pending' RETURNING *, amount::text AS amount_text`).get(row.id);++ return { charge: presentCharge(expired), replay: false, expired: true };++ }++ const updated = await db.prepare(`UPDATE vibes_charges SET status = 'cancelled', cancelled_at = ?::timestamptz++ WHERE id = ? AND status = 'pending' RETURNING *, amount::text AS amount_text`).get(iso(now), row.id);++ return { charge: presentCharge(updated), replay: false };++ });++}++++/** A sandbox app's own test grant: mint sandbox Vibes into the payer's pocket for that app. */++async function sandboxGrant(ctx, principal, body) {++ const app = requireApp(principal);++ if (app.env !== 'sandbox') fail(403, 'billing.sandbox_app_only', 'only a sandbox app may grant sandbox Vibes');++ assertVibesSandbox(ctx.config);++ await assertNotFrozen(ctx);++ const b = body == null ? {} : body;++ const subject = userSubject(b.subject, 'subject');++ const amount = positiveInt(b.amount, 'amount', MAX_VIBES);++ return await money(ctx.db, async () => {++ const db = ctx.db;++ const now = ctx.now();++ const nowIso = iso(now);++ const issuer = A.vibesSandboxIssuer();++ const target = A.vibesSandbox(app.appId, subject);++ const issuerId = await accountId(db, issuer, nowIso);++ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(issuerId);++ const targetId = await accountId(db, target, nowIso);++ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(targetId);++ const grantId = prefixedId('vg', now);++ const { txn } = await post(ctx, {++ type: 'vibes_sandbox_grant', idempotencyKey: `vibes:sandbox:${grantId}`, test: true,++ actor: { type: 'app', id: app.appId }, toSubject: subject,++ entries: [entry(issuer, -amount), entry(target, amount)],++ metadata: { vibes: 'sandbox_grant', grant_id: grantId, app_id: app.appId, project_id: app.projectId, amount },++ });++ return { grant_id: grantId, amount, vibes_credit: await balance(db, target), transaction_id: txn.id };++ });++}++++/** GET /api/v1/vibes/me: the person who delegated this app (billing.vibes-wallet@1). */++async function walletForApp(ctx, principal) {++ const app = requireApp(principal);++ if (app.env === 'sandbox') fail(403, 'billing.sandbox_refused', 'a sandbox app reads no real Vibes');++ const person = userSubject(app.onBehalfOf, 'on_behalf_of');++ const out = {++ subject: { type: 'user', id: person }, currency: 'vibes-bits',++ credit: await balance(ctx.db, A.credit(person)),++ payable: await balance(ctx.db, A.payable(person)),++ bits_per_usd: ctx.rates.bitsPerUsd,++ };++ const v = validate('billing.vibes-wallet@1', out);++ if (!v.valid) throw new BillingError(500, 'billing.wallet_invalid', `the wallet does not match its contract: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);++ return out;++}++++module.exports = {++ createCharge, getChargeForApp, refundCharge, payCharge, personCharge, cancelCharge, sandboxGrant, walletForApp,++ presentCharge, webhookPayload, findCharge, CHARGE_ID_RE,++};@@@ -60,6 +60,7 @@ function createCcbill(cfg) {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 };const receipt = { provider: 'ccbill', receiptRef: `ccbill:${txId}`, paidCents: Math.round(Number(price) * 100) };++ if (intent.kind === 'credits_topup') return { effect: 'credits_topup', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };if (receipt.paidCents + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription sale — review', review: true };return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };@@@ -28,6 +28,7 @@ const crypto = require('crypto');const { iso, BillingError, money } = require('../ledger');const { isFrozen } = require('../ops/common');const purchases = require('../ops/purchases');++const topups = require('../ops/topups');const transfers = require('../ops/transfers');const subscriptions = require('../ops/subscriptions');const reversals = require('../ops/reversals');@@@ -68,6 +69,12 @@ async function applyPlan(ctx, plan, row) {case 'none': return { effect: 'none', reason: plan.reason, review: plan.review || plan.hold || undefined };case 'update': await plan.apply(ctx); return { effect: 'updated', reason: plan.reason };case 'purchase': return settled(await purchases.settle(ctx, withEvent(plan.args)));++ case 'credits_topup': {++ // One shared plan for every provider. Off or unconfigured: recorded for review, no money moves.++ const r = await topups.settle(ctx, withEvent(plan.args));++ if (r.skipped) return { effect: 'none', reason: r.skipped, review: true };++ return settled(r);++ }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));@@@ -49,6 +49,7 @@ function createNowpayments(cfg, { fetchImpl = globalThis.fetch } = {}) {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) };++ if (intent.kind === 'credits_topup') return { effect: 'credits_topup', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };if (receipt.paidCents + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription — review', review: true };return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };@@@ -58,6 +58,7 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {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' };const receipt = { provider: 'paypal', receiptRef: `paypal:${capture.id}`, paidCents: paid };++ if (intent.kind === 'credits_topup') return { effect: 'credits_topup', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };if (paid + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription capture — review', review: true };return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };@@@ -12,13 +12,14 @@** donation.completed, by the checkout ref we minted into app_ref (appExternalRef):* pcorder:<intent> Vibes purchase on the site account → CREDIT for the buyer++ * pccredit:<intent> Balance top-up on the site account → the shared credits_topup plan* pcsub:<intent> subscription: route 'site' → MONEY (share to the creator, fee +* rest platform revenue); route 'direct' → EXTERNAL (entitlement* only, the streamer holds the money). Underpaid: site → a plain* site-routed tip to the streamer; direct → EXTERNAL tip, no effect.* pcdon:<streamer>:<donor> site-routed tip for a creator → MONEY into their payable* (legacy links carry Live user ids; they are resolved to subjects)-- * (pcorder, site pcsub and pcdon count only when the tip was paid to POWERCHAT_SITE_USERNAME:++ * (pcorder, pccredit, site pcsub and pcdon count only when the tip was paid to POWERCHAT_SITE_USERNAME:* the ref is buyer-editable in the fallback tip link; from any other account they are held)* anything else a tip on a streamer's own PowerChat → EXTERNAL: no journal entry;* stored in external_receipts and, once Billing is the authority,@@@ -104,7 +105,7 @@ function createPowerchat(cfg, { network } = {}) {const meta = { powerchat_event: paymentId, donor_name: data.donorName ? String(data.donorName).slice(0, 80) : null, app_ref: ref || null };const host = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();// Money only reaches OpenVibe when it was paid to the SITE account. app_ref rides in the-- // buyer-editable fallback tip link (?app_ref=…), so a creator could put `pcorder:` / `pcdon:` /++ // buyer-editable fallback tip link (?app_ref=…), so a creator could put `pcorder:` / `pccredit:` / `pcdon:` /// a site `pcsub:` ref on a tip to their OWN PowerChat (money they keep) and have Billing mint// credit or cash-out-able payable for it. Site-routed refs from any other account are held.const onSite = !!cfg.siteUsername && host === cfg.siteUsername;@@@ -131,6 +132,13 @@ function createPowerchat(cfg, { network } = {}) {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(/^pccredit:(.+)$/))) {++ if (!onSite) return offSite('Balance top-up');++ const intent = await intents.find(ctx.db, m[1]);++ if (!intent || intent.kind !== 'credits_topup') return { effect: 'none', reason: `Balance top-up ${ref} has no credits_topup intent` };++ if (cents < 1) return { effect: 'none', reason: 'zero-amount Balance top-up' };++ return { effect: 'credits_topup', args: { provider: 'powerchat', receiptRef, paidCents: cents, intentId: intent.id, idempotencyKey: key, actor, ...(isTest ? { test: true } : {}) } };++ }if ((m = ref.match(/^pcsub:(.+)$/))) {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` };@@@ -67,6 +67,14 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {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}` };++ // A Balance top-up (D9) maps to the one shared credits_topup plan; the shared settle credits usd_credit++ // instead of Vibes bits. It can never fall through to a purchase or subscription.++ if (intent.kind === 'credits_topup') {++ return { effect: 'credits_topup', args: {++ provider: 'stripe', receiptRef: `stripe:${obj.payment_intent || obj.id}`, paidCents: obj.amount_total,++ intentId: intent.id, 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 } };}case 'invoice.paid':@@@ -28,7 +28,8 @@* every period revoked for a refund or chargeback has the reversal of its payment* Warnings (reported, do not fail): negative user balances, unprocessed/rejected events, events and* transactions flagged for review (chargebacks on donated credit, unattributed site tips, EXTERNAL-- * tips on unmapped accounts), import holds. Totals exclude test transactions (ADR-012 rule 9).++ * tips on unmapped accounts), credit debts from refunded top-ups already spent, import holds. Totals++ * exclude test transactions (ADR-012 rule 9).** Runs on demand (API, console, scripts/reconcile.js, after an import) and on a schedule* (runScheduled, server/index.js); every run is stored in reconciliation_runs with its trigger.@@@ -45,17 +46,17 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {const checks = [];const check = (id, ok, detail) => checks.push({ id, ok, ...(detail ? { detail } : {}) });-- 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 sums = await db.prepare('SELECT a.currency, SUM(e.amount)::text 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) => String(s.total) === '0'), { per_currency: sums.map((s) => ({ currency: s.currency, sum: s.total, entries: s.n })) });-- 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++ const unbalanced = await db.prepare(`SELECT e.txn_id, a.currency, SUM(e.amount)::text 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 = 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++ const cache = await db.prepare(`SELECT a.id, a.kind, a.owner_subject AS owner, a.currency, COALESCE(b.balance, 0)::text AS cached,++ COALESCE((SELECT SUM(amount) FROM ledger_entries WHERE account_id = a.id), 0)::text 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);++ const mismatches = cache.filter((r) => String(r.cached) !== String(r.derived));check('balances.cache', !mismatches.length, mismatches.length ? { mismatches } : { accounts: cache.length });const fxRows = await db.prepare(`SELECT t.id, json_extract(t.metadata, '$.rates.bits_per_usd')::float8 AS rate,@@@ -86,12 +87,18 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {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,++ json_extract(t.metadata, '$.credit') AS credit_kind,(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_idWHERE 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();++ // A Balance top-up and its reversal move nano-USD through the provider clearing (1 cent = 10,000,000 nano-USD),++ // not usd-cents; the paid or reversed cents metadata is still the receipt-side truth.++ const NANO_PER_CENT = 10_000_000;const rcptBad = rcpt.filter((r) => {if (r.provider !== r.event_provider) return true;++ if (r.credit_kind === 'topup') return !r.receipt_ref || (r.entries > 0 && r.clearing !== -Number(r.paid_cents) * NANO_PER_CENT);++ if (r.credit_kind === 'topup_refund') return r.entries > 0 && r.clearing !== Number(r.reversed_cents) * NANO_PER_CENT;if (SETTLING.includes(r.type)) return !r.receipt_ref || (r.entries > 0 && r.clearing !== -Number(r.paid_cents));if (REVERSING.includes(r.type)) return r.entries > 0 && r.clearing !== Number(r.reversed_cents);return false;@@@ -134,8 +141,8 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {check('receipts.stale', !stale.length, stale.length ? { offenders: stale, older_than_min: staleMin } : { older_than_min: staleMin, ...(frozen ? { frozen: true } : {}) });const warnings = {-- 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', 'promo_credit') AND b.balance < 0`).all(),++ negative_balances: await db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id++ WHERE a.kind IN ('user_credit', 'creator_payable', 'project_payable', 'project_payable_sandbox', 'payouts_pending', 'promo_credit', 'usd_credit') AND b.balance < 0`).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) })),@@@ -143,6 +150,9 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {.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) })),++ // A refunded top-up whose credit was already spent: the person owes it, and it is never written off silently.++ credit_debts: await db.prepare('SELECT id, subject, project_id, provider, transaction_id, topup_txn_id, amount_nano_usd::text AS amount_nano_usd, reason, created_at FROM credit_topup_debts ORDER BY created_at')++ .all(),import_holds: await db.prepare('SELECT live_user_id, owner, reason, credit_bits, payable_bits FROM import_holds WHERE resolved_subject IS NULL').all(),};@@@ -157,8 +167,12 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {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),++ project_payable_outstanding_bits: await sumKind('project_payable', 'vibes-bits', false),payouts_pending_bits: await sumKind('payouts_pending', 'vibes-bits', false),import_adjustment_bits: await sumKind('import_adjustment', 'vibes-bits', false),++ credit_outstanding_nano_usd: (await db.prepare("SELECT COALESCE(SUM(b.balance), 0)::text AS n FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'usd_credit'").get()).n,++ // The spent part of refunded top-ups, reported as money the person owes (never netted out of the balance).++ credit_topup_debt_nano_usd: (await db.prepare('SELECT COALESCE(SUM(amount_nano_usd), 0)::text AS n FROM credit_topup_debts').get()).n,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,@@@ -39,7 +39,10 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourctry {const missing = await uncovered(d, {tables: TABLES,-- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Billing applied"},++ exempt: {++ "account_data_events": "the ledger of requests itself: which export and deletion Billing applied",++ "config_snapshots": "the owner-settings journal: the acting operator and key names, a configuration history rather than the account's own data",++ },});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);} finally { await d.close(); }nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++/**++ * GET /api/me/activity (D12, Network's /wallet): the signed-in person's own Balance and Vibes movements, newest++ * first, with plain labels and exact integer amounts. A from-Balance Vibes purchase has two rows sharing one id.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/app');++const { post } = require('../server/ledger');++const { entry, A } = require('../server/ops/common');++const { A_INF } = require('../server/ops/credits');++const balances = require('../server/ops/balance');++const transfers = require('../server/ops/transfers');++++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const MARKET = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPZ';++const key = (p) => `${p}:${crypto.randomBytes(8).toString('hex')}`;++++(async () => {++ console.log('activity');++ const t = await boot({ env: { BILLING_CREDITS: '1', BILLING_VIBES_FROM_BALANCE: '1' } });++ const alice = t.user(1, 'alice');++ const bob = t.user(2, 'bob');++ t.network.addMembership(PRJ, alice.id, 'owner', false, 'Studio');++ const aliceToken = t.network.signUser({ subject_id: alice.id, username: 'alice' });++ const bobToken = t.network.signUser({ subject_id: bob.id, username: 'bob' });++++ const fund = (amount) => post(t.ctx, {++ type: 'credit', idempotencyKey: key('fund'), test: true, actor: { type: 'service', id: 'billing' }, toSubject: alice.id,++ provider: 'stripe',++ entries: [entry(A_INF.clearing('stripe'), -BigInt(amount)), entry(A_INF.credit(alice.id, null), BigInt(amount))],++ metadata: { credit: 'topup', amount_nano_usd: String(amount) },++ });++ const get = async (token, q = '') => {++ const r = await fetch(`${t.base}/api/me/activity${q}`, { headers: { Authorization: `Bearer ${token}` } });++ const text = await r.text();++ return { status: r.status, text, body: text ? JSON.parse(text) : null };++ };++++ const topup = await fund('3000000000');++ const moved = await balances.transfer(t.ctx, { subject: alice.id, toProjectId: PRJ, amountNanoUsd: '300000000', idempotencyKey: key('tr') });++ const got = await balances.buyVibes(t.ctx, { subject: alice.id, vibes: 100, idempotencyKey: key('vb') });++ const tip = await transfers.create(t.ctx, { from: alice.id, to: bob.id, amount: 25, idempotencyKey: key('tip'), actor: { type: 'service', id: 'tips' } });++ await transfers.refund(t.ctx, { txnId: tip.txn.id, idempotencyKey: key('refund') });++ const hold = await require('../server/ops/credits').createHold(t.ctx, {++ subject: { type: 'user', id: alice.id }, max_nano_usd: '100000', rate_version: 'rc-activity',++ request_fingerprint: 'fp-activity', idempotency_key: key('hold'),++ });++ await require('../server/ops/credits').settleHold(t.ctx, {++ hold_id: hold.hold.hold_id, idempotency_key: key('settle'),++ usage: { input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 1000 },++ retail: { version: 'rc-activity', nano_usd_per_m: { input: '1000', cached_read: '0', cache_write: '0', output: '3000' }, per_request_nano_usd: '100' },++ supply_cost_nano_usd: '0', byok: false,++ attempts: [{ deployment_id: 'own:qwen/qwen3-4b:q4_k_m@3f2a1c9', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString() }],++ });++ const paid = await post(t.ctx, {++ type: 'purchase', idempotencyKey: key('app'), test: true, actor: { type: 'service', id: 'billing' }, toSubject: alice.id,++ entries: [entry(A.credit(alice.id), -50), entry(A.credit(MARKET), 50)],++ metadata: { vibes_charge_id: 'vch_01JAB2C3D4E5F6G7H8J9K0MNPQ', app_name: 'Sticker Shop', description: 'Neon cats' },++ });++++ await check('the caller\'s own rows carry exact amounts and plain labels', async () => {++ const cors = await fetch(`${t.base}/api/me/activity`, { headers: { Authorization: `Bearer ${aliceToken}`, Origin: 'https://openvibe.network' } });++ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.network');++ assert.strictEqual(cors.headers.get('cache-control'), 'private, no-store');++ const r = await get(aliceToken);++ assert.strictEqual(r.status, 200, r.text);++ const rows = r.body.activity;++ const byLabel = (l) => rows.filter((x) => x.label === l);++ assert.strictEqual(byLabel('Added money')[0].detail, 'Stripe');++ assert.strictEqual(byLabel('Added money')[0].amount, '3000000000');++ assert.strictEqual(byLabel('Moved to Studio')[0].amount, '-300000000');++ assert.strictEqual(byLabel('Tipped @bob')[0].amount, -25);++ assert.strictEqual(byLabel('Refund')[0].amount, 25);++ assert.strictEqual(byLabel('Paid Sticker Shop')[0].amount, -50);++ assert.strictEqual(byLabel('Paid Sticker Shop')[0].detail, 'Neon cats');++ const ai = byLabel('AI usage')[0];++ assert.strictEqual(ai.kind, 'balance');++ assert.ok(ai.detail.includes('qwen/qwen3-4b:q4_k_m@3f2a1c9'), ai.detail);++ assert.ok(ai.detail.includes('1 request'), ai.detail);++ assert.strictEqual(ai.amount, '-104'); // 1000 input @ 1000/m = 1; 1000 output @ 3000/m = 3; +100 per request++ assert.ok(rows.every((x) => x.kind === 'balance' || x.kind === 'vibes'));++ assert.strictEqual(rows.length, 8);++ });++++ await check('Get Vibes with Balance has two rows sharing the transaction id', async () => {++ const r = await get(aliceToken);++ const pair = r.body.activity.filter((x) => x.id === got.transaction_id);++ assert.strictEqual(pair.length, 2, JSON.stringify(pair));++ assert.deepStrictEqual(pair.map((x) => x.kind).sort(), ['balance', 'vibes']);++ const bal = pair.find((x) => x.kind === 'balance');++ const vib = pair.find((x) => x.kind === 'vibes');++ assert.strictEqual(bal.label, 'Got Vibes');++ assert.strictEqual(bal.amount, '-1000000000');++ assert.strictEqual(vib.amount, 100);++ assert.ok(pair.every((x) => x.id === got.transaction_id));++ });++++ await check('only the caller\'s rows, another token sees none of them', async () => {++ const mine = await get(aliceToken);++ const theirs = await get(bobToken);++ assert.strictEqual(theirs.status, 200, theirs.text);++ const myIds = new Set(mine.body.activity.map((x) => x.id));++ for (const id of [topup.txn.id, moved.transaction_id, got.transaction_id, tip.txn.id, paid.txn.id]) assert.ok(myIds.has(id), id);++ assert.ok(!theirs.body.activity.some((x) => myIds.has(x.id)), 'bob sees none of alice\'s transactions');++ assert.ok(!theirs.text.includes(alice.id), 'no other person\'s subject id');++ assert.ok(!mine.text.includes(bob.id), 'a tip names the creator by public name only');++ assert.ok(!/usr_[0-9A-HJKMNP-TV-Z]{26}/.test(mine.text), 'no subject ids in the activity text');++ });++++ await check('pagination uses the last transaction id as the cursor', async () => {++ const first = await get(aliceToken, '?limit=2');++ assert.strictEqual(first.status, 200, first.text);++ assert.ok(first.body.activity.length <= 4);++ assert.ok(first.body.next_before);++ const next = await get(aliceToken, `?limit=2&before=${encodeURIComponent(first.body.next_before)}`);++ assert.strictEqual(next.status, 200, next.text);++ const firstIds = new Set(first.body.activity.map((x) => x.id));++ assert.ok(!next.body.activity.some((x) => firstIds.has(x.id)), 'pages do not overlap');++ assert.strictEqual((await get(aliceToken, '?before=txn_nope')).status, 422);++ assert.strictEqual((await fetch(`${t.base}/api/me/activity`)).status, 401);++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,209 @@++'use strict';++/**++ * Balance (D12): personal and project accounts, GET /api/me/balance (and the /api/me/credits alias), transfers++ * between them, and getting Vibes with Balance. Money is integer nano-USD; every write is idempotent and every++ * journal stays balanced.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/app');++const inference = require('../server/ops/credits');++const { post } = require('../server/ledger');++const { validate } = require('openvibe-contracts');++++const INF = ['billing.credit.hold.create', 'billing.credit.hold.settle', 'billing.credit.hold.release'];++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const PRJ2 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNP2';++const PRJ3 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNP3';++const PRJ4 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNP4';++const prjBody = (subject, max, over = {}) => ({++ idempotency_key: `hold:${crypto.randomBytes(8).toString('hex')}`,++ subject: { type: 'user', id: subject.id },++ max_nano_usd: String(max),++ rate_version: 'rc-v1',++ request_fingerprint: crypto.randomBytes(8).toString('hex'),++ ...over,++});++++async function assertJournalBalances(db, label) {++ const rows = await db.prepare(`SELECT a.currency, SUM(e.amount)::text AS total FROM ledger_entries e++ JOIN accounts a ON a.id = e.account_id GROUP BY a.currency`).all();++ for (const r of rows) assert.strictEqual(String(r.total), '0', `journal not balanced for ${r.currency} ${label}: ${r.total}`);++}++++(async () => {++ console.log('Balance');++ const t = await boot({ env: { BILLING_CREDITS: '1', BILLING_VIBES_FROM_BALANCE: '1' } });++ // A real top-up booked the way checkout funding books one: money arrives at a provider and lands in an account.++ const fund = (subjectId, projectId, amount) => post(t.ctx, {++ type: 'credit', idempotencyKey: `test:fund:${crypto.randomBytes(8).toString('hex')}`, test: true,++ actor: { type: 'service', id: 'billing' }, toSubject: subjectId,++ entries: [++ { kind: 'provider_clearing', owner: 'stripe', currency: 'nano-usd', amount: -BigInt(amount) },++ { ...inference.A_INF.credit(subjectId, projectId), amount: BigInt(amount) },++ ],++ metadata: { credit: 'test_top_up' },++ });++ const balance = (subjectId, projectId) => inference.A_INF.credit(subjectId, projectId);++ const create = (body, opts = {}) => t.call('POST', '/internal/credits/holds', { body, cap: INF, sub: 'svc:ai', ...opts });++ const alice = t.user(1);++ const token = t.network.signUser({ subject_id: alice.id, username: 'alice' });++ const who = (u) => t.network.signUser({ subject_id: u.id, username: 'u' });++++ await check('a personal hold spends user:<usr_id>; a project hold spends project:<prj_id>', async () => {++ await fund(alice.id, null, '100000');++ await fund(null, PRJ, '200000');++ const personal = await create(prjBody(alice, '40000', { project_id: undefined }));++ assert.strictEqual(personal.status, 201, personal.text);++ assert.strictEqual(personal.json.project_id, null);++ const row = await t.db.prepare('SELECT subject_type, project_id, account_id FROM credit_holds WHERE id = ?').get(personal.json.hold_id);++ assert.strictEqual(row.subject_type, 'user');++ assert.strictEqual(row.project_id, null);++ const acct = await t.db.prepare('SELECT owner_subject FROM accounts WHERE id = ?').get(row.account_id);++ assert.strictEqual(acct.owner_subject, `user:${alice.id}`);++ assert.strictEqual(await t.db.prepare('SELECT balance::text AS b FROM account_balances WHERE account_id = ?').get(row.account_id).then((x) => x.b), '100000');++++ const bob = t.user(2);++ const project = await create(prjBody(bob, '50000', { project_id: PRJ }));++ assert.strictEqual(project.status, 201, project.text);++ const prow = await t.db.prepare('SELECT account_id FROM credit_holds WHERE id = ?').get(project.json.hold_id);++ const pacct = await t.db.prepare('SELECT owner_subject FROM accounts WHERE id = ?').get(prow.account_id);++ assert.strictEqual(pacct.owner_subject, `project:${PRJ}`);++ assert.strictEqual((await t.db.prepare('SELECT balance::text AS b FROM account_balances WHERE account_id = ?').get(prow.account_id)).b, '200000');++ await assertJournalBalances(t.db, 'after holds');++ });++++ await check('GET /api/me/balance has the contract shape and only the projects the person administers', async () => {++ t.network.addMembership(PRJ, alice.id, 'admin');++ t.network.addMembership(PRJ2, alice.id, 'owner');++ t.network.addMembership(PRJ3, alice.id, 'viewer');++ t.network.addMembership(PRJ4, alice.id, 'owner', true);++ const r = await fetch(`${t.base}/api/me/balance`, { headers: { Authorization: `Bearer ${token}`, Origin: 'https://openvibe.network' } });++ assert.strictEqual(r.status, 200, await r.clone().text());++ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.network');++ const doc = await r.json();++ const personalValid = validate('billing.balance@1', doc.personal);++ assert.ok(personalValid.valid, JSON.stringify(personalValid.errors));++ for (const p of doc.projects) {++ const valid = validate('billing.balance@1', p);++ assert.ok(valid.valid, JSON.stringify(valid.errors));++ }++ assert.deepStrictEqual(doc.personal.owner, { type: 'user', id: alice.id });++ assert.strictEqual(typeof doc.personal.balance_nano_usd, 'string');++ assert.deepStrictEqual(doc.projects.map((p) => p.owner.id), [PRJ, PRJ2].sort());++ assert.ok(doc.projects.every((p) => p.owner.type === 'project' && typeof p.available_nano_usd === 'string'));++ const alias = await (await fetch(`${t.base}/api/me/credits`, { headers: { Authorization: `Bearer ${token}` } })).json();++ assert.deepStrictEqual(alias, doc, '/api/me/credits is the same answer');++ assert.strictEqual((await fetch(`${t.base}/api/me/balance`)).status, 401);++ await assertJournalBalances(t.db, 'after reads');++ });++++ await check('a transfer is atomic and idempotent, and refuses a short Balance or a non-admin', async () => {++ const dana = t.user(3);++ t.network.addMembership(PRJ, dana.id, 'admin');++ await fund(dana.id, null, '100000');++ const post1 = (body, tok = who(dana)) => t.call('POST', '/api/me/balance/transfers', { body, token: tok });++ const key = `tr:${crypto.randomBytes(8).toString('hex')}`;++ const bad = await post1({ to_project_id: 'nope', amount_nano_usd: 5, idempotency_key: 'short' });++ assert.strictEqual(bad.status, 422, bad.text);++ assert.strictEqual(bad.json.code, 'billing.invalid_request');++ const first = await post1({ to_project_id: PRJ, amount_nano_usd: '30000', idempotency_key: key });++ assert.strictEqual(first.status, 201, first.text);++ assert.strictEqual(first.json.from.balance_nano_usd, '70000');++ assert.strictEqual(first.json.to.balance_nano_usd, String(200000 + 30000));++ const replay = await post1({ to_project_id: PRJ, amount_nano_usd: '30000', idempotency_key: key });++ assert.strictEqual(replay.status, 200, replay.text);++ assert.strictEqual(replay.json.transfer_id, first.json.transfer_id);++ assert.strictEqual(replay.json.from.balance_nano_usd, '70000', 'the replay moves nothing');++ const one = await t.db.prepare('SELECT COUNT(*)::int AS n FROM transactions WHERE idempotency_key = ?').get(`balance:transfer:${key}`);++ assert.strictEqual(one.n, 1);++ const reused = await post1({ to_project_id: PRJ, amount_nano_usd: '1000', idempotency_key: key });++ assert.strictEqual(reused.status, 409, reused.text);++ const short = await post1({ to_project_id: PRJ, amount_nano_usd: '999999', idempotency_key: `tr:${crypto.randomBytes(8).toString('hex')}` });++ assert.strictEqual(short.status, 402, short.text);++ const eve = t.user(4);++ t.network.addMembership(PRJ3, eve.id, 'viewer');++ await fund(eve.id, null, '100000');++ const denied = await post1({ to_project_id: PRJ3, amount_nano_usd: '1000', idempotency_key: `tr:${crypto.randomBytes(8).toString('hex')}` }, who(eve));++ assert.strictEqual(denied.status, 403, denied.text);++ assert.strictEqual(denied.json.code, 'billing.not_project_admin');++ // Two different keys for all the money: exactly one wins, the other is short, never below zero.++ const d2 = t.user(5);++ t.network.addMembership(PRJ2, d2.id, 'owner');++ await fund(d2.id, null, '10000');++ const results = await Promise.all([++ post1({ to_project_id: PRJ2, amount_nano_usd: '10000', idempotency_key: `tr:${crypto.randomBytes(8).toString('hex')}` }, who(d2)),++ post1({ to_project_id: PRJ2, amount_nano_usd: '10000', idempotency_key: `tr:${crypto.randomBytes(8).toString('hex')}` }, who(d2)),++ ]);++ assert.strictEqual(results.filter((x) => x.status === 201).length, 1, JSON.stringify(results.map((x) => [x.status, x.json && x.json.code])));++ const neg = await t.db.prepare("SELECT COUNT(*)::int AS n FROM account_balances b JOIN accounts a ON a.id = b.account_id WHERE a.kind IN ('usd_credit', 'usd_credit_sandbox') AND b.balance < 0").get();++ assert.strictEqual(neg.n, 0, 'no account below zero');++ // The same key at the same moment: one transfer, both answers the same id.++ const gina = t.user(8);++ t.network.addMembership(PRJ, gina.id, 'owner');++ await fund(gina.id, null, '5000');++ const sameKey = `tr:${crypto.randomBytes(8).toString('hex')}`;++ const pair = await Promise.all([++ post1({ to_project_id: PRJ, amount_nano_usd: '1000', idempotency_key: sameKey }, who(gina)),++ post1({ to_project_id: PRJ, amount_nano_usd: '1000', idempotency_key: sameKey }, who(gina)),++ ]);++ assert.deepStrictEqual(pair.map((x) => x.status).sort(), [200, 201], JSON.stringify(pair.map((x) => [x.status, x.json])));++ assert.strictEqual(pair[0].json.transfer_id, pair[1].json.transfer_id);++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*)::int AS n FROM transactions WHERE idempotency_key = ?').get(`balance:transfer:${sameKey}`)).n, 1);++ assert.strictEqual((await t.db.prepare('SELECT balance::text AS b FROM account_balances b JOIN accounts a ON a.id = b.account_id WHERE a.owner_subject = ?').get(`user:${gina.id}`)).b, '4000');++ await assertJournalBalances(t.db, 'after transfers');++ });++++ await check('Vibes from Balance books exactly: USD to platform_revenue, Vibes minted as a pack', async () => {++ const fred = t.user(6);++ await fund(fred.id, null, '1000000000');++ const buy = (body, tok = who(fred)) => t.call('POST', '/api/me/vibes/from-balance', { body, token: tok });++ const key = `vb:${crypto.randomBytes(8).toString('hex')}`;++ const r = await buy({ vibes: 100, idempotency_key: key });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.price_nano_usd, '1000000000', '100 Vibes at 100 per USD = $1 exactly');++ assert.strictEqual(r.json.balance.balance_nano_usd, '0');++ assert.strictEqual(r.json.vibes_credit, 100);++ const txn = await t.db.prepare('SELECT * FROM transactions WHERE idempotency_key = ?').get(`balance:vibes:${key}`);++ const entries = await t.db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, e.amount::text AS amount FROM ledger_entries e++ JOIN accounts a ON a.id = e.account_id WHERE e.txn_id = ? ORDER BY a.currency, a.kind`).all(txn.id);++ assert.deepStrictEqual(entries.map((e) => [e.kind, e.owner, e.currency, e.amount]).sort(), [++ ['fx_conversion', null, 'vibes-bits', '-100'],++ ['user_credit', fred.id, 'vibes-bits', '100'],++ ['platform_revenue', null, 'nano-usd', '1000000000'],++ ['usd_credit', `user:${fred.id}`, 'nano-usd', '-1000000000'],++ ].sort());++ const replay = await buy({ vibes: 100, idempotency_key: key });++ assert.strictEqual(replay.status, 200, replay.text);++ assert.strictEqual(replay.json.purchase_id, r.json.purchase_id);++ const again = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE idempotency_key = ?").get(`balance:vibes:${key}`);++ assert.strictEqual(again.n, 1);++ const different = await buy({ vibes: 200, idempotency_key: key });++ assert.strictEqual(different.status, 409, different.text);++ const short = await buy({ vibes: 100, idempotency_key: `vb:${crypto.randomBytes(8).toString('hex')}` });++ assert.strictEqual(short.status, 402, short.text);++ await assertJournalBalances(t.db, 'after Vibes from Balance');++ });++++ await check('billing.vibes is served by the D11 store with its danger keys', async () => {++ const r = await t.call('GET', '/internal/config/billing.vibes', { cap: ['billing.config.manage'] });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.namespace, 'billing.vibes');++ for (const k of ['vibes_pay', 'max_charge_vibes', 'daily_app_spend_vibes_per_person', 'from_balance']) assert.ok(r.json.schema.properties[k], `${k} is in the schema`);++ assert.strictEqual(r.json.schema.properties.vibes_pay['x-danger'], true);++ assert.strictEqual(r.json.schema.properties.from_balance['x-danger'], true);++ });++++ await check('the disabled switch refuses Get Vibes with Balance', async () => {++ const off = await boot({ env: { BILLING_CREDITS: '1' } });++ try {++ const u = off.user(7);++ const r = await off.call('POST', '/api/me/vibes/from-balance', { body: { vibes: 100, idempotency_key: 'vb:off:0001' }, token: off.network.signUser({ subject_id: u.id, username: 'off' }) });++ assert.strictEqual(r.status, 404, r.text);++ assert.strictEqual(r.json.code, 'billing.vibes_from_balance_disabled');++ } finally { await off.close(); }++ await assertJournalBalances(t.db, 'at the end');++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -2,7 +2,8 @@/*** Billing's capability declarations are the estate's: every `docs/capabilities-proposal/<id>.json` is* the capability of that id in the pinned openvibe-contracts, it is listed by that package's billing-- * service manifest, and each `implementedBy` is a real route in `server/api/v1.js`.++ * service manifest, and each `implementedBy` is a real route in `server/api/v1.js`, the Credits++ * router (`server/api/internal.js`) or the owner-settings router (`server/internal-config.js`).** This is the check a Network grant needs. A default grant naming a capability that exists nowhere in* the estate is inert — `auth.needs()` never matches it and a producer's posts 401 — so a capability@@@ -20,6 +21,10 @@ const proposals = fs.readdirSync(proposalDir).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(fs.readFileSync(path.join(proposalDir, f), 'utf8')));const service = JSON.parse(fs.readFileSync(path.join(contractsDir, 'manifests', 'services', 'billing.json'), 'utf8'));const routes = fs.readFileSync(path.join(root, 'server', 'api', 'v1.js'), 'utf8');++// The Credits capabilities (D9) are served by the /internal/credits router (server/api/internal.js), not /api/v1.++const internalRoutes = fs.readFileSync(path.join(root, 'server', 'api', 'internal.js'), 'utf8');++// The owner-settings capability (D11) is served by /internal/config (server/internal-config.js).++const configRoutes = fs.readFileSync(path.join(root, 'server', 'internal-config.js'), 'utf8');assert.ok(proposals.length > 0, 'docs/capabilities-proposal has no capability declarations');@@@ -36,9 +41,13 @@ for (const p of proposals) {for (const impl of p.implementedBy) {// 'POST /api/v1/usage' or 'GET|POST /api/v1/admin/freeze'.const [methods, url] = impl.split(' ');-- const route = url.replace(/^\/api\/v1/, '');++ const credits = /^\/internal\/credits(\/|$)/.test(url);++ const config = /^\/internal\/config(\/|$)/.test(url);++ const route = credits ? url.replace(/^\/internal\/credits/, '') : config ? url : url.replace(/^\/api\/v1/, '');++ const source = credits ? internalRoutes : config ? configRoutes : routes;++ const where = credits ? 'server/api/internal.js' : config ? 'server/internal-config.js' : 'server/api/v1.js';for (const m of methods.split('|')) {-- assert.ok(routes.includes(`r.${m.toLowerCase()}('${route}'`), `${p.id}: ${impl} is not a route in server/api/v1.js`);++ assert.ok(source.includes(`r.${m.toLowerCase()}('${route}'`), `${p.id}: ${impl} is not a route in ${where}`);}}}nnew file mode 100644@@@ -0,0 +1,333 @@++'use strict';++/**++ * Credits top-up (D9): a person pays USD through Billing's multi-purpose provider pipeline (PowerChat, Stripe,++ * PayPal, CCBill, NOWPayments) and the one shared credits_topup plan credits their (subject, project_id) usd_credit++ * account at nano-USD precision. The fee the owner set goes to platform_revenue, replay credits once, and a refund++ * after partial spend leaves the balance at 0 with the spent part recorded as a debt. Everything is behind++ * BILLING_CREDITS_TOPUP; the terms come only from topupTerms(ctx), and nothing is configured by default.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { startProviderStub } = require('./helpers/stubs');++++const INF = ['billing.credit.hold.create', 'billing.credit.hold.extend', 'billing.credit.hold.settle', 'billing.credit.hold.release'];++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const ZERO_RETAIL = { version: 'rc-topup', nano_usd_per_m: { input: '0', cached_read: '0', cache_write: '0', output: '0' }, per_request_nano_usd: '4000000000' };++const evt = (id, type, object) => ({ id, type, data: { object } });++++async function assertJournalBalances(db, label) {++ const rows = await db.prepare(`SELECT a.currency, SUM(e.amount)::text AS total FROM ledger_entries e++ JOIN accounts a ON a.id = e.account_id GROUP BY a.currency`).all();++ for (const r of rows) assert.strictEqual(String(r.total), '0', `journal not balanced for ${r.currency} ${label}: ${r.total}`);++}++++const nanoBalance = async (t, kind) => (await t.db.prepare(`SELECT COALESCE(SUM(b.balance), 0)::text AS n FROM accounts a++ JOIN account_balances b ON b.account_id = a.id WHERE a.kind = ? AND a.currency = 'nano-usd'`).get(kind)).n;++++(async () => {++ console.log('credits top-up');++ const pp = await startProviderStub();++ const t = await boot({++ stripe: true,++ env: {++ BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1',++ BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000,2500', BILLING_CREDITS_TOPUP_FEE_BPS: '250',++ PAYPAL_CLIENT_ID: 'pp', PAYPAL_CLIENT_SECRET: 'pps', PAYPAL_WEBHOOK_ID: 'WH-1', PAYPAL_API_BASE: pp.url,++ },++ });++ const alice = t.user(201);++ t.network.addMembership(PRJ, alice.id, 'owner');++ const aliceToken = t.network.signUser({ subject_id: alice.id, username: 'alice' });++ const start = (body, token = aliceToken) => t.call('POST', '/api/me/credits/topups', { body, token });++ const read = async (token = aliceToken) => (await fetch(`${t.base}/api/me/credits?project_id=${PRJ}`, { headers: { Authorization: `Bearer ${token}` } })).json();++ const pcDonation = (eventId, cents, ref) => ({ type: 'donation.completed', streamer: { id: 'pc1', username: 'openvibe' }, data: { eventId, amountUsdCents: cents, appExternalRef: ref } });++ const paypalHook = async (event) => {++ const r = await fetch(`${t.base}/webhooks/paypal`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', 'paypal-transmission-sig': 'good', 'paypal-transmission-id': 'x', 'paypal-auth-algo': 'SHA256withRSA' },++ body: JSON.stringify(event),++ });++ return { status: r.status, json: await r.json() };++ };++++ await check('topupTerms is the one reader of the terms: env today, no defaults, invalid refuses', async () => {++ const { topupTerms } = require('../server/ops/credits');++ assert.deepStrictEqual(topupTerms(t.ctx), { enabled: true, amountsCents: [1000, 2500], feeBps: 250, configured: true, problem: null });++ const off = topupTerms({ env: {} });++ assert.deepStrictEqual([off.enabled, off.configured, off.amountsCents, off.feeBps], [false, false, [], null]);++ const bad = topupTerms({ env: { BILLING_CREDITS_TOPUP: '1', BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000,abc', BILLING_CREDITS_TOPUP_FEE_BPS: '250' } });++ assert.strictEqual(bad.configured, false);++ assert.match(bad.problem, /invalid amount/);++ const zero = topupTerms({ env: { BILLING_CREDITS_TOPUP: '1', BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000', BILLING_CREDITS_TOPUP_FEE_BPS: '0' } });++ assert.deepStrictEqual([zero.configured, zero.feeBps], [true, 0], '0 bps is a configured fee');++ });++++ let topupIntent;++ await check('Stripe: a person starts an allowed top-up; the existing checkout charges the amount plus the fee', async () => {++ const r = await start({ provider: 'stripe', project_id: PRJ, amount_cents: 1000 });++ assert.strictEqual(r.status, 201, r.text);++ topupIntent = r.json.intent;++ assert.strictEqual(topupIntent.kind, 'credits_topup');++ assert.strictEqual(topupIntent.project_id, PRJ);++ assert.strictEqual(topupIntent.amount_cents, 1025, 'the provider charges the top-up plus the 2.5% fee');++ assert.strictEqual(topupIntent.fee_cents, 25);++ assert.strictEqual(topupIntent.topup.amount_nano_usd, '10000000000');++ assert.match(r.json.checkout_url, /checkout\.stripe\.test/);++ const call = t.stripe.calls[t.stripe.calls.length - 1];++ assert.strictEqual(call.url, '/checkout/sessions');++ assert.strictEqual(call.body['metadata[intent_id]'], topupIntent.id);++ assert.strictEqual(call.body['line_items[0][price_data][unit_amount]'], '1025');++ assert.strictEqual(call.body.mode, 'payment');++ });++++ await check('an amount the owner did not list is 422 and opens no checkout', async () => {++ const before = t.stripe.calls.length;++ const r = await start({ provider: 'stripe', project_id: PRJ, amount_cents: 999 });++ assert.strictEqual(r.status, 422, r.text);++ assert.strictEqual(r.json.code, 'billing.amount_not_allowed');++ assert.strictEqual(t.stripe.calls.length, before);++ const n = await t.db.prepare("SELECT COUNT(*)::int AS n FROM payment_intents WHERE kind = 'credits_topup'").get();++ assert.strictEqual(n.n, 1);++ });++++ await check('Stripe: the paid webhook credits exactly the amount in nano-USD and books the fee to platform_revenue', async () => {++ const r = await t.stripeEvent(evt('evt_topup_1', 'checkout.session.completed', {++ id: 'cs_topup_1', mode: 'payment', payment_status: 'paid', payment_intent: 'pi_topup_1', amount_total: 1025,++ metadata: { intent_id: topupIntent.id },++ }));++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));++ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));++ const s = await read();++ assert.strictEqual(s.balance_nano_usd, '10000000000');++ assert.strictEqual(s.topups.length, 1);++ assert.strictEqual(s.topups[0].status, 'settled');++ assert.strictEqual(s.topups[0].amount_nano_usd, '10000000000');++ assert.strictEqual(s.topups[0].fee_nano_usd, '250000000');++ assert.strictEqual(await nanoBalance(t, 'platform_revenue'), '250000000', 'the fee is revenue');++ assert.strictEqual(await nanoBalance(t, 'provider_clearing'), '-10250000000', 'the provider is holding the paid cents');++ await assertJournalBalances(t.db, 'after the paid top-up');++ });++++ await check('Stripe: a redelivery, and a second provider event for the same payment, credit exactly once', async () => {++ const paid = evt('evt_topup_1', 'checkout.session.completed', {++ id: 'cs_topup_1', mode: 'payment', payment_status: 'paid', payment_intent: 'pi_topup_1', amount_total: 1025,++ metadata: { intent_id: topupIntent.id },++ });++ const dup = await t.stripeEvent(paid);++ assert.strictEqual(dup.json.duplicate, true);++ const other = await t.stripeEvent(evt('evt_topup_1b', 'checkout.session.completed', {++ id: 'cs_topup_1', mode: 'payment', payment_status: 'paid', payment_intent: 'pi_topup_1', amount_total: 1025,++ metadata: { intent_id: topupIntent.id },++ }));++ assert.strictEqual(other.status, 200, JSON.stringify(other.json));++ assert.strictEqual(other.json.result.effect, 'duplicate_receipt', JSON.stringify(other.json));++ const n = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE json_extract(metadata, '$.credit') = 'topup'").get();++ assert.strictEqual(n.n, 1);++ assert.strictEqual((await read()).balance_nano_usd, '10000000000');++ });++++ await check('Stripe: a refund after partial spend debits what remains, leaves the balance at 0 and records the debt', async () => {++ // Spend 4.00 USD of the 10.00 USD through the inference subledger.++ const hold = await t.call('POST', '/internal/credits/holds', {++ cap: INF, sub: 'svc:ai',++ body: { idempotency_key: 'topup-spend-hold', subject: { type: 'user', id: alice.id }, project_id: PRJ, max_nano_usd: '4000000000', rate_version: 'rc-topup', request_fingerprint: 'topup-spend-fp' },++ });++ assert.strictEqual(hold.status, 201, hold.text);++ const settled = await t.call('POST', `/internal/credits/holds/${hold.json.hold_id}/settle`, {++ cap: INF, sub: 'svc:ai',++ body: {++ idempotency_key: 'topup-spend-settle', usage: { input_tokens: 0, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 0 },++ retail: ZERO_RETAIL, supply_cost_nano_usd: '0', byok: false, attempts: [],++ },++ });++ assert.strictEqual(settled.status, 201, settled.text);++ assert.strictEqual(settled.json.charge_nano_usd, '4000000000');++ assert.strictEqual((await read()).balance_nano_usd, '6000000000');++++ const r = await t.stripeEvent(evt('evt_topup_refund', 'charge.refunded', { id: 'ch_topup_1', payment_intent: 'pi_topup_1', amount_refunded: 1025 }));++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));++ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));++++ const s = await read();++ assert.strictEqual(s.balance_nano_usd, '0', 'the account never goes below zero');++ assert.strictEqual(s.available_nano_usd, '0');++ assert.strictEqual(s.topups[0].refunded_nano_usd, '6000000000');++ assert.strictEqual(s.topups[0].debt_nano_usd, '4000000000', 'the spent part is a debt');++ assert.strictEqual(s.topups[0].status, 'refunded_with_debt');++ const debts = await t.db.prepare('SELECT * FROM credit_topup_debts').all();++ assert.strictEqual(debts.length, 1);++ assert.strictEqual(String(debts[0].amount_nano_usd), '4000000000');++ assert.strictEqual(await nanoBalance(t, 'platform_revenue'), '4000000000', 'the fee is returned; the collected charge stays revenue');++ assert.strictEqual(await nanoBalance(t, 'credit_topup_debt'), '-4000000000');++ await assertJournalBalances(t.db, 'after the top-up refund');++++ const rec = await t.assertReconciled('after the top-up refund');++ assert.strictEqual(rec.warnings.credit_debts.length, 1, 'reconciliation reports the debt');++ assert.strictEqual(rec.totals.credit_topup_debt_nano_usd, '4000000000');++ });++++ await check('PowerChat: a site-account tip carrying pccredit:<intent> settles through the shared plan, once', async () => {++ const bob = t.user(202);++ t.network.addMembership(PRJ, bob.id, 'owner');++ const bobToken = t.network.signUser({ subject_id: bob.id, username: 'bob' });++ const r = await start({ provider: 'powerchat', project_id: PRJ, amount_cents: 1000 }, bobToken);++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.checkout_url, null, 'PowerChat has no hosted checkout URL');++ assert.strictEqual(r.json.intent.checkout_ref, `pccredit:${r.json.intent.id}`);++ const donation = pcDonation('don-cred-pc', 1025, r.json.intent.checkout_ref);++ const first = await t.powerchat(donation, { deliveryId: 'dlv-cred-pc' });++ assert.strictEqual(first.status, 200, JSON.stringify(first));++ assert.strictEqual(first.json.result.effect, 'settled', JSON.stringify(first));++ assert.strictEqual((await read(bobToken)).balance_nano_usd, '10000000000');++ const dup = await t.powerchat(donation, { deliveryId: 'dlv-cred-pc' });++ assert.strictEqual(dup.json.duplicate, true);++ const other = await t.powerchat(donation, { deliveryId: 'dlv-cred-pc-retry' });++ assert.strictEqual(other.json.result.effect, 'duplicate_receipt', JSON.stringify(other));++++ const refund = await t.powerchat({++ type: 'donation.refunded', streamer: { id: 'pc1', username: 'openvibe' },++ data: { eventId: 'rf-cred-pc', originalEventId: 'don-cred-pc', refundedUsdCents: 1025 },++ });++ assert.strictEqual(refund.status, 200, JSON.stringify(refund));++ assert.strictEqual(refund.json.result.effect, 'settled', JSON.stringify(refund));++ const s = await read(bobToken);++ assert.strictEqual(s.balance_nano_usd, '0', 'a full refund of unspent credit leaves nothing');++ assert.strictEqual(s.topups[0].status, 'refunded');++ assert.strictEqual(s.topups[0].debt_nano_usd, '0');++ });++++ await check('PayPal: the capture completes the shared plan; a replay and a second event for it credit once', async () => {++ const carol = t.user(203);++ t.network.addMembership(PRJ, carol.id, 'owner');++ const carolToken = t.network.signUser({ subject_id: carol.id, username: 'carol' });++ const r = await start({ provider: 'paypal', project_id: PRJ, amount_cents: 1000 }, carolToken);++ assert.strictEqual(r.status, 201, r.text);++ assert.match(r.json.checkout_url, /paypal\.test/);++ const order = pp.calls.find((c) => c.url === '/v2/checkout/orders');++ const orderBody = JSON.parse(order.raw);++ assert.strictEqual(orderBody.purchase_units[0].custom_id, r.json.intent.id);++ assert.strictEqual(orderBody.purchase_units[0].amount.value, '10.25');++++ const event = {++ id: 'WH-TOPUP-1', event_type: 'PAYMENT.CAPTURE.COMPLETED',++ resource: { id: 'CAP-TOPUP', custom_id: r.json.intent.id, amount: { currency_code: 'USD', value: '10.25' } },++ };++ const paid = await paypalHook(event);++ assert.strictEqual(paid.status, 200, JSON.stringify(paid));++ assert.strictEqual(paid.json.result.effect, 'settled', JSON.stringify(paid));++ assert.strictEqual((await read(carolToken)).balance_nano_usd, '10000000000');++ const dup = await paypalHook(event);++ assert.strictEqual(dup.json.duplicate, true);++ const other = await paypalHook({ ...event, id: 'WH-TOPUP-1b' });++ assert.strictEqual(other.json.result.effect, 'duplicate_receipt', JSON.stringify(other));++++ const refund = await paypalHook({++ id: 'WH-TOPUP-RF', event_type: 'PAYMENT.CAPTURE.REFUNDED',++ resource: { id: 'RF-TOPUP', amount: { currency_code: 'USD', value: '10.25' }, links: [{ rel: 'up', href: 'https://api.paypal.test/v2/payments/captures/CAP-TOPUP' }] },++ });++ assert.strictEqual(refund.status, 200, JSON.stringify(refund));++ assert.strictEqual(refund.json.result.effect, 'settled', JSON.stringify(refund));++ const s = await read(carolToken);++ assert.strictEqual(s.balance_nano_usd, '0');++ assert.strictEqual(s.topups[0].status, 'refunded');++ await assertJournalBalances(t.db, 'after every provider top-up');++ await t.assertReconciled('after powerchat and paypal top-ups');++ });++++ await check('a paid event while the top-up switch is off is recorded for review and moves no money', async () => {++ // The owner sets the terms live (no env pin): turn the top-up on, open a checkout, then turn it off++ // before the provider's paid delivery arrives.++ const off = await boot({ stripe: true, env: { BILLING_CREDITS: '1' } });++ const dave = off.user(204);++ off.network.addMembership(PRJ, dave.id, 'owner');++ const daveToken = off.network.signUser({ subject_id: dave.id, username: 'dave' });++ const actor = { type: 'user', id: dave.id };++ try {++ await off.ctx.configStore.apply({ topup: true, topup_amounts_cents: [1000], topup_fee_bps: 250 }, { actor, reason: 'top-up test terms' });++ const r = await off.call('POST', '/api/me/credits/topups', { token: daveToken, body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 } });++ assert.strictEqual(r.status, 201, r.text);++ await off.ctx.configStore.apply({ topup: false }, { merge: true, actor, reason: 'switch the top-up off' });++ const paid = await off.stripeEvent(evt('evt_topup_off', 'checkout.session.completed', {++ id: 'cs_topup_off', mode: 'payment', payment_status: 'paid', payment_intent: 'pi_topup_off', amount_total: 1025,++ metadata: { intent_id: r.json.intent.id },++ }));++ assert.strictEqual(paid.status, 200, JSON.stringify(paid));++ assert.strictEqual(paid.json.result.effect, 'none', JSON.stringify(paid.json));++ assert.strictEqual(paid.json.result.review, true);++ const statement = await (await fetch(`${off.base}/api/me/credits?project_id=${PRJ}`, { headers: { Authorization: `Bearer ${daveToken}` } })).json();++ assert.strictEqual(statement.topups.length, 0, 'no credit and no charge');++ const txns = await off.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE json_extract(metadata, '$.intent_id') = ?").get(r.json.intent.id);++ assert.strictEqual(txns.n, 0);++ await off.assertReconciled('after a refused paid top-up');++ } finally { await off.close(); }++ });++++ await check('the economy freeze refuses a top-up before any intent or checkout', async () => {++ await require('../server/ops/admin').setFreeze(t.ctx, { on: true, reason: 'top-up test' });++ const before = t.stripe.calls.length;++ const intentsBefore = (await t.db.prepare("SELECT COUNT(*)::int AS n FROM payment_intents WHERE kind = 'credits_topup'").get()).n;++ const r = await start({ provider: 'stripe', project_id: PRJ, amount_cents: 1000 });++ assert.strictEqual(r.status, 503, r.text);++ assert.strictEqual(r.json.code, 'billing.frozen');++ assert.strictEqual(t.stripe.calls.length, before);++ const intentsAfter = (await t.db.prepare("SELECT COUNT(*)::int AS n FROM payment_intents WHERE kind = 'credits_topup'").get()).n;++ assert.strictEqual(intentsAfter, intentsBefore, 'no intent while frozen');++ await require('../server/ops/admin').setFreeze(t.ctx, { on: false });++ });++++ await t.close();++ await pp.close();++++ await check('unset amounts or fee refuses with 503 billing.topup_unconfigured', async () => {++ const none = await boot({ env: { BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1' } });++ const u = none.user(202);++ const r = await none.call('POST', '/api/me/credits/topups', {++ token: none.network.signUser({ subject_id: u.id, username: 'u' }),++ body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 },++ });++ assert.strictEqual(r.status, 503, r.text);++ assert.strictEqual(r.json.code, 'billing.topup_unconfigured');++ await none.close();++++ const bad = await boot({ env: { BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1', BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000', BILLING_CREDITS_TOPUP_FEE_BPS: 'abc' } });++ const v = bad.user(203);++ const n = await bad.call('POST', '/api/me/credits/topups', {++ token: bad.network.signUser({ subject_id: v.id, username: 'v' }),++ body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 },++ });++ assert.strictEqual(n.status, 503, n.text);++ assert.strictEqual(n.json.code, 'billing.topup_unconfigured');++ await bad.close();++ });++++ await check('with BILLING_CREDITS_TOPUP off nothing opens: 404, no intent, no checkout; the statement still reads', async () => {++ const off = await boot({ stripe: true, env: { BILLING_CREDITS: '1' } });++ const w = off.user(204);++ off.network.addMembership(PRJ, w.id, 'owner');++ const token = off.network.signUser({ subject_id: w.id, username: 'w' });++ const r = await off.call('POST', '/api/me/credits/topups', { token, body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 } });++ assert.strictEqual(r.status, 404, r.text);++ assert.strictEqual(r.json.code, 'billing.topup_disabled');++ assert.strictEqual(off.stripe.calls.length, 0);++ const n = await off.db.prepare("SELECT COUNT(*)::int AS n FROM payment_intents WHERE kind = 'credits_topup'").get();++ assert.strictEqual(n.n, 0);++ const s = await (await fetch(`${off.base}/api/me/credits?project_id=${PRJ}`, { headers: { Authorization: `Bearer ${token}` } })).json();++ assert.deepStrictEqual(s.topups, []);++ await off.close();++ });++++ await check('a fee of 0 is allowed (it is set, not defaulted)', async () => {++ const free = await boot({ stripe: true, env: { BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1', BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000', BILLING_CREDITS_TOPUP_FEE_BPS: '0' } });++ const x = free.user(205);++ const r = await free.call('POST', '/api/me/credits/topups', {++ token: free.network.signUser({ subject_id: x.id, username: 'x' }),++ body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 },++ });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.intent.amount_cents, 1000);++ assert.strictEqual(r.json.intent.fee_cents, 0);++ await free.close();++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,365 @@++'use strict';++/**++ * Credits: the prepaid USD balance for metered services (AI Exchange D1, D2; migrations/0011_inference_subledger.sql, ops/inference.js,++ * api/internal.js): holds, extends, settlements and releases in nano-USD, behind BILLING_CREDITS.++ * Sandbox grants are behind BILLING_CREDITS_SANDBOX and land in the person's own sandbox account, which only a hold++ * marked sandbox spends (D10). Real credit comes from a top-up; until checkout funding exists, `fund()` below books++ * one straight through the ledger. Nothing here touches chargeFor or Vibes bits.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/app');++const inference = require('../server/ops/credits');++const { post } = require('../server/ledger');++++const INF = ['billing.credit.hold.create', 'billing.credit.hold.extend', 'billing.credit.hold.settle', 'billing.credit.hold.release'];++const SBX = ['billing.credit.sandbox.grant'];++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++++function retail(over = {}) {++ return {++ version: 'rc-v1',++ nano_usd_per_m: { input: '1000', cached_read: '500', cache_write: '2000', output: '3000' },++ per_request_nano_usd: '100',++ ...over,++ };++}++++const RATE_CARD = retail();++++function usage(u = {}) {++ return { input_tokens: 0, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 0, ...u };++}++++function settleBody(u, over = {}) {++ return {++ idempotency_key: `settle:${crypto.randomBytes(8).toString('hex')}`,++ usage: usage(u),++ retail: RATE_CARD,++ supply_cost_nano_usd: '0',++ byok: false,++ attempts: [],++ ...over,++ };++}++++function holdBody(subject, max, over = {}) {++ return {++ idempotency_key: `hold:${crypto.randomBytes(8).toString('hex')}`,++ subject: { type: 'user', id: subject.id },++ max_nano_usd: String(max),++ rate_version: 'rc-v1',++ request_fingerprint: crypto.randomBytes(8).toString('hex'),++ // No project means the person's own Balance (D12); a project hold passes project_id.++ ...over,++ };++}++++async function assertJournalBalances(db, label) {++ const rows = await db.prepare(`SELECT a.currency, SUM(e.amount)::text AS total FROM ledger_entries e++ JOIN accounts a ON a.id = e.account_id GROUP BY a.currency`).all();++ for (const r of rows) {++ assert.strictEqual(String(r.total), '0', `journal not balanced for ${r.currency} ${label}: ${r.total}`);++ }++}++++(async () => {++ console.log('credits ledger');++ const t = await boot({ env: { BILLING_CREDITS: '1', BILLING_CREDITS_SANDBOX: '1' } });++ const create = (body, opts = {}) => t.call('POST', '/internal/credits/holds', { body, cap: INF, sub: 'svc:ai', ...opts });++ const extend = (id, add, opts = {}) => t.call('POST', `/internal/credits/holds/${id}/extend`, { body: { add_nano_usd: String(add) }, cap: INF, sub: 'svc:ai', ...opts });++ const settle = (id, body, opts = {}) => t.call('POST', `/internal/credits/holds/${id}/settle`, { body, cap: INF, sub: 'svc:ai', ...opts });++ const release = (id, opts = {}) => t.call('POST', `/internal/credits/holds/${id}/release`, { body: {}, cap: INF, sub: 'svc:ai', ...opts });++ const grant = (body, opts = {}) => t.call('POST', '/internal/credits/sandbox/grants', { body, cap: SBX, sub: 'svc:ai', ...opts });++ // A real top-up, booked the way checkout funding will book one (money received at the provider -> the person's Credits).++ const fund = (user, amount, projectId = null) => post(t.ctx, {++ type: 'credit', idempotencyKey: `test:fund:${crypto.randomBytes(8).toString('hex')}`, test: true,++ actor: { type: 'service', id: 'billing' }, toSubject: user.id,++ entries: [++ { kind: 'provider_clearing', owner: 'stripe', currency: 'nano-usd', amount: -BigInt(amount) },++ { ...inference.A_INF.credit(user.id, projectId), amount: BigInt(amount) },++ ],++ metadata: { credit: 'test_top_up' },++ });++++ const alice = t.user(1);++++ await check('sandbox grant creates an inference credit account and is marked test', async () => {++ const r = await grant({ subject: { type: 'user', id: alice.id }, project_id: PRJ, amount_nano_usd: '100000' });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.amount_nano_usd, '100000');++ assert.strictEqual(r.json.balance_nano_usd, '100000');++ const txn = await t.db.prepare("SELECT * FROM transactions WHERE idempotency_key = ?").get(`credit:sandbox:${r.json.grant_id}`);++ assert.strictEqual(txn.test, 1);++ const entries = (await t.db.prepare('SELECT a.kind, a.owner_subject AS owner, a.currency, e.amount::text AS amount FROM ledger_entries e JOIN accounts a ON a.id = e.account_id WHERE e.txn_id = ?').all(txn.id));++ assert.deepStrictEqual(entries.map((e) => [e.kind, e.owner, e.currency, e.amount]).sort(),++ [['usd_credit_sandbox', null, 'nano-usd', '-100000'], ['usd_credit_sandbox', `project:${PRJ}`, 'nano-usd', '100000']]);++ await assertJournalBalances(t.db, 'after sandbox grant');++ });++++ await check('create hold returns 201 with held, available and lease; 402 when balance is short', async () => {++ await fund(alice, '100000');++ const r = await create(holdBody(alice, 50000));++ assert.strictEqual(r.status, 201, r.text);++ assert.match(r.json.hold_id, /^hld_/);++ assert.strictEqual(r.json.held_nano_usd, '50000');++ assert.strictEqual(r.json.available_nano_usd, '50000');++ assert.ok(new Date(r.json.lease_expires_at).getTime() > Date.now());++ const poor = t.user(2);++ const denied = await create(holdBody(poor, 1));++ assert.strictEqual(denied.status, 402, denied.text);++ assert.strictEqual(denied.json.code, 'billing.insufficient_funds');++ });++++ await check('same idempotency key + same fingerprint replays the same hold; different fingerprint is 409', async () => {++ const bob = t.user(3);++ await fund(bob, '10000');++ const body = holdBody(bob, 5000);++ const first = await create(body);++ assert.strictEqual(first.status, 201, first.text);++ const replay = await create(body);++ assert.strictEqual(replay.status, 200, replay.text);++ assert.strictEqual(replay.json.hold_id, first.json.hold_id);++ const changed = await create({ ...body, request_fingerprint: 'different-fp' });++ assert.strictEqual(changed.status, 409, changed.text);++ assert.strictEqual(changed.json.code, 'billing.hold_key_reused');++ });++++ await check('extend grows the hold; cannot extend beyond available balance', async () => {++ const carol = t.user(4);++ await fund(carol, '20000');++ const h = await create(holdBody(carol, 5000));++ const ext = await extend(h.json.hold_id, 5000);++ assert.strictEqual(ext.status, 200, ext.text);++ assert.strictEqual(ext.json.held_nano_usd, '10000');++ const over = await extend(h.json.hold_id, 20000);++ assert.strictEqual(over.status, 402, over.text);++ assert.strictEqual(over.json.code, 'billing.insufficient_funds');++ });++++ await check('4-component rounding: ceil once per component per request matches worked example', async () => {++ // input 1,500,000 tokens @ 1000/m = ceil(1500000*1000/1e6) = 1500 nano++ // cached_read 2,000,000 @ 500/m = ceil(2000000*500/1e6) = 1000 nano++ // cache_write 1 @ 2000/m = ceil(1*2000/1e6) = 1 nano++ // output 1 @ 3000/m = ceil(1*3000/1e6) = 1 nano++ // per_request = 100++ // total = 2602 nano++ const dave = t.user(5);++ await fund(dave, '10000000');++ const h = await create(holdBody(dave, 10000000));++ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 1500000, cached_read_tokens: 2000000, cache_write_tokens: 1, output_tokens: 1 }));++ assert.strictEqual(s.status, 201, s.text);++ assert.strictEqual(s.json.charge_nano_usd, '2602');++ assert.strictEqual(s.json.released_nano_usd, '9997398');++ });++++ await check('BYOK charges 0 regardless of usage', async () => {++ const erin = t.user(6);++ await fund(erin, '100000');++ const h = await create(holdBody(erin, 50000));++ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 1000000, output_tokens: 1000000 }, { byok: true }));++ assert.strictEqual(s.status, 201, s.text);++ assert.strictEqual(s.json.charge_nano_usd, '0');++ assert.strictEqual(s.json.released_nano_usd, '50000');++ });++++ await check('settle replay charges once and releases the rest exactly once', async () => {++ const frank = t.user(7);++ await fund(frank, '100000');++ const h = await create(holdBody(frank, 50000));++ const body = settleBody({ input_tokens: 1000, output_tokens: 1000 });++ const s1 = await settle(h.json.hold_id, body);++ assert.strictEqual(s1.status, 201, s1.text);++ // 1000 input @ 1000/m = 1; 1000 output @ 3000/m = 3; per_request 100 => 104++ assert.strictEqual(s1.json.charge_nano_usd, '104');++ assert.strictEqual(s1.json.released_nano_usd, '49896');++ const s2 = await settle(h.json.hold_id, body);++ assert.strictEqual(s2.status, 200, s2.text);++ assert.strictEqual(s2.json.settlement_id, s1.json.settlement_id);++ assert.strictEqual(s2.json.journal_id, s1.json.journal_id);++ const txns = await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE type = 'credit' AND idempotency_key = ?").get(`credit:settle:${body.idempotency_key}`);++ assert.strictEqual(Number(txns.n), 1);++ });++++ await check('aborted stream charges only partial usage and releases the unused hold', async () => {++ const gina = t.user(8);++ await fund(gina, '100000');++ const h = await create(holdBody(gina, 50000));++ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 100, output_tokens: 0 }));++ assert.strictEqual(s.status, 201, s.text);++ // 100 input @ 1000/m = ceil(100*1000/1e6) = 1; per_request 100 => 101++ assert.strictEqual(s.json.charge_nano_usd, '101');++ assert.strictEqual(s.json.released_nano_usd, '49899');++ const bal = await t.db.prepare("SELECT b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'usd_credit' AND a.owner_subject = ?").get(`user:${gina.id}`);++ assert.strictEqual(String(bal.balance), '99899'); // Balance reduced by the charge; the rest of the hold is released++ });++++ await check('release returns the held amount and is idempotent', async () => {++ const hank = t.user(9);++ await fund(hank, '100000');++ const h = await create(holdBody(hank, 30000));++ const r1 = await release(h.json.hold_id);++ assert.strictEqual(r1.status, 200, r1.text);++ assert.strictEqual(r1.json.released_nano_usd, '30000');++ const r2 = await release(h.json.hold_id);++ assert.strictEqual(r2.status, 200, r2.text);++ assert.strictEqual(r2.json.released_nano_usd, '30000');++ const row = await t.db.prepare('SELECT status FROM credit_holds WHERE id = ?').get(h.json.hold_id);++ assert.strictEqual(row.status, 'released');++ });++++ await check('expired lease is released by the sweeper', async () => {++ const ivy = t.user(10);++ await fund(ivy, '100000');++ const h = await create(holdBody(ivy, 10000));++ t.clock.offset += 600 * 1000; // default lease is 300s; advance past it++ const out = await inference.sweepExpired(t.ctx);++ assert.ok(out.released >= 1, `expected at least 1 release, got ${out.released}; the hold answered ${h.status} ${JSON.stringify(h.json).slice(0, 300)}`);++ const row = await t.db.prepare('SELECT status FROM credit_holds WHERE id = ?').get(h.json.hold_id);++ assert.strictEqual(row.status, 'expired');++ });++++ await check('100 concurrent holds on a balance that fits 7 succeed exactly 7 times and never go negative', async () => {++ const jack = t.user(11);++ await fund(jack, '70000');++ const key = `concurrent:${crypto.randomBytes(8).toString('hex')}`;++ const holds = await Promise.all(Array.from({ length: 100 }, (_, i) => create({++ idempotency_key: `${key}:${i}`,++ subject: { type: 'user', id: jack.id },++ max_nano_usd: '10000',++ rate_version: 'rc-v1',++ request_fingerprint: `fp-${i}`,++ })));++ const succeeded = holds.filter((r) => r.status === 201).length;++ assert.strictEqual(succeeded, 7, `expected 7 holds, got ${succeeded}`);++ const bal = await t.db.prepare("SELECT b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'usd_credit' AND a.owner_subject = ?").get(`user:${jack.id}`);++ assert.strictEqual(String(bal.balance), '70000');++ const held = await t.db.prepare("SELECT COALESCE(SUM(held_nano_usd), 0)::text AS held FROM credit_holds WHERE account_id = (SELECT id FROM accounts WHERE kind = 'usd_credit' AND owner_subject = ?) AND status IN ('active', 'extended')").get(`user:${jack.id}`);++ assert.strictEqual(String(held.held), '70000');++ assert.ok(holds.every((r) => r.status === 201 || r.json.code === 'billing.insufficient_funds'));++ });++++ await check('usage beyond the hold takes the free balance, never below zero, and records the shortfall', async () => {++ const omar = t.user(15);++ await fund(omar, '3000');++ const h = await create(holdBody(omar, 1000));++ // The request turned out to cost 5,000 nano (a per-request fee here), against 1,000 held and 3,000 funded in all.++ const rate = retail({ nano_usd_per_m: { input: '0', cached_read: '0', cache_write: '0', output: '0' }, per_request_nano_usd: '5000' });++ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 0, output_tokens: 0 }, { retail: rate }));++ assert.strictEqual(s.status, 201, s.text);++ assert.strictEqual(s.json.charge_nano_usd, '3000', 'collected: the hold and the free balance');++ assert.strictEqual(s.json.shortfall_nano_usd, '2000', 'owed, not collected, and recorded');++ assert.strictEqual(s.json.released_nano_usd, '0');++ const bal = await t.db.prepare("SELECT b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'usd_credit' AND a.owner_subject = ?").get(`user:${omar.id}`);++ assert.strictEqual(String(bal.balance), '0', 'never below zero');++ });++++ await check('a person reads their own Balance statement: balance, held, available, charges without content', async () => {++ const lena = t.user(13);++ t.network.addMembership(PRJ, lena.id, 'owner');++ await fund(lena, '100000', PRJ);++ const open = await create(holdBody(lena, 20000, { project_id: PRJ }));++ const done = await create(holdBody(lena, 10000, { project_id: PRJ }));++ await settle(done.json.hold_id, settleBody({ input_tokens: 1000, output_tokens: 1000 }, { attempts: [{ deployment_id: 'own:qwen/qwen3-4b:q4_k_m@3f2a1c9', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString() }] }));++ const token = t.network.signUser({ subject_id: lena.id, username: 'lena' });++ const read = (q, tok = token) => fetch(`${t.base}/api/me/credits${q}`, { headers: tok ? { Authorization: `Bearer ${tok}` } : {} });++ const r = await read(`?project_id=${PRJ}`);++ assert.strictEqual(r.status, 200, await r.clone().text());++ const doc = await r.json();++ assert.strictEqual(doc.balance_nano_usd, String(100000 - 104));++ assert.strictEqual(doc.held_nano_usd, '20000', 'the open hold is held');++ assert.strictEqual(doc.available_nano_usd, String(100000 - 104 - 20000));++ assert.strictEqual(doc.charges.length, 1);++ assert.strictEqual(doc.charges[0].charge_nano_usd, '104');++ assert.strictEqual(doc.charges[0].model, 'qwen/qwen3-4b:q4_k_m@3f2a1c9');++ assert.strictEqual(doc.charges[0].service, 'ai');++ assert.ok(open.json.hold_id);++ assert.strictEqual((await read(`?project_id=${PRJ}`, null)).status, 401, 'a token is needed');++ assert.strictEqual((await read('?project_id=nope')).status, 422);++ const other = t.network.signUser({ subject_id: t.user(14).id, username: 'mo' });++ const theirs = await read(`?project_id=${PRJ}`, other);++ assert.strictEqual(theirs.status, 403, 'a person who does not administer the project reads no statement');++ });++++ await check('a body that is not the contract\'s is refused with 422 before anything is held', async () => {++ const kim = t.user(14);++ await fund(kim, '100000');++ const before = await t.db.prepare('SELECT COUNT(*)::int AS n FROM credit_holds').get();++ const bad = await create({ ...holdBody(kim, 1000), max_nano_usd: 1000 }); // a number, not a decimal string++ assert.strictEqual(bad.status, 422, JSON.stringify(bad.json));++ assert.strictEqual(bad.json.code, 'billing.invalid_request');++ const extra = await create({ ...holdBody(kim, 1000), surprise: true });++ assert.strictEqual(extra.status, 422, 'an unknown field is refused');++ const after = await t.db.prepare('SELECT COUNT(*)::int AS n FROM credit_holds').get();++ assert.strictEqual(after.n, before.n, 'nothing was held');++ const ok = await create(holdBody(kim, 1000));++ const settled = await settle(ok.json.hold_id, settleBody({ output_tokens: 10 }, { attempts: [{ provider: 'own', model: 'x' }] }));++ assert.strictEqual(settled.status, 422, 'attempts carry the contract fields only (deployment_id, provider_access, times)');++ });++++ await check('sandbox Balance pays only a sandbox hold, and its charge returns to the issuer (D10)', async () => {++ const sam = t.user(15);++ const SAM_PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPS';++ t.network.addMembership(SAM_PRJ, sam.id, 'owner');++ await grant({ subject: { type: 'user', id: sam.id }, project_id: SAM_PRJ, amount_nano_usd: '50000' });++ const real = await create(holdBody(sam, 1000, { project_id: SAM_PRJ }));++ assert.strictEqual(real.status, 402, 'a real hold never spends sandbox Balance');++ const revenueBefore = await t.db.prepare("SELECT COALESCE(SUM(b.balance), 0)::text AS v FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'platform_revenue' AND a.currency = 'nano-usd'").get();++ const boxed = await create(holdBody(sam, 10000, { project_id: SAM_PRJ, sandbox: true }));++ assert.strictEqual(boxed.status, 201, boxed.text);++ const done2 = await settle(boxed.json.hold_id, settleBody({ input_tokens: 1000, output_tokens: 1000 }));++ assert.strictEqual(done2.status, 201, done2.text);++ const revenueAfter = await t.db.prepare("SELECT COALESCE(SUM(b.balance), 0)::text AS v FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'platform_revenue' AND a.currency = 'nano-usd'").get();++ assert.strictEqual(revenueAfter.v, revenueBefore.v, 'a sandbox charge is not revenue');++ const token = t.network.signUser({ subject_id: sam.id, username: 'sam' });++ const doc = await (await fetch(`${t.base}/api/me/credits?project_id=${SAM_PRJ}`, { headers: { Authorization: `Bearer ${token}` } })).json();++ assert.strictEqual(doc.balance_nano_usd, '0', 'the real Balance does not include sandbox Balance');++ assert.strictEqual(doc.sandbox.balance_nano_usd, String(50000 - 104), 'the sandbox balance is shown apart');++ assert.strictEqual(doc.charges.length, 0, 'sandbox charges are not on the real statement');++ });++++ await check('each settlement announces billing.credit.settled for aggregates, with no subject or project', async () => {++ const tom = t.user(16);++ await fund(tom, '100000');++ const h = await create(holdBody(tom, 10000));++ const at = new Date().toISOString();++ const s2 = await settle(h.json.hold_id, settleBody({ input_tokens: 1000, output_tokens: 1000 }, {++ attempts: [{ deployment_id: 'own:qwen/qwen3-4b:q4_k_m@3f2a1c9', provider_access: 'OWN_DEPLOYMENT', started_at: at, ended_at: at }],++ }));++ assert.strictEqual(s2.status, 201, s2.text);++ const ev = await t.db.prepare("SELECT envelope FROM service_outbox WHERE envelope->>'event_type' = 'billing.credit.settled' AND envelope->'payload'->>'hold_id' = ?").get(h.json.hold_id);++ assert.ok(ev, 'the event is in the outbox, in the settle transaction');++ const env = typeof ev.envelope === 'string' ? JSON.parse(ev.envelope) : ev.envelope;++ const pl = env.payload;++ assert.strictEqual(pl.deployment_id, 'own:qwen/qwen3-4b:q4_k_m@3f2a1c9');++ assert.strictEqual(pl.provider_access, 'OWN_DEPLOYMENT');++ assert.strictEqual(pl.charge_nano_usd, '104');++ assert.strictEqual(pl.sandbox, false);++ for (const k of ['subject', 'project_id', 'account_id']) assert.ok(!(k in pl), `no ${k} in the event`);++ const v = require('openvibe-contracts').validate('billing.credit.settled@1', pl);++ assert.ok(v.valid, `the payload is the contract's: ${JSON.stringify(v.errors)}`);++ });++++ await check('the journal balances after all inference operations', async () => {++ await assertJournalBalances(t.db, 'end of inference tests');++ });++++ await check('the old chargeFor amplification for tiny usage does not affect the subledger', async () => {++ // Old chargeFor: 1 token at $0.000001/m (1 nano/token) with bitsPerUsd=100 rounds up to 1 bit = $0.01.++ // Subledger: 1 token at 1 nano/m = 1 nano-USD = $0.000000001, with no per-component minimum.++ const kate = t.user(12);++ await fund(kate, '1000');++ const h = await create(holdBody(kate, 1000));++ const rate = retail({ nano_usd_per_m: { input: '1', cached_read: '1', cache_write: '1', output: '1' }, per_request_nano_usd: '0' });++ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 1, output_tokens: 0 }, { retail: rate }));++ assert.strictEqual(s.json.charge_nano_usd, '1');++ // chargeFor would have returned 1 bit = 100 nano-USD equivalent++ const { chargeFor } = require('../server/ops/rating');++ const old = chargeFor({ id: 'old', provider: 'x', metric: 'token', unit_size: 1000000, unit_price_usd: 0.000001 }, 1, { bitsPerUsd: 100 });++ assert.strictEqual(old, 1); // 1 bit++ assert.strictEqual(Number(s.json.charge_nano_usd), 1); // 1 nano, 100x smaller in USD terms than 1 bit++ });++++ await t.close();++ done();++})();@@@ -40,7 +40,7 @@ async function boot(opts = {}) {// 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, accountSend: opts.accountSend });++ const app = await createApp({ config, db, env, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow, valkey, accountSend: opts.accountSend });await app.locals.keys.refresh();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}`;@@@ -93,7 +93,7 @@ async function boot(opts = {}) {return r.json;}-- const user = (liveId) => ({ type: 'user', id: network.addUser(liveId) });++ const user = (liveId, username = null) => ({ type: 'user', id: network.addUser(liveId, username) });return {app, base, call, ctx, db: ctx.db, config, clock, network, stripe, powerchat, stripeEvent, assertReconciled, balances, user, logs, dir,@@@ -4,7 +4,8 @@** startNetwork() JWKS, client-credentials token endpoint (scope → cap), resolve-batch* (insists on a service token for openvibe.network holding identity.subject.resolve),-- * and SSO: authorize() issues a PKCE-bound code, /oauth/token redeems it, /oauth/revoke++ * project memberships (/internal/projects?subject= and /internal/projects/:id/members/:subject,++ * network.project.member.read), and SSO: authorize() issues a PKCE-bound code, /oauth/token redeems it* startStripe() /checkout/sessions and /subscriptions/:id, recording calls; `fail` makes it 500* startEvents() POST /api/v1/events recording batches and the bearer tokens it saw*/@@@ -27,6 +28,10 @@ async function startNetwork() {const legacy = {}; // live user id → subject idconst resolveCalls = [];const grants = [];++ const memberships = []; // { project_id, subject, role, archived, name }++ const publicNames = new Map(); // subject id -> username (the public name only)++ const projects = []; // { id, name, owner, archived_at }++ const apps = []; // { id, project_id, name, environment, icon }let issuer = 'http://network.test';const state = { down: false };@@@ -51,11 +56,44 @@ async function startNetwork() {iss: issuer, aud: ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'], iat: now, exp: now + 86400,}, privatePem);}-- function addUser(liveId) {++ function addUser(liveId, username = null) {const subject = ids.newId('user');if (liveId != null) legacy[String(liveId)] = subject;++ if (username) publicNames.set(subject, username);return subject;}++ /** Record a project membership; the D12 reads return it. */++ function addMembership(projectId, subject, role = 'owner', archived = false, name = null) {++ const existing = memberships.find((m) => m.project_id === projectId && m.subject === subject);++ if (existing) Object.assign(existing, { role, archived, name });++ else memberships.push({ project_id: projectId, subject, role, archived, name });++ return memberships[memberships.length - 1];++ }++ /** A developer project as GET /internal/projects/:id answers it (name, owner, archived_at). */++ function addProject({ id, name = 'A project', owner = null, archived_at = null }) {++ const existing = projects.find((p) => p.id === id);++ const row = { id, name, owner, archived_at };++ if (existing) Object.assign(existing, row); else projects.push(row);++ return row;++ }++ /** A dev_apps row as the project view carries it. */++ function addApp({ id, projectId, name = 'An app', environment = 'production', icon = null }) {++ const existing = apps.find((a) => a.id === id);++ const row = { id, project_id: projectId, name, environment, icon };++ if (existing) Object.assign(existing, row); else apps.push(row);++ return row;++ }++ /** A Network app token: sub app:app_<ULID>, project_id, env and approved grants. */++ function signApp({ appId, projectId, env = 'production', cap = [], onBehalfOf = null, expSec = 300 }) {++ const now = Math.floor(Date.now() / 1000);++ const claims = {++ iss: issuer, sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.billing'], cap,++ ns: [projectId, `app.${projectId}.*`], project_id: projectId, env,++ ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),++ iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex'),++ };++ return serviceAuth.signServiceToken(claims, privatePem);++ }const server = http.createServer(async (req, res) => {const raw = await readBody(req);@@@ -77,6 +115,35 @@ async function startNetwork() {return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });}if (req.url === '/oauth/revoke' && req.method === 'POST') { revoked.push(JSON.parse(raw || '{}').token); return send(res, 200, { revoked: true }); }++ if (req.url.startsWith('/internal/projects') && req.method === 'GET') {++ const auth = String(req.headers.authorization || '');++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ if (!v.ok) return send(res, 401, { code: v.code });++ const caps = v.claims.cap || [];++ if (!caps.includes('network.project.member.read') && !caps.includes('network.project.read')) return send(res, 403, { code: 'capability.denied' });++ const u = new URL(req.url, issuer);++ if (u.pathname === '/internal/projects') {++ if (!caps.includes('network.project.member.read')) return send(res, 403, { code: 'capability.denied' });++ const subject = u.searchParams.get('subject');++ return send(res, 200, { projects: memberships.filter((m) => m.subject === subject).map((m) => ({ ...m })) });++ }++ const view = u.pathname.match(/^\/internal\/projects\/([^/]+)$/);++ if (view) {++ if (!(v.claims.cap || []).includes('network.project.read')) return send(res, 403, { code: 'capability.denied' });++ const project = projects.find((p) => p.id === decodeURIComponent(view[1]));++ if (!project) return send(res, 404, { code: 'project.not_found' });++ return send(res, 200, {++ project: { id: project.id, name: project.name, owner: project.owner ? { type: 'user', id: project.owner } : null, archived_at: project.archived_at },++ apps: apps.filter((a) => a.project_id === project.id).map((a) => ({ id: a.id, name: a.name, environment: a.environment, ...(a.icon ? { icon: a.icon } : {}) })),++ });++ }++ const m = u.pathname.match(/^\/internal\/projects\/([^/]+)\/members\/([^/]+)$/);++ if (!m) return send(res, 404, { error: 'not found' });++ if (!caps.includes('network.project.member.read')) return send(res, 403, { code: 'capability.denied' });++ const found = memberships.find((x) => x.project_id === decodeURIComponent(m[1]) && x.subject === decodeURIComponent(m[2]));++ if (!found) return send(res, 404, { code: 'project.member_not_found' });++ return send(res, 200, { project_id: found.project_id, subject: found.subject, role: found.role, archived: found.archived, added_at: null });++ }if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {const auth = String(req.headers.authorization || '');const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });@@@ -86,9 +153,17 @@ async function startNetwork() {const body = JSON.parse(raw || '{}');resolveCalls.push(body);const results = {};-- for (const id of body.ids || []) {-- const s = body.system === 'live' ? legacy[String(id)] : null;-- results[String(id)] = s ? { subject: { type: 'user', id: s }, username: `u${id}` } : null;++ if (Array.isArray(body.subject_ids)) {++ for (const id of body.subject_ids) {++ results[String(id)] = publicNames.has(String(id))++ ? { subject: { type: 'user', id: String(id) }, username: publicNames.get(String(id)), display_name: publicNames.get(String(id)) }++ : null;++ }++ } else {++ for (const id of body.ids || []) {++ const s = body.system === 'live' ? legacy[String(id)] : null;++ results[String(id)] = s ? { subject: { type: 'user', id: s }, username: `u${id}` } : null;++ }}return send(res, 200, { results });}@@@ -98,7 +173,7 @@ async function startNetwork() {issuer = url;// A person's Network session token, as the browser holds it (the shared navbar sends it).const signUser = (u) => userToken({ role: 'user', username: 'someone', nowMs: Date.now(), ...u });-- return { url, publicPem, signService, signUser, addUser, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };++ return { url, publicPem, signService, signApp, signUser, addUser, addMembership, addProject, addApp, memberships, projects, apps, publicNames, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };}async function startStripe() {@@@ -136,4 +211,30 @@ async function startEvents() {return { url, batches, tokens, close: () => new Promise((r) => server.close(r)) };}--module.exports = { startNetwork, startStripe, startEvents };++/** PayPal/CCBill/NOWPayments test API: token, signature verification, checkout/capture and an invoice. */++async function startProviderStub() {++ const calls = [];++ const server = http.createServer(async (req, res) => {++ const raw = await readBody(req);++ calls.push({ method: req.method, url: req.url, raw });++ const json = (s, o) => send(res, s, o);++ if (req.url === '/v1/oauth2/token') return json(200, { access_token: 'pp-token' });++ if (req.url === '/v1/notifications/verify-webhook-signature') {++ const body = JSON.parse(raw);++ return json(200, { verification_status: body.transmission_sig === 'good' ? 'SUCCESS' : 'FAILURE' });++ }++ if (req.url === '/v2/checkout/orders') return json(201, { id: 'PPORDER1', links: [{ rel: 'approve', href: 'https://paypal.test/approve/PPORDER1' }] });++ const cap = req.url.match(/^\/v2\/checkout\/orders\/([^/]+)\/capture$/);++ if (cap) {++ const custom = calls.find((c) => c.url === '/v2/checkout/orders');++ const intentId = JSON.parse(custom.raw).purchase_units[0].custom_id;++ return json(201, { status: 'COMPLETED', purchase_units: [{ payments: { captures: [{ id: 'CAP-9', custom_id: intentId, amount: { currency_code: 'USD', value: '13.00' } }] } }] });++ }++ if (req.url === '/invoice') return json(200, { id: 777, invoice_url: 'https://nowpayments.test/invoice/777' });++ json(404, {});++ });++ const url = await listen(server);++ return { url, calls, close: () => new Promise((r) => server.close(r)) };++}++++module.exports = { startNetwork, startStripe, startEvents, startProviderStub };@@@ -23,5 +23,29 @@ assert.ok(tls.indexOf(m[0]) < tls.indexOf('location / {'), 'before the console cconst me = tls.match(/location = \/api\/me\/vibes \{([^}]*)\}/);assert.ok(me && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(me[1]) && /limit_req /.test(me[1]), 'a rate-limited location for /api/me/vibes');assert.ok(tls.indexOf(me[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');++const balance = tls.match(/location = \/api\/me\/balance \{([^}]*)\}/);++assert.ok(balance && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(balance[1]) && /limit_req /.test(balance[1]), 'a rate-limited location for /api/me/balance');++assert.ok(tls.indexOf(balance[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');++const transfers = tls.match(/location = \/api\/me\/balance\/transfers \{([^}]*)\}/);++assert.ok(transfers && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(transfers[1]) && /limit_req /.test(transfers[1]), 'a rate-limited location for POST /api/me/balance/transfers');++assert.ok(tls.indexOf(transfers[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');++const fromBalance = tls.match(/location = \/api\/me\/vibes\/from-balance \{([^}]*)\}/);++assert.ok(fromBalance && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(fromBalance[1]) && /limit_req /.test(fromBalance[1]), 'a rate-limited location for POST /api/me/vibes/from-balance');++assert.ok(tls.indexOf(fromBalance[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');++const activity = tls.match(/location = \/api\/me\/activity \{([^}]*)\}/);++assert.ok(activity && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(activity[1]) && /limit_req /.test(activity[1]), 'a rate-limited location for /api/me/activity');++assert.ok(tls.indexOf(activity[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');++const charge = tls.match(/location ~ \^\/api\/me\/vibes\/charges\/vch_\[0-9A-HJKMNP-TV-Z\]\{26\}\$ \{([^}]*)\}/);++assert.ok(charge && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(charge[1]) && /limit_req /.test(charge[1]), 'a rate-limited location for GET /api/me/vibes/charges/:id');++assert.ok(tls.indexOf(charge[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');++const chargeAct = tls.match(/location ~ \^\/api\/me\/vibes\/charges\/vch_\[0-9A-HJKMNP-TV-Z\]\{26\}\/\(pay\|cancel\)\$ \{([^}]*)\}/);++assert.ok(chargeAct && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(chargeAct[1]) && /limit_req /.test(chargeAct[1]), 'a rate-limited location for POST pay/cancel');++assert.ok(tls.indexOf(chargeAct[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');++const credits = tls.match(/location = \/api\/me\/credits \{([^}]*)\}/);++assert.ok(credits && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(credits[1]) && /limit_req /.test(credits[1]), 'a rate-limited location for /api/me/credits');++assert.ok(tls.indexOf(credits[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');++const topups = tls.match(/location = \/api\/me\/credits\/topups \{([^}]*)\}/);++assert.ok(topups && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(topups[1]) && /limit_req /.test(topups[1]), 'a rate-limited location for POST /api/me/credits/topups');++assert.ok(tls.indexOf(topups[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');assert.match(tls, /location \^~ \/api\/v1 \{ deny all; \}/);--console.log('nginx vhost: the policy is indexable, the rest noindex; /api/me/vibes public, /api/v1 loopback only');++console.log('nginx vhost: the policy is indexable, the rest noindex; /api/me/vibes, /api/me/balance, /api/me/balance/transfers, /api/me/vibes/from-balance, /api/me/activity, /api/me/credits and /api/me/credits/topups public, /api/v1 loopback only');nnew file mode 100644@@@ -0,0 +1,194 @@++'use strict';++/**++ * D11 owner settings: Billing's `billing.credits` namespace through openvibe-shared/config.++ *++ * The routes live at /internal/config behind Network's service token and `billing.config.manage`; the owner++ * is the X-OV-Subject usr_ id and a danger write needs X-OV-Fresh: 1. The env pins keep today's variables++ * winning; an owner change applies live to the Credits switches, the top-up terms and the hold lease.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/app');++++const CAP = ['billing.config.manage'];++const NAMESPACE = 'billing.credits';++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const KEK = 'k1:' + crypto.randomBytes(32).toString('base64');++++(async () => {++ console.log('owner settings (billing.credits)');++ const t = await boot();++ const owner = t.user(301);++ t.network.addMembership(PRJ, owner.id, 'owner');++ const ownerToken = t.network.signUser({ subject_id: owner.id, username: 'owner' });++ const headers = (fresh = false, extra = {}) => ({ 'x-ov-subject': owner.id, ...(fresh ? { 'x-ov-fresh': '1' } : {}), ...extra });++ const get = (cap = CAP, extra = {}) => t.call('GET', '/internal/config', { cap, headers: extra });++ const apply = (values, { fresh = true, cap = CAP, merge = false } = {}) =>++ t.call('POST', `/internal/config/${NAMESPACE}`, { cap, headers: headers(fresh), body: { values, merge, reason: 'owner settings test' } });++ const rollback = (to, fresh = true) =>++ t.call('POST', `/internal/config/${NAMESPACE}/rollback`, { cap: CAP, headers: headers(fresh), body: { to, reason: 'owner settings test' } });++ const summary = async () => (await get()).json.namespaces[0];++++ await check('the namespace and its schema are served, sealed:false with no KEK and no values until the owner sets them', async () => {++ const r = await get();++ assert.strictEqual(r.status, 200, r.text);++ const ns = r.json.namespaces[0];++ assert.strictEqual(ns.namespace, NAMESPACE);++ assert.strictEqual(ns.service, 'billing');++ assert.strictEqual(ns.revision, null);++ assert.strictEqual(ns.sealed, false);++ assert.strictEqual(ns.kek, null);++ const p = ns.schema.properties;++ assert.deepStrictEqual(Object.keys(p).sort(), ['credits', 'hold_lease_seconds', 'sandbox', 'topup', 'topup_amounts_cents', 'topup_fee_bps']);++ for (const k of Object.keys(p)) {++ assert.strictEqual(p[k]['x-group'], 'Balance', `${k}: x-group`);++ assert.ok(p[k].title && p[k].description, `${k}: title and description`);++ }++ assert.deepStrictEqual([p.credits['x-env'], p.credits['x-danger']], ['BILLING_CREDITS', true]);++ assert.deepStrictEqual([p.sandbox['x-env'], p.sandbox['x-danger']], ['BILLING_CREDITS_SANDBOX', undefined]);++ assert.deepStrictEqual([p.topup['x-env'], p.topup['x-danger']], ['BILLING_CREDITS_TOPUP', true]);++ assert.deepStrictEqual([p.topup_amounts_cents.type, p.topup_amounts_cents.items.type, p.topup_amounts_cents['x-env'], p.topup_amounts_cents['x-danger']], ['array', 'integer', 'BILLING_CREDITS_TOPUP_AMOUNTS_CENTS', true]);++ assert.deepStrictEqual([p.topup_fee_bps.minimum, p.topup_fee_bps.maximum, p.topup_fee_bps['x-danger']], [0, 10_000, true]);++ assert.strictEqual(p.hold_lease_seconds['x-env'], 'BILLING_INFERENCE_LEASE_SECONDS');++ // The brief's x-unit is "seconds"; Shared 4.110.0 only accepts nano_usd, ms and bps, so the unit is in the title.++ assert.strictEqual(p.hold_lease_seconds['x-unit'], undefined);++ assert.strictEqual(t.ctx.config.credits.enabled, false);++ const { topupTerms } = require('../server/ops/credits');++ assert.strictEqual(topupTerms(t.ctx).configured, false);++ });++++ await check('a caller without billing.config.manage gets 403, and an unsigned one 401', async () => {++ const denied = await get(['billing.balance.read']);++ assert.strictEqual(denied.status, 403, denied.text);++ assert.strictEqual(denied.json.code, 'capability.denied');++ const missing = await t.call('GET', '/internal/config', { token: null });++ assert.strictEqual(missing.status, 401, missing.text);++ const write = await t.call('POST', `/internal/config/${NAMESPACE}`, { cap: ['billing.balance.read'], headers: headers(true), body: { values: { credits: true }, reason: 'x' } });++ assert.strictEqual(write.status, 403, write.text);++ });++++ await check('a stale call to a danger key is 403 config.fresh_required and changes nothing', async () => {++ const r = await apply({ credits: true }, { fresh: false });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'config.fresh_required');++ assert.strictEqual(t.ctx.config.credits.enabled, false);++ });++++ await check('an owner change applies live and its audit line names the keys and actor, never a value', async () => {++ const r = await apply({ credits: true, topup: true, topup_amounts_cents: [1000, 2500], topup_fee_bps: 250 });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.revision, 1);++ assert.strictEqual(t.ctx.config.credits.enabled, true, 'the config object is updated without a restart');++ const { topupTerms } = require('../server/ops/credits');++ assert.deepStrictEqual(topupTerms(t.ctx), { enabled: true, amountsCents: [1000, 2500], feeBps: 250, configured: true, problem: null });++ const ns = await summary();++ assert.strictEqual(ns.revision, 1);++ assert.deepStrictEqual(ns.values.topup_amounts_cents, [1000, 2500]);++ const credit = await fetch(`${t.base}/api/me/credits?project_id=${PRJ}`, { headers: { Authorization: `Bearer ${ownerToken}` } });++ assert.strictEqual(credit.status, 200, await credit.text());++ const audit = await t.db.prepare("SELECT * FROM staff_audit WHERE action = 'config.change' ORDER BY seq DESC LIMIT 1").get();++ assert.ok(audit, 'no audit line');++ assert.strictEqual(audit.actor_subject, owner.id);++ assert.strictEqual(audit.target_id, NAMESPACE);++ const detail = JSON.parse(audit.detail);++ assert.strictEqual(detail.service, 'billing');++ assert.strictEqual(detail.namespace, NAMESPACE);++ assert.strictEqual(detail.revision, 1);++ assert.deepStrictEqual([...detail.keys].sort(), ['credits', 'topup', 'topup_amounts_cents', 'topup_fee_bps']);++ assert.ok(!audit.detail.includes('250') && !audit.detail.includes('1000'), 'the audit line must not carry a value');++ });++++ await check('a non-danger key needs no fresh sign-in, a danger key does', async () => {++ const lease = await apply({ hold_lease_seconds: 120 }, { fresh: false, merge: true });++ assert.strictEqual(lease.status, 200, lease.text);++ assert.strictEqual(t.ctx.config.credits.leaseSeconds, 120);++ const stale = await apply({ topup_fee_bps: 300 }, { fresh: false, merge: true });++ assert.strictEqual(stale.status, 403, stale.text);++ assert.strictEqual(stale.json.code, 'config.fresh_required');++ assert.strictEqual((await apply({ topup_fee_bps: 300 }, { merge: true })).status, 200);++ assert.strictEqual(require('../server/ops/credits').topupTerms(t.ctx).feeBps, 300);++ });++++ await check('history and a rollback apply the earlier terms live', async () => {++ const before = require('../server/ops/credits').topupTerms(t.ctx).feeBps;++ await apply({ topup_fee_bps: 400 }, { merge: true });++ assert.strictEqual(require('../server/ops/credits').topupTerms(t.ctx).feeBps, 400);++ const h = await t.call('GET', `/internal/config/${NAMESPACE}/history`, { cap: CAP });++ assert.strictEqual(h.status, 200, h.text);++ const target = h.json.snapshots.find((s) => s.values && s.values.topup_fee_bps === before && s.state !== 'active');++ assert.ok(target, 'no earlier revision with the old fee');++ const back = await rollback(target.revision);++ assert.strictEqual(back.status, 200, back.text);++ assert.strictEqual(require('../server/ops/credits').topupTerms(t.ctx).feeBps, before);++ });++++ await check('the env pins win and an owner write to a pinned key is 409 config.pinned_in_env', async () => {++ const pinned = await boot({++ env: {++ BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1',++ BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000, 2500', BILLING_CREDITS_TOPUP_FEE_BPS: '250', BILLING_INFERENCE_LEASE_SECONDS: '600',++ },++ });++ try {++ const o = pinned.user(302);++ const h = (fresh) => ({ 'x-ov-subject': o.id, ...(fresh ? { 'x-ov-fresh': '1' } : {}) });++ const ns = (await pinned.call('GET', '/internal/config', { cap: CAP })).json.namespaces[0];++ assert.strictEqual(ns.values.credits, true);++ assert.strictEqual(ns.values.topup, true);++ assert.deepStrictEqual(ns.values.topup_amounts_cents, [1000, 2500]);++ assert.strictEqual(ns.values.topup_fee_bps, 250);++ assert.strictEqual(ns.values.hold_lease_seconds, 600);++ assert.deepStrictEqual(ns.pins.topup, { env: 'BILLING_CREDITS_TOPUP', pinned: true });++ const terms = require('../server/ops/credits').topupTerms(pinned.ctx);++ assert.deepStrictEqual([terms.enabled, terms.amountsCents, terms.feeBps, terms.configured], [true, [1000, 2500], 250, true]);++ const r = await pinned.call('POST', `/internal/config/${NAMESPACE}`, { cap: CAP, headers: h(true), body: { values: { topup: false }, reason: 'x' } });++ assert.strictEqual(r.status, 409, r.text);++ assert.strictEqual(r.json.code, 'config.pinned_in_env');++ const lease = await pinned.call('POST', `/internal/config/${NAMESPACE}`, { cap: CAP, headers: h(true), body: { values: { hold_lease_seconds: 120 }, reason: 'x' } });++ assert.strictEqual(lease.status, 409, lease.text);++ assert.strictEqual(pinned.ctx.config.credits.leaseSeconds, 600);++ } finally { await pinned.close(); }++ });++++ await check('the old boolean forms still pin, and an empty variable means unset so the owner can set it', async () => {++ const legacy = await boot({ env: { BILLING_CREDITS: 'yes', BILLING_CREDITS_SANDBOX: '' } });++ try {++ const o = legacy.user(303);++ const h = { 'x-ov-subject': o.id, 'x-ov-fresh': '1' };++ const ns = (await legacy.call('GET', '/internal/config', { cap: CAP })).json.namespaces[0];++ assert.strictEqual(ns.values.credits, true);++ assert.ok(ns.pins.credits && ns.pins.credits.pinned === true);++ assert.strictEqual(ns.pins.sandbox, undefined, 'an empty env variable is unset, not a pin');++ const r = await legacy.call('POST', `/internal/config/${NAMESPACE}`, { cap: CAP, headers: h, body: { values: { sandbox: true }, merge: true, reason: 'x' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(legacy.ctx.config.credits.sandbox, true);++ } finally { await legacy.close(); }++ });++++ await check('a secret route is write-only and this namespace has no secret keys: 422, and no value is echoed', async () => {++ const r = await t.call('PUT', `/internal/config/${NAMESPACE}/secrets/topup_fee_bps`, { cap: CAP, headers: headers(true), body: { value: 777, reason: 'x' } });++ assert.strictEqual(r.status, 422, r.text);++ assert.strictEqual(r.json.code, 'config.not_secret');++ assert.ok(!r.text.includes('777'));++ assert.ok(!t.logs.join('\n').includes('777'), 'a refused secret must not reach a log line');++ const ns = await summary();++ assert.notStrictEqual(ns.values.topup_fee_bps, 777);++ assert.ok(!JSON.stringify(ns.secrets || {}).includes('777'));++ });++++ await check('with a KEK the namespace reports sealed:true and reseals without breaking', async () => {++ const sealed = await boot({ env: { OV_CONFIG_KEKS: KEK, OV_CONFIG_KEK_CURRENT: 'k1' } });++ try {++ const ns = (await sealed.call('GET', '/internal/config', { cap: CAP })).json.namespaces[0];++ assert.strictEqual(ns.sealed, true);++ assert.strictEqual(ns.kek, 'k1');++ assert.strictEqual(sealed.ctx.configStore.sealed, true);++ const report = await sealed.ctx.configStore.reseal();++ assert.strictEqual(report.kek, 'k1');++ assert.deepStrictEqual(report.errors, []);++ } finally { await sealed.close(); }++ });++++ await t.close();++ done();++})();@@@ -4,40 +4,11 @@* a stub provider API: verification, settlement exactly once, and refunds/chargebacks as reversals.*/const assert = require('assert');--const http = require('http');const crypto = require('crypto');const { boot, check, done } = require('./helpers/app');++const { startProviderStub } = require('./helpers/stubs');const { sortObject } = require('../server/providers/nowpayments');--async function startProviderStub() {-- const calls = [];-- const server = http.createServer((req, res) => {-- const chunks = [];-- req.on('data', (c) => chunks.push(c));-- req.on('end', () => {-- const raw = Buffer.concat(chunks).toString('utf8');-- calls.push({ method: req.method, url: req.url, raw });-- const json = (s, o) => { res.writeHead(s, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };-- if (req.url === '/v1/oauth2/token') return json(200, { access_token: 'pp-token' });-- if (req.url === '/v1/notifications/verify-webhook-signature') {-- const body = JSON.parse(raw);-- return json(200, { verification_status: body.transmission_sig === 'good' ? 'SUCCESS' : 'FAILURE' });-- }-- if (req.url === '/v2/checkout/orders') return json(201, { id: 'PPORDER1', links: [{ rel: 'approve', href: 'https://paypal.test/approve/PPORDER1' }] });-- const cap = req.url.match(/^\/v2\/checkout\/orders\/([^/]+)\/capture$/);-- if (cap) {-- const custom = calls.find((c) => c.url === '/v2/checkout/orders');-- const intentId = JSON.parse(custom.raw).purchase_units[0].custom_id;-- return json(201, { status: 'COMPLETED', purchase_units: [{ payments: { captures: [{ id: 'CAP-9', custom_id: intentId, amount: { currency_code: 'USD', value: '13.00' } }] } }] });-- }-- if (req.url === '/invoice') return json(200, { id: 777, invoice_url: 'https://nowpayments.test/invoice/777' });-- json(404, {});-- });-- });-- await new Promise((r) => server.listen(0, '127.0.0.1', r));-- return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };--}--(async () => {const stub = await startProviderStub();const t = await boot({nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++// D12 public words: every page, doc and error says "Balance", never "Credits". The wire names stay as they are++// (`usd_credit`, `/internal/credits`, `billing.credit.*`), so this test reads text and string literals only.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const root = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');++const docFiles = ['README.md', 'STATUS.json', 'docs/balance.md', 'docs/vibes-pay.md', 'docs/service-manifest-proposal.json', 'deploy/nginx/billing.openvibe.network.conf'];++const textFiles = ['server/policy.js', 'server/console/views.js', 'server/config-store.js', 'server/api/me.js', 'server/ops/credits.js', 'server/ops/topups.js', 'server/ops/balance.js', 'server/ops/activity.js', 'server/ops/intents.js', 'server/ops/reversals.js', 'server/api/internal.js'];++++for (const f of docFiles) assert.ok(!/Credits/.test(read(f)), `${f} has no "Credits"`);++for (const f of textFiles) {++ const text = read(f);++ // A quoted public string: "Credits", 'Credits', `Credits…` (an identifier such as createCreditsTopup is not text).++ const quoted = text.match(/(["'`])(?:[^"'`\n]*?)Credits(?:[^"'`\n]*?)\1/);++ assert.ok(!quoted, `${f} has no quoted "Credits" (found ${quoted && quoted[0]})`);++}++for (const f of docFiles) assert.ok(!/earn(?:ed|s)?\s+vibes/i.test(read(f)), `${f} says Vibes are bought, never earned`);++assert.ok(/Balance/.test(read('docs/balance.md')), 'the Balance doc says Balance');++console.log('public words: Balance in every public text and string; no "Credits", no "earn Vibes"');nnew file mode 100644@@@ -0,0 +1,404 @@++'use strict';++/**++ * Vibes Pay (D12 step 4): app charges, the pay sheet, the project wallet, signed webhooks, refunds, limits,++ * sandbox Vibes and the journal. Money is whole Vibes; a charge moves once and never below zero.++ */++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const { boot, fund, check, done } = require('./helpers/app');++const { validate } = require('openvibe-contracts');++const { createVibesClient } = require('openvibe-sdk/vibes');++const vibesWebhooks = require('../server/ops/vibes-webhooks');++++const APP = 'app_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const APP2 = 'app_01JAB2C3D4E5F6G7H8J9K0MNP2';++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const PRJ2 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNP2';++const KEK = 'k1:' + crypto.randomBytes(32).toString('base64');++const CAP = ['billing.vibes.charge', 'billing.vibes.read'];++// The released SDK's webhook verifier, exactly as an app uses it: openvibe-sdk/vibes webhooks.verify.++const sdkVibes = createVibesClient({ appToken: 'test-app-token', billingUrl: 'https://billing.example' });++const verify = (raw, header, secret, now) => sdkVibes.webhooks.verify(raw, header, secret, { now });++++function startHook() {++ const received = [];++ const state = { failStatus: 0 };++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const delivery = { headers: req.headers, raw: Buffer.concat(chunks).toString('utf8'), failed: Boolean(state.failStatus) };++ received.push(delivery);++ if (delivery.failed) { res.writeHead(state.failStatus); res.end('no'); return; }++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end('{"ok":true}');++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${server.address().port}/hook`, received, state,++ close: () => new Promise((r) => server.close(r)),++ })));++}++++function signatureTime(header) {++ const parts = Object.fromEntries(String(header || '').split(',').map((p) => p.split('=')));++ return Number(parts.t);++}++++async function journalBalanced(db, label) {++ const rows = await db.prepare(`SELECT a.currency, SUM(e.amount)::text AS total FROM ledger_entries e++ JOIN accounts a ON a.id = e.account_id GROUP BY a.currency`).all();++ for (const r of rows) assert.strictEqual(String(r.total), '0', `journal not balanced for ${r.currency} ${label}: ${r.total}`);++}++++(async () => {++ console.log('Vibes Pay');++ const hook = await startHook();++ const t = await boot({++ env: {++ BILLING_AUTHORITY: 'billing',++ BILLING_VIBES_PAY: '1',++ BILLING_VIBES_MAX_CHARGE: '10000',++ BILLING_VIBES_DAILY_APP_SPEND: '10000',++ BILLING_VIBES_SANDBOX: '1',++ BILLING_VIBES_CHARGE_TTL_SECONDS: '1800',++ OV_CONFIG_KEKS: KEK, OV_CONFIG_KEK_CURRENT: 'k1',++ },++ });++ try {++ const payer = t.user(401);++ const other = t.user(402);++ t.network.addProject({ id: PRJ, name: 'Neon Studio', owner: payer.id });++ t.network.addProject({ id: PRJ2, name: 'Sandbox Studio', owner: payer.id });++ t.network.addMembership(PRJ, payer.id, 'owner', false, 'Neon Studio');++ t.network.addApp({ id: APP, projectId: PRJ, name: 'Neon Cats', environment: 'production', icon: 'https://openvibe.media/assets/neon.png' });++ t.network.addApp({ id: APP2, projectId: PRJ2, name: 'Sandbox Cats', environment: 'sandbox' });++ const appToken = t.network.signApp({ appId: APP, projectId: PRJ, env: 'production', cap: CAP });++ const app2Token = t.network.signApp({ appId: APP2, projectId: PRJ2, env: 'sandbox', cap: CAP });++ const payerToken = t.network.signUser({ subject_id: payer.id, username: 'payer' });++ const otherToken = t.network.signUser({ subject_id: other.id, username: 'other' });++ await fund(t, payer, 1000);++ const appHeaders = (token = appToken) => ({ token });++ const create = (body, token = appToken) => t.call('POST', '/api/v1/vibes/charges', { token, body });++ const charge = async (body, token = appToken) => {++ const r = await create(body, token);++ assert.strictEqual(r.status, 201, r.text);++ return r.json;++ };++ const pay = (id, token = payerToken, key = `pay_${crypto.randomBytes(8).toString('hex')}`) =>++ t.call('POST', `/api/me/vibes/charges/${id}/pay`, { token, body: { idempotency_key: key }, key: null });++ const cancel = (id, token = payerToken) => t.call('POST', `/api/me/vibes/charges/${id}/cancel`, { token, body: {}, key: null });++ const viewOf = async (id, token = payerToken) => await t.call('GET', `/api/me/vibes/charges/${id}`, { token });++ const balanceOf = async (account) => Number(String((await t.db.prepare(`SELECT COALESCE(b.balance, 0)::text AS b FROM accounts a++ LEFT 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(account.kind, account.owner, account.currency)).b));++ const payerAcct = { kind: 'user_credit', owner: payer.id, currency: 'vibes-bits' };++ const projectAcct = { kind: 'project_payable', owner: `project:${PRJ}`, currency: 'vibes-bits' };++++ let secret;++ await check('the app stores its webhook url and a sealed secret; the marker is all a read returns', async () => {++ const put = await t.call('PUT', '/api/v1/vibes/webhook', { token: appToken, body: { url: hook.url } });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual(put.json.sealed, true);++ assert.strictEqual(put.json.secret_set, true);++ assert.ok(put.json.shown_once && typeof put.json.secret === 'string' && put.json.secret.length >= 16, put.text);++ secret = put.json.secret;++ const get = await t.call('GET', '/api/v1/vibes/webhook', { token: appToken });++ assert.strictEqual(get.status, 200, get.text);++ assert.strictEqual(get.json.secret, undefined, 'a read never returns the secret');++ assert.strictEqual(get.json.secret_hint, secret.slice(-4));++ assert.strictEqual(get.json.url, hook.url);++ const row = await t.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = ? ORDER BY revision DESC LIMIT 1").get(`billing.app.${APP.toLowerCase()}`);++ assert.ok(row, 'the app webhook has a D11 revision');++ assert.ok(!String(row.values_json).includes(secret), 'the sealed store carries no plaintext secret');++ const otherApp = await t.call('GET', '/api/v1/vibes/webhook', { token: app2Token });++ assert.strictEqual(otherApp.status, 200, otherApp.text);++ assert.strictEqual(otherApp.json.url, null, 'another app sees its own empty settings');++ });++++ let paidCharge;++ await check('create is idempotent on the app reference, and a different body under it conflicts', async () => {++ const body = { amount: 50, description: 'Sticker pack: Neon cats', reference: 'order_123', return_url: 'https://myapp.example/thanks' };++ const r = await create(body);++ assert.strictEqual(r.status, 201, r.text);++ paidCharge = r.json;++ const valid = validate('billing.vibes-charge@1', paidCharge);++ assert.ok(valid.valid, JSON.stringify(valid.errors));++ assert.strictEqual(paidCharge.status, 'pending');++ assert.match(paidCharge.pay_url, /^https:\/\/openvibe\.network\/pay\/vch_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(paidCharge.sandbox, false);++ const replay = await create(body);++ assert.strictEqual(replay.status, 200, replay.text);++ assert.strictEqual(replay.json.id, paidCharge.id);++ const conflict = await create({ ...body, amount: 51 });++ assert.strictEqual(conflict.status, 409, conflict.text);++ assert.strictEqual(conflict.json.code, 'billing.reference_reused');++ const notMine = await t.call('GET', `/api/v1/vibes/charges/${paidCharge.id}`, { token: app2Token });++ assert.strictEqual(notMine.status, 404, notMine.text);++ });++++ await check('the pay sheet shows the app from Network, the item, the price and the person\'s Vibes', async () => {++ const r = await viewOf(paidCharge.id);++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(validate('billing.vibes-charge@1', r.json.charge).valid);++ assert.deepStrictEqual(r.json.app, { id: APP, name: 'Neon Cats', icon: 'https://openvibe.media/assets/neon.png' });++ assert.strictEqual(r.json.charge.description, 'Sticker pack: Neon cats');++ assert.strictEqual(r.json.vibes_credit, 1000);++ assert.strictEqual(r.json.shortfall_vibes, 0);++ assert.strictEqual(r.json.can_pay, true);++ assert.strictEqual((await t.call('GET', `/api/me/vibes/charges/${paidCharge.id}`)).status, 401);++ assert.strictEqual((await t.call('GET', '/api/me/vibes/charges/vch_01JAB2C3D4E5F6G7H8J9K0MNPQ')).status, 401);++ const cors = await fetch(`${t.base}/api/me/vibes/charges/${paidCharge.id}`, { headers: { Authorization: `Bearer ${payerToken}`, Origin: 'https://openvibe.network' } });++ assert.strictEqual(cors.status, 200);++ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.network');++ assert.strictEqual(cors.headers.get('cache-control'), 'private, no-store');++ });++++ await check('a person-delegated app reads only that person\'s Vibes (billing.vibes.read)', async () => {++ const delegated = t.network.signApp({ appId: APP, projectId: PRJ, env: 'production', cap: ['billing.vibes.read'], onBehalfOf: payer.id });++ const r = await t.call('GET', '/api/v1/vibes/me', { token: delegated });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(validate('billing.vibes-wallet@1', r.json).valid, JSON.stringify(r.json));++ assert.deepStrictEqual(r.json.subject, { type: 'user', id: payer.id });++ assert.strictEqual(r.json.credit, 1000);++ const noPerson = t.network.signApp({ appId: APP, projectId: PRJ, env: 'production', cap: ['billing.vibes.read'] });++ assert.strictEqual((await t.call('GET', '/api/v1/vibes/me', { token: noPerson })).status, 422);++ const sandbox = t.network.signApp({ appId: APP2, projectId: PRJ2, env: 'sandbox', cap: ['billing.vibes.read'], onBehalfOf: payer.id });++ const refused = await t.call('GET', '/api/v1/vibes/me', { token: sandbox });++ assert.strictEqual(refused.status, 403, refused.text);++ assert.strictEqual(refused.json.code, 'billing.sandbox_refused');++ });++++ await check('the person pays once, atomically: 50 Vibes move to the project wallet', async () => {++ const r = await pay(paidCharge.id, payerToken, `pay_first_${crypto.randomBytes(8).toString('hex')}`);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.charge.status, 'paid');++ assert.ok(r.json.charge.paid_at);++ assert.strictEqual(r.json.vibes_credit, 950);++ assert.strictEqual(await balanceOf(payerAcct), 950);++ assert.strictEqual(await balanceOf(projectAcct), 50);++ const rows = await t.db.prepare("SELECT id FROM transactions WHERE type = 'vibes_charge'").all();++ assert.strictEqual(rows.length, 1);++ await journalBalanced(t.db, 'after a payment');++ });++++ await check('the app gets a signed paid webhook with no person id or email, and the event too', async () => {++ const out = await vibesWebhooks.deliverDue(t.ctx);++ assert.strictEqual(out.delivered, 1, JSON.stringify(out));++ assert.strictEqual(hook.received.length, 1);++ const delivery = hook.received[0];++ assert.ok(verify(delivery.raw, delivery.headers['x-openvibe-signature-v2'], secret, t.ctx.now()), 'openvibe-sdk/vibes verifies the HMAC-SHA256 signature');++ assert.strictEqual(delivery.headers['x-openvibe-event'], 'billing.vibes.charge.paid');++ const body = JSON.parse(delivery.raw);++ const valid = validate('billing.vibes.charge.paid@1', body);++ assert.ok(valid.valid, JSON.stringify(valid.errors));++ assert.strictEqual(body.status, 'paid');++ assert.strictEqual(body.amount, 50);++ assert.match(body.payer_ref, /^pr_[0-9a-f]{32}$/);++ assert.ok(!delivery.raw.includes(payer.id), 'no person id in the webhook');++ assert.ok(!delivery.raw.includes('usr_'), 'no subject id in the webhook');++ assert.ok(!delivery.raw.includes('@'), 'no email in the webhook');++ const ev = await t.db.prepare("SELECT envelope FROM service_outbox WHERE envelope #>> '{event_type}' = 'billing.vibes.charge.paid'").get();++ assert.ok(ev, 'the first-party event is in the outbox');++ const envelope = typeof ev.envelope === 'string' ? JSON.parse(ev.envelope) : ev.envelope;++ assert.strictEqual(envelope.payload.payer_ref, body.payer_ref);++ assert.ok(!JSON.stringify(envelope).includes(payer.id));++ });++++ await check('a double pay pays once: a replay for the same person, a conflict for another', async () => {++ const same = await pay(paidCharge.id, payerToken, `pay_second_${crypto.randomBytes(8).toString('hex')}`);++ assert.strictEqual(same.status, 200, same.text);++ assert.strictEqual(same.json.replay, true);++ assert.strictEqual(same.json.charge.status, 'paid');++ const count = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE type = 'vibes_charge'").get();++ assert.strictEqual(count.n, 1);++ assert.strictEqual(await balanceOf(payerAcct), 950);++ assert.strictEqual(await balanceOf(projectAcct), 50);++ const rival = await pay(paidCharge.id, otherToken);++ assert.strictEqual(rival.status, 409, rival.text);++ assert.strictEqual(rival.json.code, 'billing.charge_not_pending');++ assert.strictEqual((await cancel(paidCharge.id)).status, 409);++ });++++ await check('a refund returns the Vibes once, from the project wallet, and emits the refunded webhook', async () => {++ const r = await t.call('POST', `/api/v1/vibes/charges/${paidCharge.id}/refund`, { token: appToken, body: { reason: 'bought twice' }, key: null });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.status, 'refunded');++ assert.ok(r.json.refunded_at);++ assert.strictEqual(await balanceOf(payerAcct), 1000);++ assert.strictEqual(await balanceOf(projectAcct), 0);++ const again = await t.call('POST', `/api/v1/vibes/charges/${paidCharge.id}/refund`, { token: appToken, body: {}, key: null });++ assert.strictEqual(again.status, 200, again.text);++ assert.strictEqual(again.json.status, 'refunded');++ const refunds = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE type = 'vibes_refund'").get();++ assert.strictEqual(refunds.n, 1);++ const out = await vibesWebhooks.deliverDue(t.ctx);++ assert.strictEqual(out.delivered, 1, JSON.stringify(out));++ const delivery = hook.received[hook.received.length - 1];++ assert.ok(verify(delivery.raw, delivery.headers['x-openvibe-signature-v2'], secret, t.ctx.now()));++ const body = JSON.parse(delivery.raw);++ assert.ok(validate('billing.vibes.charge.refunded@1', body).valid);++ assert.strictEqual(body.status, 'refunded');++ assert.strictEqual(body.payer_ref, JSON.parse(hook.received[0].raw).payer_ref, 'the payer reference is stable per app');++ assert.ok(!delivery.raw.includes(payer.id));++ await journalBalanced(t.db, 'after a refund');++ });++++ await check('an expired charge cannot be paid and never moves money', async () => {++ const c = await charge({ amount: 10, description: 'Late sticker', reference: 'order_expired' });++ t.clock.offset += 31 * 60 * 1000;++ const r = await pay(c.id);++ assert.strictEqual(r.status, 409, r.text);++ assert.strictEqual(r.json.code, 'billing.charge_expired');++ const row = await t.db.prepare('SELECT status FROM vibes_charges WHERE id = ?').get(c.id);++ assert.strictEqual(row.status, 'expired');++ const count = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE idempotency_key = ?").get(`vibes:charge:${c.id}`);++ assert.strictEqual(count.n, 0);++ });++++ await check('short of Vibes is 402 and the charge stays pending', async () => {++ const c = await charge({ amount: 1001, description: 'Too much', reference: 'order_short' });++ const r = await pay(c.id);++ assert.strictEqual(r.status, 402, r.text);++ assert.strictEqual(r.json.code, 'billing.insufficient_vibes');++ assert.strictEqual(r.json.details.required, 1001);++ const row = await t.db.prepare('SELECT status FROM vibes_charges WHERE id = ?').get(c.id);++ assert.strictEqual(row.status, 'pending');++ });++++ await check('limit: the largest single charge and the daily app spend are owner settings', async () => {++ const tooBig = await create({ amount: 10001, description: 'Over the top', reference: 'order_big' });++ assert.strictEqual(tooBig.status, 403, tooBig.text);++ assert.strictEqual(tooBig.json.code, 'billing.vibes_limit_exceeded');++ await fund(t, payer, 20000);++ // The boot caps the day at 10,000; spend it with one allowed charge, then a little one is refused.++ const big = await charge({ amount: 10000, description: 'One big one', reference: 'order_big_ok' });++ assert.strictEqual((await pay(big.id)).status, 200);++ const little = await charge({ amount: 1, description: 'One more', reference: 'order_over_daily' });++ const r = await pay(little.id);++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'billing.vibes_limit_exceeded');++ // Paying a big allowed charge back leaves the daily spend net of the refund.++ const refund = await t.call('POST', `/api/v1/vibes/charges/${big.id}/refund`, { token: appToken, body: {}, key: null });++ assert.strictEqual(refund.status, 201, refund.text);++ });++++ await check('a webhook that fails is retried with backoff and delivered later', async () => {++ await vibesWebhooks.deliverDue(t.ctx); // drain earlier deliveries so this check sees only its own row++ hook.state.failStatus = 500;++ const c = await charge({ amount: 5, description: 'Retry me', reference: 'order_retry' });++ assert.strictEqual((await pay(c.id)).status, 200);++ const first = await vibesWebhooks.deliverDue(t.ctx);++ assert.strictEqual(first.failed, 1, JSON.stringify(first));++ const row = await t.db.prepare('SELECT * FROM vibes_webhook_outbox WHERE charge_id = ?').get(c.id);++ assert.strictEqual(Number(row.attempts), 1);++ assert.strictEqual(Number(row.last_status), 500);++ assert.ok(row.delivered_at == null);++ assert.ok(new Date(row.next_attempt_at).getTime() > t.ctx.now(), 'the retry is scheduled in the future');++ const firstTry = hook.received[hook.received.length - 1];++ assert.ok(firstTry.failed);++ assert.ok(verify(firstTry.raw, firstTry.headers['x-openvibe-signature-v2'], secret, t.ctx.now()), 'the failed attempt was signed too');++ hook.state.failStatus = 0;++ t.clock.offset += 2000; // a retry is a new delivery attempt: a fresh t, still inside the 300 s tolerance++ await t.db.prepare('UPDATE vibes_webhook_outbox SET next_attempt_at = ?::timestamptz WHERE charge_id = ?').run(new Date(t.ctx.now() - 1000).toISOString(), c.id);++ const second = await vibesWebhooks.deliverDue(t.ctx);++ assert.strictEqual(second.delivered, 1, JSON.stringify(second));++ const done = await t.db.prepare('SELECT * FROM vibes_webhook_outbox WHERE charge_id = ?').get(c.id);++ assert.ok(done.delivered_at);++ assert.strictEqual(Number(done.attempts), 2);++ const retry = hook.received[hook.received.length - 1];++ assert.ok(!retry.failed);++ assert.ok(verify(retry.raw, retry.headers['x-openvibe-signature-v2'], secret, t.ctx.now()), 'the retried delivery verifies with openvibe-sdk/vibes');++ assert.ok(signatureTime(retry.headers['x-openvibe-signature-v2']) > signatureTime(firstTry.headers['x-openvibe-signature-v2']), 'the retry carries a fresh t');++ });++++ await check('the project owner cashes out the project wallet through the existing cashout path', async () => {++ const c = await charge({ amount: 500, description: 'Cashout item', reference: 'order_cashout' });++ assert.strictEqual((await pay(c.id)).status, 200);++ const before = await balanceOf(projectAcct);++ const request = (body, cap = ['billing.cashout.request'], key) => t.call('POST', '/api/v1/cashouts', { cap, body, ...(key ? { key } : {}) });++ const body = {++ subject: payer.id, project_id: PRJ, amount: 500,++ payout_method: { type: 'paypal', address: '[email protected]' },++ };++ const r = await request(body);++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.cashout.project_id, PRJ, 'the cashout names the project');++ assert.strictEqual(await balanceOf(projectAcct), before - 500);++ const pending = Number((await t.db.prepare(`SELECT b.balance::text AS b FROM accounts a JOIN account_balances b ON b.account_id = a.id++ WHERE a.kind = 'payouts_pending' AND a.owner_subject = ?`).get(payer.id)).b);++ assert.strictEqual(pending, 500);++ const otherBody = { ...body, subject: other.id };++ const denied = await request(otherBody);++ assert.strictEqual(denied.status, 403, denied.text);++ assert.strictEqual(denied.json.code, 'billing.not_project_owner');++ await journalBalanced(t.db, 'after a project cashout');++ });++++ await check('the economy freeze refuses app charges and payments, reads still answer', async () => {++ const c = await charge({ amount: 5, description: 'Before freeze', reference: 'order_before_freeze' });++ await t.call('POST', '/api/v1/admin/freeze', { key: null, body: { on: true, reason: 'Vibes Pay test' } });++ const frozen = await create({ amount: 5, description: 'Frozen', reference: 'order_frozen' });++ assert.strictEqual(frozen.status, 503, frozen.text);++ assert.strictEqual(frozen.json.code, 'billing.frozen');++ const r = await pay(c.id);++ assert.strictEqual(r.status, 503, r.text);++ assert.strictEqual(r.json.code, 'billing.frozen');++ assert.strictEqual((await viewOf(c.id)).status, 200);++ await t.call('POST', '/api/v1/admin/freeze', { key: null, body: { on: false } });++ });++++ await check('sandbox: test grants only, test Vibes only, never a real account', async () => {++ const realBefore = await balanceOf(payerAcct);++ const projectBefore = await balanceOf(projectAcct);++ const c = await charge({ amount: 30, description: 'Sandbox sticker', reference: 'sandbox_1' }, app2Token);++ assert.strictEqual(c.sandbox, true);++ const short = await pay(c.id);++ assert.strictEqual(short.status, 402, short.text);++ const grant = await t.call('POST', '/api/v1/vibes/sandbox/grants', {++ token: app2Token, body: { subject: { type: 'user', id: payer.id }, amount: 100 }, key: null,++ });++ assert.strictEqual(grant.status, 201, grant.text);++ assert.strictEqual(grant.json.vibes_credit, 100);++ const view = await viewOf(c.id);++ assert.strictEqual(view.json.vibes_credit, 100);++ assert.strictEqual(view.json.can_pay, true);++ const paid = await pay(c.id);++ assert.strictEqual(paid.status, 200, paid.text);++ assert.strictEqual(paid.json.charge.status, 'paid');++ assert.strictEqual(paid.json.vibes_credit, 70);++ assert.strictEqual(await balanceOf(payerAcct), realBefore, 'real Vibes were not touched');++ assert.strictEqual(await balanceOf(projectAcct), projectBefore, 'the real project wallet was not touched');++ const row = await t.db.prepare(`SELECT t.test, a.kind, b.balance::text AS balance FROM transactions t++ JOIN ledger_entries e ON e.txn_id = t.id JOIN accounts a ON a.id = e.account_id JOIN account_balances b ON b.account_id = a.id++ WHERE t.idempotency_key = ? AND a.kind = 'project_payable_sandbox'`).get(`vibes:charge:${c.id}`);++ assert.ok(row && Number(row.test) === 1 && row.balance === '30', JSON.stringify(row));++ // A production app cannot mint sandbox Vibes.++ const refused = await t.call('POST', '/api/v1/vibes/sandbox/grants', { token: appToken, body: { subject: { type: 'user', id: payer.id }, amount: 10 }, key: null });++ assert.strictEqual(refused.status, 403, refused.text);++ assert.strictEqual(refused.json.code, 'billing.sandbox_app_only');++ // A person with only sandbox Vibes cannot pay a real charge.++ const onlySandbox = t.user(403);++ const onlyToken = t.network.signUser({ subject_id: onlySandbox.id, username: 'sandbox' });++ await t.call('POST', '/api/v1/vibes/sandbox/grants', { token: app2Token, body: { subject: { type: 'user', id: onlySandbox.id }, amount: 500 }, key: null });++ const real = await charge({ amount: 10, description: 'Real item', reference: 'real_after_sandbox' });++ const denied = await pay(real.id, onlyToken);++ assert.strictEqual(denied.status, 402, denied.text);++ // A sandbox refund returns test Vibes to the test pocket.++ const refund = await t.call('POST', `/api/v1/vibes/charges/${c.id}/refund`, { token: app2Token, body: {}, key: null });++ assert.strictEqual(refund.status, 201, refund.text);++ const after = await t.call('GET', '/api/v1/vibes/charges/' + c.id, { token: app2Token });++ assert.strictEqual(after.json.status, 'refunded');++ });++++ await check('every journal transaction balances and reconciliation passes', async () => {++ await journalBalanced(t.db, 'at the end');++ const rec = await t.assertReconciled('after Vibes Pay');++ assert.ok(rec.ok);++ });++ } finally {++ await hook.close();++ await t.close();++ }++ done();++})();
@@ -137,3 +137,18 @@ POWERCHAT_SITE_USERNAME=
@@ -137,3 +137,18 @@ POWERCHAT_SITE_USERNAME=
# Account export and deletion (ADR-033): the signing secret of Billing's own Events subscriptions (32+ characters;
# Account export and deletion (ADR-033): the signing secret of Billing's own Events subscriptions (32+ characters;
# comma-separated during a rotation). Without it, the account subscriptions are not created and /internal/events answers 503.
# comma-separated during a rotation). Without it, the account subscriptions are not created and /internal/events answers 503.
#BILLING_EVENTS_SECRET=
#BILLING_EVENTS_SECRET=
+
+# ── Vibes Pay (D12): developer app charges, the pay sheet and sandbox test Vibes ──
+# These are the pre-D11 names of the billing.vibes namespace keys; while set they pin the owner's value.
+#BILLING_VIBES_PAY=0
+#BILLING_VIBES_MAX_CHARGE=0
+#BILLING_VIBES_DAILY_APP_SPEND=0
+# Test grants and sandbox charges, worth nothing (one switch, like BILLING_CREDITS_SANDBOX).
+#BILLING_VIBES_SANDBOX=0
+# Charge lifetime and the openvibe.network pay-sheet origin.
+#BILLING_VIBES_CHARGE_TTL_SECONDS=1800
+#BILLING_VIBES_PAY_BASE_URL=https://openvibe.network
+# The webhook delivery job (0 = off; tests call deliverDue directly).
+#BILLING_VIBES_WEBHOOK_INTERVAL_MS=5000
+# Development only: allow a private webhook address. Production always applies the egress guard.
+#BILLING_VIBES_ALLOW_PRIVATE_WEBHOOKS=0
@@ -51,6 +51,8 @@ promo write changes both in one transaction.
@@ -51,6 +51,8 @@ promo write changes both in one transaction.
schema in [migrations/](migrations/))
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)
+- Vibes Pay (D12): app charges, the project's `project_payable` Vibes wallet, its signed webhooks and sandbox test
+ Vibes ([docs/vibes-pay.md](docs/vibes-pay.md))
## Does not own
## Does not own
@@ -64,7 +66,7 @@ promo write changes both in one transaction.
@@ -64,7 +66,7 @@ promo write changes both in one transaction.
- 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 SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are set)
- OpenVibe.Events (the SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are 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.222.0, `openvibe-sdk` v0.82.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`), `openvibe-shared` v4.109.2, pinned by release tarball
+- `openvibe-contracts` v0.289.1, `openvibe-sdk` v0.84.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.110.0, pinned by release tarball
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
- **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)
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
@@ -73,10 +75,11 @@ promo write changes both in one transaction.
@@ -73,10 +75,11 @@ promo write changes both in one transaction.
Implemented here (the service manifest's `capabilities`, audience `openvibe.billing`; routes under
Implemented here (the service manifest's `capabilities`, audience `openvibe.billing`; routes under
[API](#api)): `billing.intent.create`, `billing.transfer.create`, `billing.balance.read`,
[API](#api)): `billing.intent.create`, `billing.transfer.create`, `billing.balance.read`,
`billing.cashout.request`, `billing.cashout.manage`, `billing.subscription.manage`,
`billing.cashout.request`, `billing.cashout.manage`, `billing.subscription.manage`,
-`billing.entitlement.check`, `billing.ledger.admin` and `billing.usage.record` (usage readings; released in
-Contracts v0.85.0). Callers today are Live (after the cutover),
-Tips (`billing.intent.create`, `billing.transfer.create`) and VIP (`billing.intent.create`,
-`billing.subscription.manage`, `billing.entitlement.check`).
+`billing.entitlement.check`, `billing.ledger.admin`, `billing.usage.record` (usage readings; released in
+Contracts v0.85.0), and the public `billing.vibes.charge` and `billing.vibes.read` for developer apps (D12).
+Callers today are Live (after the cutover),
+Tips (`billing.intent.create`, `billing.transfer.create`), VIP (`billing.intent.create`,
+`billing.subscription.manage`, `billing.entitlement.check`) and a developer app with its own app token for Vibes Pay.
Called elsewhere, as the service principal `billing`: `events.event.publish` (Events) and
Called elsewhere, as the service principal `billing`: `events.event.publish` (Events) and
`identity.subject.resolve` (Network, the Live import).
`identity.subject.resolve` (Network, the Live import).
@@ -107,6 +110,13 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
@@ -107,6 +110,13 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
same transaction and verified by reconciliation. Funds checks run inside the transaction that moves
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
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.
serialization failure), so two movements that each passed a check cannot both commit.
+- **Vibes Pay** (`server/ops/vibes.js`, `server/ops/vibes-webhooks.js`, [docs/vibes-pay.md](docs/vibes-pay.md)):
+ an app creates a charge (whole Vibes, idempotent on its reference), the person pays it on the pay sheet, and the
+ Vibes move once from `user_credit` to `project_payable:project:<prj_id>`; a project owner cashes out through the
+ existing cashout path. Each paid/refunded charge publishes `billing.vibes.charge.paid@1`/`.refunded@1` to Events
+ and queues a signed HMAC-SHA256 webhook in `vibes_webhook_outbox`, retried with backoff. Sandbox apps use
+ `vibes_credit_sandbox`/`project_payable_sandbox` and test transactions only. The owner's `billing.vibes` limits
+ and the economy freeze are checked inside the money transaction.
- **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`.
@@ -119,7 +129,22 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
@@ -119,7 +129,22 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
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
`503 billing.frozen`, reads work, webhooks are stored (202) and processed in arrival order after
`503 billing.frozen`, reads work, webhooks are stored (202) and processed in arrival order after
- unfreeze. Jobs pause.
+ unfreeze. Jobs pause. The owner's `money_writes_frozen` switch keeps its own audited path
+ (`/api/admin/money`); it is not configuration and no rollback may move it.
+- **Owner settings (D11)** ([server/config-store.js](server/config-store.js)): the namespace
+ `billing.credits` holds the Balance switch, the sandbox grants, the top-up switch, its amounts and
+ fee, and the hold lease, through openvibe-shared/config. Each key keeps the environment variable it
+ had as an `x-env` pin: while that variable is set its value wins and a write is `409
+ config.pinned_in_env`; otherwise the owner sets it from Network's AI tab and it applies live. The
+ routes are `/internal/config` (capability `billing.config.manage`, Network's service token), with the
+ owner's `usr_` id in `X-OV-Subject` and `X-OV-Fresh: 1` for the danger keys (`credits`, `topup`,
+ `topup_amounts_cents`, `topup_fee_bps`). Every activation writes a `staff_audit` row with the keys,
+ actor and revision, never a value. With `OV_CONFIG_KEKS` set the store seals secrets and reseals old
+ plaintext rows at boot; this namespace has no secret-class keys, so it reports `sealed` but exposes no
+ value. `billing.vibes` holds the Vibes Pay switch and limit, the daily app spend per person and the
+ `from_balance` switch (`vibes_pay` and `from_balance` are danger keys); `GET /api/me/balance`, the transfer
+ and the Vibes purchase are in [docs/balance.md](docs/balance.md); `GET /api/me/activity` is the person's own
+ movement list for Network's wallet.
- **Outbox**: openvibe-sdk/events stores pending envelopes in `service_outbox` and relays them with Billing's service token. The legacy `outbox` table stays through the N-1 rollback window. Events include `billing.transaction.settled|reversed`, `billing.entitlement.changed`,
- **Outbox**: openvibe-sdk/events stores pending envelopes in `service_outbox` and relays them with Billing's service token. The legacy `outbox` table stays through the N-1 rollback window. Events include `billing.transaction.settled|reversed`, `billing.entitlement.changed`,
`billing.subscription.canceled`, `billing.cashout.requested|paid|denied`, `billing.staff.action`,
`billing.subscription.canceled`, `billing.cashout.requested|paid|denied`, `billing.staff.action`,
`billing.receipt.external`, written in the same transaction as the effect (events.event-envelope@1,
`billing.receipt.external`, written in the same transaction as the effect (events.event-envelope@1,
@@ -228,6 +253,7 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30
@@ -228,6 +253,7 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30
| `POST /api/v1/admin/sweep`, `GET /api/v1/admin/import-holds` | `billing.ledger.admin` | renewal sweep now; unmapped import users |
| `POST /api/v1/admin/sweep`, `GET /api/v1/admin/import-holds` | `billing.ledger.admin` | renewal sweep now; unmapped import users |
| `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` |
| `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` |
| `GET /metrics` | direct loopback caller | Prometheus text (openvibe-shared/metrics): HTTP golden signals, process, `release_info`, and `billing_*` gauges — freeze, authority, receipts by state, oldest pending receipt, EXTERNAL receipts, outbox backlog, cashouts, the latest reconciliation. 404 to anything a proxy relayed; nginx answers 404 on the public host |
| `GET /metrics` | direct loopback caller | Prometheus text (openvibe-shared/metrics): HTTP golden signals, process, `release_info`, and `billing_*` gauges — freeze, authority, receipts by state, oldest pending receipt, EXTERNAL receipts, outbox backlog, cashouts, the latest reconciliation. 404 to anything a proxy relayed; nginx answers 404 on the public host |
+| `GET\|POST /internal/config[/:namespace[/history\|rollback]]`, `PUT\|DELETE …/secrets/:key` | `billing.config.manage` | owner settings (D11): namespace summaries with the schema, values, pins and `sealed`; apply a change (danger keys need `X-OV-Fresh: 1`); history and rollback. Network's AI tab is the only caller; nginx 404s `/internal/` on the public host |
The existing capabilities and service manifest are released in openvibe-contracts (v0.8.0;
The existing capabilities and service manifest are released in openvibe-contracts (v0.8.0;
`billing.staff.action` since v0.17.0). `billing.usage.record` is released in Contracts v0.85.0: input is
`billing.staff.action` since v0.17.0). `billing.usage.record` is released in Contracts v0.85.0: input is
@@ -487,7 +513,7 @@ PowerChat purchase settling end to end after the cutover.
@@ -487,7 +513,7 @@ PowerChat purchase settling end to end after the cutover.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.222.0
-- openvibe-sdk: v0.82.0
-- openvibe-shared: v4.109.2
+- openvibe-contracts: v0.289.1
+- openvibe-sdk: v0.84.0
+- openvibe-shared: v4.110.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,9 +8,9 @@
@@ -8,9 +8,9 @@
"authoritative": true,
"authoritative": true,
"deployed": true,
"deployed": true,
"deployment": "The money ledger since the cutover of 2026-10-17 04:50-04:56 UTC (BILLING_AUTHORITY=billing in billing.env and live.env): deployed 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), a person's own Vibes (/api/me/vibes, CORS for OpenVibe's sites), /api/health, /api/ready, /release.json and /webhooks/*. PowerChat deliveries reach /webhooks/powerchat through openvibe.live's /api/powerchat/webhook (proxied by Live's vhost, OpenVibe.Live#172).",
"deployment": "The money ledger since the cutover of 2026-10-17 04:50-04:56 UTC (BILLING_AUTHORITY=billing in billing.env and live.env): deployed 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), a person's own Vibes (/api/me/vibes, CORS for OpenVibe's sites), /api/health, /api/ready, /release.json and /webhooks/*. PowerChat deliveries reach /webhooks/powerchat through openvibe.live's /api/powerchat/webhook (proxied by Live's vhost, OpenVibe.Live#172).",
- "contracts": "openvibe-contracts v0.222.0",
- "sdk": "openvibe-sdk v0.82.0",
- "shared": "openvibe-shared v4.109.2",
+ "contracts": "openvibe-contracts v0.289.1",
+ "sdk": "openvibe-sdk v0.84.0",
+ "shared": "openvibe-shared v4.110.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, EXTERNAL PowerChat receipts (billing.receipt.external, announced for OpenVibe.Tips), overflow guards and serializable settlement (PostgreSQL, ADR-035). The cutover (README runbook, docs/live-cutover.md) ran on 2026-10-17: final import 0 new rows, 0 holds, 0 anomalies; reconciliation OK before and after; Live's money tables are read-only behind a database tripwire. OpenVibe.Tips and OpenVibe.VIP launched right after (VIP checkout open). Still due: a small real PowerChat purchase settling end to end after the cutover. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
"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, EXTERNAL PowerChat receipts (billing.receipt.external, announced for OpenVibe.Tips), overflow guards and serializable settlement (PostgreSQL, ADR-035). The cutover (README runbook, docs/live-cutover.md) ran on 2026-10-17: final import 0 new rows, 0 holds, 0 anomalies; reconciliation OK before and after; Live's money tables are read-only behind a database tripwire. OpenVibe.Tips and OpenVibe.VIP launched right after (VIP checkout open). Still due: a small real PowerChat purchase settling end to end after the cutover. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
"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",
@@ -32,8 +32,15 @@
@@ -32,8 +32,15 @@
"usage rating: a rate card -> the subject's promo allowance -> one usage transaction per reading, under hard budgets (BILLING_RATING_INTERVAL_MS off by default; nothing charged without a loaded card, key usage:<reading key>)",
"usage rating: a rate card -> the subject's promo allowance -> one usage transaction per reading, under hard budgets (BILLING_RATING_INTERVAL_MS off by default; nothing charged without a loaded card, key usage:<reading key>)",
"promo allowance ledger",
"promo allowance ledger",
"a person's own Vibes for every site's account menu (/api/me/vibes)",
"a person's own Vibes for every site's account menu (/api/me/vibes)",
+ "Balance held by a person (usd_credit: user:<usr_id>) or a project (project:<prj_id>), with personal and project holds",
+ "GET /api/me/balance (and the /api/me/credits alias): the person's Balance plus every project they administer, read from Network with Billing's service token",
+ "moving personal Balance to a project (atomic, idempotent, admin only) and getting Vibes with Balance at the pack value rate",
+ "GET /api/me/activity: the person's own Balance and Vibes movements with plain labels, two rows for a two-currency transaction, and cursor pagination",
+ "owner settings: billing.vibes (Vibes Pay, the largest charge, the daily app spend per person and from_balance)",
"network-wide money counts with no person in them, for products' public stats (billing.stats.read)",
"network-wide money counts with no person in them, for products' public stats (billing.stats.read)",
- "account export and deletion (ADR-033)"
+ "account export and deletion (ADR-033)",
+ "owner settings: billing.credits (Balance, sandbox, top-up switch/amounts/fee and the hold lease) and billing.vibes (Vibes Pay limits, Get Vibes with Balance) through openvibe-shared/config, with env pins and live changes (D11/D12)",
+ "Vibes Pay (D12): app-token charges with the app's reference as the idempotency key, the person's pay-sheet routes, the project's Vibes wallet (project_payable) with the project owner's cashout through the existing path, HMAC-SHA256 webhooks from vibes_webhook_outbox retried with backoff, sandbox Vibes gated by BILLING_VIBES_SANDBOX, and the billing.vibes limits and frozen-economy checks"
],
],
- "updated": "2026-11-03"
+ "updated": "2026-11-04"
}
}
@@ -59,11 +59,53 @@ server {
@@ -59,11 +59,53 @@ server {
location = /api/health { proxy_pass http://127.0.0.1:4600; }
location = /api/health { proxy_pass http://127.0.0.1:4600; }
# A signed-in person's own Vibes, read by the shared navbar on every OpenVibe site (server/api/me.js: a user token,
# A signed-in person's own Vibes, read by the shared navbar on every OpenVibe site (server/api/me.js: a user token,
- # CORS for OpenVibe's own origins). The only other public /api path.
+ # CORS for OpenVibe's own origins).
location = /api/me/vibes {
location = /api/me/vibes {
limit_req zone=ovbilling_console burst=20 nodelay;
limit_req zone=ovbilling_console burst=20 nodelay;
proxy_pass http://127.0.0.1:4600;
proxy_pass http://127.0.0.1:4600;
}
}
+ # A person's Balance: their own and the projects they administer (docs/balance.md; 404 while BILLING_CREDITS is off).
+ location = /api/me/balance {
+ limit_req zone=ovbilling_console burst=20 nodelay;
+ proxy_pass http://127.0.0.1:4600;
+ }
+ # Moving personal Balance to a project the person administers (docs/balance.md).
+ location = /api/me/balance/transfers {
+ limit_req zone=ovbilling_console burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4600;
+ }
+ # The person's own Balance and Vibes movements for Network's wallet (docs/balance.md).
+ location = /api/me/activity {
+ limit_req zone=ovbilling_console burst=20 nodelay;
+ proxy_pass http://127.0.0.1:4600;
+ }
+ # The old wire path stays an alias of /api/me/balance (and ?project_id= for one project's statement).
+ location = /api/me/credits {
+ limit_req zone=ovbilling_console burst=20 nodelay;
+ proxy_pass http://127.0.0.1:4600;
+ }
+ # Starting a Balance top-up (docs/balance.md; one of the owner's amounts, behind BILLING_CREDITS_TOPUP). The same
+ # public /api/me surface, with its own burst so a checkout cannot be used to flood the provider.
+ location = /api/me/credits/topups {
+ limit_req zone=ovbilling_console burst=5 nodelay;
+ proxy_pass http://127.0.0.1:4600;
+ }
+ # Getting Vibes with Balance (docs/balance.md; behind billing.vibes.from_balance).
+ location = /api/me/vibes/from-balance {
+ limit_req zone=ovbilling_console burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4600;
+ }
+ # Vibes Pay's pay sheet charge: the app, item, price and the person's Vibes (docs/vibes-pay.md). A charge id is
+ # vch_ + 26 Crockford characters; anything else stays a 404 at the catch-all below.
+ location ~ ^/api/me/vibes/charges/vch_[0-9A-HJKMNP-TV-Z]{26}$ {
+ limit_req zone=ovbilling_console burst=20 nodelay;
+ proxy_pass http://127.0.0.1:4600;
+ }
+ # Pay or cancel that charge; both are POSTs with the person's own bearer token.
+ location ~ ^/api/me/vibes/charges/vch_[0-9A-HJKMNP-TV-Z]{26}/(pay|cancel)$ {
+ limit_req zone=ovbilling_console burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4600;
+ }
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4600/metrics). Node also
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4600/metrics). Node also
# answers 404 to any request a proxy relayed.
# answers 404 to any request a proxy relayed.
new file mode 100644
new file mode 100644
@@ -0,0 +1,214 @@
@@ -0,0 +1,214 @@
+# Balance: the prepaid USD balance for metered services
+
+OpenVibe has two currencies (AI Exchange D9, owner 2026-11-03):
+
+| Currency | For | Precision |
+|---|---|---|
+| **Vibes** | Social and consumer use: tips and bits on Live, creators, cashout, consumer apps priced in whole Vibes. | A whole Vibes bit, 100 per USD. |
+| **Balance** | A topped-up USD balance that metered developer and platform services spend: the AI API first, then compute. One for one with the dollar. | nano-USD (1e-9 USD) |
+
+Nothing about Vibes changes. Balance is built on the same double-entry journal, in its own currency (`nano-usd`)
+and its own accounts, and never goes through `chargeFor`.
+
+Everything is behind `BILLING_CREDITS` (off). Sandbox grants are also behind `BILLING_CREDITS_SANDBOX` (off).
+
+**Who holds a Balance (D12).**
+- A person's Balance is `usd_credit` owned by `user:<usr_id>`.
+- A project's Balance is `usd_credit` owned by `project:<prj_id>`.
+- A hold with a `project_id` spends the project's Balance; a hold without one spends the person's.
+- Sandbox balances follow the same split under `usd_credit_sandbox`.
+
+**Sandbox Balance is kept apart (D10).**
+- A grant lands in the project's `usd_credit_sandbox` account (`project:<prj_id>`), never in `usd_credit`.
+- Only a hold with `sandbox: true` spends it, and a real hold never sees it: granted test credit can never pay for
+ real supply.
+- A sandbox charge returns to the sandbox issuer, not to `platform_revenue`, and its journal transaction is marked
+ test.
+- The statement shows the sandbox Balance apart (`sandbox`).
+
+Every internal request body is checked against its `openvibe-contracts` schema first; another shape is a 422
+`billing.invalid_request`, before anything is held.
+
+## How a request is paid
+
+1. **Hold.** The service (OpenVibe AI) asks for a hold of the worst case before it does anything paid, with
+ `POST /internal/credits/holds` and `{ idempotency_key, subject, project_id, service, max_nano_usd, rate_version,
+ request_fingerprint, sandbox? }`.
+ - The account row is locked (`FOR UPDATE`), and the hold is created only if `available = balance − active holds`
+ covers it. Otherwise the answer is 402 and nothing runs.
+2. **Extend.** While a stream grows, the service extends the hold in steps (`/holds/:id/extend`), each one checked the
+ same way.
+3. **Settle, once.** At the end the service sends the four token counts, the retail card it used (version and rates),
+ the supply cost, `byok` and the attempts (`/holds/:id/settle`).
+ - Billing computes the charge itself: for each token class, `ceil(tokens × rate_nano_per_m / 1,000,000)`, plus the
+ per-request fee.
+ - It moves that amount from the account the hold locked (the person's or the project's `usd_credit`) to
+ `platform_revenue` (a sandbox hold: from `usd_credit_sandbox` back to the issuer), and releases the rest of
+ the hold.
+ - In the same transaction it announces `billing.credit.settled@1`, for aggregates (OpenVibe.Report). The event
+ carries the answering deployment, its `provider_access`, the four token counts, what was collected, the supply
+ cost and the shortfall. It never carries the subject, the project or the account: a person's charges are read
+ from their statement.
+4. **Release.** A request that never ran releases its hold with nothing charged (`/holds/:id/release`). A hold that is
+ forgotten is released by the lease sweeper.
+
+## The invariants, and the tests that hold them (`test/credits.test.js`)
+
+| Invariant | How it is kept | Test |
+|---|---|---|
+| **No overspend.** Holds plus charges never exceed the funded balance, under any concurrency. | Every hold, extend and settle locks the account row and re-reads `available` inside the transaction. | 100 concurrent holds on a balance that fits 7: exactly 7 succeed, the balance is untouched, the holds sum to the balance. |
+| **Exactly one charge per request.** | The settle's idempotency key is unique. A replay returns the first settlement. Two concurrent settles serialize on the hold lock, and the second replays. A key reused for another hold is a 409. | settle replay: one journal transaction, the same settlement. |
+| **One hold per key.** | The key is unique and re-checked after the account lock. The same key with another fingerprint is a 409. | idempotent create; 409 on a fingerprint change. |
+| **Only what was used is charged.** | The settle computes the charge from usage and releases the rest. An aborted stream settles its partial usage. | an aborted stream: a partial charge, the rest released. |
+| **No silent loss.** | Usage beyond the hold is taken from the free balance. What cannot be collected (never below zero) is recorded as `shortfall_nano_usd` for reconciliation. | usage beyond the hold: the free balance is taken, the shortfall recorded, the balance never below zero. |
+| **Sandbox Balance never pays for real supply.** | A grant credits `usd_credit_sandbox`; only a `sandbox: true` hold draws on it, and its charge goes back to the issuer. | a real hold on granted credit is 402; a sandbox hold settles, `platform_revenue` unchanged. |
+| **BYOK is never billed twice.** | `byok: true` charges 0; the provider bills the person directly. | BYOK settle: charge 0. |
+| **The journal balances.** | Every movement is a balanced journal entry, in its own currency. Reconciliation sums per currency as text, so no total exceeds JavaScript's safe range. | the journal balances at the end. |
+| **Rounding is once per token class per request.** | `computeCharge`. | the 4-class rounding test, with a worked example. |
+
+**The old amplification, gone.** `chargeFor` rounds every usage reading up to one whole Vibes bit ($0.01). One token at
+$1 per billion tokens is charged a full cent there, a ten-million-fold over-charge. Balance charges it 1 nano-USD.
+(test: "the old chargeFor amplification for tiny usage does not affect the subledger")
+
+## Reading it
+
+`GET /api/me/balance` (a person token, the same CORS as `/api/me/vibes`) answers:
+
+```json
+{
+ "personal": { "owner": { "type": "user", "id": "usr_…" }, "balance_nano_usd": "…", "held_nano_usd": "…", "available_nano_usd": "…" },
+ "projects": [ { "owner": { "type": "project", "id": "prj_…" }, "balance_nano_usd": "…", "held_nano_usd": "…", "available_nano_usd": "…" } ]
+}
+```
+
+The `projects` are the ones the person administers: Billing asks Network with its own service token (`network.project.member.read`), and a failed read is an error, never an empty list. `GET /api/me/credits` is an alias of the same answer; with `?project_id=prj_…` it still answers that one project's statement (after Network confirms the person administers it):
+- the balance, what is held and what is available;
+- the last 50 charges: service, model, token counts, charge, shortfall, BYOK, rate version.
+
+There is never any prompt or answer: Billing never receives them.
+
+## Activity
+
+`GET /api/me/activity?limit=50&before=<transaction id>` (the same person token and CORS as `/api/me/balance`,
+rate limited) is the person's own movement list for Network's wallet:
+
+- one row per currency part of a transaction: `{ id, at, kind, label, detail, amount }`;
+- `kind` is `balance` (signed nano-USD decimal string) or `vibes` (signed whole integer);
+- a transaction that moves both (Get Vibes with Balance) has two rows with the same `id`, so the client groups them;
+- labels are plain words: Added money (the provider in detail), AI usage (the model and request count from the
+ settlement), Got Vibes, Tipped @name (the creator's public name only), Paid <app> (Vibes Pay), Moved to <project>,
+ Refund;
+- only Balance (`usd_credit user:<usr_id>`) and spendable Vibes (`user_credit`) are read: never sandbox, another
+ person's account, a project secret or an email.
+
+The answer is `{ activity: [...], next_before: <transaction id>|null }`; pass `next_before` back as `before`.
+
+## Moving Balance
+
+`POST /api/me/balance/transfers` with `{ to_project_id, amount_nano_usd, idempotency_key }` moves money from the
+person's Balance to a project they administer (admin or owner, unarchived). One serializable transaction locks both
+accounts; the source spends available Balance only (never money under an active hold), so it can never go below zero.
+A replay with the same key returns the same transfer and moves nothing; a different request under that key is a 409.
+
+`POST /api/me/vibes/from-balance` with `{ vibes, idempotency_key }` buys Vibes at the pack value rate:
+
+ `price_nano_usd = ceil(vibes × 1,000,000,000 / bits_per_usd)`
+
+with `bits_per_usd` = `BILLING_BITS_PER_USD` (default 100). The journal moves the exact nano-USD from the person's
+`usd_credit` to `platform_revenue:nano-usd` and mints the Vibes from `fx_conversion:vibes-bits` into the person's
+Vibes account exactly as a pack purchase does. It is behind `billing.vibes.from_balance` (danger, off) and
+`BILLING_CREDITS`.
+
+## Top-up: a person adds money to a Balance
+
+Behind `BILLING_CREDITS_TOPUP` (off, and only meaningful with `BILLING_CREDITS=1`). The owner sets every amount and
+the fee; the code has no default amount and no default fee. **One function reads the terms**:
+`topupTerms(ctx)` in `server/ops/credits.js`. Since D11 they are the `billing.credits` namespace of Billing's
+owner settings (`server/config-store.js`), set from Network's AI tab and applied live:
+
+- `topup_amounts_cents` (env pin `BILLING_CREDITS_TOPUP_AMOUNTS_CENTS`): whole USD cents. These are the only
+ amounts a person may buy. The env pin still takes a comma-separated list; the owner writes a JSON array.
+- `topup_fee_bps` (env pin `BILLING_CREDITS_TOPUP_FEE_BPS`): the fee in integer basis points, charged on top of
+ the amount. `0` is a real, configured fee (free); leaving it unset is not.
+
+The same namespace holds `credits` (`BILLING_CREDITS`), `sandbox` (`BILLING_CREDITS_SANDBOX`), `topup`
+(`BILLING_CREDITS_TOPUP`) and `hold_lease_seconds` (`BILLING_INFERENCE_LEASE_SECONDS`). While an env pin is set
+it wins and the owner's write is `409 config.pinned_in_env`; nothing changes until the owner sets a value.
+`money_writes_frozen` is **not** in the namespace: it keeps its own audited switch at `/api/admin/money`.
+
+Both are required. With either unset or malformed, `POST /api/me/credits/topups` refuses with
+`503 billing.topup_unconfigured` before creating an intent or opening a provider checkout.
+
+**Every provider carries a Balance top-up intent** through the same multi-purpose pipeline as a Vibes purchase
+(`server/providers/index.js` receive → store → process → `applyPlan`):
+
+| Provider | How the person pays | The adapter maps its verified delivery to |
+|---|---|---|
+| Stripe | hosted checkout (`checkout.session.completed`) | the shared `credits_topup` plan |
+| PayPal | hosted checkout, then capture (`PAYMENT.CAPTURE.COMPLETED`) | the shared `credits_topup` plan |
+| CCBill | hosted FlexForm (`NewSaleSuccess`) | the shared `credits_topup` plan |
+| NOWPayments | hosted invoice (`payment_status` finished/confirmed/sending) | the shared `credits_topup` plan |
+| PowerChat | a site-account tip carrying `app_ref=pccredit:<intent>` (`donation.completed`) | the shared `credits_topup` plan |
+
+There is one plan effect (`credits_topup`) and one settle function: adapters only map their own verified delivery to
+it, exactly as they map to `purchase`. PowerChat has no hosted checkout URL; Billing returns the intent's
+`checkout_ref` (`pccredit:<intent>`) for the product to put in the tip's `app_ref`. No provider is silently skipped:
+an adapter that is not enabled is refused at start (`409 billing.provider_disabled`), and one without a mapping is
+listed here before it is offered.
+
+1. **Start.** A signed-in person (`Authorization: Bearer <user token>`) calls
+ `POST /api/me/credits/topups` with `{ provider, project_id?, amount_cents }`. A project is optional: without one
+the top-up adds to the person's own Balance.
+ - The amount must be one the owner listed, otherwise `422 billing.amount_not_allowed`.
+ - The fee is `ceil(amount_cents × fee_bps / 10000)` cents, rounded once; the provider charges amount + fee.
+ - Billing creates a `payment_intents` row with `kind='credits_topup'` (the project, amount and fee in its
+ metadata) and opens the provider's existing checkout with the same `intent_id` metadata as a purchase.
+2. **Paid.** The provider's paid webhook for that intent is mapped to `credits_topup` and settles through the one
+ shared plan. It credits the intent's account — `usd_credit:user:<usr_id>` without a project, or
+ `usd_credit:project:<prj_id>` with one — with the intent's amount at `1 cent = 10,000,000 nano-USD`, and books
+ the fee (and any overpayment) to `platform_revenue`. The journal, all in `nano-usd`:
+ - `provider_clearing:<provider>` −(what was paid);
+ - `usd_credit:user:<usr_id>` (or `usd_credit:project:<prj_id>`) +amount;
+ - `platform_revenue` +fee.
+3. **Once.** The provider event is stored once by its event id, and the settled transaction's receipt reference is
+ unique. A redelivery returns `duplicate_receipt`; a second event for the same payment credits nothing further. The
+ intent is marked `settled` in the same transaction.
+4. **Statement.** `GET /api/me/credits?project_id=prj_…` lists `topups` beside `charges`: amount, fee, what was paid,
+ the reversal and any debt.
+5. **Refund or chargeback** (the provider's receipt reversal, `ops/reversals.js`):
+ - the credited part the refund covers is debited from `usd_credit` by what the account still holds, never below
+ zero;
+ - the part already spent is a debt of the person: a `credit_topup_debts` row, the reversal transaction is flagged
+ for review, and reconciliation lists it under `warnings.credit_debts` and totals it as
+ `credit_topup_debt_nano_usd`. It is never written off silently;
+ - the fee portion is taken back from `platform_revenue`; the reversal's clearing entry is the refunded cents in
+ nano-USD, so the journal stays balanced.
+6. **Off.** `BILLING_CREDITS_TOPUP=0` answers `404 billing.topup_disabled`, creates no intent and opens no checkout. A
+ paid event that arrives while the feature is off or unconfigured is recorded for review and moves no money.
+
+## Launch runbook (each step needs the owner's go-ahead)
+
+1. **Canary.** In a staging copy: `BILLING_CREDITS=1` and `BILLING_CREDITS_SANDBOX=1`.
+ - Fund a staff account with a test top-up booked straight through the ledger (a one-off script, the way
+ `fund()` in `test/credits.test.js` books one; sandbox grants cannot pay real holds). Then run OpenVibe AI with
+ `AI_CREDITS=1` against an own-deployment model, and settle about a hundred requests.
+ - The reconciliation job must report zero problems (`journal.zero_sum`, no negative `usd_credit`, no unbalanced
+ transaction).
+2. **Production, dark.**
+ - Deploy with `BILLING_CREDITS=0`. Migrations `0011` (the subledger) and `0012` (the top-up intent kind and its
+ debt table) are expand-only: they widen checks (`NOT VALID`, then validated) and add new tables.
+ - Turn the flag on for staff projects only. Funding is still off, so real balances are zero: staff try the flow
+ with sandbox tokens on sandbox credit, which pays for nothing real.
+3. **Funding.** Add the checkout top-up into `usd_credit`; this is a separate change, and the owner sets the prices
+ and terms in `billing.credits` from Network's AI tab (or through the old env pins
+ `BILLING_CREDITS_TOPUP`, `BILLING_CREDITS_TOPUP_AMOUNTS_CENTS` and `BILLING_CREDITS_TOPUP_FEE_BPS` while the
+ deployment still sets them; an env pin wins and the owner's write is 409). Turn the top-up on only after the
+ owner sets the amounts and the fee (no default exists), and confirm `POST /api/me/credits/topups` is reachable
+ through the vhost. Reconcile before and after the first real payment.
+4. **Kill switch.** `BILLING_CREDITS=0` refuses every hold (503). OpenVibe AI then refuses paid requests with a clear
+ error, while BYOK, the person's own machine and sandbox keep working. `money_writes_frozen` freezes the whole
+ economy, Balance included.
+5. **Rollback.** Turn the flag off. The tables stay, unused: N-1 code never reads them.
+6. **Reconcile daily.**
+ - The reconciliation job covers `nano-usd` like every other currency.
+ - Shortfalls are listed for review, never written off silently.
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "billing.config.manage",
+ "version": "1.0",
+ "owner": "billing",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Manage the billing.credits namespace of OpenVibe.Billing through openvibe-shared/config (D11): GET /internal/config lists the namespace summaries, GET /internal/config/:namespace returns one as common.config-namespace@1, POST applies common.config-change@1, and PUT /internal/config/:namespace/secrets/:key sets a secret with common.config-secret-put@1 (write-only; the answer is the marker) while DELETE removes it. Only Network's admin proxy, acting for the owner, holds this capability; it is never grantable to apps. Each request carries the owner as actor, and the service audits every write with that actor and key, never a secret value.",
+ "permissions": [
+ "config:manage"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "config-manage",
+ "events": [],
+ "implementedBy": [
+ "GET /internal/config",
+ "GET|POST /internal/config/:namespace",
+ "PUT|DELETE /internal/config/:namespace/secrets/:key"
+ ],
+ "inputSchema": "common.config-change@1",
+ "outputSchema": "common.config-namespace@1"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "billing.credit.hold.create",
+ "version": "1.0",
+ "owner": "billing",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Create an inference credit hold (D2): POST /internal/credits/holds. Service-authenticated; reserves up to max_nano_usd against a (subject, project_id) account.",
+ "inputSchema": "billing.credit-hold-create-request@1",
+ "outputSchema": "billing.credit-hold-create-result@1",
+ "permissions": [
+ "billing:credit:hold"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "billing-credit",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/credits/holds"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "billing.credit.hold.extend",
+ "version": "1.0",
+ "owner": "billing",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Extend an active inference hold (D2): POST /internal/credits/holds/:id/extend. Grows the held amount mid-stream; returns 402 billing.insufficient_funds if the balance is short.",
+ "inputSchema": "billing.credit-hold-extend-request@1",
+ "outputSchema": "billing.credit-hold-create-result@1",
+ "permissions": [
+ "billing:credit:hold"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "billing-credit",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/credits/holds/:id/extend"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "billing.credit.hold.release",
+ "version": "1.0",
+ "owner": "billing",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Release a credit hold (D2): POST /internal/credits/holds/:id/release. Releases the remaining held amount with nothing charged.",
+ "inputSchema": "common.no-body@1",
+ "outputSchema": "billing.credit-hold-release-result@1",
+ "permissions": [
+ "billing:credit:hold"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "billing-credit",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/credits/holds/:id/release"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "billing.credit.hold.settle",
+ "version": "1.0",
+ "owner": "billing",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Settle a credit hold (D2): POST /internal/credits/holds/:id/settle. Records measured usage and the immutable retail rate version, charges the project account and releases the unused hold. Idempotent: replays return the same settlement.",
+ "inputSchema": "billing.credit-hold-settle-request@1",
+ "outputSchema": "billing.credit-hold-settle-result@1",
+ "permissions": [
+ "billing:credit:settle"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "billing-credit",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/credits/holds/:id/settle"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "billing.credit.sandbox.grant",
+ "version": "1.0",
+ "owner": "billing",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Grant synthetic credit to a (subject, project) for tests and staging (D9): POST /internal/credits/sandbox/grants. Refused unless BILLING_CREDITS_SANDBOX=1; a test transaction, never money.",
+ "inputSchema": "billing.credit-sandbox-grant-request@1",
+ "outputSchema": "billing.credit-sandbox-grant-result@1",
+ "permissions": [
+ "billing:credit:sandbox"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "billing-credit",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/credits/sandbox/grants"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+{
+ "id": "billing.vibes.charge",
+ "version": "1.0",
+ "owner": "billing",
+ "status": "active",
+ "visibility": "public",
+ "sensitive": true,
+ "description": "Sell something for Vibes as a developer app (Vibes Pay, D12). POST /api/v1/vibes/charges creates a charge from the app's token (billing.vibes-charge-create-request@1; idempotent on the app's reference) and answers billing.vibes-charge@1 with a pay_url; GET /api/v1/vibes/charges/:id reads it; POST /api/v1/vibes/charges/:id/refund returns the Vibes from the project's Vibes wallet to the person (billing.vibes-charge-refund-request@1). The person pays on OpenVibe's pay sheet; Billing moves the Vibes atomically and once to the app's project Vibes wallet, then POSTs a signed webhook (HMAC-SHA256 with the app's webhook secret, retried with backoff) whose body is billing.vibes.charge.paid@1 or billing.vibes.charge.refunded@1 — no person id or email, only an opaque payer_ref stable per app. A sandbox app charges sandbox Vibes only (test grants, worth nothing). The owner's billing.vibes settings cap the amount, the daily per-person spend and whether Vibes Pay is on.",
+ "permissions": [
+ "billing:vibes:charge"
+ ],
+ "resourceConstraints": [
+ "project"
+ ],
+ "quotaClass": "billing-vibes-write",
+ "events": [
+ "billing.vibes.charge.paid",
+ "billing.vibes.charge.refunded"
+ ],
+ "implementedBy": [
+ "POST /api/v1/vibes/charges",
+ "GET /api/v1/vibes/charges/:id",
+ "POST /api/v1/vibes/charges/:id/refund"
+ ],
+ "inputSchema": "billing.vibes-charge-create-request@1",
+ "outputSchema": "billing.vibes-charge@1"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "billing.vibes.read",
+ "version": "1.0",
+ "owner": "billing",
+ "status": "active",
+ "visibility": "public",
+ "description": "Read a person's own Vibes as an app the person approved (D12): GET /api/v1/vibes/me with a person-delegated app token answers billing.vibes-wallet@1 — the Vibes to spend, the creator payable that can be cashed out (never into Balance) and today's bits_per_usd. The app sees only the person who approved it, never another person's Vibes.",
+ "permissions": [
+ "billing:vibes:read"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "billing-vibes-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/vibes/me"
+ ],
+ "inputSchema": "common.no-body@1",
+ "outputSchema": "billing.vibes-wallet@1"
+}
@@ -22,7 +22,11 @@
@@ -22,7 +22,11 @@
"billing.subscription.manage",
"billing.subscription.manage",
"billing.entitlement.check",
"billing.entitlement.check",
"billing.ledger.admin",
"billing.ledger.admin",
- "billing.usage.record"
+ "billing.usage.record",
+ "billing.credit.hold",
+ "billing.credit.sandbox.grant",
+ "billing.vibes.charge",
+ "billing.vibes.read"
],
],
"eventsProduced": [
"eventsProduced": [
"billing.transaction.settled",
"billing.transaction.settled",
@@ -33,11 +37,14 @@
@@ -33,11 +37,14 @@
"billing.cashout.paid",
"billing.cashout.paid",
"billing.cashout.denied",
"billing.cashout.denied",
"billing.staff.action",
"billing.staff.action",
- "billing.receipt.external"
+ "billing.receipt.external",
+ "billing.credit.settled",
+ "billing.vibes.charge.paid",
+ "billing.vibes.charge.refunded"
],
],
"eventsConsumed": [],
"eventsConsumed": [],
"namespacesOwned": [
"namespacesOwned": [
"billing.*"
"billing.*"
],
],
- "notes": "The isolated money ledger (ADR-012): balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts, and a staff console (Network SSO, staff only) for payouts, review, reconciliation and the freeze. Proposed manifest: adds billing.staff.action (internal; released in contracts v0.17.0) and billing.receipt.external (internal: an EXTERNAL tip on a streamer's own PowerChat — subject provider_receipt <provider>:<payment id>; payload streamer SubjectRef, amount_cents, currency usd-cents, value_bits, donor_name (null when anonymous), anonymous, message, provider, provider_event_id, delivery_id, receipt_ref, receiving_account, app_ref, app_purpose, occurred_at, test, rates — sent only while BILLING_AUTHORITY=billing, consumed by OpenVibe.Tips) to the released 0.2.0."
+ "notes": "The isolated money ledger (ADR-012): balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts, and a staff console (Network SSO, staff only) for payouts, review, reconciliation and the freeze. Proposed manifest: adds billing.staff.action (internal; released in contracts v0.17.0) and billing.receipt.external (internal: an EXTERNAL tip on a streamer's own PowerChat \u2014 subject provider_receipt <provider>:<payment id>; payload streamer SubjectRef, amount_cents, currency usd-cents, value_bits, donor_name (null when anonymous), anonymous, message, provider, provider_event_id, delivery_id, receipt_ref, receiving_account, app_ref, app_purpose, occurred_at, test, rates \u2014 sent only while BILLING_AUTHORITY=billing, consumed by OpenVibe.Tips) to the released 0.2.0."
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+# Vibes Pay: sell things for Vibes
+
+Vibes Pay (D12 step 4) lets any OpenVibe app sell an item for whole Vibes, on its own site or inside
+OpenVibe. Billing moves the Vibes once from the person to the app's project; the app learns by a signed
+webhook and by `GET /api/v1/vibes/charges/:id`.
+
+Money is whole Vibes (integers). A charge is never priced in Balance, cents or nano-USD.
+
+## The app's routes
+
+An app authenticates with its own Network app token (capability `billing.vibes.charge`), never with a
+service token. The token names the app, its project and its environment (`production` or `sandbox`).
+
+```
+POST /api/v1/vibes/charges
+{ "amount": 50, "description": "Sticker pack: Neon cats", "reference": "order_123",
+ "return_url": "https://myapp.example/thanks" }
+→ { "id": "vch_…", "pay_url": "https://openvibe.network/pay/vch_…", "status": "pending",
+ "expires_at": "…", … } 201 (200 on a replay)
+
+GET /api/v1/vibes/charges/:id the charge, as billing.vibes-charge@1 (the app's own only)
+POST /api/v1/vibes/charges/:id/refund { "reason": "…" } → the refunded charge; once
+```
+
+`reference` is the app's idempotency key: the same reference and body returns the same charge, a different
+body is `409 billing.reference_reused`. A charge expires 30 minutes after creation
+(`BILLING_VIBES_CHARGE_TTL_SECONDS`, 1–86 400).
+
+The app's webhook settings are its own, in Billing's D11 store (a `billing.app.<app_id>` namespace; the
+secret is a secret-class value sealed with the service's KEK when one is configured):
+
+```
+GET /api/v1/vibes/webhook → { url, secret_set, secret_hint, sealed, … }
+PUT /api/v1/vibes/webhook { url, secret?, rotate_secret? }
+ → the settings; a generated secret is returned once
+DELETE /api/v1/vibes/webhook → { configured: false }
+```
+
+The webhook URL is https in production and must name a public address; deliveries are checked against the
+egress guard and do not follow redirects.
+
+## The person's routes
+
+Signed in with their own account token; the same CORS and `Cache-Control: private, no-store` as
+`/api/me/vibes`.
+
+```
+GET /api/me/vibes/charges/:id the pay sheet: charge, app { id, name, icon }, vibes_credit,
+ shortfall_vibes, can_pay, expired, daily_remaining_vibes
+POST /api/me/vibes/charges/:id/pay { idempotency_key } → { charge, vibes_credit } (200)
+POST /api/me/vibes/charges/:id/cancel → { charge }
+```
+
+The person's Vibes never go below zero. Short of Vibes is `402 billing.insufficient_vibes` with
+`available` and `required`; the sheet offers "Get Vibes". A paid charge cannot be paid again: the same
+person gets a replay (and one journal transaction), another person gets `409 billing.charge_not_pending`.
+An expired charge is `409 billing.charge_expired`. A double refund returns the same refunded charge.
+
+The app's name and icon come from Network's app registry (`GET /internal/projects/:project_id`, the
+`dev_apps` entry). When Billing's service token cannot read that project view, the charge still answers:
+the app id is shown and Network's pay sheet fills the name from its own `dev_apps` row.
+
+## The project's wallet and cashout
+
+Paid Vibes land in **`project_payable`, owner `project:<prj_id>`** — a kind of its own, not
+`creator_payable`, because its owner is a project: a personal cashout, a tip refund or an account export
+must never touch a project's money.
+
+The project's owner cashes it out through the existing cashout path:
+`POST /api/v1/cashouts { subject, amount, payout_method, project_id }` with `billing.cashout.request`.
+Billing checks with Network that the person is the project's **owner**, spends `project_payable`, and the
+normal escrow, approve and deny rules apply. A deny returns the Vibes to the owner's `creator_payable`
+(payouts are for the owner).
+
+## Webhooks
+
+Each paid or refunded charge queues one row in `vibes_webhook_outbox` in the same transaction as the
+money. The delivery is:
+
+```
+POST <the app's webhook url>
+Content-Type: application/json
+X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">
+X-OpenVibe-Timestamp: <unix seconds>
+X-OpenVibe-Delivery-Id: <stable across retries>
+X-OpenVibe-Delivery-Attempt: <1, 2, …>
+X-OpenVibe-Event: billing.vibes.charge.paid | billing.vibes.charge.refunded
+```
+
+The body is `billing.vibes.charge.paid@1` or `billing.vibes.charge.refunded@1`. It carries the charge
+fields, `status`, the whole `amount`, `sandbox` and an opaque `payer_ref` that is random and stable per
+(app, person). It never carries a person id, username or email. A 2xx is delivered; anything else is
+retried after 15 s, 30 s, 1 min, 2 min, 5 min, 15 min, 1 h and 6 h, then abandoned. Every attempt signs
+again with a fresh `t`; `openvibe-sdk` v0.84.0 verifies the header with
+`webhooks.verify(rawBody, signature, secret)` against the raw body and the ±300 s window.
+
+The same payload is published to OpenVibe.Events as `billing.vibes.charge.paid` / `.refunded`, so
+first-party products can consume it without a webhook.
+
+## Sandbox
+
+A sandbox app (`env: sandbox` in its app token) charges **sandbox Vibes only**, worth nothing. Its test
+grant route mints them for a person for that app:
+
+```
+POST /api/v1/vibes/sandbox/grants { "subject": { "type": "user", "id": "usr_…" }, "amount": 500 }
+```
+
+Sandbox payments move `vibes_credit_sandbox` → `project_payable_sandbox`, and their journal transaction
+is marked test. They never read or write a real Vibes account, never count toward the real daily limit,
+and are available only while `BILLING_VIBES_SANDBOX=1`. A sandbox app cannot read real Vibes through
+`GET /api/v1/vibes/me`.
+
+For a sandbox app to get an app token for this audience, Network must list `openvibe.billing` in
+`DEV_SANDBOX_AUDIENCES` (a Network production setting, the same requirement AI's sandbox has). Its
+`billing.vibes.charge` grant is approved inside the project's allowance as usual.
+
+## Limits and the frozen economy
+
+The owner sets these in the `billing.vibes` namespace (D11; env pins win while set):
+
+| Key | What it does |
+|---|---|
+| `vibes_pay` | On/off for real app charges. Off is `403 billing.vibes_pay_disabled`. |
+| `max_charge_vibes` | The largest single real charge. 0 closes new charges. |
+| `daily_app_spend_vibes_per_person` | The most one person may pay in apps in a day (paid charges in the last 24 h). 0 closes spending. |
+
+A refusal is `403 billing.vibes_limit_exceeded` with the limit and the amount in `details`. Sandbox charges
+have no owner limit; they move test Vibes only. Every write (create, pay, refund, cancel, sandbox grant,
+webhook setting) is refused with `503 billing.frozen` while the economy is frozen; reads keep working.
+
+Real payments also require `BILLING_AUTHORITY=billing`: while Live is the authority, a real pay or refund
+answers `409 billing.not_authoritative`, because Billing's Vibes numbers are a shadow then.
+
+## Environment
+
+- `BILLING_VIBES_PAY`, `BILLING_VIBES_MAX_CHARGE`, `BILLING_VIBES_DAILY_APP_SPEND` — the pre-D11 names of the
+ `billing.vibes` keys (still env pins while set).
+- `BILLING_VIBES_SANDBOX=1` — sandbox test grants and charges.
+- `BILLING_VIBES_CHARGE_TTL_SECONDS` — charge lifetime (default 1800; 1–86 400).
+- `BILLING_VIBES_PAY_BASE_URL` — the pay-sheet origin (default `https://openvibe.network`).
+- `BILLING_VIBES_WEBHOOK_INTERVAL_MS` — the delivery job interval (default 5000; 0 disables the timer; tests
+ call `deliverDue` directly).
+- `BILLING_VIBES_ALLOW_PRIVATE_WEBHOOKS=1` — development only: allow a private webhook address. Production
+ always applies the egress guard.
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+-- phase: expand
+-- Credits: the prepaid USD balance for metered services (AI Exchange D1, D2): per-(subject, project_id) nano-USD accounts,
+-- reservation holds, final settlements and sandbox grants. Integer nano-USD (1e-9 USD) stored as BIGINT,
+-- computed with BigInt arithmetic in JS. Nothing here touches chargeFor, Vibes bits, creator payables,
+-- cashout or promo. The subledger is off until BILLING_CREDITS=1; sandbox grants need
+-- BILLING_CREDITS_SANDBOX=1.
+
+-- Widen the existing account-kind and currency enumerations. The (kind, owner_subject, currency) unique
+-- key stays; inference accounts use kind='usd_credit', currency='nano-usd' and owner_subject
+-- '<subject>:<project_id>' so the ledger remains balanced without a second journal.
+ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;
+ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',
+ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve', 'usd_credit', 'usd_credit_sandbox')) NOT VALID;
+ALTER TABLE accounts VALIDATE CONSTRAINT accounts_kind_check;
+ALTER TABLE accounts DROP CONSTRAINT accounts_currency_check;
+ALTER TABLE accounts ADD CONSTRAINT accounts_currency_check CHECK (currency IN ('vibes-bits', 'usd-cents', 'nano-usd')) NOT VALID;
+ALTER TABLE accounts VALIDATE CONSTRAINT accounts_currency_check;
+
+-- Inference charges are a distinct transaction type. They never go through 'usage' or chargeFor.
+ALTER TABLE transactions DROP CONSTRAINT transactions_type_check;
+ALTER TABLE transactions ADD CONSTRAINT transactions_type_check CHECK (type IN ('purchase', 'donation', 'subscription', 'subscription_share',
+ 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage', 'credit')) NOT VALID;
+ALTER TABLE transactions VALIDATE CONSTRAINT transactions_type_check;
+
+-- Holds: an active reservation against an inference account. held_nano_usd reduces the available balance
+-- while the hold is active. The hold is created, extended, settled or released exactly once.
+CREATE TABLE credit_holds (
+ id text COLLATE "C" PRIMARY KEY,
+ idempotency_key text COLLATE "C" NOT NULL,
+ subject text COLLATE "C" NOT NULL,
+ project_id text COLLATE "C" NOT NULL,
+ credential_id text COLLATE "C",
+ service text COLLATE "C" NOT NULL DEFAULT 'ai' CHECK (service ~ '^[a-z][a-z0-9-]{0,39}$'), -- which metered service spends it (ai, run, ...)
+ account_id bigint NOT NULL REFERENCES accounts (id),
+ held_nano_usd bigint NOT NULL CHECK (held_nano_usd >= 0),
+ max_nano_usd bigint NOT NULL CHECK (max_nano_usd >= 0),
+ rate_version text COLLATE "C" NOT NULL,
+ request_fingerprint text COLLATE "C" NOT NULL,
+ lease_expires_at timestamptz NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'extended', 'settled', 'released', 'expired')),
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL,
+ UNIQUE (idempotency_key)
+);
+CREATE INDEX credit_holds_account_active ON credit_holds (account_id, status) WHERE status IN ('active', 'extended');
+CREATE INDEX credit_holds_lease ON credit_holds (lease_expires_at) WHERE status IN ('active', 'extended');
+
+-- Settlements: the final charge for one hold. The retail rate version and usage are kept exactly as
+-- supplied so the charge is reproducible. supply_cost_nano_usd is recorded for margin analytics; it is
+-- not a ledger movement until a supplier is actually paid.
+CREATE TABLE credit_settlements (
+ id text COLLATE "C" PRIMARY KEY,
+ hold_id text COLLATE "C" NOT NULL UNIQUE REFERENCES credit_holds (id),
+ idempotency_key text COLLATE "C" NOT NULL,
+ subject text COLLATE "C" NOT NULL,
+ project_id text COLLATE "C" NOT NULL,
+ account_id bigint NOT NULL REFERENCES accounts (id),
+ charge_nano_usd bigint NOT NULL CHECK (charge_nano_usd >= 0),
+ released_nano_usd bigint NOT NULL CHECK (released_nano_usd >= 0),
+ shortfall_nano_usd bigint NOT NULL DEFAULT 0 CHECK (shortfall_nano_usd >= 0), -- usage beyond hold + balance: owed, not collected
+ journal_id text COLLATE "C" NOT NULL REFERENCES transactions (id),
+ usage jsonb NOT NULL,
+ retail jsonb NOT NULL,
+ supply_cost_nano_usd bigint NOT NULL CHECK (supply_cost_nano_usd >= 0),
+ byok boolean NOT NULL DEFAULT false,
+ attempts jsonb NOT NULL DEFAULT '[]',
+ created_at timestamptz NOT NULL,
+ UNIQUE (idempotency_key)
+);
+CREATE INDEX credit_settlements_account ON credit_settlements (account_id);
+
+-- Sandbox grants: synthetic inference credit, available only when BILLING_CREDITS_SANDBOX=1.
+-- Each grant is one inference transaction usd_credit_sandbox -> usd_credit, marked test.
+CREATE TABLE credit_sandbox_grants (
+ id text COLLATE "C" PRIMARY KEY,
+ subject text COLLATE "C" NOT NULL,
+ project_id text COLLATE "C" NOT NULL,
+ account_id bigint NOT NULL REFERENCES accounts (id),
+ amount_nano_usd bigint NOT NULL CHECK (amount_nano_usd > 0),
+ journal_id text COLLATE "C" NOT NULL REFERENCES transactions (id),
+ reason text COLLATE "C" NOT NULL DEFAULT '',
+ created_at timestamptz NOT NULL
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+-- phase: expand
+-- Credits top-up (D9): a person pays through the existing checkout provider, the paid webhook credits their
+-- (subject, project_id) usd_credit account with the allowed amount in nano-USD, and the fee the owner set goes to
+-- platform_revenue. Everything is behind BILLING_CREDITS_TOPUP and the owner's amounts/fee configuration.
+--
+-- A refund or chargeback debits the account by what remains (never below zero). The part already spent is a debt of
+-- the person, never written off: one row here per reversal, listed by reconciliation. The debt also has its own
+-- ledger account kind so the reversal transaction balances (credit_topup_debt: negative = owed to the platform).
+
+ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;
+ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',
+ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve', 'usd_credit', 'usd_credit_sandbox',
+ 'credit_topup_debt')) NOT VALID;
+ALTER TABLE accounts VALIDATE CONSTRAINT accounts_kind_check;
+
+-- A top-up is its own checkout intent kind: it is created by the person's Credits route, never by /api/v1, and its
+-- paid webhook credits usd_credit instead of Vibes.
+ALTER TABLE payment_intents DROP CONSTRAINT payment_intents_kind_check;
+ALTER TABLE payment_intents ADD CONSTRAINT payment_intents_kind_check CHECK (kind IN ('purchase', 'subscription', 'credits_topup')) NOT VALID;
+ALTER TABLE payment_intents VALIDATE CONSTRAINT payment_intents_kind_check;
+
+CREATE TABLE credit_topup_debts (
+ id text COLLATE "C" PRIMARY KEY,
+ topup_txn_id text COLLATE "C" NOT NULL REFERENCES transactions (id),
+ transaction_id text COLLATE "C" NOT NULL UNIQUE REFERENCES transactions (id), -- the refund/chargeback that left the debt
+ reversal_ref text COLLATE "C" NOT NULL,
+ subject text COLLATE "C" NOT NULL,
+ project_id text COLLATE "C" NOT NULL,
+ provider text COLLATE "C" NOT NULL,
+ amount_nano_usd bigint NOT NULL CHECK (amount_nano_usd > 0), -- the spent part of the top-up that was refunded
+ reason text COLLATE "C" NOT NULL DEFAULT '',
+ created_at timestamptz NOT NULL
+);
+CREATE INDEX credit_topup_debts_subject ON credit_topup_debts (subject, project_id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+-- phase: expand
+-- D11 owner settings. The revisioned-configuration journal of openvibe-shared/config on PostgreSQL
+-- (configSchema() in that package's config-pg.js): Billing's namespace billing.credits keeps the Credits
+-- switches, the top-up terms and the hold lease, with history and rollback. The runtime role creates no
+-- tables, so a service's migrations own them (Network's 0006_config.sql is the pattern).
+
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL,
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+-- phase: expand
+-- D12: Balance and Vibes.
+--
+-- Balance accounts are usd_credit owned by user:<usr_id> (a person) or project:<prj_id> (a project); sandbox
+-- Balance follows the same split under usd_credit_sandbox. The old '<subject>:<project>' owner format is gone
+-- (nothing was in production), so a personal hold has no project_id and a project hold spends the project's
+-- account. A hold's subject may now be an app acting for a project, so its type is stored beside its id.
+
+ALTER TABLE credit_holds ADD COLUMN subject_type text COLLATE "C" NOT NULL DEFAULT 'user'
+ CHECK (subject_type IN ('user', 'app'));
+ALTER TABLE credit_holds ALTER COLUMN project_id DROP NOT NULL;
+
+ALTER TABLE credit_settlements ADD COLUMN subject_type text COLLATE "C" NOT NULL DEFAULT 'user'
+ CHECK (subject_type IN ('user', 'app'));
+ALTER TABLE credit_settlements ALTER COLUMN project_id DROP NOT NULL;
+
+ALTER TABLE credit_sandbox_grants ALTER COLUMN project_id DROP NOT NULL;
+ALTER TABLE credit_topup_debts ALTER COLUMN project_id DROP NOT NULL;
+
+-- A move from a person's Balance to a project's Balance: one balanced journal transaction, recorded here so the
+-- idempotency key names one transfer and a replay can answer with the same result.
+CREATE TABLE balance_transfers (
+ id text COLLATE "C" PRIMARY KEY,
+ idempotency_key text COLLATE "C" NOT NULL UNIQUE,
+ subject text COLLATE "C" NOT NULL,
+ to_project_id text COLLATE "C" NOT NULL,
+ amount_nano_usd bigint NOT NULL CHECK (amount_nano_usd > 0),
+ journal_id text COLLATE "C" NOT NULL REFERENCES transactions (id),
+ created_at timestamptz NOT NULL
+);
+CREATE INDEX balance_transfers_subject ON balance_transfers (subject, created_at);
+CREATE INDEX balance_transfers_project ON balance_transfers (to_project_id, created_at);
+
+-- Buying Vibes with Balance: the USD moves to platform_revenue in nano-USD and the Vibes are minted into the person's
+-- Vibes account exactly as a pack purchase mints them. One row per purchase so the idempotency key is exact.
+CREATE TABLE vibes_from_balance (
+ id text COLLATE "C" PRIMARY KEY,
+ idempotency_key text COLLATE "C" NOT NULL UNIQUE,
+ subject text COLLATE "C" NOT NULL,
+ vibes bigint NOT NULL CHECK (vibes > 0),
+ price_nano_usd bigint NOT NULL CHECK (price_nano_usd > 0),
+ bits_per_usd integer NOT NULL CHECK (bits_per_usd > 0),
+ journal_id text COLLATE "C" NOT NULL REFERENCES transactions (id),
+ created_at timestamptz NOT NULL
+);
+CREATE INDEX vibes_from_balance_subject ON vibes_from_balance (subject, created_at);
+
+-- A Balance transfer is its own transaction type; it is not a provider receipt and never goes through chargeFor.
+ALTER TABLE transactions DROP CONSTRAINT transactions_type_check;
+ALTER TABLE transactions ADD CONSTRAINT transactions_type_check CHECK (type IN ('purchase', 'donation', 'subscription', 'subscription_share',
+ 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage', 'credit',
+ 'balance_transfer')) NOT VALID;
+ALTER TABLE transactions VALIDATE CONSTRAINT transactions_type_check;
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+-- phase: expand
+-- Vibes Pay (D12 step 4): a developer app creates a charge, a person pays it on OpenVibe's pay sheet, and the
+-- Vibes move once from the person to the app's project. Amounts are whole Vibes (bigint, never a fraction).
+--
+-- vibes_charges one row per charge; (app_id, reference) is the app's idempotency key
+-- vibes_payers the opaque payer_ref of a person, stable per app (never a person id in a webhook)
+-- vibes_webhook_outbox durable signed deliveries, retried with backoff
+--
+-- The project's Vibes wallet is a new account kind (project_payable), and sandbox pay uses its own kinds and
+-- test transactions, so a sandbox charge can never touch real Vibes. A project cashout spends project_payable
+-- through the existing cashout path (POST /api/v1/cashouts with project_id), checked against Network's owner role.
+
+ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;
+ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',
+ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve', 'usd_credit', 'usd_credit_sandbox',
+ 'credit_topup_debt', 'project_payable', 'vibes_credit_sandbox', 'project_payable_sandbox', 'vibes_sandbox')) NOT VALID;
+ALTER TABLE accounts VALIDATE CONSTRAINT accounts_kind_check;
+
+ALTER TABLE transactions DROP CONSTRAINT transactions_type_check;
+ALTER TABLE transactions ADD CONSTRAINT transactions_type_check CHECK (type IN ('purchase', 'donation', 'subscription', 'subscription_share',
+ 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage', 'credit',
+ 'balance_transfer', 'vibes_charge', 'vibes_refund', 'vibes_sandbox_grant')) NOT VALID;
+ALTER TABLE transactions VALIDATE CONSTRAINT transactions_type_check;
+
+-- A project's received Vibes are cashed out by its owner; record which project a cashout belongs to.
+ALTER TABLE cashouts ADD COLUMN project_id text COLLATE "C";
+
+CREATE TABLE vibes_charges (
+ id text COLLATE "C" PRIMARY KEY,
+ app_id text COLLATE "C" NOT NULL,
+ project_id text COLLATE "C" NOT NULL,
+ amount bigint NOT NULL CHECK (amount > 0),
+ description text COLLATE "C" NOT NULL,
+ reference text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL CHECK (status IN ('pending', 'paid', 'refunded', 'expired', 'cancelled')),
+ pay_url text COLLATE "C" NOT NULL,
+ return_url text COLLATE "C",
+ sandbox bigint NOT NULL DEFAULT 0 CHECK (sandbox IN (0, 1)),
+ payer_subject text COLLATE "C",
+ payer_ref text COLLATE "C",
+ pay_idempotency_key text COLLATE "C",
+ refund_reason text COLLATE "C",
+ expires_at timestamptz NOT NULL,
+ created_at timestamptz NOT NULL,
+ paid_at timestamptz,
+ refunded_at timestamptz,
+ cancelled_at timestamptz,
+ UNIQUE (app_id, reference)
+);
+CREATE INDEX vibes_charges_app_status ON vibes_charges (app_id, status, created_at);
+CREATE INDEX vibes_charges_payer ON vibes_charges (payer_subject, paid_at) WHERE payer_subject IS NOT NULL;
+
+-- payer_ref is opaque and stable per app: the webhook carries it instead of a person id or email. A random value,
+-- never a hash of the subject (a dump of this table plus a guessable subject space must not link the two).
+CREATE TABLE vibes_payers (
+ app_id text COLLATE "C" NOT NULL,
+ subject text COLLATE "C" NOT NULL,
+ payer_ref text COLLATE "C" NOT NULL UNIQUE,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (app_id, subject)
+);
+
+CREATE TABLE vibes_webhook_outbox (
+ id text COLLATE "C" PRIMARY KEY,
+ charge_id text COLLATE "C" NOT NULL REFERENCES vibes_charges (id),
+ app_id text COLLATE "C" NOT NULL,
+ event_type text COLLATE "C" NOT NULL CHECK (event_type IN ('billing.vibes.charge.paid', 'billing.vibes.charge.refunded')),
+ payload text COLLATE "C" NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at timestamptz NOT NULL,
+ delivered_at timestamptz,
+ abandoned_at timestamptz,
+ last_status integer,
+ last_error text COLLATE "C",
+ created_at timestamptz NOT NULL
+);
+CREATE INDEX vibes_webhook_due ON vibes_webhook_outbox (next_attempt_at, id)
+ WHERE delivered_at IS NULL AND abandoned_at IS NULL;
@@ -12,9 +12,9 @@
@@ -12,9 +12,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1103,9 +1103,9 @@
@@ -1103,9 +1103,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.222.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",
+ "version": "0.289.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1119,9 +1119,9 @@
@@ -1119,9 +1119,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.82.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",
+ "version": "0.84.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",
+ "integrity": "sha512-OLngno3URAIUOqvEtQ9e/dvg3mhZrndGOEBEIgA7yn/uEDtYykgpc6HKajiyeub4UvZ8Dn2Vqn0WdqIYr/8/Uw==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1147,9 +1147,9 @@
@@ -1147,9 +1147,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.109.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
+ "version": "4.110.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
+ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -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 \u2014 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 — 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",
@@ -26,9 +26,9 @@
@@ -26,9 +26,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.84.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -6,7 +6,9 @@
@@ -6,7 +6,9 @@
*
*
* export the person's accounts with their balances, every transaction they sent or received, their checkouts,
* export the person's accounts with their balances, every transaction they sent or received, their checkouts,
* cash-outs, subscriptions (as a member, and as a creator without the members' identities), entitlements,
* cash-outs, subscriptions (as a member, and as a creator without the members' identities), entitlements,
- * tips on their own PowerChat, usage readings, promo allowances, tier prices and connected payment accounts
+ * tips on their own PowerChat, usage readings, promo allowances, Balance holds, settlements, sandbox
+ * grants and refunded-top-up debts, Balance transfers and Vibes bought with Balance, tier prices and
+ * connected payment accounts
* erase money records stay (the ledger, receipts, checkouts, cash-outs, subscriptions, entitlements, usage and
* erase money records stay (the ledger, receipts, checkouts, cash-outs, subscriptions, entitlements, usage and
* promo rows are records the law and the reconciliation need): counted as retained. What is only settings
* promo rows are records the law and the reconciliation need): counted as retained. What is only settings
* goes: tier prices, connected payment accounts (the attribution of a provider username to the person),
* goes: tier prices, connected payment accounts (the attribution of a provider username to the person),
@@ -20,7 +22,7 @@ const TABLES = [
@@ -20,7 +22,7 @@ const TABLES = [
{ table: 'transactions', subject: 'from_subject', file: 'transactions-sent.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger } },
{ table: 'transactions', subject: 'from_subject', file: 'transactions-sent.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger } },
{ table: 'transactions', subject: 'to_subject', file: 'transactions-received.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger }, kind: 'transactions_received' },
{ table: 'transactions', subject: 'to_subject', file: 'transactions-received.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger }, kind: 'transactions_received' },
{ table: 'payment_intents', subject: 'subject', file: 'checkouts.json', columns: ['id', 'provider', 'kind', 'streamer_subject', 'amount_cents', 'fee_cents', 'bits', 'route', 'auto_renew', 'status', 'settled_txn', 'created_at', 'updated_at'], erase: { keep: ledger } },
{ table: 'payment_intents', subject: 'subject', file: 'checkouts.json', columns: ['id', 'provider', 'kind', 'streamer_subject', 'amount_cents', 'fee_cents', 'bits', 'route', 'auto_renew', 'status', 'settled_txn', 'created_at', 'updated_at'], erase: { keep: ledger } },
- { table: 'cashouts', subject: 'subject', file: 'cashouts.json', columns: ['id', 'amount_bits', 'value_cents', 'status', 'payout_method', 'escrow_until', 'payout_provider', 'payout_reference', 'reason', 'created_at', 'updated_at'], erase: { keep: ledger } },
+ { table: 'cashouts', subject: 'subject', file: 'cashouts.json', columns: ['id', 'project_id', 'amount_bits', 'value_cents', 'status', 'payout_method', 'escrow_until', 'payout_provider', 'payout_reference', 'reason', 'created_at', 'updated_at'], erase: { keep: ledger } },
{ table: 'subscriptions', subject: 'subscriber', file: 'subscriptions.json', columns: ['id', 'streamer', 'tier', 'provider', 'status', 'auto_renew', 'cancel_at_period_end', 'price_cents', 'current_period_end', 'created_at', 'updated_at'], erase: { keep: ledger } },
{ table: 'subscriptions', subject: 'subscriber', file: 'subscriptions.json', columns: ['id', 'streamer', 'tier', 'provider', 'status', 'auto_renew', 'cancel_at_period_end', 'price_cents', 'current_period_end', 'created_at', 'updated_at'], erase: { keep: ledger } },
// As a creator: their subscriptions without who the members are (that is the members' data).
// As a creator: their subscriptions without who the members are (that is the members' data).
{ table: 'subscriptions', subject: 'streamer', file: 'subscriptions-to-you.json', columns: ['id', 'tier', 'status', 'price_cents', 'current_period_end', 'created_at'], erase: { keep: ledger }, kind: 'subscriptions_to_you' },
{ table: 'subscriptions', subject: 'streamer', file: 'subscriptions-to-you.json', columns: ['id', 'tier', 'status', 'price_cents', 'current_period_end', 'created_at'], erase: { keep: ledger }, kind: 'subscriptions_to_you' },
@@ -34,12 +36,25 @@ const TABLES = [
@@ -34,12 +36,25 @@ const TABLES = [
// Import holds and usage budgets are part of the books: the import, its credits and the spend must still reconcile.
// Import holds and usage budgets are part of the books: the import, its credits and the spend must still reconcile.
{ table: 'import_holds', subject: 'owner', file: 'import-holds.json', columns: ['live_user_id', 'owner', 'reason', 'credit_bits', 'payable_bits', 'resolved_subject', 'first_seen_at', 'updated_at'], erase: { keep: ledger } },
{ table: 'import_holds', subject: 'owner', file: 'import-holds.json', columns: ['live_user_id', 'owner', 'reason', 'credit_bits', 'payable_bits', 'resolved_subject', 'first_seen_at', 'updated_at'], erase: { keep: ledger } },
{ table: 'usage_budgets', subject: 'subject', file: 'usage-budgets.json', erase: { keep: ledger } },
{ table: 'usage_budgets', subject: 'subject', file: 'usage-budgets.json', erase: { keep: ledger } },
+ // The credits ledger (AI Exchange D2): holds, settlements and sandbox grants are money records too.
+ { table: 'credit_holds', subject: 'subject', file: 'inference-holds.json', columns: ['id', 'project_id', 'held_nano_usd', 'max_nano_usd', 'rate_version', 'status', 'lease_expires_at', 'created_at', 'updated_at'], erase: { keep: ledger } },
+ { table: 'credit_settlements', subject: 'subject', file: 'inference-charges.json', columns: ['id', 'hold_id', 'project_id', 'charge_nano_usd', 'released_nano_usd', 'journal_id', 'usage', 'retail', 'byok', 'created_at'], erase: { keep: ledger } },
+ { table: 'credit_sandbox_grants', subject: 'subject', file: 'inference-sandbox-grants.json', columns: ['id', 'project_id', 'amount_nano_usd', 'reason', 'created_at'], erase: { keep: ledger } },
+ // A refunded top-up whose credit was already spent: a money record of the person (and the debt they owe).
+ { table: 'credit_topup_debts', subject: 'subject', file: 'credit-debts.json', columns: ['id', 'topup_txn_id', 'transaction_id', 'reversal_ref', 'project_id', 'provider', 'amount_nano_usd', 'reason', 'created_at'], erase: { keep: ledger } },
+ // D12: moving Balance to a project and buying Vibes with Balance are money records too.
+ { table: 'balance_transfers', subject: 'subject', file: 'balance-transfers.json', columns: ['id', 'idempotency_key', 'to_project_id', 'amount_nano_usd', 'journal_id', 'created_at'], erase: { keep: ledger } },
+ { table: 'vibes_from_balance', subject: 'subject', file: 'vibes-from-balance.json', columns: ['id', 'idempotency_key', 'vibes', 'price_nano_usd', 'bits_per_usd', 'journal_id', 'created_at'], erase: { keep: ledger } },
+ // D12 Vibes Pay: the charges this person paid in an app, and the opaque reference that app knows them by. A
+ // charge they never paid has no payer and is not their data; the app's own settings are not people's records.
+ { table: 'vibes_charges', subject: 'payer_subject', file: 'vibes-pay-charges.json', columns: ['id', 'app_id', 'project_id', 'amount', 'description', 'reference', 'status', 'sandbox', 'paid_at', 'refunded_at', 'created_at'], erase: { keep: ledger } },
+ { table: 'vibes_payers', subject: 'subject', file: 'vibes-pay-payers.json', columns: ['app_id', 'payer_ref', 'created_at'], erase: { keep: ledger } },
];
];
/** Accounts with their balances (a balance is the account's cached sum of ledger entries). */
/** Accounts with their balances (a balance is the account's cached sum of ledger entries). */
async function balances(db, subject) {
async function balances(db, subject) {
return await db.prepare(`SELECT a.kind, a.currency, COALESCE(b.balance, 0) AS balance, a.created_at FROM accounts a
return await db.prepare(`SELECT a.kind, a.currency, COALESCE(b.balance, 0) AS balance, a.created_at FROM accounts a
- LEFT JOIN account_balances b ON b.account_id = a.id WHERE a.owner_subject = ? ORDER BY a.id`).all(subject);
+ LEFT JOIN account_balances b ON b.account_id = a.id WHERE a.owner_subject IN (?, ?) ORDER BY a.id`).all(subject, `user:${subject}`);
}
}
function create({ db, log = console } = {}) {
function create({ db, log = console } = {}) {
@@ -50,7 +65,7 @@ function create({ db, log = console } = {}) {
@@ -50,7 +65,7 @@ function create({ db, log = console } = {}) {
// Accounts stay (the ledger points at them): counted as retained with the other money records.
// Accounts stay (the ledger points at them): counted as retained with the other money records.
extraErase: async (t, subjects, counts) => {
extraErase: async (t, subjects, counts) => {
for (const subject of subjects) {
for (const subject of subjects) {
- counts.add(counts.retained, 'accounts', Number(await t.value('SELECT COUNT(*)::int FROM accounts WHERE owner_subject = $1', [subject])) || 0);
+ counts.add(counts.retained, 'accounts', Number(await t.value('SELECT COUNT(*)::int FROM accounts WHERE owner_subject IN ($1, $2)', [subject, `user:${subject}`])) || 0);
}
}
},
},
});
});
@@ -30,6 +30,13 @@ const BUDGETS = {
@@ -30,6 +30,13 @@ const BUDGETS = {
// Someone buying Vibes or subscribing starts one, and perhaps retries it once or twice; dozens are card
// Someone buying Vibes or subscribing starts one, and perhaps retries it once or twice; dozens are card
// testing. Tips' supporters are also capped at 20 unpaid checkouts a day there.
// testing. Tips' supporters are also capped at 20 unpaid checkouts a day there.
'billing.intent.create': { minute: 5, hour: 30 },
'billing.intent.create': { minute: 5, hour: 30 },
+ // Starting a Balance top-up (POST /api/me/credits/topups) opens a provider checkout for real money; the same
+ // budget as a purchase checkout, counted per signed-in person.
+ 'billing.credits.topup.create': { minute: 5, hour: 30 },
+ // Moving Balance to a project (POST /api/me/balance/transfers) is a wallet write, not a checkout.
+ 'billing.balance.transfer': { minute: 10, hour: 100 },
+ // Getting Vibes with Balance (POST /api/me/vibes/from-balance): a few buys at a time.
+ 'billing.vibes.from_balance': { minute: 5, hour: 30 },
// A tip, donation or paid interaction from a person's credit: one a second at most, above Tips' own
// A tip, donation or paid interaction from a person's credit: one a second at most, above Tips' own
// 20 a minute per supporter, so no real supporter meets it.
// 20 a minute per supporter, so no real supporter meets it.
'billing.transfer.create': { minute: 60, hour: 1200 },
'billing.transfer.create': { minute: 60, hour: 1200 },
@@ -44,6 +51,14 @@ const BUDGETS = {
@@ -44,6 +51,14 @@ const BUDGETS = {
'billing.recycle': { minute: 10, hour: 60 },
'billing.recycle': { minute: 10, hour: 60 },
// A subscription period paid from credit: a person subscribes to a few channels at a time.
// A subscription period paid from credit: a person subscribes to a few channels at a time.
'billing.subscription.create': { minute: 10, hour: 60 },
'billing.subscription.create': { minute: 10, hour: 60 },
+ // Vibes Pay (D12): an app creates charges and refunds them from its server; a person pays and cancels on the pay
+ // sheet. The owner's billing.vibes limits (amount, daily spend, on/off) are separate and checked by ops/vibes.js.
+ 'billing.vibes.charge.create': { minute: 60, hour: 1200 },
+ 'billing.vibes.charge.refund': { minute: 20, hour: 200 },
+ 'billing.vibes.pay': { minute: 30, hour: 300 },
+ 'billing.vibes.cancel': { minute: 30, hour: 300 },
+ 'billing.vibes.sandbox.grant': { minute: 30, hour: 300 },
+ 'billing.vibes.webhook': { minute: 10, hour: 60 },
};
};
/** The bare usr_… id from a SubjectRef or a bare id, else null. */
/** The bare usr_… id from a SubjectRef or a bare id, else null. */
@@ -15,19 +15,73 @@ const { verifyServiceToken } = require('openvibe-sdk/auth');
@@ -15,19 +15,73 @@ const { verifyServiceToken } = require('openvibe-sdk/auth');
const { capabilities, http, validate } = contracts;
const { capabilities, http, validate } = contracts;
+const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
+const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);
+const PROJECT_RE = new RegExp(`^prj_(${ULID})$`);
+const ENVS = ['sandbox', 'production'];
+
+/** True for a Network app token: sub app:app_<ULID> and actor_type app. */
+function isAppClaims(claims) {
+ return Boolean(claims && claims.actor_type === 'app' && APP_SUB_RE.test(String(claims.sub)));
+}
+
+/** The app a verified token belongs to, or null for anything that is not a usable app token. */
+function appPrincipal(claims) {
+ const m = APP_SUB_RE.exec(String(claims && claims.sub));
+ if (!m) return null;
+ if (!PROJECT_RE.test(String(claims.project_id || ''))) return null;
+ const env = claims.env === undefined ? 'production' : claims.env;
+ if (!ENVS.includes(env)) return null;
+ return {
+ sub: claims.sub, appId: `app_${m[1]}`, projectId: claims.project_id, env,
+ onBehalfOf: typeof claims.on_behalf_of === 'string' ? claims.on_behalf_of : null,
+ };
+}
+
function createAuth({ config, keys }) {
function createAuth({ config, keys }) {
+ /** The verified claims, or { error: { status, code, detail } } (the token was not verified). */
+ async function verify(req, { acceptSandbox = false } = {}) {
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return { error: { status: 401, code: 'token.missing', detail: 'a service token is required' } };
+ const r = await verifyServiceToken(header.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, audience: config.audience, contracts, acceptSandbox });
+ if (!r.ok) {
+ if (r.code === 'token.unavailable') return { error: { status: 503, code: 'auth.unavailable', detail: 'the Network key is not loaded yet' } };
+ return { error: { status: 401, code: r.code, detail: r.reason } };
+ }
+ return { claims: r.claims };
+ }
+
/** true when req.principal is set; otherwise the problem has been answered. */
/** true when req.principal is set; otherwise the problem has been answered. */
async function authenticate(req, res) {
async function authenticate(req, res) {
if (req.principal) return true;
if (req.principal) return true;
- const header = String(req.headers.authorization || '');
- if (!header.startsWith('Bearer ')) { http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: req.ov }); return false; }
- const r = await verifyServiceToken(header.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, audience: config.audience, contracts });
- if (!r.ok && r.code === 'token.unavailable') { http.sendProblem(res, 503, 'auth.unavailable', { detail: 'the Network key is not loaded yet', ctx: req.ov }); return false; }
- if (!r.ok) { http.sendProblem(res, 401, r.code, { detail: r.reason, ctx: req.ov }); return false; }
- req.principal = { sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti };
+ const out = await verify(req);
+ if (out.error) { http.sendProblem(res, out.error.status, out.error.code, { detail: out.error.detail, ctx: req.ov }); return false; }
+ req.principal = { sub: out.claims.sub, cap: out.claims.cap || [], jti: out.claims.jti };
return true;
return true;
}
}
+ /**
+ * The guard for the routes a developer app may use (D12 Vibes Pay; billing.vibes.charge / billing.vibes.read).
+ * Only an app token is accepted: no capability on a service token can name the app whose Vibes are charged.
+ * A sandbox app token is verified here; the routes decide whether the operation is sandbox-aware.
+ */
+ function appOnly(cap) {
+ return async function appOnlyGuard(req, res, next) {
+ const out = await verify(req, { acceptSandbox: true });
+ if (out.error) return http.sendProblem(res, out.error.status, out.error.code, { detail: out.error.detail, ctx: req.ov });
+ if (!isAppClaims(out.claims)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'an app token is required', ctx: req.ov });
+ const app = appPrincipal(out.claims);
+ if (!app) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: 'the app token names no project or a bad environment', ctx: req.ov });
+ const decision = capabilities.check(out.claims, cap);
+ if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: decision.reason, ctx: req.ov });
+ req.principal = {
+ kind: 'app', sub: out.claims.sub, appId: app.appId, projectId: app.projectId, env: app.env,
+ onBehalfOf: app.onBehalfOf, cap: out.claims.cap || [], jti: out.claims.jti, claims: out.claims,
+ };
+ return next();
+ };
+ }
+
/** needs('billing.x.y') or needs(['a', 'b']) — any one of the listed capabilities. */
/** needs('billing.x.y') or needs(['a', 'b']) — any one of the listed capabilities. */
function needs(caps) {
function needs(caps) {
const list = Array.isArray(caps) ? caps : [caps];
const list = Array.isArray(caps) ? caps : [caps];
@@ -42,7 +96,7 @@ function createAuth({ config, keys }) {
@@ -42,7 +96,7 @@ function createAuth({ config, keys }) {
};
};
}
}
- return { needs, authenticate };
+ return { needs, authenticate, verify, appOnly };
}
}
/** Audit actor for a request: the calling principal, plus the person it acts for when given. */
/** Audit actor for a request: the calling principal, plus the person it acts for when given. */
@@ -53,4 +107,4 @@ function actorOf(req) {
@@ -53,4 +107,4 @@ function actorOf(req) {
return actor;
return actor;
}
}
-module.exports = { createAuth, actorOf };
+module.exports = { createAuth, actorOf, isAppClaims, appPrincipal };
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+
+/**
+ * Internal credits ledger API (AI Exchange D2). Service-token authenticated (audience openvibe.billing),
+ * mounted under /internal/credits always, each handler refusing while the `credits` owner setting is off.
+ * This is the authority surface
+ * OpenVibe.AI calls to reserve, extend, settle and release inference credit.
+ */
+const express = require('express');
+const { http, validate } = require('openvibe-contracts');
+const { BillingError } = require('../ledger');
+const { isFrozen } = require('../ops/common');
+const { actorOf } = require('./auth');
+const inference = require('../ops/credits');
+
+// One capability per operation, as openvibe-contracts v0.284.0 defines them (D9).
+const CAP = {
+ create: 'billing.credit.hold.create',
+ extend: 'billing.credit.hold.extend',
+ settle: 'billing.credit.hold.settle',
+ release: 'billing.credit.hold.release',
+ sandbox: 'billing.credit.sandbox.grant',
+};
+
+/**
+ * The request body must be the contract's (openvibe-contracts v0.289.1, D9/D10/D12): a caller that sends another shape
+ * gets 422 before anything is held or charged, and the ledger code only ever sees validated input. D12's request
+ * schema makes project_id optional (a personal hold); an app subject still always names its project.
+ */
+function body(schemaId) {
+ return (req, res, next) => {
+ const v = validate(schemaId, req.body == null ? {} : req.body);
+ if (v.valid) return next();
+ const detail = v.errors.slice(0, 5).map((e) => `${e.path || '/'} ${e.message}`).join('; ');
+ return http.sendProblem(res, 422, 'billing.invalid_request', { detail: `not a ${schemaId}: ${detail}`, ctx: req.ov });
+ };
+}
+
+function internalRouter({ ctx, auth }) {
+ const r = express.Router();
+ const notFrozen = async (req, res, next) => (await isFrozen(ctx.db)
+ ? http.sendProblem(res, 503, 'billing.frozen', { detail: 'the economy is frozen', ctx: req.ov })
+ : next());
+
+ r.post('/holds', auth.needs(CAP.create), body('billing.credit-hold-create-request@1'), notFrozen, wrap(async (req, res) => {
+ const out = await inference.createHold(ctx, { ...req.body, actor: actorOf(req) });
+ res.status(out.replay ? 200 : 201).json(out.hold);
+ }));
+
+ r.post('/holds/:id/extend', auth.needs(CAP.extend), body('billing.credit-hold-extend-request@1'), notFrozen, wrap(async (req, res) => {
+ const out = await inference.extendHold(ctx, { hold_id: req.params.id, add_nano_usd: req.body.add_nano_usd, actor: actorOf(req) });
+ res.json(out.hold);
+ }));
+
+ r.post('/holds/:id/settle', auth.needs(CAP.settle), body('billing.credit-hold-settle-request@1'), notFrozen, wrap(async (req, res) => {
+ const out = await inference.settleHold(ctx, { hold_id: req.params.id, ...req.body, actor: actorOf(req) });
+ res.status(out.replay ? 200 : 201).json(out.settlement);
+ }));
+
+ r.post('/holds/:id/release', auth.needs(CAP.release), notFrozen, wrap(async (req, res) => {
+ const out = await inference.releaseHold(ctx, { hold_id: req.params.id, actor: actorOf(req) });
+ res.json(out);
+ }));
+
+ r.post('/sandbox/grants', auth.needs(CAP.sandbox), body('billing.credit-sandbox-grant-request@1'), notFrozen, wrap(async (req, res) => {
+ const out = await inference.sandboxGrant(ctx, { ...req.body, actor: actorOf(req) });
+ res.status(201).json(out);
+ }));
+
+ return r;
+}
+
+function wrap(fn) {
+ return async (req, res, next) => {
+ try {
+ 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); }
+ };
+}
+
+function sendError(req, res, e) {
+ if (res.headersSent) return;
+ if (e instanceof BillingError) {
+ return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });
+ }
+ console.error('[Billing] unexpected internal error:', e);
+ return http.sendProblem(res, 500, 'billing.internal', { detail: 'internal error', ctx: req.ov });
+}
+
+module.exports = { internalRouter, CAP };
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
/**
/**
- * The signed-in person's own Vibes, for every OpenVibe site (owner 2026-10-08: "make vibes cross site too like opencoins";
- * plan T5 step 2). The shared navbar shows them next to OpenCoins.
+ * The signed-in person's own wallet for every OpenVibe site: Vibes (T5) and Balance (D12). The shared navbar shows
+ * Vibes next to OpenCoins; the wallet shows Balance, the projects the person administers, and the moves between them.
*
*
* GET /api/me/vibes Authorization: Bearer <the person's Network user JWT>
* GET /api/me/vibes Authorization: Bearer <the person's Network user JWT>
* → 200 { subject, currency: 'vibes-bits', credit, payable, bits_per_usd }
* → 200 { subject, currency: 'vibes-bits', credit, payable, bits_per_usd }
@@ -11,15 +11,26 @@
@@ -11,15 +11,26 @@
* numbers are a shadow then, so the chip stays hidden rather than show them
* numbers are a shadow then, so the chip stays hidden rather than show them
* → 401 without a person's token (service, app, module and typed tokens are refused)
* → 401 without a person's token (service, app, module and typed tokens are refused)
*
*
+ * GET /api/me/balance the person's Balance plus the Balance of every project they administer
+ * GET /api/me/credits the same answer (the old wire path stays an alias)
+ * GET /api/me/activity the person's own Balance and Vibes movements, newest first (before=<txn id>)
+ * POST /api/me/balance/transfers { to_project_id, amount_nano_usd, idempotency_key }
+ * POST /api/me/vibes/from-balance { vibes, idempotency_key }
+ *
* CORS: OpenVibe's own sites only (exact https origins from the Contracts service manifests' domains, as Network does;
* CORS: OpenVibe's own sites only (exact https origins from the Contracts service manifests' domains, as Network does;
* never a wildcard, so a tenant subdomain of a hosting product is never allowed), Authorization allowed, no cookies.
* never a wildcard, so a tenant subdomain of a hosting product is never allowed), Authorization allowed, no cookies.
* The answer is private and never cached.
* The answer is private and never cached.
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
+const { validate } = contracts;
const { verifyUserToken } = require('openvibe-sdk/auth');
const { verifyUserToken } = require('openvibe-sdk/auth');
const { balance } = require('../ledger');
const { balance } = require('../ledger');
const { A } = require('../ops/common');
const { A } = require('../ops/common');
+const topups = require('../ops/topups');
+const balances = require('../ops/balance');
+const vibesPay = require('../ops/vibes');
+const activity = require('../ops/activity');
const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
let origins = null;
let origins = null;
@@ -36,7 +47,7 @@ function firstPartyOrigins() {
@@ -36,7 +47,7 @@ function firstPartyOrigins() {
return out;
return out;
}
}
-function meRouter({ ctx, config, keys, limits }) {
+function meRouter({ ctx, config, keys, limits, adapters }) {
const r = express.Router();
const r = express.Router();
const problem = (res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail });
const problem = (res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail });
@@ -44,7 +55,7 @@ function meRouter({ ctx, config, keys, limits }) {
@@ -44,7 +55,7 @@ function meRouter({ ctx, config, keys, limits }) {
const origin = req.get('origin');
const origin = req.get('origin');
res.set({ 'Cache-Control': 'private, no-store', Vary: 'Origin' });
res.set({ 'Cache-Control': 'private, no-store', Vary: 'Origin' });
if (origin && firstPartyOrigins().has(origin)) {
if (origin && firstPartyOrigins().has(origin)) {
- res.set({ 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Headers': 'Authorization', 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Max-Age': '600' });
+ res.set({ 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Max-Age': '600' });
}
}
if (req.method === 'OPTIONS') return res.status(204).end();
if (req.method === 'OPTIONS') return res.status(204).end();
return next();
return next();
@@ -52,7 +63,7 @@ function meRouter({ ctx, config, keys, limits }) {
@@ -52,7 +63,7 @@ function meRouter({ ctx, config, keys, limits }) {
async function person(req, res, next) {
async function person(req, res, next) {
const header = String(req.get('authorization') || '');
const header = String(req.get('authorization') || '');
- if (!header.startsWith('Bearer ')) return problem(res, 401, 'token.missing', 'sign in to see your Vibes');
+ if (!header.startsWith('Bearer ')) return problem(res, 401, 'token.missing', 'sign in to see your wallet');
try {
try {
const claims = await verifyUserToken(header.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, now: ctx.now() });
const claims = await verifyUserToken(header.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, now: ctx.now() });
const subject = claims.subject_id;
const subject = claims.subject_id;
@@ -61,7 +72,7 @@ function meRouter({ ctx, config, keys, limits }) {
@@ -61,7 +72,7 @@ function meRouter({ ctx, config, keys, limits }) {
return next();
return next();
} catch (e) {
} catch (e) {
if (e && e.code === 'token.unavailable') return problem(res, 503, 'auth.unavailable', 'the Network key is not loaded yet');
if (e && e.code === 'token.unavailable') return problem(res, 503, 'auth.unavailable', 'the Network key is not loaded yet');
- return problem(res, 401, (e && e.code) || 'token.invalid', 'sign in again to see your Vibes');
+ return problem(res, 401, (e && e.code) || 'token.invalid', 'sign in again to see your wallet');
}
}
}
}
@@ -74,7 +85,142 @@ function meRouter({ ctx, config, keys, limits }) {
@@ -74,7 +85,142 @@ function meRouter({ ctx, config, keys, limits }) {
});
});
});
});
+ // A signed-in person's Balance (D12): their own and every project they administer. Off with the ledger.
+ const readBalance = async (req, res) => {
+ if (!config.credits || !config.credits.enabled) return problem(res, 404, 'billing.not_enabled', 'Balance is not open yet');
+ try {
+ return res.json(await balances.readBalance(ctx, req.person.subject));
+ } catch (e) {
+ return failure(req, res, e, 'the project list is unavailable');
+ }
+ };
+ r.get('/balance', person, limits.selfRead, readBalance);
+
+ // The old wire path stays an alias of /api/me/balance. With ?project_id it answers that one project's statement,
+ // as before, after Network confirms the person administers it.
+ r.get('/credits', person, limits.selfRead, async (req, res) => {
+ if (!config.credits || !config.credits.enabled) return problem(res, 404, 'billing.not_enabled', 'Balance is not open yet');
+ if (req.query.project_id == null) return await readBalance(req, res);
+ const projectId = String(req.query.project_id || '');
+ if (!/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(projectId)) return problem(res, 422, 'billing.invalid_input', 'project_id is a project id');
+ try {
+ await balances.assertProjectAdmin(ctx, req.person.subject, projectId);
+ return res.json(await require('../ops/credits').statement(ctx, req.person.subject, projectId));
+ } catch (e) {
+ return failure(req, res, e, 'the project is unavailable');
+ }
+ });
+
+ // The person's own movements over Balance and Vibes (D12, Network's /wallet). Their token, their accounts only.
+ r.get('/activity', person, limits.selfRead, async (req, res) => {
+ try {
+ return res.json(await activity.activity(ctx, req.person.subject, { limit: req.query.limit, before: req.query.before }));
+ } catch (e) {
+ return failure(req, res, e, 'the activity is unavailable');
+ }
+ });
+
+ // Move money from the person's Balance to a project they administer (D12): atomic, idempotent, never below zero.
+ r.post('/balance/transfers', express.json({ limit: '16kb' }), person, body('billing.balance-transfer-request@1'),
+ limits.budget('billing.balance.transfer', (req) => req.person && req.person.subject), async (req, res) => {
+ try {
+ const body = req.body || {};
+ const out = await balances.transfer(ctx, {
+ subject: req.person.subject, toProjectId: body.to_project_id,
+ amountNanoUsd: body.amount_nano_usd, idempotencyKey: body.idempotency_key,
+ });
+ return res.status(out.replay ? 200 : 201).json(out);
+ } catch (e) {
+ return failure(req, res, e, 'the transfer is unavailable');
+ }
+ });
+
+ // Get Vibes with Balance (D12): the pack value rate, exact to the nano-USD, minted as a pack is. Behind
+ // billing.vibes.from_balance (danger, off by default).
+ r.post('/vibes/from-balance', express.json({ limit: '16kb' }), person, body('billing.vibes-purchase-from-balance-request@1'),
+ limits.budget('billing.vibes.from_balance', (req) => req.person && req.person.subject), async (req, res) => {
+ try {
+ const body = req.body || {};
+ const out = await balances.buyVibes(ctx, { subject: req.person.subject, vibes: body.vibes, idempotencyKey: body.idempotency_key });
+ return res.status(out.replay ? 200 : 201).json(out);
+ } catch (e) {
+ return failure(req, res, e, 'the purchase is unavailable');
+ }
+ });
+
+ // ── Vibes Pay (D12): the pay sheet's charge ───────────────
+ // What the sheet shows: the app (name and icon from Network's registry, the id when it cannot be read), the item,
+ // the price, the person's Vibes and whether they can pay now. Paying is atomic and once; a double pay is a replay
+ // for the same person and 409 for anyone else. Cancelling a pending charge moves no money.
+ r.get('/vibes/charges/:id', person, limits.selfRead, async (req, res) => {
+ try {
+ return res.json(await vibesPay.personCharge(ctx, req.person.subject, req.params.id));
+ } catch (e) {
+ return failure(req, res, e, 'the payment link is unavailable');
+ }
+ });
+
+ r.post('/vibes/charges/:id/pay', express.json({ limit: '16kb' }), person,
+ limits.budget('billing.vibes.pay', (req) => req.person && req.person.subject), async (req, res) => {
+ try {
+ const out = await vibesPay.payCharge(ctx, req.person.subject, req.params.id, req.body || {});
+ return res.status(200).json({ charge: out.charge, vibes_credit: out.vibes_credit, replay: out.replay });
+ } catch (e) {
+ return failure(req, res, e, 'the payment could not be completed');
+ }
+ });
+
+ r.post('/vibes/charges/:id/cancel', express.json({ limit: '16kb' }), person,
+ limits.budget('billing.vibes.cancel', (req) => req.person && req.person.subject), async (req, res) => {
+ try {
+ const out = await vibesPay.cancelCharge(ctx, req.person.subject, req.params.id);
+ return res.status(200).json({ charge: out.charge, replay: out.replay, ...(out.expired ? { expired: true } : {}) });
+ } catch (e) {
+ return failure(req, res, e, 'the payment could not be cancelled');
+ }
+ });
+
+ // Start a Balance top-up (D9) with the person's own token: one of the owner's configured amounts, the owner's fee,
+ // and the existing provider checkout. With the feature or its configuration off it refuses before any intent.
+ r.post('/credits/topups', express.json({ limit: '16kb' }), person,
+ limits.budget('billing.credits.topup.create', (req) => req.person && req.person.subject), async (req, res) => {
+ if (!config.credits || !config.credits.enabled) return problem(res, 404, 'billing.not_enabled', 'Balance is not open yet');
+ try {
+ const out = await topups.startTopup(ctx, adapters, { ...(req.body || {}), subject: req.person.subject });
+ return res.status(201).json(out);
+ } catch (e) {
+ if (e && e.status && e.code) {
+ return contracts.http.sendProblem(res, e.status, e.code, {
+ detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined,
+ });
+ }
+ (ctx.log || console).error('[Billing] /api/me/credits/topups error:', e);
+ return problem(res, 500, 'billing.internal', 'internal error');
+ }
+ });
+
return r;
return r;
}
}
+/** A D12 request body must be the contract's, before any money moves. */
+function body(schemaId) {
+ return (req, res, next) => {
+ const v = validate(schemaId, req.body == null ? {} : req.body);
+ if (v.valid) return next();
+ const detail = v.errors.slice(0, 5).map((e) => `${e.path || '/'} ${e.message}`).join('; ');
+ return contracts.http.sendProblem(res, 422, 'billing.invalid_request', { detail: `not a ${schemaId}: ${detail}`, ctx: req.ov });
+ };
+}
+
+/** A route's failures as problems: an expected BillingError keeps its code; anything else is a 502 upstream. */
+function failure(req, res, e, detail) {
+ if (e && e.status && e.code) {
+ return contracts.http.sendProblem(res, e.status, e.code, {
+ detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined,
+ });
+ }
+ (req.app.locals && req.app.locals.ctx ? req.app.locals.ctx.log : console).error('[Billing] /api/me error:', e);
+ return contracts.http.sendProblem(res, 502, 'billing.upstream_unavailable', { detail, ctx: req.ov });
+}
+
module.exports = { meRouter, firstPartyOrigins };
module.exports = { meRouter, firstPartyOrigins };
@@ -31,6 +31,7 @@ const promo = require('../ops/promo');
@@ -31,6 +31,7 @@ const promo = require('../ops/promo');
const rating = require('../ops/rating');
const rating = require('../ops/rating');
const tiers = require('../ops/tiers');
const tiers = require('../ops/tiers');
const { stats } = require('../ops/stats');
const { stats } = require('../ops/stats');
+const vibes = require('../ops/vibes');
const CAP = {
const CAP = {
intent: 'billing.intent.create',
intent: 'billing.intent.create',
@@ -44,6 +45,8 @@ const CAP = {
@@ -44,6 +45,8 @@ const CAP = {
stats: 'billing.stats.read',
stats: 'billing.stats.read',
admin: 'billing.ledger.admin',
admin: 'billing.ledger.admin',
usage: 'billing.usage.record',
usage: 'billing.usage.record',
+ vibesCharge: 'billing.vibes.charge',
+ vibesRead: 'billing.vibes.read',
};
};
function v1Router({ ctx, auth, adapters, limits }) {
function v1Router({ ctx, auth, adapters, limits }) {
@@ -157,7 +160,10 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -157,7 +160,10 @@ function v1Router({ ctx, auth, adapters, limits }) {
// ── Cashouts ─────────────────────────────────────────────
// ── Cashouts ─────────────────────────────────────────────
r.post('/cashouts', ...write(CAP.cashoutRequest, async (req, res) => {
r.post('/cashouts', ...write(CAP.cashoutRequest, async (req, res) => {
const b = req.body || {};
const b = req.body || {};
- const out = await 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,
+ projectId: b.project_id, 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, async (req, res) => {
r.get('/cashouts', ...read(CAP.cashoutManage, async (req, res) => {
@@ -297,6 +303,53 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -297,6 +303,53 @@ function v1Router({ ctx, auth, adapters, limits }) {
}));
}));
r.get('/usage', ...read(CAP.admin, async (req, res) => res.json(await usage.list(db, req.query))));
r.get('/usage', ...read(CAP.admin, async (req, res) => res.json(await usage.list(db, req.query))));
+ // ── Vibes Pay (D12): a developer app sells an item for whole Vibes ──
+ // The app authenticates with its own Network app token (billing.vibes.charge); the person pays on the pay sheet
+ // through /api/me/vibes/charges/:id. A charge is idempotent on the app's reference, and only its own app may read
+ // or refund it.
+ const appWrite = (handler) => [auth.appOnly(CAP.vibesCharge), notFrozen,
+ limits.budget('billing.vibes.charge.create', () => null), wrap(handler)];
+ r.post('/vibes/charges', ...appWrite(async (req, res) => {
+ const out = await vibes.createCharge(ctx, req.principal, req.body || {});
+ res.status(out.replay ? 200 : 201).json(out.charge);
+ }));
+ r.get('/vibes/charges/:id', auth.appOnly(CAP.vibesCharge), wrap(async (req, res) => {
+ res.json((await vibes.getChargeForApp(ctx, req.principal, req.params.id)).charge);
+ }));
+ r.post('/vibes/charges/:id/refund', auth.appOnly(CAP.vibesCharge), notFrozen,
+ limits.budget('billing.vibes.charge.refund', () => null), wrap(async (req, res) => {
+ const out = await vibes.refundCharge(ctx, req.principal, req.params.id, req.body || {});
+ res.status(out.replay ? 200 : 201).json(out.charge);
+ }));
+ // The app's own test grant: sandbox Vibes for a person, never real ones.
+ r.post('/vibes/sandbox/grants', auth.appOnly(CAP.vibesCharge), notFrozen,
+ limits.budget('billing.vibes.sandbox.grant', () => null), wrap(async (req, res) => {
+ res.status(201).json(await vibes.sandboxGrant(ctx, req.principal, req.body || {}));
+ }));
+ // The person who delegated this app reads their own Vibes (billing.vibes.read; no first-party route is widened).
+ r.get('/vibes/me', auth.appOnly(CAP.vibesRead), wrap(async (req, res) => {
+ res.json(await vibes.walletForApp(ctx, req.principal));
+ }));
+ // Webhook settings for the app's own charges. The secret is write-only: PUT answers it once when generated.
+ r.get('/vibes/webhook', auth.appOnly(CAP.vibesCharge), wrap(async (req, res) => {
+ const out = await ctx.vibesWebhooks.get(req.principal.appId);
+ res.json({
+ app_id: req.principal.appId, url: out.url, configured: Boolean(out.url && out.marker),
+ secret_set: Boolean(out.marker), secret_hint: out.marker ? out.marker.hint : null,
+ sealed: out.sealed, updated_at: out.marker ? out.marker.set_at : null,
+ });
+ }));
+ r.put('/vibes/webhook', auth.appOnly(CAP.vibesCharge), notFrozen,
+ limits.budget('billing.vibes.webhook', () => null), wrap(async (req, res) => {
+ const b = req.body || {};
+ const url = ctx.vibesWebhooks.safeUrl(b.url, ctx.config);
+ res.json(await ctx.vibesWebhooks.configure(req.principal.appId, { url, secret: b.secret, rotate: b.rotate_secret === true }));
+ }));
+ r.delete('/vibes/webhook', auth.appOnly(CAP.vibesCharge), notFrozen,
+ limits.budget('billing.vibes.webhook', () => null), wrap(async (req, res) => {
+ res.json(await ctx.vibesWebhooks.remove(req.principal.appId));
+ }));
+
// ── Admin (billing.ledger.admin) ─────────────────────────
// ── Admin (billing.ledger.admin) ─────────────────────────
r.get('/admin/freeze', ...read(CAP.admin, async (req, res) => res.json(await 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) => {
@@ -11,8 +11,8 @@
@@ -11,8 +11,8 @@
* /webhooks/<provider> provider receipts (see api/webhooks.js)
* /webhooks/<provider> provider receipts (see api/webhooks.js)
* /, /auth/*, /cashouts, … staff console (Network SSO, server-rendered; see console/index.js)
* /, /auth/*, /cashouts, … staff console (Network SSO, server-rendered; see console/index.js)
*
*
- * createApp({ config, db, keys, identity, adapters, now, fetchImpl, log, limitsNow }) — everything injectable
- * (limitsNow: the per-person limiter's clock, tests; default the wall clock).
+ * createApp({ config, db, env, keys, identity, adapters, now, fetchImpl, log, limitsNow }) — everything injectable
+ * (env: the raw environment, default process.env; limitsNow: the per-person limiter's clock, tests; default the wall clock).
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -22,9 +22,13 @@ const { createRelease } = require('openvibe-shared/release');
@@ -22,9 +22,13 @@ const { createRelease } = require('openvibe-shared/release');
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
const { openDb } = require('./db');
const { createRates } = require('./rates');
const { createRates } = require('./rates');
-const { createKeyProvider, createIdentity } = require('./network');
+const { createKeyProvider, createIdentity, createProjects, createApps } = require('./network');
const { createAuth } = require('./api/auth');
const { createAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
+const { internalRouter } = require('./api/internal');
+const { createBillingConfig, createVibesConfig, applyValues, applyVibesValues } = require('./config-store');
+const { internalConfigRouter } = require('./internal-config');
+const { createVibesWebhooks } = require('./ops/vibes-webhooks');
const { createActorLimits } = require('./api/actor-limits');
const { createActorLimits } = require('./api/actor-limits');
const { webhooksRouter } = require('./api/webhooks');
const { webhooksRouter } = require('./api/webhooks');
const { consoleRouter } = require('./console');
const { consoleRouter } = require('./console');
@@ -51,7 +55,23 @@ async function createApp(opts = {}) {
@@ -51,7 +55,23 @@ async function createApp(opts = {}) {
validate: (env) => contracts.validate('events.event-envelope@1', env),
validate: (env) => contracts.validate('events.event-envelope@1', env),
...(opts.fetchImpl ? { fetch: fetchImpl } : {}),
...(opts.fetchImpl ? { fetch: fetchImpl } : {}),
});
});
- const ctx = { db, config, rates: createRates(config.rates), now: opts.now || (() => Date.now()), network: identity, log, outbox };
+ // ctx.env is the raw environment the owner-settings store reads its x-env pins from; the service reads the
+ // settings themselves through ctx.configStore (D11). Tests pass their own env through here.
+ const ctx = { db, config, env: opts.env || process.env, rates: createRates(config.rates), now: opts.now || (() => Date.now()), network: identity, log, outbox };
+ // D11 owner settings: `billing.credits` is the source of the Balance switches, the top-up terms and the
+ // hold lease; `billing.vibes` holds the Vibes Pay limits and the Get Vibes with Balance switch. The env pins
+ // keep today's variables winning while they are set; a change applies live.
+ ctx.configStore = await createBillingConfig({ db, env: ctx.env, log, onActivate: (values) => applyValues(config, values) });
+ applyValues(config, ctx.configStore.get());
+ ctx.vibesConfigStore = await createVibesConfig({ db, env: ctx.env, log, onActivate: (values) => applyVibesValues(config, values) });
+ applyVibesValues(config, ctx.vibesConfigStore.get());
+ // Network's project-membership reads (D12): the projects a person administers and one role check.
+ ctx.projects = opts.projects || createProjects(config, { fetchImpl });
+ // Network's app registry (D12 Vibes Pay): the app name/icon on the pay sheet. Best effort; Network's own
+ // pay sheet fills the name from dev_apps when this read is not permitted.
+ ctx.apps = opts.apps || createApps(config, { fetchImpl, log });
+ // Per-app Vibes Pay webhook settings (D12): a D11 namespace each, sealed with the service KEK when present.
+ ctx.vibesWebhooks = createVibesWebhooks({ db, config, env: ctx.env, log });
const adapters = opts.adapters || providers.createAdapters(config, { fetchImpl, network: identity });
const adapters = opts.adapters || providers.createAdapters(config, { fetchImpl, network: identity });
const auth = createAuth({ config, keys });
const auth = createAuth({ config, keys });
@@ -99,6 +119,8 @@ async function createApp(opts = {}) {
@@ -99,6 +119,8 @@ async function createApp(opts = {}) {
});
});
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
+ // Owner settings (D11): Network's service token, capability billing.config.manage. Before every public route.
+ app.use(internalConfigRouter({ ctx, auth }));
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
// Account export and deletion (ADR-033, server/account-data.js): OpenVibe.Events delivers network.account.* to
// Account export and deletion (ADR-033, server/account-data.js): OpenVibe.Events delivers network.account.* to
// 127.0.0.1:4600 directly (the consumer refuses anything that came through a proxy; nginx 404s /internal/ too), and
// 127.0.0.1:4600 directly (the consumer refuses anything that came through a proxy; nginx 404s /internal/ too), and
@@ -110,11 +132,14 @@ async function createApp(opts = {}) {
@@ -110,11 +132,14 @@ async function createApp(opts = {}) {
app.post('/internal/events', accountData.consumer({ secrets: config.events.webhookSecrets, send: accountSend }));
app.post('/internal/events', accountData.consumer({ secrets: config.events.webhookSecrets, send: accountSend }));
app.locals.accountData = accountData;
app.locals.accountData = accountData;
app.locals.accountSend = accountSend;
app.locals.accountSend = accountSend;
+ // Inference subledger (AI Exchange D2): mounted always, each handler refuses with 503 billing.not_enabled
+ // while the `credits` setting is off, so the owner can open it live from Network's AI tab.
+ app.use('/internal/credits', express.json({ limit: '64kb' }), internalRouter({ ctx, auth }));
// 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, valkey });
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 }));
// A signed-in person's own Vibes, for the shared navbar on every OpenVibe site (api/me.js; user tokens, CORS for owned sites).
// A signed-in person's own Vibes, for the shared navbar on every OpenVibe site (api/me.js; user tokens, CORS for owned sites).
- app.use('/api/me', require('./api/me').meRouter({ ctx, config, keys, limits }));
+ app.use('/api/me', require('./api/me').meRouter({ ctx, config, keys, limits, adapters }));
// 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.
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,247 @@
@@ -0,0 +1,247 @@
+'use strict';
+
+/**
+ * Billing's owner settings (D11): the namespaces `billing.credits` and `billing.vibes`, read through
+ * openvibe-shared/config.
+ *
+ * Every key keeps the environment variable that configured it before D11 as an `x-env` pin: while that
+ * variable is set, its value is effective and read-only (the owner's value is refused with
+ * 409 config.pinned_in_env). Nothing changes until the owner sets a value. `topup` and the two terms stay
+ * without defaults: unset means `topupTerms()` reports `configured: false` and the person's route answers
+ * 503 billing.topup_unconfigured. `hold_lease_seconds` is an owner setting with the old
+ * BILLING_INFERENCE_LEASE_SECONDS as its pin; when neither is set the loader's 300 s default applies.
+ *
+ * `credits`, `topup` and the two top-up terms are danger keys (they open or move money). There are no secret-class
+ * keys in this namespace: Billing's provider credentials stay in the service's environment, so the
+ * write-only `/secrets/:key` route refuses every key here with 422 config.not_secret.
+ */
+const config = require('openvibe-shared/config');
+const { MAX_RECEIPT_CENTS } = require('./ops/common');
+const { iso, prefixedId } = require('./ledger');
+
+const SERVICE = 'billing';
+const NAMESPACE = 'billing.credits';
+const GROUP = 'Balance';
+const VIBES_NAMESPACE = 'billing.vibes';
+const VIBES_GROUP = 'Vibes';
+
+const SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Billing Balance',
+ description: 'Balance ledger and the owner terms of the checkout top-up.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ credits: {
+ type: 'boolean', default: false,
+ title: 'Balance ledger', description: 'Open the Balance ledger: the internal hold/settle API and the person\'s Balance routes.',
+ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS', 'x-danger': true,
+ },
+ sandbox: {
+ type: 'boolean', default: false,
+ title: 'Sandbox credit grants', description: 'Allow synthetic sandbox credit grants, for development and integration tests only.',
+ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS_SANDBOX',
+ },
+ topup: {
+ type: 'boolean', default: false,
+ title: 'Balance top-up', description: 'Offer checkout top-ups of Balance through the payment providers.',
+ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP', 'x-danger': true,
+ },
+ topup_amounts_cents: {
+ type: 'array', items: { type: 'integer', minimum: 1, maximum: MAX_RECEIPT_CENTS }, minItems: 1, uniqueItems: true,
+ title: 'Top-up amounts (cents)', description: 'The only amounts a person may top up, in whole USD cents.',
+ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP_AMOUNTS_CENTS', 'x-danger': true,
+ },
+ topup_fee_bps: {
+ type: 'integer', minimum: 0, maximum: 10_000,
+ title: 'Top-up fee (bps)', description: 'Fee in basis points charged on top of the top-up at checkout; 0 is a real, configured fee.',
+ 'x-group': GROUP, 'x-env': 'BILLING_CREDITS_TOPUP_FEE_BPS', 'x-danger': true,
+ },
+ // The unit is seconds; Shared 4.110.0's x-unit only accepts nano_usd, ms and bps, so it is in the title
+ // rather than an x-unit that would make createConfigStore throw.
+ hold_lease_seconds: {
+ type: 'integer', minimum: 1, maximum: 86_400, default: 300,
+ title: 'Hold lease (seconds)', description: 'How long a Balance hold lasts before it expires.',
+ 'x-group': GROUP, 'x-env': 'BILLING_INFERENCE_LEASE_SECONDS',
+ },
+ },
+};
+
+const VIBES_SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Billing Vibes',
+ description: 'Vibes Pay limits and the switch that lets a person get Vibes with Balance.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ vibes_pay: {
+ type: 'boolean', default: false,
+ title: 'Vibes Pay', description: 'Let apps charge Vibes on the pay sheet. Turning it on enables spending real Vibes.',
+ 'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_PAY', 'x-danger': true,
+ },
+ max_charge_vibes: {
+ type: 'integer', minimum: 0, maximum: 10_000_000, default: 0,
+ title: 'Largest single charge (Vibes)', description: 'The most Vibes one app charge may ask for; 0 closes app charges.',
+ 'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_MAX_CHARGE',
+ },
+ daily_app_spend_vibes_per_person: {
+ type: 'integer', minimum: 0, maximum: 10_000_000, default: 0,
+ title: 'Daily app spend per person (Vibes)', description: 'The most Vibes one person may spend in apps in a day; 0 closes app spending.',
+ 'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_DAILY_APP_SPEND',
+ },
+ from_balance: {
+ type: 'boolean', default: false,
+ title: 'Get Vibes with Balance', description: 'Let a person buy Vibes with their Balance, at the pack value rate.',
+ 'x-group': VIBES_GROUP, 'x-env': 'BILLING_VIBES_FROM_BALANCE', 'x-danger': true,
+ },
+ },
+};
+
+const AMOUNT_RE = /^[0-9]+$/;
+
+/** A top-up amount list from a JSON array, a JSON string, or today's comma-separated env value. */
+function parseAmounts(raw) {
+ const bad = [];
+ let parts;
+ if (Array.isArray(raw)) parts = raw;
+ else {
+ const text = String(raw == null ? '' : raw).trim();
+ if (!text) return { values: [], ok: false, empty: true, invalid: [] };
+ if (text.startsWith('[')) {
+ try { parts = JSON.parse(text); } catch { return { values: [], ok: false, invalid: [text] }; }
+ if (!Array.isArray(parts)) return { values: [], ok: false, invalid: [text] };
+ } else parts = text.split(',').map((s) => s.trim());
+ }
+ const values = [];
+ for (const part of parts) {
+ const s = String(part);
+ const n = Number(s);
+ if (!AMOUNT_RE.test(s) || !Number.isSafeInteger(n) || n <= 0 || n > MAX_RECEIPT_CENTS) { bad.push(s); continue; }
+ values.push(n);
+ }
+ return { values: [...new Set(values)].sort((a, b) => a - b), ok: bad.length === 0, invalid: bad };
+}
+
+const PINNED = ['BILLING_CREDITS', 'BILLING_CREDITS_SANDBOX', 'BILLING_CREDITS_TOPUP', 'BILLING_CREDITS_TOPUP_AMOUNTS_CENTS',
+ 'BILLING_CREDITS_TOPUP_FEE_BPS', 'BILLING_INFERENCE_LEASE_SECONDS'];
+const VIBES_PINNED = ['BILLING_VIBES_PAY', 'BILLING_VIBES_MAX_CHARGE', 'BILLING_VIBES_DAILY_APP_SPEND', 'BILLING_VIBES_FROM_BALANCE'];
+
+/**
+ * The environment as the store should read it. An empty variable means unset (as `loadConfig` already reads
+ * it), a boolean keeps every form `loadConfig` accepted (`1`, `true`, `yes`, `on`), and a comma-separated
+ * amount list becomes the JSON array its array pin needs.
+ */
+function storeEnv(env) {
+ const out = { ...(env || {}) };
+ for (const name of PINNED) if (out[name] != null && String(out[name]).trim() === '') delete out[name];
+ for (const name of ['BILLING_CREDITS', 'BILLING_CREDITS_SANDBOX', 'BILLING_CREDITS_TOPUP']) {
+ if (out[name] == null) continue;
+ out[name] = ['1', 'true', 'yes', 'on'].includes(String(out[name]).toLowerCase()) ? '1' : '0';
+ }
+ const raw = out.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS;
+ if (raw != null) {
+ const parsed = parseAmounts(raw);
+ if (parsed.ok) out.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS = JSON.stringify(parsed.values);
+ }
+ return out;
+}
+
+/** The values the namespace would have from the environment alone (the pre-D11 reader; unit tests and `topupTerms` fallback). */
+function valuesFromEnv(env) {
+ const on = (name) => ['1', 'true', 'yes', 'on'].includes(String(env && env[name] != null ? env[name] : '').toLowerCase());
+ return {
+ credits: on('BILLING_CREDITS'),
+ sandbox: on('BILLING_CREDITS_SANDBOX'),
+ topup: on('BILLING_CREDITS_TOPUP'),
+ ...(env && env.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS != null && String(env.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS).trim() !== ''
+ ? { topup_amounts_cents: env.BILLING_CREDITS_TOPUP_AMOUNTS_CENTS } : {}),
+ ...(env && env.BILLING_CREDITS_TOPUP_FEE_BPS != null && String(env.BILLING_CREDITS_TOPUP_FEE_BPS).trim() !== ''
+ ? { topup_fee_bps: Number(env.BILLING_CREDITS_TOPUP_FEE_BPS) } : {}),
+ ...(env && env.BILLING_INFERENCE_LEASE_SECONDS != null && String(env.BILLING_INFERENCE_LEASE_SECONDS).trim() !== ''
+ ? { hold_lease_seconds: Number(env.BILLING_INFERENCE_LEASE_SECONDS) } : {}),
+ };
+}
+
+/** The Vibes namespace's environment: empty means unset and a switch keeps every form loadConfig accepted. */
+function vibesStoreEnv(env) {
+ const out = { ...(env || {}) };
+ for (const name of VIBES_PINNED) if (out[name] != null && String(out[name]).trim() === '') delete out[name];
+ for (const name of ['BILLING_VIBES_PAY', 'BILLING_VIBES_FROM_BALANCE']) {
+ if (out[name] == null) continue;
+ out[name] = ['1', 'true', 'yes', 'on'].includes(String(out[name]).toLowerCase()) ? '1' : '0';
+ }
+ return out;
+}
+
+/** Apply the store's effective values to the config object the rest of the service already reads (live). */
+function applyValues(config, values) {
+ const credits = config.credits || (config.credits = {});
+ credits.enabled = values.credits === true;
+ credits.sandbox = values.sandbox === true;
+ const lease = Number(values.hold_lease_seconds);
+ credits.leaseSeconds = Number.isFinite(lease) ? Math.max(1, Math.min(86_400, Math.trunc(lease))) : 300;
+}
+
+function applyVibesValues(config, values) {
+ const vibes = config.vibes || (config.vibes = {});
+ vibes.pay = values.vibes_pay === true;
+ vibes.fromBalance = values.from_balance === true;
+ const max = Number(values.max_charge_vibes);
+ const daily = Number(values.daily_app_spend_vibes_per_person);
+ vibes.maxChargeVibes = Number.isSafeInteger(max) && max >= 0 ? max : 0;
+ vibes.dailyAppSpendVibesPerPerson = Number.isSafeInteger(daily) && daily >= 0 ? daily : 0;
+}
+
+/** One append-only staff_audit row per activation: service, namespace, keys, actor, revision; never a value. */
+async function auditChange(db, { namespace, service, keys, actor, revision }, log) {
+ try {
+ const at = iso(Date.now());
+ await db.prepare(`INSERT INTO staff_audit (id, at, actor_subject, action, target_type, target_id, reason, outcome, detail)
+ VALUES (?, ?, ?, 'config.change', 'config_namespace', ?, 'configuration activated', 'done', ?)`)
+ .run(prefixedId('sa', Date.now()), at, actor && actor.type === 'user' ? actor.id : null, namespace,
+ JSON.stringify({ service, namespace, keys: [...(keys || [])], revision }));
+ } catch (err) {
+ // The audit must never refuse a revision: log the failure without the values.
+ (log || console).warn(`[config] audit line failed: ${err && err.message ? err.message.split('\n')[0] : 'unknown error'}`);
+ }
+}
+
+/** Create the store, apply its values and (when a KEK is configured) reseal old plaintext rows once at boot. */
+async function createBillingConfig({ db, env, log = console, schema = SCHEMA, onActivate = null }) {
+ const store = await config.createConfigStore({
+ db, service: SERVICE, namespace: NAMESPACE, schema,
+ env: storeEnv(env),
+ classify: () => 'internal',
+ onActivate,
+ onChange: async (change) => await auditChange(db, { ...change, namespace: NAMESPACE, service: SERVICE }, log),
+ log,
+ });
+ if (store.sealed) {
+ try {
+ const report = await store.reseal();
+ if (report.plaintext || report.resealed || report.errors.length) {
+ log.info(`[config] ${NAMESPACE} reseal at boot: ${report.resealed} resealed, ${report.plaintext} plaintext, ${report.errors.length} error(s)`);
+ }
+ } catch (err) {
+ log.warn(`[config] ${NAMESPACE} reseal at boot failed: ${err && err.message ? err.message.split('\n')[0] : 'unknown error'}`);
+ }
+ }
+ return store;
+}
+
+/** The D11 `billing.vibes` namespace (Vibes Pay limits and Get Vibes with Balance). */
+async function createVibesConfig({ db, env, log = console, schema = VIBES_SCHEMA, onActivate = null }) {
+ return await config.createConfigStore({
+ db, service: SERVICE, namespace: VIBES_NAMESPACE, schema,
+ env: vibesStoreEnv(env),
+ classify: () => 'internal',
+ onActivate,
+ onChange: async (change) => await auditChange(db, { ...change, namespace: VIBES_NAMESPACE, service: SERVICE }, log),
+ log,
+ });
+}
+
+module.exports = {
+ SERVICE, NAMESPACE, VIBES_NAMESPACE, SCHEMA, VIBES_SCHEMA, parseAmounts, storeEnv, valuesFromEnv, applyValues,
+ vibesStoreEnv, applyVibesValues, createBillingConfig, createVibesConfig, auditChange,
+};
@@ -167,6 +167,8 @@ function loadConfig(env = process.env) {
@@ -167,6 +167,8 @@ function loadConfig(env = process.env) {
enabled: env.BILLING_JOBS !== 'off',
enabled: env.BILLING_JOBS !== 'off',
sweepIntervalMs: int(env.BILLING_SWEEP_INTERVAL_MS, 60 * 60 * 1000),
sweepIntervalMs: int(env.BILLING_SWEEP_INTERVAL_MS, 60 * 60 * 1000),
webhookRetryMs: int(env.BILLING_WEBHOOK_RETRY_MS, 60 * 1000),
webhookRetryMs: int(env.BILLING_WEBHOOK_RETRY_MS, 60 * 1000),
+ // Vibes Pay app webhooks (D12): due deliveries from vibes_webhook_outbox, retried with backoff.
+ vibesWebhookIntervalMs: Math.max(0, int(env.BILLING_VIBES_WEBHOOK_INTERVAL_MS, 5000)),
// Scheduled reconciliation (0 turns it off): stored like any other run; passing scheduled
// Scheduled reconciliation (0 turns it off): stored like any other run; passing scheduled
// runs older than reconcileKeepDays are pruned, failed ones are kept.
// runs older than reconcileKeepDays are pruned, failed ones are kept.
reconcileIntervalMs: int(env.BILLING_RECONCILE_INTERVAL_MS, 60 * 60 * 1000),
reconcileIntervalMs: int(env.BILLING_RECONCILE_INTERVAL_MS, 60 * 60 * 1000),
@@ -187,6 +189,32 @@ function loadConfig(env = process.env) {
@@ -187,6 +189,32 @@ function loadConfig(env = process.env) {
// fails reconciliation: the provider was paid and the ledger does not show it yet.
// fails reconciliation: the provider was paid and the ledger does not show it yet.
staleReceiptMin: int(env.BILLING_RECONCILE_STALE_RECEIPT_MIN, 60),
staleReceiptMin: int(env.BILLING_RECONCILE_STALE_RECEIPT_MIN, 60),
},
},
+ credits: {
+ // AI Exchange D2/D12: high-precision Balance ledger. Off by default; no inference money moves without it.
+ enabled: bool(env.BILLING_CREDITS),
+ // Synthetic inference credit grants, for development and integration tests only.
+ sandbox: bool(env.BILLING_CREDITS_SANDBOX),
+ // Default hold lease duration in seconds; extended holds get at least one extra minute.
+ leaseSeconds: Math.max(1, Math.min(86400, int(env.BILLING_INFERENCE_LEASE_SECONDS, 300))),
+ // Background sweeper interval for expired holds (0 = off, default off until the subledger is enabled).
+ sweepIntervalMs: int(env.BILLING_INFERENCE_SWEEP_MS, 0),
+ },
+
+ // D12 `billing.vibes`: Vibes Pay limits and Get Vibes with Balance. The defaults are closed; the store in
+ // server/config-store.js applies the owner's values live, with these env names as x-env pins.
+ vibes: {
+ pay: bool(env.BILLING_VIBES_PAY),
+ maxChargeVibes: Math.max(0, int(env.BILLING_VIBES_MAX_CHARGE, 0)),
+ dailyAppSpendVibesPerPerson: Math.max(0, int(env.BILLING_VIBES_DAILY_APP_SPEND, 0)),
+ fromBalance: bool(env.BILLING_VIBES_FROM_BALANCE),
+ // D12 step 4: the pay sheet lives on openvibe.network; a charge's expires_at is this far after creation.
+ payBaseUrl: trim(env.BILLING_VIBES_PAY_BASE_URL || 'https://openvibe.network'),
+ chargeTtlSeconds: Math.max(1, Math.min(86_400, int(env.BILLING_VIBES_CHARGE_TTL_SECONDS, 1800))),
+ // Test grants and sandbox charges (like BILLING_CREDITS_SANDBOX): off by default, worth nothing.
+ sandbox: bool(env.BILLING_VIBES_SANDBOX),
+ // Development only: let a webhook point at a private address. Production always applies the egress guard.
+ allowPrivateWebhooks: bool(env.BILLING_VIBES_ALLOW_PRIVATE_WEBHOOKS),
+ },
// Staff console (server/console): Network SSO (authorization code + PKCE S256, OAuth
// Staff console (server/console): Network SSO (authorization code + PKCE S256, OAuth
// client `billing`), only for Network admins listed in BILLING_STAFF_SUBJECTS.
// client `billing`), only for Network admins listed in BILLING_STAFF_SUBJECTS.
@@ -15,6 +15,7 @@ async function outstanding(db) {
@@ -15,6 +15,7 @@ async function outstanding(db) {
return {
return {
credit_bits: await sum('user_credit', 'vibes-bits'),
credit_bits: await sum('user_credit', 'vibes-bits'),
payable_bits: await sum('creator_payable', 'vibes-bits'),
payable_bits: await sum('creator_payable', 'vibes-bits'),
+ project_payable_bits: await sum('project_payable', 'vibes-bits'),
payouts_pending_bits: await sum('payouts_pending', 'vibes-bits'),
payouts_pending_bits: await sum('payouts_pending', 'vibes-bits'),
platform_revenue_cents: await sum('platform_revenue', 'usd-cents'),
platform_revenue_cents: await sum('platform_revenue', 'usd-cents'),
chargeback_loss_cents: await sum('chargeback_loss', 'usd-cents'),
chargeback_loss_cents: await sum('chargeback_loss', 'usd-cents'),
@@ -18,12 +18,15 @@ const path = require('path');
@@ -18,12 +18,15 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
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', 'promo_credit', 'promo_reserve'];
+ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve',
+ 'usd_credit', 'usd_credit_sandbox', 'credit_topup_debt', 'project_payable', 'vibes_credit_sandbox',
+ 'project_payable_sandbox', 'vibes_sandbox'];
// The promo ledger's kinds (migrations/0003): their entries never share a transaction with any other kind.
// The promo ledger's kinds (migrations/0003): their entries never share a transaction with any other kind.
const PROMO_KINDS = ['promo_credit', 'promo_reserve'];
const PROMO_KINDS = ['promo_credit', 'promo_reserve'];
-const CURRENCIES = ['vibes-bits', 'usd-cents'];
+const CURRENCIES = ['vibes-bits', 'usd-cents', 'nano-usd'];
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', 'usage'];
+ 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage', 'credit', 'inference',
+ 'balance_transfer', 'vibes_charge', 'vibes_refund', 'vibes_sandbox_grant'];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
@@ -18,6 +18,8 @@ const { createApp } = require('./app');
@@ -18,6 +18,8 @@ const { createApp } = require('./app');
const providers = require('./providers');
const providers = require('./providers');
const subscriptions = require('./ops/subscriptions');
const subscriptions = require('./ops/subscriptions');
const rating = require('./ops/rating');
const rating = require('./ops/rating');
+const inference = require('./ops/credits');
+const vibesWebhooks = require('./ops/vibes-webhooks');
const { isFrozen } = require('./ops/common');
const { isFrozen } = require('./ops/common');
const { runScheduled } = require('./reconcile');
const { runScheduled } = require('./reconcile');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
@@ -53,6 +55,21 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -53,6 +55,21 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
if (out.rated || out.skipped || out.failed) console.log(`[Billing] rating: ${out.rated} rated, ${out.skipped} left unrated, ${out.failed} failed`);
if (out.rated || out.skipped || out.failed) console.log(`[Billing] rating: ${out.rated} rated, ${out.skipped} left unrated, ${out.failed} failed`);
});
});
}
}
+ if (config.jobs.vibesWebhookIntervalMs > 0) {
+ every(config.jobs.vibesWebhookIntervalMs, async () => {
+ // Vibes Pay app webhooks (D12): due rows in vibes_webhook_outbox, signed and retried with backoff.
+ const out = await vibesWebhooks.deliverDue(ctx);
+ if (out.delivered || out.failed || out.abandoned) console.log(`[Billing] Vibes Pay webhooks: ${out.delivered} delivered, ${out.failed} retried, ${out.abandoned} abandoned`);
+ });
+ }
+ if (config.credits.sweepIntervalMs > 0) {
+ every(config.credits.sweepIntervalMs, async () => {
+ // The owner may open the ledger live; the sweep follows the current setting.
+ if (!ctx.config.credits.enabled) return;
+ const out = await inference.sweepExpired(ctx);
+ if (out.released) console.log(`[Billing] inference lease sweep: ${out.released} expired holds released`);
+ });
+ }
relay.start();
relay.start();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * The owner-settings surface (D11): openvibe-shared/config's adminRoutes mounted at /internal/config.
+ *
+ * Only Network's service token holds `billing.config.manage`; every route is guarded by it (server/api/auth.js).
+ * The acting subject is the owner's usr_ id in X-OV-Subject, and only for a caller that holds the capability;
+ * X-OV-Fresh: 1 is required for a write to an x-danger key, so a stale session can never turn Balance or the
+ * top-up switch on. Both namespaces (`billing.credits`, `billing.vibes`) are served here; their onChange writes
+ * Billing's audit line (server/config-store.js).
+ *
+ * The paths are full paths (the router is mounted at the app root) so the capability declaration's
+ * implementedBy list points at real routes.
+ */
+const express = require('express');
+const { capabilities } = require('openvibe-contracts');
+const { adminRoutes } = require('openvibe-shared/config');
+
+const CAP = 'billing.config.manage';
+const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function internalConfigRouter({ ctx, auth }) {
+ const r = express.Router();
+ // The store is the only caller of the config routes, so its body parser needs to run only on these paths.
+ r.use('/internal/config', express.json({ limit: '64kb' }));
+ const routes = adminRoutes([ctx.configStore, ctx.vibesConfigStore], {
+ requireAdmin: auth.needs(CAP),
+ basePath: '/internal/config',
+ actor: (req) => {
+ // Only a caller holding the capability may name an owner (the guard above already ran).
+ if (!req.principal || !capabilities.grants(req.principal.cap, CAP)) return null;
+ const id = String(req.headers['x-ov-subject'] || '');
+ return USER_SUBJECT_RE.test(id) ? { type: 'user', id } : null;
+ },
+ requireFresh: (req) => req.headers['x-ov-fresh'] === '1',
+ log: ctx.log,
+ });
+ r.get('/internal/config', routes.list);
+ r.get('/internal/config/:namespace', routes.get);
+ r.get('/internal/config/:namespace/history', routes.history);
+ r.post('/internal/config/:namespace', routes.apply);
+ r.post('/internal/config/:namespace/rollback', routes.rollback);
+ r.put('/internal/config/:namespace/secrets/:key', routes.secretPut);
+ r.delete('/internal/config/:namespace/secrets/:key', routes.secretDelete);
+ return r;
+}
+
+module.exports = { internalConfigRouter, CAP, USER_SUBJECT_RE };
@@ -24,6 +24,20 @@
@@ -24,6 +24,20 @@
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
+const NANO_USD = 'nano-usd';
+const isBigCurrency = (currency) => currency === NANO_USD;
+const PG_BIGINT_MAX = 9223372036854775807n;
+
+/** Parse a bigint column value (returned as text via ::text) into the right JS type for its currency. */
+function parseMoney(value, currency) {
+ if (value == null) return isBigCurrency(currency) ? 0n : 0;
+ if (isBigCurrency(currency)) return BigInt(String(value));
+ return Number(value);
+}
+
+/** Zero value matching the currency's numeric type. */
+function zeroFor(currency) { return isBigCurrency(currency) ? 0n : 0; }
+
class BillingError extends Error {
class BillingError extends Error {
constructor(status, code, detail, extra) {
constructor(status, code, detail, extra) {
super(detail || code);
super(detail || code);
@@ -46,11 +60,11 @@ async function accountId(db, { kind, owner = null, currency }, nowIso) {
@@ -46,11 +60,11 @@ async function accountId(db, { kind, owner = null, currency }, nowIso) {
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). Returns BigInt for nano-usd, Number otherwise. */
async function balance(db, { kind, owner = null, 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
+ const row = await db.prepare(`SELECT b.balance::text AS 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);
WHERE a.kind = ? AND a.owner_subject IS NOT DISTINCT FROM ? AND a.currency = ?`).get(kind, owner, currency);
- return row ? row.balance : 0;
+ return parseMoney(row ? row.balance : null, currency);
}
}
function parseTxn(row) {
function parseTxn(row) {
@@ -59,8 +73,9 @@ function parseTxn(row) {
@@ -59,8 +73,9 @@ function parseTxn(row) {
}
}
async function entriesOf(db, txnId) {
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
+ const rows = await db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, e.amount::text AS 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);
+ return rows.map((r) => ({ kind: r.kind, owner: r.owner, currency: r.currency, amount: parseMoney(r.amount, r.currency) }));
}
}
async function getTxn(db, id) {
async function getTxn(db, id) {
@@ -91,18 +106,31 @@ async function post(ctx, spec) {
@@ -91,18 +106,31 @@ async function post(ctx, spec) {
const merged = new Map();
const merged = new Map();
const sums = new Map();
const sums = new Map();
for (const e of spec.entries || []) {
for (const e of spec.entries || []) {
- if (!Number.isInteger(e.amount)) throw new BillingError(500, 'ledger.bad_amount', `non-integer amount ${e.amount}`);
- if (!Number.isSafeInteger(e.amount)) throw new BillingError(422, 'billing.amount_overflow', `amount ${e.amount} is beyond the exact integer range`);
- if (e.amount === 0) continue;
+ const isBig = typeof e.amount === 'bigint';
+ if (isBigCurrency(e.currency)) {
+ if (!isBig) throw new BillingError(500, 'ledger.bad_amount', `${e.currency} amounts must be BigInt`);
+ if (e.amount > PG_BIGINT_MAX || e.amount < -PG_BIGINT_MAX) throw new BillingError(422, 'billing.amount_overflow', `amount ${e.amount} ${e.currency} does not fit in the ledger column`);
+ } else {
+ if (isBig) throw new BillingError(500, 'ledger.bad_amount', `${e.currency} amounts must be Number`);
+ if (!Number.isInteger(e.amount)) throw new BillingError(500, 'ledger.bad_amount', `non-integer amount ${e.amount}`);
+ if (!Number.isSafeInteger(e.amount)) throw new BillingError(422, 'billing.amount_overflow', `amount ${e.amount} is beyond the exact integer range`);
+ }
+ if (e.amount == 0) continue; // loose: 0n == 0
const key = `${e.kind}|${e.owner == null ? '' : e.owner}|${e.currency}`;
const key = `${e.kind}|${e.owner == null ? '' : e.owner}|${e.currency}`;
- const m = merged.get(key) || { kind: e.kind, owner: e.owner == null ? null : e.owner, currency: e.currency, amount: 0 };
+ const m = merged.get(key) || { kind: e.kind, owner: e.owner == null ? null : e.owner, currency: e.currency, amount: zeroFor(e.currency) };
m.amount += e.amount;
m.amount += e.amount;
merged.set(key, m);
merged.set(key, m);
- sums.set(e.currency, (sums.get(e.currency) || 0) + e.amount);
+ const curSum = sums.get(e.currency) ?? zeroFor(e.currency);
+ sums.set(e.currency, curSum + e.amount);
}
}
for (const [currency, sum] of sums) {
for (const [currency, sum] of sums) {
- if (!Number.isSafeInteger(sum)) throw new BillingError(422, 'billing.amount_overflow', `entries in ${currency} overflow the exact integer range`);
- if (sum !== 0) throw new BillingError(500, 'ledger.unbalanced', `entries sum to ${sum} ${currency}, not zero`, { entries: spec.entries });
+ if (isBigCurrency(currency)) {
+ if (typeof sum !== 'bigint') throw new BillingError(500, 'ledger.unbalanced', `entries in ${currency} are not all BigInt`, { entries: spec.entries });
+ if (sum !== 0n) throw new BillingError(500, 'ledger.unbalanced', `entries sum to ${sum} ${currency}, not zero`, { entries: spec.entries });
+ } else {
+ if (!Number.isSafeInteger(sum)) throw new BillingError(422, 'billing.amount_overflow', `entries in ${currency} overflow the exact integer range`);
+ if (sum !== 0) throw new BillingError(500, 'ledger.unbalanced', `entries sum to ${sum} ${currency}, not zero`, { entries: spec.entries });
+ }
}
}
const id = newTxnId(ms);
const id = newTxnId(ms);
@@ -117,14 +145,19 @@ async function post(ctx, spec) {
@@ -117,14 +145,19 @@ async function post(ctx, spec) {
const insEntry = db.prepare('INSERT INTO ledger_entries (txn_id, account_id, amount) VALUES (?, ?, ?)');
const insEntry = db.prepare('INSERT INTO ledger_entries (txn_id, account_id, amount) VALUES (?, ?, ?)');
const bump = db.prepare('UPDATE account_balances SET balance = balance + ?, updated_at = ? WHERE account_id = ?');
const bump = db.prepare('UPDATE account_balances SET balance = balance + ?, updated_at = ? WHERE account_id = ?');
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 (m.amount === 0) continue;
- if (!Number.isSafeInteger(await balance(db, m) + m.amount)) {
+ const isBig = isBigCurrency(m.currency);
+ if (!isBig && !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;
+ const have = await balance(db, m);
+ const would = have + m.amount;
+ if (isBig) {
+ if (would > PG_BIGINT_MAX || would < -PG_BIGINT_MAX) throw new BillingError(422, 'billing.amount_overflow', `${m.kind}${m.owner ? `:${m.owner}` : ''} would leave the bigint range`);
+ } else if (!Number.isSafeInteger(would)) {
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 = await accountId(db, m, nowIso);
const acct = await accountId(db, m, nowIso);
await insEntry.run(id, acct, m.amount);
await insEntry.run(id, acct, m.amount);
await bump.run(m.amount, nowIso, acct);
await bump.run(m.amount, nowIso, acct);
@@ -147,7 +180,8 @@ async function money(db, fn) {
@@ -147,7 +180,8 @@ async function money(db, fn) {
async function requireFunds(db, account, amount, what = 'balance') {
async function requireFunds(db, account, amount, what = 'balance') {
const have = await balance(db, account);
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 });
+ const extra = isBigCurrency(account.currency) ? { available: String(have), required: String(amount) } : { available: have, required: amount };
+ throw new BillingError(409, 'billing.insufficient_funds', `insufficient ${what}: ${have} < ${amount} ${account.currency}`, extra);
}
}
return have;
return have;
}
}
@@ -161,7 +195,7 @@ function present(txn) {
@@ -161,7 +195,7 @@ function present(txn) {
from_subject: txn.from_subject || null, to_subject: txn.to_subject || null,
from_subject: txn.from_subject || null, to_subject: txn.to_subject || null,
provider: txn.provider || null, receipt_ref: txn.receipt_ref || null,
provider: txn.provider || null, receipt_ref: txn.receipt_ref || null,
actor: txn.actor, metadata: txn.metadata, created_at: txn.created_at,
actor: txn.actor, metadata: txn.metadata, created_at: txn.created_at,
- entries: (txn.entries || []).map((e) => ({ account: { kind: e.kind, owner: e.owner, currency: e.currency }, amount: e.amount })),
+ entries: (txn.entries || []).map((e) => ({ account: { kind: e.kind, owner: e.owner, currency: e.currency }, amount: isBigCurrency(e.currency) ? String(e.amount) : e.amount })),
};
};
}
}
@@ -6,9 +6,12 @@
@@ -6,9 +6,12 @@
* keys the Network's RS256 signing keys: openvibe-sdk/auth createNetworkKeys (OV_NETWORK_PUBLIC_KEY pinned,
* keys the Network's RS256 signing keys: openvibe-sdk/auth createNetworkKeys (OV_NETWORK_PUBLIC_KEY pinned,
* else the JWKS, retried every 30 s until it loads, refreshed every 15 minutes, a rotation followed on
* else the JWKS, retried every 30 s until it loads, refreshed every 15 minutes, a rotation followed on
* an unknown kid) — verifies service tokens and the console's sign-in
* an unknown kid) — verifies service tokens and the console's sign-in
- * identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch
- * { system: 'live', type: 'user', ids } with a client-credentials token
+ * identity Live user ids → canonical subjects, and subject ids → their public names, via
+ * POST /internal/identity/resolve-batch with a client-credentials token
* (audience openvibe.network, capability identity.subject.resolve)
* (audience openvibe.network, capability identity.subject.resolve)
+ * projects the projects a person administers (GET /internal/projects?subject=usr_…, capability
+ * network.project.member.read) and one membership (GET /internal/projects/:id/members/:subject),
+ * with Billing's own service token
*/
*/
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { createNetworkKeys } = require('openvibe-sdk/auth');
const { createNetworkKeys } = require('openvibe-sdk/auth');
@@ -44,6 +47,23 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -44,6 +47,23 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
return data.results;
return data.results;
}
}
+ /** Subject ids → Map(subject id → { username, display_name } | null), for public names only. */
+ async function profiles(subjectIds) {
+ const out = new Map();
+ const list = [...new Set(subjectIds.map(String))];
+ for (let i = 0; i < list.length; i += BATCH) {
+ const chunk = list.slice(i, i + BATCH);
+ const results = await post({ subject_ids: chunk });
+ for (const k of chunk) {
+ const p = results[k];
+ out.set(k, p && p.subject && p.subject.type === 'user'
+ ? { username: typeof p.username === 'string' ? p.username : null, display_name: typeof p.display_name === 'string' ? p.display_name : null }
+ : null);
+ }
+ }
+ return out;
+ }
+
/** Live user ids → Map(String(id) → subject id | null). */
/** Live user ids → Map(String(id) → subject id | null). */
async function resolveLiveUsers(liveIds) {
async function resolveLiveUsers(liveIds) {
const out = new Map();
const out = new Map();
@@ -58,7 +78,107 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -58,7 +78,107 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
}
}
return out;
return out;
}
}
- return { resolveLiveUsers };
+ return { resolveLiveUsers, profiles };
+}
+
+/**
+ * Network's project-membership reads, with Billing's service token. A person's own token never authorises them:
+ * Network states the role, and Billing only lists or moves money for admin/owner memberships of unarchived projects.
+ */
+const PROJECT_ADMINS = new Set(['admin', 'owner']);
+function createProjects(config, { fetchImpl = globalThis.fetch } = {}) {
+ const base = config.network.internalUrl;
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network',
+ scope: 'network.project.member.read',
+ fetchImpl,
+ });
+
+ async function get(path, { retried = false } = {}) {
+ const res = await fetchImpl(`${base}${path}`, {
+ method: 'GET',
+ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.status === 401 && !retried) { tokens.invalidate(); return await get(path, { retried: true }); }
+ const data = await res.json().catch(() => null);
+ if (res.status === 404) return { status: 404, data: null };
+ if (!res.ok || data == null) throw new Error(`network project read ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
+ return { status: res.status, data };
+ }
+
+ /** The person's memberships, filtered to the projects they administer (admin or owner, not archived). */
+ async function administered(subject) {
+ const out = await get(`/internal/projects?subject=${encodeURIComponent(subject)}`);
+ if (!Array.isArray(out.data.projects)) throw new Error('network project list: bad response');
+ return out.data.projects.filter((membership) => !membership.archived && PROJECT_ADMINS.has(membership.role));
+ }
+
+ /** One membership; null when the person is not a member (Network answers 404 for a non-member and an unknown project alike). */
+ async function member(projectId, subject) {
+ const out = await get(`/internal/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(subject)}`);
+ return out.status === 404 ? null : out.data;
+ }
+
+ return { administered, member };
+}
+
+/**
+ * Network's app registry: the name (and icon, when Network carries one) of a developer app, for the pay sheet.
+ * One read of the app's project (GET /internal/projects/:project_id, network.project.read). Best effort: a
+ * Network hiccup must not hide a charge, and Network's own pay sheet fills the name from dev_apps anyway.
+ */
+function createApps(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
+ const base = config.network.internalUrl;
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network',
+ scope: 'network.project.read',
+ fetchImpl,
+ });
+ const cache = new Map();
+ const TTL_MS = 60 * 1000;
+ let warned = false;
+
+ async function get(path, { retried = false } = {}) {
+ const res = await fetchImpl(`${base}${path}`, {
+ method: 'GET',
+ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.status === 401 && !retried) { tokens.invalidate(); return await get(path, { retried: true }); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || data == null) throw new Error(`network app read ${res.status}`);
+ return data;
+ }
+
+ /** { id, name, icon } for one app; name/icon are null when Network does not know it (or the read failed). */
+ async function app(projectId, appId) {
+ const key = `${projectId}:${appId}`;
+ const hit = cache.get(key);
+ if (hit && Date.now() - hit.at < TTL_MS) return hit.value;
+ let value = { id: appId, name: null, icon: null };
+ try {
+ const out = await get(`/internal/projects/${encodeURIComponent(projectId)}`);
+ const found = (out.apps || []).find((a) => a && a.id === appId);
+ if (found) value = { id: appId, name: found.name || null, icon: found.icon || found.icon_url || null };
+ } catch (err) {
+ if (!warned) {
+ warned = true;
+ // The error names neither a person nor a secret; it is a service permission or a Network outage.
+ if (log && log.warn) log.warn(`[Billing] app registry read failed (${err && err.message ? err.message : 'network'}); the pay sheet falls back to the app id`);
+ }
+ }
+ cache.set(key, { at: Date.now(), value });
+ return value;
+ }
+
+ return { app };
}
}
-module.exports = { createKeyProvider, createIdentity };
+module.exports = { createKeyProvider, createIdentity, createProjects, createApps };
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+'use strict';
+
+/**
+ * A person's own activity (D12, Network's /wallet): one row per currency part of every journal transaction that
+ * touched their Balance (`usd_credit`, user:<usr_id>) or their spendable Vibes (`user_credit`). A transaction that
+ * moved both (Get Vibes with Balance) has two rows with the same id, so the wallet can group them.
+ *
+ * Labels are the public words: Added money, AI usage, Got Vibes, Tipped @name, Paid <app>, Moved to <project>,
+ * Refund. Only a creator's public name is read from Network; no other subject id, email or project secret is in a
+ * row. Balance amounts are signed nano-USD decimal strings; Vibes are signed whole integers.
+ */
+const { fail, userSubject } = require('./common');
+
+const DEFAULT_LIMIT = 50;
+const MAX_LIMIT = 100;
+const PROVIDERS = { stripe: 'Stripe', paypal: 'PayPal', ccbill: 'CCBill', nowpayments: 'NOWPayments', powerchat: 'PowerChat' };
+const APP_NAME_MAX = 80;
+
+function parseJson(v) {
+ if (v == null) return {};
+ if (typeof v === 'object') return v;
+ try { return JSON.parse(v) || {}; } catch { return {}; }
+}
+
+function titleCase(s) {
+ return String(s || '').replace(/[_-]+/g, ' ').replace(/\b[a-z]/g, (c) => c.toUpperCase()).trim();
+}
+
+/** The model in a settlement attempt's deployment_id: "<provider>:<model>". */
+function modelOf(attempt) {
+ if (!attempt || typeof attempt.deployment_id !== 'string') return null;
+ const i = attempt.deployment_id.indexOf(':');
+ const model = i >= 0 ? attempt.deployment_id.slice(i + 1) : '';
+ return model ? model.slice(0, 120) : null;
+}
+
+function labelFor(t, { settlement, projectName, creatorName }) {
+ const m = parseJson(t.metadata);
+ if (t.type === 'credit' && m.credit === 'settle') {
+ const attempts = settlement && Array.isArray(settlement.attempts) ? settlement.attempts : [];
+ const model = modelOf(attempts[attempts.length - 1]);
+ const requests = attempts.length || 1;
+ return { label: 'AI usage', detail: [model, `${requests} request${requests === 1 ? '' : 's'}`].filter(Boolean).join(' · ') };
+ }
+ if (t.type === 'credit' && m.credit === 'topup') {
+ const provider = PROVIDERS[String(t.provider || m.provider || '').toLowerCase()] || titleCase(t.provider || m.provider || '');
+ return { label: 'Added money', detail: provider };
+ }
+ if (t.type === 'purchase' && m.balance === 'vibes_from_balance') {
+ const n = Number(m.vibes || 0);
+ return { label: 'Got Vibes', detail: n ? `${n} Vibes` : '' };
+ }
+ if ((t.type === 'purchase' && (m.vibes_charge || m.vibes_charge_id || m.charge === 'vibes_pay' || m.app_name)) || t.type === 'vibes_charge') {
+ const app = m.app_name || (m.app && typeof m.app === 'object' ? m.app.name : null) || (m.app && typeof m.app !== 'object' ? m.app : null);
+ const name = app ? String(app).slice(0, APP_NAME_MAX) : null;
+ return { label: name ? `Paid ${name}` : 'Paid an app', detail: m.description ? String(m.description).slice(0, 120) : '' };
+ }
+ if (t.type === 'purchase') {
+ const n = Number(m.bits || m.vibes || m.amount_bits || 0);
+ return { label: 'Got Vibes', detail: n ? `${n} Vibes` : '' };
+ }
+ if (t.type === 'balance_transfer') {
+ return { label: projectName ? `Moved to ${projectName}` : 'Moved to a project', detail: '' };
+ }
+ if (t.type === 'donation') {
+ return { label: creatorName ? `Tipped @${creatorName}` : 'Tipped a creator', detail: '' };
+ }
+ if (t.type === 'refund' || t.type === 'vibes_refund') return { label: 'Refund', detail: 'Returned' };
+ if (t.type === 'cashout_request' || t.type === 'cashout_paid') return { label: 'Cash out', detail: 'Payable' };
+ return { label: titleCase(t.type) || 'Activity', detail: '' };
+}
+
+/**
+ * GET /api/me/activity. `before` is the id of the last transaction on the previous page; the answer carries rows
+ * (newest first) and `next_before` when more exist.
+ */
+async function activity(ctx, rawSubject, { limit = DEFAULT_LIMIT, before = null } = {}) {
+ const db = ctx.db;
+ const subject = userSubject(rawSubject, 'subject');
+ const n = Number(limit);
+ const take = Number.isSafeInteger(n) && n > 0 ? Math.min(n, MAX_LIMIT) : DEFAULT_LIMIT;
+
+ let cursor = null;
+ if (before != null && before !== '') {
+ const id = String(before);
+ if (!/^txn_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) fail(422, 'billing.invalid_cursor', 'before must be a transaction id from this list');
+ cursor = await db.prepare('SELECT id, created_at FROM transactions WHERE id = ?').get(id);
+ if (!cursor) fail(422, 'billing.invalid_cursor', 'before names no transaction');
+ }
+
+ const touch = `((a.kind = 'usd_credit' AND a.currency = 'nano-usd' AND a.owner_subject = ?)
+ OR (a.kind = 'user_credit' AND a.currency = 'vibes-bits' AND a.owner_subject = ?))`;
+ const args = [`user:${subject}`, subject];
+ let where = `WHERE ${touch}`;
+ if (cursor) {
+ where += ' AND (t.created_at::timestamptz, t.id) < (?::timestamptz, ?)';
+ args.push(cursor.created_at, cursor.id);
+ }
+ const txns = await db.prepare(`SELECT DISTINCT t.id, t.created_at FROM transactions t
+ JOIN ledger_entries e ON e.txn_id = t.id JOIN accounts a ON a.id = e.account_id
+ ${where} ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...args, take + 1);
+ const page = txns.slice(0, take);
+ const more = txns.length > take;
+ if (!page.length) return { activity: [], next_before: null };
+
+ const ids = page.map((t) => t.id);
+ const holes = ids.map(() => '?').join(',');
+ const rows = await db.prepare(`SELECT t.id, t.type, t.created_at, t.metadata, t.provider, t.to_subject,
+ a.kind AS account_kind, e.amount::text AS amount
+ FROM transactions t JOIN ledger_entries e ON e.txn_id = t.id JOIN accounts a ON a.id = e.account_id
+ WHERE t.id IN (${holes}) AND ${touch}
+ ORDER BY t.created_at DESC, t.id DESC, a.kind`).all(...ids, `user:${subject}`, subject);
+ const settlements = await db.prepare(`SELECT journal_id, attempts FROM credit_settlements WHERE journal_id IN (${holes})`).all(...ids);
+ const settlementOf = new Map(settlements.map((s) => [s.journal_id, { attempts: parseJson(s.attempts) }]));
+
+ // Public names for the creators a tip names, and the person's project names for a transfer. Both are best-effort:
+ // a Network hiccup must not hide the person's own movements.
+ const donationTargets = [...new Set(rows.filter((r) => r.type === 'donation' && r.to_subject).map((r) => r.to_subject))];
+ const names = new Map();
+ if (donationTargets.length && ctx.network && typeof ctx.network.profiles === 'function') {
+ try { for (const [k, v] of await ctx.network.profiles(donationTargets)) names.set(k, v); } catch { /* fall back to "a creator" */ }
+ }
+ const projects = new Map();
+ if (rows.some((r) => r.type === 'balance_transfer') && ctx.projects && typeof ctx.projects.administered === 'function') {
+ try { for (const p of await ctx.projects.administered(subject)) projects.set(p.project_id, p.name); } catch { /* fall back to "a project" */ }
+ }
+
+ const byTxn = new Map();
+ for (const r of rows) {
+ const cur = byTxn.get(r.id) || { txn: r, nano: 0n, vibes: 0 };
+ if (r.account_kind === 'usd_credit') cur.nano += BigInt(String(r.amount));
+ else cur.vibes += Number(r.amount);
+ byTxn.set(r.id, cur);
+ }
+
+ const out = [];
+ for (const t of page) {
+ const cur = byTxn.get(t.id);
+ if (!cur) continue;
+ const meta = parseJson(cur.txn.metadata);
+ let projectName = null;
+ if (cur.txn.type === 'balance_transfer') projectName = projects.get(meta.to_project_id) || null;
+ const creator = names.get(cur.txn.to_subject);
+ const d = labelFor(cur.txn, { settlement: settlementOf.get(t.id), projectName, creatorName: creator && creator.username ? creator.username : null });
+ if (cur.nano !== 0n) out.push({ id: t.id, at: t.created_at, kind: 'balance', label: d.label, detail: d.detail, amount: cur.nano.toString() });
+ if (cur.vibes !== 0) out.push({ id: t.id, at: t.created_at, kind: 'vibes', label: d.label, detail: d.detail, amount: cur.vibes });
+ }
+ return { activity: out, next_before: more ? page[page.length - 1].id : null };
+}
+
+module.exports = { activity, labelFor };
new file mode 100644
new file mode 100644
@@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
+'use strict';
+
+/**
+ * Balance (D12): a person's or a project's prepaid USD, and the two ways a person moves it.
+ *
+ * GET /api/me/balance personal Balance plus the projects the person administers
+ * POST /api/me/balance/transfers personal Balance -> a project's Balance (atomic, idempotent, admin only)
+ * POST /api/me/vibes/from-balance personal Balance -> Vibes, at the pack value rate (atomic, idempotent)
+ *
+ * A person's Balance is usd_credit owned by user:<usr_id>; a project's is usd_credit owned by project:<prj_id>.
+ * Everything is integer nano-USD as BigInt. The two writes are one balanced journal transaction each and are
+ * idempotent by their idempotency key; a replay never moves money twice and a different request under the same
+ * key is refused. The source only ever spends available Balance (balance minus active holds), so a hold can never
+ * be spent out from under a transfer or a Vibes purchase.
+ */
+const { money, post, balance, iso, prefixedId } = require('../ledger');
+const { A, entry, fail, userSubject, positiveInt, isFrozen } = require('./common');
+const { A_INF, nano, heldOnAccount, ownerOf } = require('./credits');
+
+const KEY_RE = /^[A-Za-z0-9._:-]{8,128}$/;
+const NANO_PER_USD = 1_000_000_000n;
+
+function assertEnabled(config) {
+ if (!config.credits || !config.credits.enabled) fail(404, 'billing.not_enabled', 'Balance is not open yet');
+}
+
+async function assertNotFrozen(db) {
+ if (await isFrozen(db)) fail(503, 'billing.frozen', 'the economy is frozen: Balance writes are refused');
+}
+
+function idempotencyKey(v) {
+ const s = String(v == null ? '' : v).trim();
+ if (!KEY_RE.test(s)) fail(422, 'billing.invalid_input', 'idempotency_key must be 8-128 characters of letters, digits, dot, colon, underscore or dash');
+ return s;
+}
+
+/** One side of a Balance read. `projectId` null is the person's own Balance. */
+async function presentBalance(ctx, subject, projectId = null) {
+ const { db } = ctx;
+ const owner = ownerOf(subject, projectId);
+ const ownerRef = projectId ? { type: 'project', id: projectId } : { type: 'user', id: subject };
+ const row = await db.prepare(`SELECT a.id, b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
+ WHERE a.kind = 'usd_credit' AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(owner);
+ let bal = 0n;
+ let held = 0n;
+ if (row) {
+ bal = BigInt(String(row.balance));
+ held = await heldOnAccount(db, row.id);
+ }
+ return {
+ owner: ownerRef,
+ balance_nano_usd: String(bal),
+ held_nano_usd: String(held),
+ available_nano_usd: String(bal - held < 0n ? 0n : bal - held),
+ };
+}
+
+/**
+ * The person's Balance plus the Balance of every project they administer (role admin or owner, unarchived).
+ * Network answers the membership list with Billing's service token; a failed read is an error, never an empty
+ * list, because an empty list would hide a project from its admin.
+ */
+async function readBalance(ctx, subject) {
+ assertEnabled(ctx.config);
+ const memberships = await ctx.projects.administered(subject);
+ const projects = [];
+ for (const m of memberships) projects.push(await presentBalance(ctx, null, m.project_id));
+ projects.sort((a, b) => (a.owner.id < b.owner.id ? -1 : a.owner.id > b.owner.id ? 1 : 0));
+ return { personal: await presentBalance(ctx, subject), projects };
+}
+
+/** The person must be an admin (or owner) of an unarchived project before its Balance is touched. */
+async function assertProjectAdmin(ctx, subject, projectId) {
+ const m = await ctx.projects.member(projectId, subject);
+ if (!m || m.archived || !['admin', 'owner'].includes(m.role)) {
+ fail(403, 'billing.not_project_admin', 'you are not an admin of that project');
+ }
+ return m;
+}
+
+/**
+ * Move money from the person's Balance to a project's Balance. Both accounts are locked inside one serializable
+ * transaction; the source spends available (never held) Balance only, so it can never go below zero.
+ */
+async function transfer(ctx, { subject: rawSubject, toProjectId, amountNanoUsd, idempotencyKey: rawKey }) {
+ const { db, config } = ctx;
+ assertEnabled(config);
+ await assertNotFrozen(db);
+ const subject = userSubject(rawSubject, 'subject');
+ const projectId = String(toProjectId == null ? '' : toProjectId);
+ if (!/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(projectId)) fail(422, 'billing.invalid_input', 'to_project_id is a project id');
+ const amount = nano(amountNanoUsd, 'amount_nano_usd');
+ if (amount === 0n) fail(422, 'billing.invalid_amount', 'amount_nano_usd must be positive');
+ const key = idempotencyKey(rawKey);
+ await assertProjectAdmin(ctx, subject, projectId);
+
+ return await money(db, async () => {
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const check = async () => {
+ const row = await db.prepare('SELECT *, amount_nano_usd::text AS amount_text FROM balance_transfers WHERE idempotency_key = ?').get(key);
+ if (!row) return null;
+ if (row.subject !== subject || row.to_project_id !== projectId || BigInt(row.amount_text) !== amount) {
+ fail(409, 'billing.idempotency_key_reused', 'the same idempotency_key was used for another transfer');
+ }
+ return row;
+ };
+ const first = await check();
+ if (first) return await transferResult(ctx, first, subject, projectId, amount, true);
+
+ const fromId = await requireBalanceAccount(db, subject, null, nowIso);
+ // The account lock serializes two transfers with the same key, so the loser sees the winner's row.
+ const raced = await check();
+ if (raced) return await transferResult(ctx, raced, subject, projectId, amount, true);
+
+ const available = BigInt(String(await availableOf(db, fromId)));
+ if (available < amount) fail(402, 'billing.insufficient_funds', `insufficient Balance: ${available} < ${amount} nano-USD`, { available: String(available), required: String(amount) });
+ await requireBalanceAccount(db, null, projectId, nowIso);
+ const { txn } = await post(ctx, {
+ type: 'balance_transfer', idempotencyKey: `balance:transfer:${key}`, actor: { type: 'user', id: subject },
+ fromSubject: subject, toSubject: null,
+ entries: [entry(A_INF.credit(subject, null), -amount), entry(A_INF.credit(null, projectId), amount)],
+ metadata: { balance: 'transfer', idempotency_key: key, to_project_id: projectId, amount_nano_usd: String(amount) },
+ });
+ const row = await db.prepare(`INSERT INTO balance_transfers (id, idempotency_key, subject, to_project_id, amount_nano_usd, journal_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?::timestamptz) RETURNING *, amount_nano_usd::text AS amount_text`)
+ .get(prefixedId('btr', now), key, subject, projectId, amount, txn.id, nowIso);
+ return await transferResult(ctx, row, subject, projectId, amount, false);
+ });
+}
+
+async function transferResult(ctx, row, subject, projectId, amount, replay) {
+ return {
+ transfer_id: row.id,
+ transaction_id: row.journal_id,
+ replay,
+ amount_nano_usd: String(amount),
+ from: await presentBalance(ctx, subject),
+ to: await presentBalance(ctx, null, projectId),
+ };
+}
+
+/** Lock (creating if needed) a real usd_credit account. `projectId` null is the person's own Balance. */
+async function requireBalanceAccount(db, subject, projectId, nowIso) {
+ const owner = ownerOf(subject, projectId);
+ const existing = await db.prepare(`SELECT id FROM accounts WHERE kind = 'usd_credit' AND owner_subject = ? AND currency = 'nano-usd'`).get(owner);
+ if (!existing) {
+ await db.prepare(`INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES ('usd_credit', ?, 'nano-usd', ?)
+ ON CONFLICT DO NOTHING`).run(owner, nowIso);
+ const made = await db.prepare(`SELECT id FROM accounts WHERE kind = 'usd_credit' AND owner_subject = ? AND currency = 'nano-usd'`).get(owner);
+ await db.prepare('INSERT INTO account_balances (account_id, balance, updated_at) VALUES (?, 0, ?) ON CONFLICT DO NOTHING').run(made.id, nowIso);
+ }
+ const row = await db.prepare(`SELECT id FROM accounts WHERE kind = 'usd_credit' AND owner_subject = ? AND currency = 'nano-usd'`).get(owner);
+ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(row.id);
+ return row.id;
+}
+
+/** Balance minus active holds (the only money a write may spend). */
+async function availableOf(db, accountId) {
+ const row = await db.prepare('SELECT balance::text AS balance FROM account_balances WHERE account_id = ?').get(accountId);
+ const bal = BigInt(String(row ? row.balance : '0'));
+ return bal - await heldOnAccount(db, accountId);
+}
+
+/**
+ * Buy Vibes with Balance at the pack value rate: price_nano = ceil(vibes * 1e9 / bits_per_usd), exact to the
+ * nano-USD. The USD moves to platform_revenue:nano-usd; the Vibes are minted from fx_conversion in vibes-bits into
+ * the person's Vibes account, exactly as purchases.js mints a pack.
+ */
+async function buyVibes(ctx, { subject: rawSubject, vibes: rawVibes, idempotencyKey: rawKey }) {
+ const { db, config, rates } = ctx;
+ assertEnabled(config);
+ if (!config.vibes || !config.vibes.fromBalance) fail(404, 'billing.vibes_from_balance_disabled', 'Get Vibes with Balance is not open');
+ await assertNotFrozen(db);
+ const subject = userSubject(rawSubject, 'subject');
+ const vibes = positiveInt(rawVibes, 'vibes', rates.maxBits);
+ if (vibes < rates.minPurchaseBits) fail(422, 'billing.amount_too_small', `the minimum is ${rates.minPurchaseBits} Vibes`);
+ const bitsPerUsd = Number(rates.bitsPerUsd);
+ if (!Number.isInteger(bitsPerUsd) || bitsPerUsd <= 0) fail(503, 'billing.rates_unavailable', 'the Vibes rate is not configured');
+ const price = (BigInt(vibes) * NANO_PER_USD + BigInt(bitsPerUsd) - 1n) / BigInt(bitsPerUsd);
+ const key = idempotencyKey(rawKey);
+
+ return await money(db, async () => {
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const check = async () => {
+ const row = await db.prepare('SELECT *, vibes::text AS vibes_text, price_nano_usd::text AS price_text FROM vibes_from_balance WHERE idempotency_key = ?').get(key);
+ if (!row) return null;
+ if (row.subject !== subject || BigInt(row.vibes_text) !== BigInt(vibes)) {
+ fail(409, 'billing.idempotency_key_reused', 'the same idempotency_key was used for another Vibes purchase');
+ }
+ return row;
+ };
+ const first = await check();
+ if (first) return await vibesResult(ctx, first, subject, vibes, BigInt(first.price_text), BigInt(first.bits_per_usd), true);
+
+ const fromId = await requireBalanceAccount(db, subject, null, nowIso);
+ const raced = await check();
+ if (raced) return await vibesResult(ctx, raced, subject, vibes, BigInt(raced.price_text), BigInt(raced.bits_per_usd), true);
+
+ const available = BigInt(String(await availableOf(db, fromId)));
+ if (available < price) fail(402, 'billing.insufficient_funds', `insufficient Balance: ${available} < ${price} nano-USD for ${vibes} Vibes`, { available: String(available), required: String(price) });
+ const { txn } = await post(ctx, {
+ type: 'purchase', idempotencyKey: `balance:vibes:${key}`, actor: { type: 'user', id: subject },
+ fromSubject: subject, toSubject: subject, provider: 'balance',
+ entries: [
+ entry(A_INF.credit(subject, null), -price),
+ entry(A_INF.revenue(), price),
+ entry(A.fxBits(), -vibes),
+ entry(A.credit(subject), vibes),
+ ],
+ metadata: { balance: 'vibes_from_balance', idempotency_key: key, vibes, price_nano_usd: String(price), bits_per_usd: bitsPerUsd },
+ });
+ const row = await db.prepare(`INSERT INTO vibes_from_balance (id, idempotency_key, subject, vibes, price_nano_usd, bits_per_usd, journal_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?::timestamptz) RETURNING *, vibes::text AS vibes_text, price_nano_usd::text AS price_text`)
+ .get(prefixedId('vfb', now), key, subject, vibes, price, bitsPerUsd, txn.id, nowIso);
+ return await vibesResult(ctx, row, subject, vibes, price, BigInt(bitsPerUsd), false);
+ });
+}
+
+async function vibesResult(ctx, row, subject, vibes, price, bitsPerUsd, replay) {
+ return {
+ purchase_id: row.id,
+ transaction_id: row.journal_id,
+ replay,
+ vibes: Number(vibes),
+ price_nano_usd: String(price),
+ bits_per_usd: Number(bitsPerUsd),
+ balance: await presentBalance(ctx, subject),
+ vibes_credit: await balance(ctx.db, A.credit(subject)),
+ };
+}
+
+module.exports = { readBalance, presentBalance, transfer, buyVibes, assertProjectAdmin, idempotencyKey, availableOf };
@@ -4,6 +4,8 @@
@@ -4,6 +4,8 @@
* Cashouts (MONEY leaving the platform) and recycling (MONEY → CREDIT, ADR-012 rule 4).
* Cashouts (MONEY leaving the platform) and recycling (MONEY → CREDIT, ADR-012 rule 4).
*
*
* request() creator_payable → payouts_pending; escrow_until = now + escrowDays
* request() creator_payable → payouts_pending; escrow_until = now + escrowDays
+ * with project_id: project_payable:<prj> → payouts_pending:<the project's owner> (D12 Vibes Pay). The
+ * owner role is checked with Network's membership read; deny returns to the owner's creator_payable.
* approve() payouts_pending → out through the payout provider; needs a payout reference from
* approve() payouts_pending → out through the payout provider; needs a payout reference from
* the provider and is refused while the escrow period runs (rule 10)
* the provider and is refused while the escrow period runs (rule 10)
* deny() reverses the request: payouts_pending → creator_payable
* deny() reverses the request: payouts_pending → creator_payable
@@ -22,7 +24,7 @@ async function find(db, id) { return parse(await db.prepare('SELECT * FROM casho
@@ -22,7 +24,7 @@ async function find(db, id) { return parse(await db.prepare('SELECT * FROM casho
function present(c) {
function present(c) {
if (!c) return null;
if (!c) return null;
return {
return {
- id: c.id, subject: { type: 'user', id: c.subject }, amount_bits: c.amount_bits, value_cents: c.value_cents,
+ id: c.id, subject: { type: 'user', id: c.subject }, project_id: c.project_id || null, amount_bits: c.amount_bits, value_cents: c.value_cents,
status: c.status, payout_method: c.payout_method, escrow_until: c.escrow_until,
status: c.status, payout_method: c.payout_method, escrow_until: c.escrow_until,
request_txn: c.request_txn, settle_txn: c.settle_txn || null,
request_txn: c.request_txn, settle_txn: c.settle_txn || null,
payout_provider: c.payout_provider || null, payout_reference: c.payout_reference || null,
payout_provider: c.payout_provider || null, payout_reference: c.payout_reference || null,
@@ -56,23 +58,42 @@ async function request(ctx, input) {
@@ -56,23 +58,42 @@ async function request(ctx, input) {
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);
+ const projectId = input.projectId == null || input.projectId === '' ? null : String(input.projectId);
+ if (projectId && !/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(projectId)) fail(422, 'billing.invalid_input', 'project_id is a project id');
+ if (projectId) {
+ // D12: only the project's owner cashes out its Vibes, under the same escrow and payout rules as a creator.
+ const m = await ctx.projects.member(projectId, input.subject);
+ if (!m || m.archived || m.role !== 'owner') fail(403, 'billing.not_project_owner', "only the project's owner may cash out its Vibes");
+ }
+ const source = projectId ? A.projectPayable(projectId) : A.payable(input.subject);
return await money(db, async () => {
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 existing = await db.prepare('SELECT * FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
+ if (existing) {
+ const meta = JSON.parse(existing.metadata || '{}');
+ if (existing.from_subject !== input.subject || Number(meta.amount_bits) !== amount || (meta.project_id || null) !== projectId) {
+ fail(409, 'billing.idempotency_key_reused', 'the same idempotency_key was used for another cashout');
+ }
+ return { cashout: parse(await db.prepare('SELECT * FROM cashouts WHERE request_txn = ?').get(existing.id)), replay: true };
+ }
+ await requireFunds(db, source, amount, projectId ? 'the project\'s Vibes' : '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 entries = projectId
+ // The project's Vibes become the owner's payable in the same request, then the normal cashout escrow
+ // applies. A deny returns them to the owner's creator_payable (payouts are for the owner).
+ ? [entry(source, -amount), entry(A.payable(input.subject), amount), entry(A.payable(input.subject), -amount), entry(A.pending(input.subject), amount)]
+ : [entry(source, -amount), entry(A.pending(input.subject), amount)];
const { txn } = await 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,
actor: input.actor,
actor: input.actor,
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(), ...(projectId ? { project_id: projectId } : {}) },
});
});
- 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));
+ await db.prepare(`INSERT INTO cashouts (id, subject, project_id, amount_bits, value_cents, status, payout_method, escrow_until, request_txn, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'requested', ?, ?, ?, ?, ?)`).run(id, input.subject, projectId, amount, rates.valueCents(amount), JSON.stringify(method), escrowUntil, txn.id, iso(ms), iso(ms));
const cashout = await find(db, 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 } });
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 };
@@ -20,6 +20,15 @@ const A = {
@@ -20,6 +20,15 @@ const A = {
promo: (s) => ({ kind: 'promo_credit', owner: s, currency: BITS }),
promo: (s) => ({ kind: 'promo_credit', owner: s, currency: BITS }),
promoReserve: () => ({ kind: 'promo_reserve', owner: null, currency: BITS }),
promoReserve: () => ({ kind: 'promo_reserve', owner: null, currency: BITS }),
payable: (s) => ({ kind: 'creator_payable', owner: s, currency: BITS }),
payable: (s) => ({ kind: 'creator_payable', owner: s, currency: BITS }),
+ // D12 Vibes Pay: a project's received Vibes wait here until its owner cashes them out through the existing
+ // cashout path. A separate kind from creator_payable because the owner is a project, not a person: a personal
+ // cashout, account export or tip-refund must never see a project's money.
+ projectPayable: (projectId) => ({ kind: 'project_payable', owner: `project:${projectId}`, currency: BITS }),
+ // Sandbox Vibes (D12): the app's test pocket for a person, the app's test project wallet, and the issuer the
+ // grants come from. All three are test-only and never mixed with the real kinds above.
+ vibesSandbox: (appId, subject) => ({ kind: 'vibes_credit_sandbox', owner: `sandbox:${appId}:user:${subject}`, currency: BITS }),
+ projectPayableSandbox: (appId, projectId) => ({ kind: 'project_payable_sandbox', owner: `sandbox:${appId}:project:${projectId}`, currency: BITS }),
+ vibesSandboxIssuer: () => ({ kind: 'vibes_sandbox', owner: null, currency: BITS }),
pending: (s) => ({ kind: 'payouts_pending', owner: s, currency: BITS }),
pending: (s) => ({ kind: 'payouts_pending', owner: s, currency: BITS }),
clearing: (provider) => ({ kind: 'provider_clearing', owner: provider, currency: CENTS }),
clearing: (provider) => ({ kind: 'provider_clearing', owner: provider, currency: CENTS }),
revenue: (currency) => ({ kind: 'platform_revenue', owner: null, currency }),
revenue: (currency) => ({ kind: 'platform_revenue', owner: null, currency }),
new file mode 100644
new file mode 100644
@@ -0,0 +1,584 @@
@@ -0,0 +1,584 @@
+'use strict';
+
+/**
+ * Balance: the prepaid USD balance for metered services (AI Exchange D12). All amounts are integer nano-USD (1e-9 USD),
+ * represented as JS BigInt. A person's Balance is kind='usd_credit', currency='nano-usd', owner_subject='user:<usr_id>';
+ * a project's Balance is the same kind and currency owned by 'project:<prj_id>'. Holds are reservations; settlement
+ * moves the actual charge to platform_revenue:nano-usd and releases the unused reservation. Sandbox balances follow the
+ * same split under usd_credit_sandbox and are only available when BILLING_CREDITS_SANDBOX=1.
+ *
+ * Nothing here goes through chargeFor or the Vibes bits ledger.
+ */
+const { ids } = require('openvibe-contracts');
+const { enqueue } = require('../outbox');
+const { post, balance, requireFunds, money, iso, prefixedId } = require('../ledger');
+const { A, entry, fail, userSubject, positiveInt, text, isFrozen, MAX_RECEIPT_CENTS } = require('./common');
+const { valuesFromEnv, parseAmounts } = require('../config-store');
+
+const CURRENCY = 'nano-usd';
+// D9: 1 cent = 10,000,000 nano-USD. The checkout top-up is priced in whole cents at the edge.
+const NANO_PER_CENT = 10_000_000n;
+const ACTOR = { type: 'service', id: 'billing' };
+const ACTIVE_STATUSES = ['active', 'extended'];
+const TERMINAL_STATUSES = ['settled', 'released', 'expired'];
+
+/**
+ * The owner's Balance top-up terms: the only reader of the amounts, the fee and the top-up switch.
+ *
+ * D11: they come from Billing's own config namespace `billing.credits`, through the store on ctx, so the
+ * owner sets them from Network's AI tab and a change applies live. There is no default amount and no
+ * default fee: with either unset or invalid the result is `configured: false` and the person's route
+ * refuses (503 billing.topup_unconfigured). A context without a store (a unit test) reads the old env
+ * names, which are also the namespace's x-env pins while they are set.
+ *
+ * Returns { enabled, amountsCents: number[], feeBps: number|null, configured, problem }.
+ */
+function topupTerms(ctx) {
+ const values = ctx && ctx.configStore ? ctx.configStore.get() : valuesFromEnv(ctx && ctx.env);
+ const problems = [];
+ const enabled = values.topup === true;
+ let amountsCents = [];
+ const raw = values.topup_amounts_cents;
+ if (raw == null || (Array.isArray(raw) && !raw.length)) {
+ problems.push('topup_amounts_cents is not set');
+ } else {
+ const parsed = parseAmounts(raw);
+ if (parsed.empty) problems.push('topup_amounts_cents is not set');
+ else for (const s of parsed.invalid) problems.push(`topup_amounts_cents has an invalid amount "${s}"`);
+ amountsCents = parsed.values;
+ }
+ let feeBps = null;
+ const rawFee = values.topup_fee_bps == null ? '' : String(values.topup_fee_bps).trim();
+ if (!rawFee) {
+ problems.push('topup_fee_bps is not set');
+ } else if (!/^[0-9]+$/.test(rawFee) || !Number.isSafeInteger(Number(rawFee)) || Number(rawFee) > 10_000) {
+ problems.push(`topup_fee_bps must be an integer from 0 to 10000 (got "${rawFee}")`);
+ } else {
+ feeBps = Number(rawFee);
+ }
+ return { enabled, amountsCents, feeBps, configured: problems.length === 0, problem: problems.length ? problems.join('; ') : null };
+}
+
+/** The one owner format (D12): a person's Balance is user:<usr_id>; a project's is project:<prj_id>. */
+const ownerOf = (subject, projectId) => (projectId ? `project:${projectId}` : `user:${subject}`);
+
+const A_INF = {
+ credit: (subject, projectId) => ({ kind: 'usd_credit', owner: ownerOf(subject, projectId), currency: CURRENCY }),
+ // A person's or project's sandbox Balance (D10/D12): its own account, spent only by a hold marked sandbox, so a
+ // grant never becomes spending power on real supply.
+ sandboxCredit: (subject, projectId) => ({ kind: 'usd_credit_sandbox', owner: ownerOf(subject, projectId), currency: CURRENCY }),
+ // The issuer the grants come from and sandbox charges return to (owner null).
+ sandbox: () => ({ kind: 'usd_credit_sandbox', owner: null, currency: CURRENCY }),
+ revenue: () => ({ kind: 'platform_revenue', owner: null, currency: CURRENCY }),
+ // A top-up's money arrives from the checkout provider in nano-USD (D9), not through the Vibes usd-cents clearing.
+ clearing: (provider) => ({ kind: 'provider_clearing', owner: provider, currency: CURRENCY }),
+ // The spent part of a refunded top-up: a debt of the person, reported by reconciliation, never written off.
+ debt: () => ({ kind: 'credit_topup_debt', owner: null, currency: CURRENCY }),
+};
+
+const nano = (v, field) => {
+ if (typeof v === 'bigint') {
+ if (v < 0n) fail(422, 'billing.invalid_amount', `${field} must be non-negative`);
+ return v;
+ }
+ if (typeof v === 'number') {
+ if (!Number.isSafeInteger(v) || v < 0) fail(422, 'billing.invalid_amount', `${field} must be a safe integer`);
+ return BigInt(v);
+ }
+ if (typeof v === 'string' && /^[0-9]+$/.test(v)) {
+ return BigInt(v);
+ }
+ fail(422, 'billing.invalid_amount', `${field} must be integer nano-USD`);
+};
+
+const nanoNonNeg = nano;
+
+const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
+const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** A hold's SubjectRef: a person, or (with a project) an app acting for that project. */
+function parseSubject(v) {
+ if (v && typeof v === 'object' && v.type === 'app') {
+ if (typeof v.id !== 'string' || !APP_ID_RE.test(v.id)) fail(422, 'billing.invalid_subject', 'an app subject must be { type: "app", id: "app_…" }');
+ return { type: 'app', id: v.id };
+ }
+ if (v && typeof v === 'object' && v.type === 'user') return { type: 'user', id: userSubject(v) };
+ if (typeof v === 'string' && v.startsWith('user:')) return { type: 'user', id: v.slice(5) };
+ return { type: 'user', id: userSubject(v) };
+}
+
+function assertEnabled(config) {
+ if (!config.credits || !config.credits.enabled) fail(503, 'billing.not_enabled', 'BILLING_CREDITS is off');
+}
+
+function assertSandbox(config) {
+ if (!config.credits || !config.credits.sandbox) fail(503, 'billing.sandbox_disabled', 'BILLING_CREDITS_SANDBOX is off');
+}
+
+async function assertNotFrozen(db) {
+ if (await isFrozen(db)) fail(503, 'billing.frozen', 'the economy is frozen: inference writes are refused');
+}
+
+const KINDS = new Set(['usd_credit', 'usd_credit_sandbox']);
+
+/**
+ * The account row id for a (subject, project_id), selected FOR UPDATE. Creates the account and balance row if absent.
+ * `kind` is usd_credit (real Balance) or usd_credit_sandbox (that person's sandbox credit for the project).
+ */
+async function lockAccount(db, subject, projectId, nowIso, kind = 'usd_credit') {
+ if (!KINDS.has(kind)) throw new Error(`lockAccount: unknown kind ${kind}`);
+ const owner = ownerOf(subject, projectId);
+ let row = await db.prepare(`SELECT a.id FROM accounts a WHERE a.kind = ? AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(kind, owner);
+ if (!row) {
+ // Two first holds at once: one inserts, the other finds it (the account's unique key), never a 500.
+ await db.prepare(`INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES (?, ?, 'nano-usd', ?)
+ ON CONFLICT DO NOTHING`).run(kind, owner, nowIso);
+ row = await db.prepare(`SELECT a.id FROM accounts a WHERE a.kind = ? AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(kind, owner);
+ await db.prepare('INSERT INTO account_balances (account_id, balance, updated_at) VALUES (?, 0, ?) ON CONFLICT DO NOTHING').run(row.id, nowIso);
+ }
+ // Re-select FOR UPDATE to serialize concurrent holds on the same account.
+ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(row.id);
+ return row.id;
+}
+
+/** Sum of active holds for an account. */
+async function heldOnAccount(db, accountId) {
+ const r = await db.prepare(`SELECT COALESCE(SUM(held_nano_usd), 0)::text AS held FROM credit_holds
+ WHERE account_id = ? AND status IN ('active', 'extended')`).get(accountId);
+ return BigInt(String(r.held));
+}
+
+/** Available balance = credit balance - active holds. */
+async function available(db, accountId) {
+ const row = await db.prepare(`SELECT b.balance::text AS balance FROM account_balances b WHERE b.account_id = ?`).get(accountId);
+ const bal = BigInt(String(row.balance));
+ return bal - await heldOnAccount(db, accountId);
+}
+
+function presentHold(row, availableAfter = null) {
+ return {
+ hold_id: row.id,
+ subject: { type: row.subject_type || 'user', id: row.subject },
+ project_id: row.project_id,
+ credential_id: row.credential_id || null,
+ service: row.service || 'ai',
+ held_nano_usd: String(row.held_nano_usd),
+ // What the account has left to hold after this one (null when not read with the hold).
+ available_nano_usd: availableAfter == null ? null : String(availableAfter < 0n ? 0n : availableAfter),
+ max_nano_usd: row.max_nano_usd == null ? null : String(row.max_nano_usd),
+ rate_version: row.rate_version,
+ request_fingerprint: row.request_fingerprint,
+ lease_expires_at: row.lease_expires_at,
+ status: row.status,
+ created_at: row.created_at,
+ };
+}
+
+function presentSettlement(row) {
+ return {
+ settlement_id: row.id,
+ hold_id: row.hold_id,
+ subject: { type: row.subject_type || 'user', id: row.subject },
+ project_id: row.project_id,
+ charge_nano_usd: String(row.charge_nano_usd),
+ released_nano_usd: String(row.released_nano_usd),
+ shortfall_nano_usd: String(row.shortfall_nano_usd == null ? 0 : row.shortfall_nano_usd),
+ journal_id: row.journal_id,
+ usage: row.usage,
+ retail: row.retail,
+ supply_cost_nano_usd: String(row.supply_cost_nano_usd),
+ byok: !!row.byok,
+ attempts: row.attempts,
+ created_at: row.created_at,
+ };
+}
+
+/**
+ * Create or replay a hold. Idempotent by idempotency_key + request_fingerprint.
+ * Returns { hold, replay }.
+ */
+async function createHold(ctx, input) {
+ const { db, config } = ctx;
+ assertEnabled(config);
+ await assertNotFrozen(db);
+ const subject = parseSubject(input.subject);
+ const projectId = text(input.project_id, 'project_id');
+ if (projectId && !PROJECT_ID_RE.test(projectId)) fail(422, 'billing.invalid_input', 'project_id must be a project ULID');
+ // A personal hold (no project) always spends the person's own Balance. A project hold spends the project's.
+ if (!projectId && subject.type !== 'user') fail(422, 'billing.invalid_input', 'an app subject needs a project_id');
+ const maxNano = nanoNonNeg(input.max_nano_usd, 'max_nano_usd');
+ const rateVersion = text(input.rate_version, 'rate_version', 200) || fail(422, 'billing.invalid_input', 'rate_version is required');
+ const fingerprint = text(input.request_fingerprint, 'request_fingerprint', 1024) || fail(422, 'billing.invalid_input', 'request_fingerprint is required');
+ const idempotencyKey = text(input.idempotency_key, 'idempotency_key', 128) || fail(422, 'billing.invalid_input', 'idempotency_key is required');
+ const credentialId = input.credential_id ? text(input.credential_id, 'credential_id') : null;
+ // Which metered service spends the credit (D9): AI inference today; compute and others later.
+ const service = String(input.service == null ? 'ai' : input.service);
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(service)) fail(422, 'billing.invalid_input', 'service is a lowercase service name (ai, run, ...)');
+ const leaseSeconds = Math.max(1, Math.min(config.credits.leaseSeconds || 300, 86400));
+ // D10: a sandbox caller's hold draws on its sandbox credit only; a real hold never sees sandbox credit.
+ const sandbox = input.sandbox === true;
+ if (sandbox) assertSandbox(config);
+
+ return await money(db, async () => {
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const existing = await db.prepare('SELECT * FROM credit_holds WHERE idempotency_key = ?').get(idempotencyKey);
+ if (existing) {
+ if (existing.request_fingerprint !== fingerprint) fail(409, 'billing.hold_key_reused', 'the same idempotency_key with a different fingerprint is already used');
+ return { hold: presentHold(existing, await available(db, existing.account_id)), replay: true };
+ }
+ const accountId = await lockAccount(db, subject.id, projectId, nowIso, sandbox ? 'usd_credit_sandbox' : 'usd_credit');
+ // The same key at the same moment: the account lock serializes them, so the second sees the first's hold here.
+ const raced = await db.prepare('SELECT * FROM credit_holds WHERE idempotency_key = ?').get(idempotencyKey);
+ if (raced) {
+ if (raced.request_fingerprint !== fingerprint) fail(409, 'billing.hold_key_reused', 'the same idempotency_key with a different fingerprint is already used');
+ return { hold: presentHold(raced, await available(db, raced.account_id)), replay: true };
+ }
+ const avail = await available(db, accountId);
+ if (avail < maxNano) fail(402, 'billing.insufficient_funds', `insufficient ${sandbox ? 'sandbox ' : ''}Balance: ${avail} < ${maxNano} nano-USD`, { available: String(avail), required: String(maxNano) });
+
+ const holdId = prefixedId('hld', now);
+ const leaseExpires = iso(now + leaseSeconds * 1000);
+ const row = await db.prepare(`INSERT INTO credit_holds
+ (id, idempotency_key, subject, subject_type, project_id, credential_id, service, account_id, held_nano_usd, max_nano_usd, rate_version, request_fingerprint, lease_expires_at, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::timestamptz, 'active', ?::timestamptz, ?::timestamptz) RETURNING *`).get(
+ holdId, idempotencyKey, subject.id, subject.type, projectId, credentialId, service, accountId, maxNano, maxNano, rateVersion, fingerprint, leaseExpires, nowIso, nowIso);
+ return { hold: presentHold(row, avail - maxNano), replay: false };
+ });
+}
+
+/**
+ * Extend an active hold. Grows held_nano_usd by add_nano_usd; answers 402 if the account has no room.
+ */
+async function extendHold(ctx, input) {
+ const { db, config } = ctx;
+ assertEnabled(config);
+ await assertNotFrozen(db);
+ const holdId = text(input.hold_id, 'hold_id', 100) || fail(422, 'billing.invalid_input', 'hold_id is required');
+ const addNano = nanoNonNeg(input.add_nano_usd, 'add_nano_usd');
+
+ return await money(db, async () => {
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const hold = await db.prepare('SELECT * FROM credit_holds WHERE id = ? FOR UPDATE').get(holdId);
+ if (!hold) fail(404, 'billing.hold_not_found', `no hold ${holdId}`);
+ // Past its lease but not yet swept, a hold still counts against the balance, so it may still grow: the sweeper alone
+ // decides expiry (a settle that arrives late must not lose billable usage).
+ if (TERMINAL_STATUSES.includes(hold.status)) fail(409, 'billing.hold_terminal', `hold ${holdId} is already ${hold.status}`);
+
+ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(hold.account_id);
+ const avail = await available(db, hold.account_id);
+ if (avail < addNano) fail(402, 'billing.insufficient_funds', `cannot extend hold: ${avail} < ${addNano} nano-USD available`, { available: String(avail), required: String(addNano) });
+
+ const newHeld = BigInt(String(hold.held_nano_usd)) + addNano;
+ const row = await db.prepare(`UPDATE credit_holds SET held_nano_usd = ?, max_nano_usd = ?, status = 'extended', lease_expires_at = GREATEST(lease_expires_at, ?::timestamptz), updated_at = ?::timestamptz WHERE id = ? RETURNING *`).get(
+ newHeld, newHeld, iso(now + 60 * 1000), nowIso, holdId);
+ return { hold: presentHold(row) };
+ });
+}
+
+/** Compute retail charge from usage and rate (ceil once per component). */
+function computeCharge(usage, retail) {
+ const perM = retail.nano_usd_per_m;
+ const components = [
+ [BigInt(usage.input_tokens || 0), nano(perM.input, 'retail.nano_usd_per_m.input')],
+ [BigInt(usage.cached_read_tokens || 0), nano(perM.cached_read, 'retail.nano_usd_per_m.cached_read')],
+ [BigInt(usage.cache_write_tokens || 0), nano(perM.cache_write, 'retail.nano_usd_per_m.cache_write')],
+ [BigInt(usage.output_tokens || 0), nano(perM.output, 'retail.nano_usd_per_m.output')],
+ ];
+ let charge = 0n;
+ for (const [tokens, rate] of components) {
+ if (tokens === 0n || rate === 0n) continue;
+ // ceil(tokens * rate / 1_000_000)
+ const num = tokens * rate;
+ const div = num / 1_000_000n;
+ charge += (num % 1_000_000n === 0n ? div : div + 1n);
+ }
+ charge += nano(retail.per_request_nano_usd, 'retail.per_request_nano_usd');
+ return charge;
+}
+
+/**
+ * Settle a hold. Idempotent by idempotency_key. Charges the computed retail amount, releases the rest.
+ * Returns { settlement, replay }.
+ */
+async function settleHold(ctx, input) {
+ const { db, config } = ctx;
+ assertEnabled(config);
+ await assertNotFrozen(db);
+ const holdId = text(input.hold_id, 'hold_id', 100) || fail(422, 'billing.invalid_input', 'hold_id is required');
+ const idempotencyKey = text(input.idempotency_key, 'idempotency_key', 128) || fail(422, 'billing.invalid_input', 'idempotency_key is required');
+ const usage = input.usage || {};
+ const retail = input.retail || {};
+ const byok = !!input.byok;
+ const supplyCost = nano(input.supply_cost_nano_usd || '0', 'supply_cost_nano_usd');
+ const attempts = Array.isArray(input.attempts) ? input.attempts : [];
+
+ const tokenOk = (v) => Number.isSafeInteger(v) && v >= 0;
+ if (!tokenOk(usage.input_tokens) || !tokenOk(usage.cached_read_tokens) || !tokenOk(usage.cache_write_tokens) || !tokenOk(usage.output_tokens)) {
+ fail(422, 'billing.invalid_input', 'usage must have non-negative integer token counts');
+ }
+ if (!retail.version || typeof retail.nano_usd_per_m !== 'object' || typeof retail.per_request_nano_usd === 'undefined') {
+ fail(422, 'billing.invalid_input', 'retail must have version, nano_usd_per_m and per_request_nano_usd');
+ }
+
+ const charge = byok ? 0n : computeCharge(usage, retail);
+
+ return await money(db, async () => {
+ const now = ctx.now();
+ const nowIso = iso(now);
+
+ const replayOf = (row) => {
+ // A key names one settlement of one hold: the same key for another hold is a conflict, never its answer.
+ if (row.hold_id !== holdId) fail(409, 'billing.settle_key_reused', 'this idempotency_key already settled another hold');
+ return { settlement: presentSettlement(row), replay: true };
+ };
+ const existing = await db.prepare('SELECT * FROM credit_settlements WHERE idempotency_key = ?').get(idempotencyKey);
+ if (existing) return replayOf(existing);
+
+ const hold = await db.prepare('SELECT * FROM credit_holds WHERE id = ? FOR UPDATE').get(holdId);
+ if (!hold) fail(404, 'billing.hold_not_found', `no hold ${holdId}`);
+ // Two settles at once with one key: the hold lock serializes them; the second sees the first's settlement.
+ const raced = await db.prepare('SELECT * FROM credit_settlements WHERE idempotency_key = ?').get(idempotencyKey);
+ if (raced) return replayOf(raced);
+ if (TERMINAL_STATUSES.includes(hold.status)) fail(409, 'billing.hold_terminal', `hold ${holdId} is already ${hold.status}`);
+
+ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(hold.account_id);
+ const held = BigInt(String(hold.held_nano_usd));
+ // Usage beyond the hold (a stream longer than its estimate that was not extended) is taken from what the account
+ // still has, never below zero; what cannot be collected is recorded as a shortfall for reconciliation, so
+ // billable usage is never silently lost and the balance never goes negative.
+ let collected = charge;
+ let shortfall = 0n;
+ if (charge > held) {
+ const room = held + await available(db, hold.account_id); // the account's other room (this hold excluded)
+ if (charge > room) { collected = room; shortfall = charge - room; }
+ }
+ const released = collected >= held ? 0n : held - collected;
+
+ const sandbox = (await kindOf(db, hold.account_id)) === 'usd_credit_sandbox';
+ const { txn } = await post(ctx, {
+ type: 'credit', idempotencyKey: `credit:settle:${idempotencyKey}`, actor: input.actor || ACTOR,
+ fromSubject: hold.subject, toSubject: null, ...(sandbox ? { test: true } : {}),
+ entries: sandbox
+ ? [entry(A_INF.sandboxCredit(hold.subject, hold.project_id), -collected), entry(A_INF.sandbox(), collected)]
+ : [entry(A_INF.credit(hold.subject, hold.project_id), -collected), entry(A_INF.revenue(), collected)],
+ metadata: {
+ credit: 'settle', hold_id: holdId, project_id: hold.project_id, credential_id: hold.credential_id,
+ usage, retail, byok, supply_cost_nano_usd: String(supplyCost), attempts, released_nano_usd: String(released),
+ charge_nano_usd: String(charge), shortfall_nano_usd: String(shortfall),
+ },
+ });
+
+ await db.prepare(`UPDATE credit_holds SET status = 'settled', held_nano_usd = 0, updated_at = ?::timestamptz WHERE id = ?`).run(nowIso, holdId);
+ const row = await db.prepare(`INSERT INTO credit_settlements
+ (id, hold_id, idempotency_key, subject, subject_type, project_id, account_id, charge_nano_usd, released_nano_usd, shortfall_nano_usd, journal_id, usage, retail, supply_cost_nano_usd, byok, attempts, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?, ?::jsonb, ?::timestamptz) RETURNING *`).get(
+ prefixedId('stl', now), holdId, idempotencyKey, hold.subject, hold.subject_type || 'user', hold.project_id, hold.account_id,
+ collected, released, shortfall, txn.id, JSON.stringify(usage), JSON.stringify(retail), supplyCost, byok, JSON.stringify(attempts), nowIso);
+ // billing.credit.settled@1, in the same transaction: for aggregates only (no subject, project or account).
+ const answered = attempts.filter((a) => a && !a.error_code).pop() || attempts[attempts.length - 1] || null;
+ await enqueue(ctx, {
+ event_type: 'billing.credit.settled', subject: { type: 'credit-settlement', id: row.id },
+ payload: {
+ settlement_id: row.id, hold_id: holdId, service: hold.service || 'ai', settled_at: nowIso,
+ deployment_id: answered && answered.deployment_id ? String(answered.deployment_id) : null,
+ provider_access: answered && answered.provider_access ? answered.provider_access : null,
+ rate_version: String(retail.version),
+ usage: { input_tokens: usage.input_tokens, cached_read_tokens: usage.cached_read_tokens, cache_write_tokens: usage.cache_write_tokens, output_tokens: usage.output_tokens },
+ charge_nano_usd: String(collected), supply_cost_nano_usd: String(supplyCost), shortfall_nano_usd: String(shortfall),
+ released_nano_usd: String(released), byok, sandbox,
+ },
+ });
+ return { settlement: presentSettlement(row), replay: false };
+ });
+}
+
+/**
+ * Release a hold with nothing charged. Idempotent: a replay returns the same result.
+ */
+async function releaseHold(ctx, input) {
+ const { db, config } = ctx;
+ assertEnabled(config);
+ await assertNotFrozen(db);
+ const holdId = text(input.hold_id, 'hold_id', 100) || fail(422, 'billing.invalid_input', 'hold_id is required');
+
+ return await money(db, async () => {
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const hold = await db.prepare('SELECT * FROM credit_holds WHERE id = ? FOR UPDATE').get(holdId);
+ if (!hold) fail(404, 'billing.hold_not_found', `no hold ${holdId}`);
+ if (hold.status === 'released') return { hold_id: holdId, released_nano_usd: String(hold.max_nano_usd) };
+ if (hold.status === 'settled') fail(409, 'billing.hold_terminal', `hold ${holdId} is already settled`);
+ if (TERMINAL_STATUSES.includes(hold.status)) fail(409, 'billing.hold_terminal', `hold ${holdId} is already ${hold.status}`);
+
+ const released = BigInt(String(hold.held_nano_usd));
+ await db.prepare(`UPDATE credit_holds SET status = 'released', held_nano_usd = 0, updated_at = ?::timestamptz WHERE id = ?`).run(nowIso, holdId);
+ return { hold_id: holdId, released_nano_usd: String(released) };
+ });
+}
+
+/**
+ * Release holds whose lease has expired. Called by the scheduled sweeper.
+ */
+async function sweepExpired(ctx) {
+ const { db, config } = ctx;
+ if (!config.credits || !config.credits.enabled) return { released: 0 };
+ if (await isFrozen(db)) return { released: 0, frozen: true };
+ const nowIso = iso(ctx.now());
+ const due = await db.prepare(`SELECT id FROM credit_holds WHERE status IN ('active', 'extended') AND lease_expires_at <= ?::timestamptz ORDER BY lease_expires_at, id FOR UPDATE`).all(nowIso);
+ let released = 0;
+ for (const { id } of due) {
+ await db.prepare(`UPDATE credit_holds SET status = 'expired', held_nano_usd = 0, updated_at = ?::timestamptz WHERE id = ? AND status IN ('active', 'extended')`).run(nowIso, id);
+ released++;
+ }
+ return { released };
+}
+
+/**
+ * Sandbox grant: add synthetic inference credit to an account. Only when BILLING_CREDITS_SANDBOX=1.
+ */
+async function sandboxGrant(ctx, input) {
+ const { db, config } = ctx;
+ assertEnabled(config);
+ assertSandbox(config);
+ await assertNotFrozen(db);
+ const subject = parseSubject(input.subject);
+ const projectId = text(input.project_id, 'project_id');
+ if (projectId && !PROJECT_ID_RE.test(projectId)) fail(422, 'billing.invalid_input', 'project_id must be a project ULID');
+ // A personal hold (no project) always spends the person's own Balance. A project hold spends the project's.
+ if (!projectId && subject.type !== 'user') fail(422, 'billing.invalid_input', 'an app subject needs a project_id');
+ const amount = nanoNonNeg(input.amount_nano_usd, 'amount_nano_usd');
+ if (amount === 0n) fail(422, 'billing.invalid_amount', 'amount_nano_usd must be positive');
+ const reason = text(input.reason, 'reason', 500) || 'sandbox grant';
+
+ return await money(db, async () => {
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const accountId = await lockAccount(db, subject.id, projectId, nowIso, 'usd_credit_sandbox');
+ const grantId = prefixedId('igr', now);
+ const { txn } = await post(ctx, {
+ type: 'credit', idempotencyKey: `credit:sandbox:${grantId}`, actor: input.actor || ACTOR, toSubject: subject.id,
+ test: true,
+ entries: [entry(A_INF.sandbox(), -amount), entry(A_INF.sandboxCredit(subject.id, projectId), amount)],
+ metadata: { credit: 'sandbox_grant', grant_id: grantId, project_id: projectId, reason, amount_nano_usd: String(amount) },
+ });
+ await db.prepare(`INSERT INTO credit_sandbox_grants (id, subject, project_id, account_id, amount_nano_usd, journal_id, reason, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?::timestamptz)`).run(grantId, subject.id, projectId, accountId, amount, txn.id, reason, nowIso);
+ return { grant_id: grantId, amount_nano_usd: String(amount), balance_nano_usd: String(await balance(db, A_INF.sandboxCredit(subject.id, projectId))) };
+ });
+}
+
+/**
+ * A person's Balance statement (GET /api/me/credits): the balance, what is held now, what is available, the last 50
+ * top-ups beside the last 50 charges (no prompt or answer is ever here: only counts, prices and ids). With a
+ * projectId it reads that project's Balance instead of the person's. A top-up's reversal shows what was clawed back
+ * and what remains a debt.
+ */
+async function statement(ctx, subject, projectId = null, { limit = 50 } = {}) {
+ const { db, config } = ctx;
+ assertEnabled(config);
+ const owner = ownerOf(subject, projectId);
+ const acct = await db.prepare(`SELECT a.id, b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
+ WHERE a.kind = 'usd_credit' AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(owner);
+ // Sandbox credit is shown apart (D10): it never pays for a real request, so it is never part of the balance.
+ const box = await db.prepare(`SELECT a.id, b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
+ WHERE a.kind = 'usd_credit_sandbox' AND a.owner_subject = ? AND a.currency = 'nano-usd'`).get(owner);
+ let sandbox = null;
+ if (box) {
+ const boxHeld = await heldOnAccount(db, box.id);
+ const boxBal = BigInt(String(box.balance));
+ sandbox = { balance_nano_usd: String(boxBal), held_nano_usd: String(boxHeld), available_nano_usd: String(boxBal - boxHeld < 0n ? 0n : boxBal - boxHeld) };
+ }
+ const ownerRef = projectId ? { type: 'project', id: projectId } : { type: 'user', id: subject };
+ if (!acct) return { owner: ownerRef, project_id: projectId || null, balance_nano_usd: '0', held_nano_usd: '0', available_nano_usd: '0', sandbox, topups: [], charges: [] };
+ const held = await heldOnAccount(db, acct.id);
+ const rows = await db.prepare(`SELECT s.*, h.service FROM credit_settlements s JOIN credit_holds h ON h.id = s.hold_id
+ WHERE s.account_id = ? ORDER BY s.created_at DESC, s.id DESC LIMIT ?`).all(acct.id, Math.max(1, Math.min(200, limit)));
+ const tops = await db.prepare(`SELECT t.id, t.created_at, t.provider, t.receipt_ref, t.metadata FROM transactions t
+ WHERE json_extract(t.metadata, '$.credit') = 'topup'
+ AND EXISTS (SELECT 1 FROM ledger_entries e WHERE e.txn_id = t.id AND e.account_id = ?)
+ ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(acct.id, Math.max(1, Math.min(200, limit)));
+ const topIds = tops.map((r) => r.id);
+ const revs = topIds.length ? await db.prepare(`SELECT r.reverses_txn, r.type, r.created_at,
+ json_extract(r.metadata, '$.cents') AS cents, json_extract(r.metadata, '$.clawed_nano_usd') AS clawed,
+ json_extract(r.metadata, '$.debt_nano_usd') AS debt
+ FROM transactions r WHERE r.reverses_txn IN (${topIds.map(() => '?').join(',')}) ORDER BY r.created_at, r.id`).all(...topIds) : [];
+ const reversed = new Map();
+ for (const r of revs) {
+ const o = reversed.get(r.reverses_txn) || { cents: 0, clawed: 0n, debt: 0n };
+ o.cents += Number(r.cents || 0);
+ o.clawed += BigInt(r.clawed || 0);
+ o.debt += BigInt(r.debt || 0);
+ reversed.set(r.reverses_txn, o);
+ }
+ const bal = BigInt(String(acct.balance));
+ return {
+ owner: ownerRef,
+ project_id: projectId || null,
+ balance_nano_usd: String(bal), held_nano_usd: String(held), available_nano_usd: String(bal - held < 0n ? 0n : bal - held),
+ sandbox,
+ topups: tops.map((r) => {
+ const m = parseJson(r.metadata);
+ const rev = reversed.get(r.id) || { cents: 0, clawed: 0n, debt: 0n };
+ const paid = Number(m.paid_cents || 0);
+ return {
+ at: r.created_at, transaction_id: r.id, provider: r.provider, receipt_ref: r.receipt_ref || null,
+ amount_nano_usd: String(m.amount_nano_usd || '0'), fee_nano_usd: String(m.fee_nano_usd || '0'),
+ paid_cents: paid, fee_bps: Number(m.fee_bps || 0),
+ reversed_cents: rev.cents, refunded_nano_usd: String(rev.clawed), debt_nano_usd: String(rev.debt),
+ status: rev.cents <= 0 ? 'settled' : (rev.cents >= paid ? (rev.debt > 0n ? 'refunded_with_debt' : 'refunded') : 'partially_refunded'),
+ };
+ }),
+ charges: rows.map((r) => {
+ const attempts = Array.isArray(r.attempts) ? r.attempts : [];
+ const last = attempts.length ? attempts[attempts.length - 1] : null;
+ return {
+ at: r.created_at, settlement_id: r.id, service: r.service || 'ai', model: modelOfAttempt(last),
+ charge_nano_usd: String(r.charge_nano_usd), shortfall_nano_usd: String(r.shortfall_nano_usd || 0), byok: !!r.byok,
+ usage: r.usage, rate_version: r.retail && r.retail.version ? String(r.retail.version) : null,
+ };
+ }),
+ };
+}
+
+/** JSON from a text column, never a throw. */
+function parseJson(v) {
+ if (v == null) return {};
+ if (typeof v === 'object') return v;
+ try { return JSON.parse(v) || {}; } catch { return {}; }
+}
+
+/** The model of a settle attempt: the contract's deployment_id is "<provider>:<model>" (AI's form). */
+function modelOfAttempt(a) {
+ if (!a || typeof a.deployment_id !== 'string') return null;
+ const i = a.deployment_id.indexOf(':');
+ const model = i >= 0 ? a.deployment_id.slice(i + 1) : '';
+ return model ? model.slice(0, 160) : null;
+}
+
+/** An account's kind (usd_credit or usd_credit_sandbox for a hold's account). */
+async function kindOf(db, accountId) {
+ const row = await db.prepare('SELECT kind FROM accounts WHERE id = ?').get(accountId);
+ return row ? row.kind : null;
+}
+
+/** Read a hold by id. */
+async function findHold(db, id) {
+ const row = await db.prepare('SELECT * FROM credit_holds WHERE id = ?').get(id);
+ return row ? presentHold(row) : null;
+}
+
+/** Read a settlement by hold id or id. */
+async function findSettlement(db, { holdId, id } = {}) {
+ const row = holdId
+ ? await db.prepare('SELECT * FROM credit_settlements WHERE hold_id = ?').get(holdId)
+ : await db.prepare('SELECT * FROM credit_settlements WHERE id = ?').get(id);
+ return row ? presentSettlement(row) : null;
+}
+
+module.exports = {
+ createHold, extendHold, settleHold, releaseHold, sweepExpired, sandboxGrant, statement,
+ findHold, findSettlement, computeCharge, available, topupTerms, A_INF, CURRENCY, NANO_PER_CENT,
+ ownerOf, parseSubject, PROJECT_ID_RE, nano, heldOnAccount,
+};
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
*/
*/
const { prefixedId, iso } = require('../ledger');
const { prefixedId, iso } = require('../ledger');
const { fail, positiveInt, userSubject } = require('./common');
const { fail, positiveInt, userSubject } = require('./common');
+const { NANO_PER_CENT } = require('./credits');
const tiers = require('./tiers');
const tiers = require('./tiers');
function parse(row) {
function parse(row) {
@@ -84,6 +85,23 @@ async function create(ctx, input) {
@@ -84,6 +85,23 @@ async function create(ctx, input) {
return await find(db, row.id);
return await find(db, row.id);
}
}
+/**
+ * A Balance top-up intent (D9). Created only by the signed-in person's own route (ops/topups.js), never by
+ * /api/v1: the amount and the fee were validated against the owner's configured list first. amount_cents is
+ * what the provider charges (the top-up plus the fee); the top-up itself is metadata.topup_amount_cents.
+ */
+async function createCreditsTopup(ctx, { provider, subject, projectId, amountCents, feeCents, feeBps }) {
+ const ms = ctx.now();
+ const nowIso = iso(ms);
+ const id = prefixedId('pi', ms);
+ const metadata = { project_id: projectId || null, topup_amount_cents: amountCents, topup_fee_cents: feeCents, topup_fee_bps: feeBps };
+ await ctx.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, 'credits_topup', ?, NULL, ?, ?, 0, NULL, 0, 'created', ?, ?, ?)`)
+ .run(id, provider, subject, amountCents + feeCents, feeCents, JSON.stringify(metadata), nowIso, nowIso);
+ return await find(ctx.db, id);
+}
+
/**
/**
* An intent imported from a Live payment order that Live had already credited before the cutover.
* An intent imported from a Live payment order that Live had already credited before the cutover.
* Its money effect lives in the imported history/opening balances, not in a Billing settlement, so
* Its money effect lives in the imported history/opening balances, not in a Billing settlement, so
@@ -115,15 +133,26 @@ async function markSettled(ctx, id, txnId) {
@@ -115,15 +133,26 @@ async function markSettled(ctx, id, txnId) {
function present(i) {
function present(i) {
if (!i) return null;
if (!i) return null;
+ const m = i.metadata || {};
return {
return {
id: i.id, provider: i.provider, provider_ref: i.provider_ref, kind: i.kind,
id: i.id, provider: i.provider, provider_ref: i.provider_ref, kind: i.kind,
subject: { type: 'user', id: i.subject }, streamer: i.streamer_subject ? { type: 'user', id: i.streamer_subject } : null,
subject: { type: 'user', id: i.subject }, streamer: i.streamer_subject ? { type: 'user', id: i.streamer_subject } : null,
amount_cents: i.amount_cents, fee_cents: i.fee_cents, bits: i.bits, route: i.route, auto_renew: i.auto_renew,
amount_cents: i.amount_cents, fee_cents: i.fee_cents, bits: i.bits, route: i.route, auto_renew: i.auto_renew,
status: i.status, settled_txn: i.settled_txn || null, created_at: i.created_at,
status: i.status, settled_txn: i.settled_txn || null, created_at: i.created_at,
...(i.kind === 'subscription' ? { tier: Number((i.metadata && i.metadata.tier) || 1) } : {}),
...(i.kind === 'subscription' ? { tier: Number((i.metadata && i.metadata.tier) || 1) } : {}),
- // PowerChat checkouts carry this in app_ref; the webhook echoes it back.
- checkout_ref: i.provider === 'powerchat' ? `${i.kind === 'purchase' ? 'pcorder' : 'pcsub'}:${i.id}` : undefined,
+ // A Balance top-up: the project it funds (null for the person's own Balance), the amount added and the fee.
+ ...(i.kind === 'credits_topup' ? {
+ project_id: m.project_id || null,
+ topup: {
+ amount_cents: Number(m.topup_amount_cents || 0), fee_cents: Number(m.topup_fee_cents || 0),
+ fee_bps: Number(m.topup_fee_bps || 0), amount_nano_usd: String(BigInt(m.topup_amount_cents || 0) * NANO_PER_CENT),
+ },
+ } : {}),
+ // PowerChat checkouts carry this in app_ref; the webhook echoes it back. A Balance top-up uses pccredit.
+ checkout_ref: i.provider === 'powerchat'
+ ? `${i.kind === 'purchase' ? 'pcorder' : i.kind === 'subscription' ? 'pcsub' : 'pccredit'}:${i.id}`
+ : undefined,
};
};
}
}
-module.exports = { find, findByProviderRef, create, setProviderRef, mergeMetadata, setStatus, markSettled, present, parse, settledInLive, refuseIfSettledInLive };
+module.exports = { find, findByProviderRef, create, createCreditsTopup, setProviderRef, mergeMetadata, setStatus, markSettled, present, parse, settledInLive, refuseIfSettledInLive };
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
const { post, getTxn, balance, money } = require('../ledger');
const { post, getTxn, balance, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { enqueue } = require('../outbox');
const { A, entry, fail, text } = require('./common');
const { A, entry, fail, text } = require('./common');
+const { A_INF, NANO_PER_CENT } = require('./credits');
const { summary } = require('./purchases');
const { summary } = require('./purchases');
const intents = require('./intents');
const intents = require('./intents');
@@ -68,6 +69,32 @@ async function reverseReceipt(ctx, input) {
@@ -68,6 +69,32 @@ async function reverseReceipt(ctx, input) {
}
}
Object.assign(meta, { bits_reversed: b, bits_clawed_back: clawed, unrecovered_bits: unrecovered });
Object.assign(meta, { bits_reversed: b, bits_clawed_back: clawed, unrecovered_bits: unrecovered });
review = unrecovered > 0;
review = unrecovered > 0;
+ } else if (orig.type === 'credit' && orig.metadata.credit === 'topup') {
+ // A Balance top-up (D9/D12) is reversed in nano-USD: the part of the credited amount this refund covers is
+ // debited from usd_credit by what the account still holds (never below zero). The spent part is the
+ // person's debt, in its own ledger account and a row reconciliation reports; it is never written off.
+ const projectId = orig.metadata.project_id == null || orig.metadata.project_id === '' ? null : String(orig.metadata.project_id);
+ const amountNano = BigInt(orig.metadata.amount_nano_usd || 0);
+ if (amountNano <= 0n) fail(422, 'billing.not_reversible', `top-up transaction ${orig.id} has no Balance amount`);
+ const totalPaid = Number(orig.metadata.paid_cents) || paid;
+ const cumBefore = (amountNano * BigInt(done)) / BigInt(totalPaid);
+ const cumAfter = (amountNano * BigInt(done + cents)) / BigInt(totalPaid);
+ const creditPart = cumAfter - cumBefore;
+ const held = await balance(db, A_INF.credit(orig.to_subject, projectId));
+ const clawed = creditPart <= 0n || held <= 0n ? 0n : (held < creditPart ? held : creditPart);
+ const debt = creditPart - clawed;
+ const centsNano = BigInt(cents) * NANO_PER_CENT;
+ const revenuePart = centsNano - creditPart;
+ entries.push(entry(A_INF.clearing(orig.provider), centsNano));
+ if (clawed > 0n) entries.push(entry(A_INF.credit(orig.to_subject, projectId), -clawed));
+ if (revenuePart > 0n) entries.push(entry(A_INF.revenue(), -revenuePart));
+ if (debt > 0n) entries.push(entry(A_INF.debt(), -debt));
+ Object.assign(meta, {
+ credit: 'topup_refund', credit_topup: true, project_id: projectId, amount_nano_usd: String(amountNano), paid_nano_usd: String(centsNano),
+ credit_revoked_nano_usd: String(creditPart), clawed_nano_usd: String(clawed), debt_nano_usd: String(debt),
+ revenue_reversed_nano_usd: String(revenuePart),
+ });
+ review = debt > 0n;
} else if (orig.type === 'subscription' || orig.type === 'donation') {
} else if (orig.type === 'subscription' || orig.type === 'donation') {
if (orig.entries.length) {
if (orig.entries.length) {
entries.push(entry(A.clearing(orig.provider), cents), entry(lossOrRefund, -cents));
entries.push(entry(A.clearing(orig.provider), cents), entry(lossOrRefund, -cents));
@@ -95,6 +122,12 @@ async function reverseReceipt(ctx, input) {
@@ -95,6 +122,12 @@ async function reverseReceipt(ctx, input) {
sourceEventId: input.sourceEventId,
sourceEventId: input.sourceEventId,
metadata: { ...meta, rates: rates.snapshot() },
metadata: { ...meta, rates: rates.snapshot() },
});
});
+ if (orig.type === 'credit' && orig.metadata.credit === 'topup' && BigInt(meta.debt_nano_usd || 0) > 0n) {
+ await require('./topups').recordDebt(ctx, {
+ topupTxnId: orig.id, transactionId: txn.id, reversalRef: input.reversalRef || '', subject: orig.to_subject,
+ projectId: meta.project_id, provider: orig.provider, amountNano: BigInt(meta.debt_nano_usd), reason: kind,
+ });
+ }
if (orig.type === 'subscription') revoked = await require('./subscriptions').revokeForTxn(ctx, orig.id, kind);
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');
if (done + cents >= paid && orig.metadata.intent_id) await intents.setStatus(ctx, orig.metadata.intent_id, 'refunded');
await enqueue(ctx, {
await enqueue(ctx, {
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+'use strict';
+
+/**
+ * Balance top-up (D9/D12): a person pays USD through the existing checkout provider and the paid webhook credits
+ * their usd_credit account, at nano-USD precision (1 cent = 10,000,000 nano-USD). Without a project_id it credits the
+ * person's own Balance (owner user:<usr_id>); with one it credits that project's Balance (owner project:<prj_id>). The
+ * fee the owner configured is charged on top and booked to platform_revenue.
+ *
+ * Everything is behind BILLING_CREDITS_TOPUP (off). The owner sets every allowed amount and the fee through
+ * topupTerms(ctx) in ops/credits.js, the one reader of the terms; there is no default amount and no default fee.
+ * With either unset or invalid the person's route answers 503 billing.topup_unconfigured, and a paid event that
+ * arrives while the feature is off/unconfigured is recorded for review and moves no money.
+ *
+ * Every adapter maps its own verified delivery to the one `credits_topup` plan (providers/index.js applyPlan), so a
+ * PowerChat tip, a Stripe checkout, a PayPal capture, a CCBill sale and a NOWPayments payment all settle through
+ * settle() below. No adapter carries Balance logic beyond that mapping.
+ *
+ * Journal on a paid top-up (nano-usd, all balanced):
+ * provider_clearing:<provider> -(what was paid)
+ * usd_credit:user:<usr_id> (or usd_credit:project:<prj_id>) +amount
+ * platform_revenue +fee (and any overpayment)
+ *
+ * The receipt reference is unique in the journal and the intent is marked settled in the same transaction, so a
+ * provider redelivery (same event id or a new one for the same payment) credits exactly once. Refunds and
+ * chargebacks go through ops/reversals.js.
+ */
+const { post, getTxn, money, prefixedId, iso } = require('../ledger');
+const { enqueue } = require('../outbox');
+const { MAX_RECEIPT_CENTS, entry, fail, positiveInt, userSubject, isFrozen } = require('./common');
+const { A_INF, NANO_PER_CENT, topupTerms } = require('./credits');
+const intents = require('./intents');
+const { summary } = require('./purchases');
+
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ACTOR = { type: 'service', id: 'billing' };
+
+/** The fee in whole cents the person pays on top of the top-up: ceil(amount × bps / 10000). 0 bps is 0. */
+function feeCents(amountCents, feeBps) {
+ return Number((BigInt(amountCents) * BigInt(feeBps) + 9999n) / 10000n);
+}
+
+function assertStartable(ctx) {
+ if (!ctx.config.credits || !ctx.config.credits.enabled) fail(404, 'billing.not_enabled', 'Balance is not open yet');
+ const t = topupTerms(ctx);
+ if (!t.enabled) fail(404, 'billing.topup_disabled', 'Balance top-up is not open');
+ if (!t.configured) fail(503, 'billing.topup_unconfigured', `the Balance top-up is not configured: ${t.problem}`);
+ return t;
+}
+
+/**
+ * Start a top-up: validate the person's choice against the owner's terms, create a credits_topup intent and open the
+ * provider's existing checkout. PowerChat has no hosted checkout: its intent carries `checkout_ref` (pccredit:<id>)
+ * for the product to put in the tip's app_ref, exactly as a PowerChat purchase intent does.
+ */
+async function startTopup(ctx, adapters, input) {
+ const { config } = ctx;
+ const t = assertStartable(ctx);
+ // The freeze stops money writes; opening a checkout that would take money is one, as on /api/v1/intents.
+ if (await isFrozen(ctx.db)) fail(503, 'billing.frozen', 'the economy is frozen: top-ups are refused');
+ const subject = userSubject(input.subject, 'subject');
+ // A top-up with no project adds to the person's own Balance (D12); with one it adds to that project's Balance.
+ const projectId = input.project_id == null || input.project_id === '' ? null : String(input.project_id);
+ if (projectId != null && !PROJECT_RE.test(projectId)) fail(422, 'billing.invalid_input', 'project_id is a project id');
+ const amountCents = positiveInt(input.amount_cents, 'amount_cents', MAX_RECEIPT_CENTS);
+ if (!t.amountsCents.includes(amountCents)) fail(422, 'billing.amount_not_allowed', 'this top-up amount is not offered');
+ const provider = String(input.provider || '').toLowerCase();
+ const adapter = adapters[provider];
+ if (!adapter) fail(422, 'billing.invalid_input', `unknown provider ${input.provider}`);
+ if (!adapter.enabled) fail(409, 'billing.provider_disabled', `${adapter.name} is not enabled on Billing`);
+
+ const fee = feeCents(amountCents, t.feeBps);
+ if (amountCents + fee > MAX_RECEIPT_CENTS) fail(422, 'billing.invalid_amount', 'the top-up plus its fee is above the maximum');
+ const intent = await intents.createCreditsTopup(ctx, { provider, subject, projectId, amountCents, feeCents: fee, feeBps: t.feeBps });
+ let checkoutUrl = null;
+ if (adapter.createCheckout) {
+ try {
+ const out = await adapter.createCheckout(ctx, intent, {
+ name: 'OpenVibe Balance',
+ successUrl: `${config.baseUrl}/checkout/success`, cancelUrl: `${config.baseUrl}/checkout/cancel`,
+ ipnUrl: `${config.baseUrl}/webhooks/${adapter.name}`,
+ });
+ if (out.providerRef) await intents.setProviderRef(ctx, intent.id, out.providerRef);
+ checkoutUrl = out.url || null;
+ } catch (e) {
+ await intents.setStatus(ctx, intent.id, 'failed');
+ fail(502, 'billing.provider_error', `${adapter.name}: ${e.message}`);
+ }
+ }
+ return { intent: intents.present(await intents.find(ctx.db, intent.id)), checkout_url: checkoutUrl };
+}
+
+/**
+ * Settle a paid top-up exactly once, for every provider. When the feature is off or unconfigured the event is
+ * recorded for review and moves nothing (money received but not credited is visible to an operator, never silent).
+ * Idempotent on the provider event key and on the receipt reference (a second event for the same payment returns the
+ * first transaction). Adds exactly the intent's amount to the Balance; the fee (and any overpayment) is platform
+ * revenue. Marks
+ * the intent settled in the same transaction.
+ */
+async function settle(ctx, input) {
+ const { db } = ctx;
+ const t = topupTerms(ctx);
+ if (!ctx.config.credits || !ctx.config.credits.enabled || !t.enabled) return { skipped: 'Balance top-up is off' };
+ if (!t.configured) return { skipped: `Balance top-up is not configured: ${t.problem}` };
+ return await money(db, async () => {
+ const receiptRef = input.receiptRef;
+ if (!receiptRef) fail(422, 'billing.invalid_input', 'a provider receipt reference is required');
+ if (!input.idempotencyKey) fail(422, 'billing.invalid_input', 'an idempotency key is required');
+ const dup = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ? OR (receipt_ref IS NOT NULL AND receipt_ref = ?)').get(input.idempotencyKey, receiptRef);
+ if (dup) return { txn: await getTxn(db, dup.id), replay: true, duplicateReceipt: true };
+
+ if (!input.intentId) fail(422, 'billing.invalid_input', 'a top-up needs its intent');
+ const intent = await intents.find(db, input.intentId);
+ if (!intent) fail(404, 'billing.intent_not_found', `no payment intent ${input.intentId}`);
+ if (intent.kind !== 'credits_topup') fail(422, 'billing.intent_mismatch', `intent ${intent.id} is a ${intent.kind}, not a credits_topup`);
+ if (intent.provider !== input.provider) fail(422, 'billing.intent_mismatch', `intent ${intent.id} belongs to ${intent.provider}, not ${input.provider}`);
+ if (intent.status === 'settled' && intent.settled_txn) return { txn: await getTxn(db, intent.settled_txn), replay: true };
+ intents.refuseIfSettledInLive(intent);
+
+ const paidCents = positiveInt(input.paidCents, 'amount_cents', MAX_RECEIPT_CENTS);
+ if (paidCents < intent.amount_cents) fail(422, 'billing.underpaid', `the payment of ${paidCents} cents does not cover the top-up of ${intent.amount_cents} cents`);
+ const amountCents = Number(intent.metadata.topup_amount_cents);
+ const feeBps = Number(intent.metadata.topup_fee_bps);
+ const rawProject = intent.metadata.project_id;
+ const projectId = rawProject == null || rawProject === '' ? null : String(rawProject);
+ if (!Number.isSafeInteger(amountCents) || amountCents <= 0 || (projectId != null && !PROJECT_RE.test(projectId))) fail(422, 'billing.intent_mismatch', `intent ${intent.id} is not a valid Balance top-up`);
+
+ const amountNano = BigInt(amountCents) * NANO_PER_CENT;
+ const paidNano = BigInt(paidCents) * NANO_PER_CENT;
+ const revenueNano = paidNano - amountNano;
+ const { txn } = await post(ctx, {
+ type: 'credit', idempotencyKey: input.idempotencyKey, actor: input.actor || ACTOR, toSubject: intent.subject,
+ provider: input.provider, receiptRef, sourceEventId: input.sourceEventId, test: !!input.test,
+ entries: [
+ entry(A_INF.clearing(input.provider), -paidNano),
+ entry(A_INF.credit(intent.subject, projectId), amountNano),
+ entry(A_INF.revenue(), revenueNano),
+ ],
+ metadata: {
+ credit: 'topup', project_id: projectId, intent_id: intent.id,
+ amount_nano_usd: String(amountNano), fee_nano_usd: String(revenueNano), paid_nano_usd: String(paidNano),
+ paid_cents: paidCents, topup_amount_cents: amountCents, fee_cents: paidCents - amountCents, fee_bps: feeBps,
+ },
+ });
+ 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) });
+ return { txn, replay: false };
+ });
+}
+
+/** Record the spent part of a refunded top-up: a debt of the person, reported by reconciliation. */
+async function recordDebt(ctx, { topupTxnId, transactionId, reversalRef, subject, projectId, provider, amountNano, reason }) {
+ const nowIso = iso(ctx.now());
+ await ctx.db.prepare(`INSERT INTO credit_topup_debts
+ (id, topup_txn_id, transaction_id, reversal_ref, subject, project_id, provider, amount_nano_usd, reason, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?::timestamptz)`)
+ .run(prefixedId('cdt', ctx.now()), topupTxnId, transactionId, reversalRef, subject, projectId, provider, amountNano, String(reason || '').slice(0, 300), nowIso);
+}
+
+module.exports = { startTopup, settle, recordDebt, feeCents };
new file mode 100644
new file mode 100644
@@ -0,0 +1,259 @@
@@ -0,0 +1,259 @@
+'use strict';
+
+/**
+ * The app side of Vibes Pay: the per-app webhook URL and secret, and the durable outbox that delivers the signed
+ * billing.vibes.charge.paid@1 / billing.vibes.charge.refunded@1 bodies with backoff.
+ *
+ * Network's developer apps have no webhook settings of their own, so Billing keeps them in its D11 store: one
+ * namespace `billing.app.<app_id>` per app, with `webhook_url` internal and `webhook_secret` secret-class. The
+ * secret is sealed with the service's KEK whenever one is configured (OV_CONFIG_KEKS); summaries and logs only
+ * ever carry its marker. The app sets both from its own token; no other app can read or change them.
+ *
+ * Delivery header (the SDK's verifyWebhook): X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of
+ * "<t>.<raw body>">. X-OpenVibe-Delivery-Id is stable across retries. A 2xx is delivered; anything else is
+ * retried on a 15 s → 30 s → … → 6 h schedule, eight attempts, then abandoned. Production deliveries go through the
+ * egress guard (https, a public address, no redirects); tests and development may name a private address.
+ */
+const crypto = require('crypto');
+const net = require('net');
+const http = require('http');
+const https = require('https');
+const { createConfigStore } = require('openvibe-shared/config');
+const { assertPublicUrl, safeLookup, isInternalName, isPublicAddress } = require('openvibe-shared/egress');
+const { iso, prefixedId, BillingError } = require('../ledger');
+const { fail } = require('./common');
+
+const SERVICE = 'billing';
+const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
+const RETRY_DELAYS_MS = [15_000, 30_000, 60_000, 120_000, 300_000, 900_000, 3_600_000, 21_600_000];
+const MAX_ATTEMPTS = RETRY_DELAYS_MS.length;
+const DELIVER_TIMEOUT_MS = 10_000;
+const CLAIM_MS = 120_000;
+
+const SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Vibes Pay app webhook',
+ description: 'Where OpenVibe.Billing posts this app\'s Vibes Pay events, and the secret that signs them.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ webhook_url: {
+ type: 'string', maxLength: 2000,
+ title: 'Webhook URL', description: 'The https endpoint this app\'s Vibes Pay events are posted to.',
+ },
+ webhook_secret: {
+ type: 'string', minLength: 16, maxLength: 200,
+ title: 'Webhook secret', description: 'The signing secret (write-only; the answer is a marker).',
+ },
+ },
+};
+
+/** Parse and validate a webhook URL; in production it must be https and name a public host. */
+function safeUrl(value, config) {
+ const raw = String(value == null ? '' : value).trim();
+ if (!raw || raw.length > 2000) fail(422, 'billing.invalid_webhook_url', 'webhook url is required (at most 2000 characters)');
+ let u;
+ try { u = new URL(raw); } catch { fail(422, 'billing.invalid_webhook_url', 'webhook url must be an http(s) URL'); }
+ if (u.username || u.password || u.hash) fail(422, 'billing.invalid_webhook_url', 'webhook url carries no credentials or fragment');
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && config.nodeEnv !== 'production')) {
+ fail(422, 'billing.invalid_webhook_url', 'webhook url must be https');
+ }
+ if (config.isProduction) {
+ if (isInternalName(u.hostname)) fail(422, 'billing.invalid_webhook_url', 'webhook url must name a public host');
+ if (net.isIP(u.hostname) && !isPublicAddress(u.hostname)) fail(422, 'billing.invalid_webhook_url', 'webhook url must name a public address');
+ }
+ return u.toString();
+}
+
+function secretValue(v) {
+ if (typeof v !== 'string' || v.length < 16 || v.length > 200) fail(422, 'billing.invalid_webhook_secret', 'webhook secret must be 16-200 characters');
+ return v;
+}
+
+/** The D11 per-app webhook store, created on first use and cached: one process, one store instance per app. */
+function createVibesWebhooks({ db, config, env = process.env, log = console }) {
+ const stores = new Map();
+ async function storeFor(appId) {
+ const id = String(appId == null ? '' : appId);
+ if (!APP_ID_RE.test(id)) throw new BillingError(500, 'billing.bad_app', 'a webhook store needs an app id');
+ if (stores.has(id)) return stores.get(id);
+ const store = await createConfigStore({
+ db, service: SERVICE, namespace: `billing.app.${id.toLowerCase()}`, schema: SCHEMA, env,
+ classify: (key) => (key === 'webhook_secret' ? 'secret' : 'internal'), log,
+ });
+ stores.set(id, store);
+ return store;
+ }
+
+ async function get(appId) {
+ const store = await storeFor(appId);
+ const values = store.get();
+ const marker = await store.secretMarker('webhook_secret');
+ return {
+ store,
+ url: typeof values.webhook_url === 'string' ? values.webhook_url : null,
+ secret: typeof values.webhook_secret === 'string' ? values.webhook_secret : null,
+ marker,
+ sealed: store.sealed,
+ };
+ }
+
+ /** Set the URL and, when given or rotated, the secret. A generated secret is returned once. */
+ async function configure(appId, { url, secret = null, rotate = false }) {
+ const store = await storeFor(appId);
+ const current = store.get();
+ const actor = { type: 'app', id: appId };
+ if (current.webhook_url !== url) {
+ await store.apply({ webhook_url: url }, { merge: true, actor, reason: 'Vibes Pay webhook URL' });
+ }
+ const before = await store.secretMarker('webhook_secret');
+ let generated = null;
+ let value = secret;
+ if (value == null && (rotate || !before)) {
+ generated = crypto.randomBytes(32).toString('base64url');
+ value = generated;
+ }
+ if (value != null) {
+ await store.setSecret('webhook_secret', secretValue(value), { actor, reason: generated ? 'Vibes Pay webhook secret created' : 'Vibes Pay webhook secret set' });
+ }
+ const marker = await store.secretMarker('webhook_secret');
+ return {
+ app_id: appId, url, secret_set: Boolean(marker), secret_hint: marker ? marker.hint : null,
+ sealed: store.sealed, updated_at: marker ? marker.set_at : null,
+ ...(generated ? { secret: generated, shown_once: true } : {}),
+ };
+ }
+
+ async function remove(appId) {
+ const store = await storeFor(appId);
+ const values = store.get();
+ const actor = { type: 'app', id: appId };
+ if (values.webhook_url != null) {
+ await store.apply({}, { merge: true, unset: ['webhook_url'], actor, reason: 'Vibes Pay webhook URL removed' });
+ }
+ if (await store.secretMarker('webhook_secret')) {
+ await store.deleteSecret('webhook_secret', { actor, reason: 'Vibes Pay webhook secret removed' });
+ }
+ return { app_id: appId, configured: false };
+ }
+
+ return { get, configure, remove, storeFor, safeUrl };
+}
+
+/** Queue one signed delivery in the same transaction as the charge state change. */
+async function enqueueWebhook(ctx, charge, eventType, payload) {
+ const nowIso = iso(ctx.now());
+ await ctx.db.prepare(`INSERT INTO vibes_webhook_outbox
+ (id, charge_id, app_id, event_type, payload, attempts, next_attempt_at, created_at)
+ VALUES (?, ?, ?, ?, ?, 0, ?::timestamptz, ?::timestamptz)`).run(
+ prefixedId('vwh', ctx.now()), charge.id, charge.app_id, eventType, JSON.stringify(payload), nowIso, nowIso);
+}
+
+function retryDelay(attempts) {
+ return RETRY_DELAYS_MS[Math.min(Math.max(attempts - 1, 0), RETRY_DELAYS_MS.length - 1)];
+}
+
+function shortError(e) {
+ if (e && e.code === 'EGRESS_DENIED') return 'the webhook URL is not a public address';
+ return 'the delivery failed';
+}
+
+function canReachPrivate(config) {
+ return !config.isProduction && (config.nodeEnv === 'test' || (config.vibes && config.vibes.allowPrivateWebhooks === true));
+}
+
+async function deliverOne(ctx, row) {
+ const db = ctx.db;
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const claim = await db.prepare(`UPDATE vibes_webhook_outbox SET next_attempt_at = ?::timestamptz
+ WHERE id = ? AND delivered_at IS NULL AND abandoned_at IS NULL AND next_attempt_at <= ?::timestamptz
+ RETURNING *`).get(iso(now + CLAIM_MS), row.id, nowIso);
+ if (!claim) return { skipped: true };
+ const settings = await ctx.vibesWebhooks.get(row.app_id);
+ if (!settings.url || !settings.secret) {
+ await db.prepare(`UPDATE vibes_webhook_outbox SET abandoned_at = ?::timestamptz, last_error = ?
+ WHERE id = ?`).run(nowIso, 'no webhook configured for this app', row.id);
+ return { abandoned: true };
+ }
+ try {
+ if (!canReachPrivate(ctx.config)) await assertPublicUrl(settings.url, { protocols: ctx.config.isProduction ? ['https:'] : ['https:', 'http:'] });
+ } catch (e) {
+ await failAttempt(ctx, row, { error: shortError(e) });
+ return { failed: true };
+ }
+
+ const ts = Math.floor(now / 1000);
+ const signature = crypto.createHmac('sha256', settings.secret).update(`${ts}.`).update(row.payload).digest('hex');
+ let response;
+ try {
+ response = await postWebhook(settings.url, row.payload, {
+ 'Content-Type': 'application/json', Accept: 'application/json',
+ 'User-Agent': 'OpenVibe.Billing Vibes Pay', 'X-OpenVibe-Delivery-Id': row.id,
+ 'X-OpenVibe-Delivery-Attempt': String(Number(row.attempts) + 1), 'X-OpenVibe-Event': row.event_type,
+ 'X-OpenVibe-Timestamp': String(ts), 'X-OpenVibe-Signature-V2': `t=${ts},v2=${signature}`,
+ }, { pinAddress: !canReachPrivate(ctx.config) });
+ } catch (e) {
+ await failAttempt(ctx, row, { error: shortError(e) });
+ return { failed: true };
+ }
+ if (response.status >= 200 && response.status < 300) {
+ await db.prepare('UPDATE vibes_webhook_outbox SET delivered_at = ?::timestamptz, attempts = attempts + 1, last_status = ? WHERE id = ?')
+ .run(iso(ctx.now()), response.status, row.id);
+ return { delivered: true };
+ }
+ await failAttempt(ctx, row, { status: response.status, error: `HTTP ${response.status}` });
+ return { failed: true };
+}
+
+/**
+ * POST the raw body without following redirects. In production the socket uses the egress guard's safeLookup, so a
+ * DNS answer that was public at assertPublicUrl time cannot rebind to a private address before the connection.
+ */
+function postWebhook(url, raw, headers, { pinAddress = true, timeoutMs = DELIVER_TIMEOUT_MS } = {}) {
+ return new Promise((resolve, reject) => {
+ let u;
+ try { u = new URL(url); } catch { reject(new Error('bad url')); return; }
+ const mod = u.protocol === 'https:' ? https : http;
+ const body = Buffer.from(String(raw), 'utf8');
+ const req = mod.request({
+ protocol: u.protocol, hostname: u.hostname, port: u.port || undefined,
+ path: `${u.pathname}${u.search}`, method: 'POST',
+ headers: { ...headers, 'Content-Length': body.length },
+ ...(pinAddress ? { lookup: safeLookup } : {}),
+ }, (res) => {
+ res.resume();
+ resolve({ status: res.statusCode });
+ });
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')));
+ req.on('error', reject);
+ req.end(body);
+ });
+}
+
+async function failAttempt(ctx, row, { status = null, error = null }) {
+ const attempts = Number(row.attempts) + 1;
+ const abandoned = attempts >= MAX_ATTEMPTS;
+ await ctx.db.prepare(`UPDATE vibes_webhook_outbox SET attempts = ?, last_status = ?, last_error = ?,
+ next_attempt_at = ?::timestamptz, abandoned_at = ?::timestamptz WHERE id = ?`).run(
+ attempts, status, String(error || 'delivery failed').slice(0, 500),
+ iso(ctx.now() + retryDelay(attempts)), abandoned ? iso(ctx.now()) : null, row.id);
+}
+
+/** Deliver every due webhook once: the job timer in server/index.js and the tests call this. */
+async function deliverDue(ctx, { limit = 20 } = {}) {
+ const rows = await ctx.db.prepare(`SELECT * FROM vibes_webhook_outbox
+ WHERE delivered_at IS NULL AND abandoned_at IS NULL AND next_attempt_at <= ?::timestamptz
+ ORDER BY next_attempt_at, id LIMIT ?`).all(iso(ctx.now()), Math.max(1, Math.min(100, limit)));
+ const out = { delivered: 0, failed: 0, abandoned: 0, skipped: 0 };
+ for (const row of rows) {
+ const r = await deliverOne(ctx, row);
+ if (r.delivered) out.delivered++;
+ else if (r.abandoned) out.abandoned++;
+ else if (r.failed) out.failed++;
+ else out.skipped++;
+ }
+ return out;
+}
+
+module.exports = { createVibesWebhooks, enqueueWebhook, deliverDue, safeUrl, SCHEMA, RETRY_DELAYS_MS, MAX_ATTEMPTS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,424 @@
@@ -0,0 +1,424 @@
+'use strict';
+
+/**
+ * Vibes Pay (D12 step 4): a developer app sells an item for whole Vibes.
+ *
+ * POST /api/v1/vibes/charges app token, billing.vibes.charge; idempotent on the app's reference
+ * GET /api/v1/vibes/charges/:id app token; its own charge only
+ * POST /api/v1/vibes/charges/:id/refund app token; paid -> refunded, once
+ * POST /api/v1/vibes/sandbox/grants sandbox app token; test Vibes for the app's testing
+ * GET /api/v1/vibes/me app token delegated by a person; billing.vibes-wallet@1
+ *
+ * GET /api/me/vibes/charges/:id the pay sheet: the app, the item, the price, the person's Vibes
+ * POST /api/me/vibes/charges/:id/pay the person pays (atomic, once, never below zero)
+ * POST /api/me/vibes/charges/:id/cancel the person closes a pending charge
+ *
+ * A real payment moves whole Vibes from user_credit:<person> to project_payable:project:<prj_id> in one balanced
+ * journal transaction. A sandbox payment moves test Vibes between vibes_credit_sandbox and
+ * project_payable_sandbox and is marked test, so it never touches a real balance. The charge row is locked and its
+ * status checked before any movement; the journal's idempotency key makes a replay move nothing.
+ *
+ * An app webhook is enqueued in vibes_webhook_outbox in the same transaction (src/ops/vibes-webhooks.js delivers
+ * it, signed and retried). The same payload goes to OpenVibe.Events as billing.vibes.charge.paid@1/refunded@1; it
+ * carries an opaque payer_ref and no person id or email.
+ */
+const crypto = require('crypto');
+const { validate } = require('openvibe-contracts');
+const { balance, post, money, accountId, iso, prefixedId, BillingError } = require('../ledger');
+const { A, entry, fail, userSubject, positiveInt, text, assertNotFrozen } = require('./common');
+const { enqueue } = require('../outbox');
+const { enqueueWebhook } = require('./vibes-webhooks');
+
+const CHARGE_ID_RE = /^vch_[0-9A-HJKMNP-TV-Z]{26}$/;
+const KEY_RE = /^[A-Za-z0-9._:-]{8,128}$/;
+const DAY_MS = 86_400_000;
+const MAX_VIBES = 10_000_000;
+
+const isoOf = (v) => {
+ if (v == null) return null;
+ const d = v instanceof Date ? v : new Date(v);
+ return Number.isFinite(d.getTime()) ? d.toISOString() : String(v);
+};
+const num = (v) => Number(String(v == null ? 0 : v));
+const isSandbox = (row) => row.sandbox === true || row.sandbox === 1 || row.sandbox === '1';
+
+function requireApp(principal) {
+ if (!principal || principal.kind !== 'app' || !principal.appId) fail(403, 'capability.denied', 'an app token is required');
+ return principal;
+}
+
+function assertVibesPay(config) {
+ if (!config.vibes || config.vibes.pay !== true) fail(403, 'billing.vibes_pay_disabled', 'Vibes Pay is off');
+}
+function assertVibesSandbox(config) {
+ if (!config.vibes || config.vibes.sandbox !== true) fail(403, 'billing.vibes_sandbox_disabled', 'sandbox Vibes are off');
+}
+function assertAuthority(config) {
+ if (config.authority !== 'billing') fail(409, 'billing.not_authoritative', 'OpenVibe.Live keeps the Vibes ledger until Billing is authoritative');
+}
+
+function chargeId(v) {
+ const id = String(v == null ? '' : v);
+ if (!CHARGE_ID_RE.test(id)) fail(404, 'billing.charge_not_found', 'no such payment link');
+ return id;
+}
+function idempotencyKey(v) {
+ const s = String(v == null ? '' : v).trim();
+ if (!KEY_RE.test(s)) fail(422, 'billing.invalid_input', 'idempotency_key must be 8-128 characters of letters, digits, dot, colon, underscore or dash');
+ return s;
+}
+
+/** A return URL is https in production (http only in development/tests), never a fragment or credentials. */
+function returnUrlOf(v, config) {
+ if (v == null || v === '') return null;
+ let u;
+ try { u = new URL(String(v)); } catch { fail(422, 'billing.invalid_input', 'return_url must be an http(s) URL'); }
+ if (u.username || u.password || u.hash) fail(422, 'billing.invalid_input', 'return_url carries no credentials or fragment');
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && config.nodeEnv !== 'production')) fail(422, 'billing.invalid_input', 'return_url must be https');
+ if (u.href.length > 1000) fail(422, 'billing.invalid_input', 'return_url is too long');
+ return u.href;
+}
+
+/** The pay sheet's public shape (billing.vibes-charge@1): the app id as a string, whole Vibes, ISO times. */
+function presentCharge(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ app: row.app_id,
+ project_id: row.project_id,
+ amount: row.amount_text != null ? num(row.amount_text) : num(row.amount),
+ description: row.description,
+ reference: row.reference,
+ status: row.status,
+ pay_url: row.pay_url,
+ return_url: row.return_url || null,
+ created_at: isoOf(row.created_at),
+ paid_at: isoOf(row.paid_at),
+ refunded_at: isoOf(row.refunded_at),
+ expires_at: isoOf(row.expires_at),
+ sandbox: isSandbox(row),
+ };
+}
+
+/** The webhook and Events body (billing.vibes.charge.paid@1 / .refunded@1): the charge plus an opaque payer_ref. */
+function webhookPayload(row, status) {
+ const p = {
+ id: row.id,
+ app: row.app_id,
+ project_id: row.project_id,
+ amount: row.amount_text != null ? num(row.amount_text) : num(row.amount),
+ description: row.description,
+ reference: row.reference,
+ status,
+ created_at: isoOf(row.created_at),
+ paid_at: isoOf(row.paid_at),
+ refunded_at: status === 'refunded' ? isoOf(row.refunded_at) : null,
+ expires_at: isoOf(row.expires_at),
+ sandbox: isSandbox(row),
+ payer_ref: row.payer_ref,
+ };
+ const v = validate(`billing.vibes.charge.${status}@1`, p);
+ if (!v.valid) throw new BillingError(500, 'billing.webhook_invalid', `the ${status} webhook does not match its contract: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
+ return p;
+}
+
+/** The account a charge's payer spends from: a person's real Vibes, or that app's test pocket. */
+function payerAccount(sandbox, appId, subject) {
+ return sandbox ? A.vibesSandbox(appId, subject) : A.credit(subject);
+}
+/** The account a paid charge lands in: the project's real Vibes wallet, or its test wallet. */
+function projectAccount(sandbox, appId, projectId) {
+ return sandbox ? A.projectPayableSandbox(appId, projectId) : A.projectPayable(projectId);
+}
+
+/** Create or replay a charge. (app_id, reference) is the app's idempotency key; a different body is a conflict. */
+async function createCharge(ctx, principal, body) {
+ const app = requireApp(principal);
+ const b = body == null ? {} : body;
+ const v = validate('billing.vibes-charge-create-request@1', b);
+ if (!v.valid) fail(422, 'billing.invalid_request', `not a billing.vibes-charge-create-request@1: ${v.errors.slice(0, 5).map((e) => `${e.path || '/'} ${e.message}`).join('; ')}`);
+ const amount = positiveInt(b.amount, 'amount', MAX_VIBES);
+ const sandbox = app.env === 'sandbox';
+ if (sandbox) assertVibesSandbox(ctx.config);
+ else {
+ assertVibesPay(ctx.config);
+ const max = Number(ctx.config.vibes.maxChargeVibes) || 0;
+ if (max <= 0) fail(403, 'billing.vibes_limit_exceeded', 'the largest single charge is closed by the owner');
+ if (amount > max) fail(403, 'billing.vibes_limit_exceeded', `the largest single charge is ${max} Vibes`, { max_charge_vibes: max, amount });
+ }
+ await assertNotFrozen(ctx);
+ const returnUrl = returnUrlOf(b.return_url, ctx.config);
+ const description = String(b.description).trim();
+ const reference = String(b.reference).trim();
+
+ return await money(ctx.db, async () => {
+ const db = ctx.db;
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const find = async () => await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE app_id = ? AND reference = ?').get(app.appId, reference);
+ const replay = (row) => {
+ const same = num(row.amount_text) === amount && row.description === description && (row.return_url || null) === returnUrl;
+ if (!same) fail(409, 'billing.reference_reused', 'the same reference was used for a different charge');
+ return { charge: presentCharge(row), replay: true };
+ };
+ const existing = await find();
+ if (existing) return replay(existing);
+ const id = prefixedId('vch', now);
+ const row = await db.prepare(`INSERT INTO vibes_charges
+ (id, app_id, project_id, amount, description, reference, status, pay_url, return_url, sandbox, expires_at, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?::timestamptz, ?::timestamptz)
+ ON CONFLICT (app_id, reference) DO NOTHING
+ RETURNING *, amount::text AS amount_text`).get(
+ id, app.appId, app.projectId, amount, description, reference,
+ `${String(ctx.config.vibes.payBaseUrl).replace(/\/+$/, '')}/pay/${id}`, returnUrl, sandbox ? 1 : 0,
+ iso(now + ctx.config.vibes.chargeTtlSeconds * 1000), nowIso);
+ if (!row) {
+ const raced = await find();
+ if (!raced) throw new BillingError(409, 'billing.reference_reused', 'this reference is already being used');
+ return replay(raced);
+ }
+ return { charge: presentCharge(row), replay: false };
+ });
+}
+
+async function findCharge(db, id) {
+ return await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE id = ?').get(id);
+}
+
+async function getChargeForApp(ctx, principal, id) {
+ const app = requireApp(principal);
+ const row = await findCharge(ctx.db, chargeId(id));
+ if (!row || row.app_id !== app.appId) fail(404, 'billing.charge_not_found', 'no such payment link');
+ return { charge: presentCharge(row) };
+}
+
+/** Refund a paid charge whole, once, from the project's Vibes wallet back to the person who paid it. */
+async function refundCharge(ctx, principal, id, body) {
+ const app = requireApp(principal);
+ const cid = chargeId(id);
+ const v = validate('billing.vibes-charge-refund-request@1', body == null ? {} : body);
+ if (!v.valid) fail(422, 'billing.invalid_request', `not a billing.vibes-charge-refund-request@1: ${v.errors.slice(0, 5).map((e) => `${e.path || '/'} ${e.message}`).join('; ')}`);
+ const reason = text(body && body.reason, 'reason', 300);
+ await assertNotFrozen(ctx);
+
+ return await money(ctx.db, async () => {
+ const db = ctx.db;
+ const row = await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE id = ? FOR UPDATE').get(cid);
+ if (!row || row.app_id !== app.appId) fail(404, 'billing.charge_not_found', 'no such payment link');
+ if (row.status === 'refunded') return { charge: presentCharge(row), replay: true };
+ if (row.status !== 'paid') fail(409, 'billing.charge_not_paid', `this charge is ${row.status}, not paid`);
+ const sandbox = isSandbox(row);
+ if (!row.payer_subject) fail(409, 'billing.charge_not_paid', 'this charge has no payer to refund');
+ if (!sandbox) assertAuthority(ctx.config);
+ const amount = num(row.amount_text);
+ const from = projectAccount(sandbox, row.app_id, row.project_id);
+ const to = payerAccount(sandbox, row.app_id, row.payer_subject);
+ const have = await balance(db, from);
+ if (have < amount) fail(402, 'billing.insufficient_vibes', `the project's Vibes wallet holds ${have}, not the ${amount} to refund`, { available: have, required: amount });
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const { txn } = await post(ctx, {
+ type: 'vibes_refund', idempotencyKey: `vibes:refund:${row.id}`, test: sandbox,
+ actor: { type: 'app', id: row.app_id }, fromSubject: null, toSubject: row.payer_subject,
+ entries: [entry(from, -amount), entry(to, amount)],
+ metadata: { vibes_charge: 'refund', charge_id: row.id, app_id: row.app_id, project_id: row.project_id, amount },
+ });
+ const updated = await db.prepare(`UPDATE vibes_charges SET status = 'refunded', refunded_at = ?::timestamptz, refund_reason = ?
+ WHERE id = ? RETURNING *, amount::text AS amount_text`).get(nowIso, reason, row.id);
+ const payload = webhookPayload(updated, 'refunded');
+ await enqueueWebhook(ctx, updated, 'billing.vibes.charge.refunded', payload);
+ await enqueue(ctx, { event_type: 'billing.vibes.charge.refunded', subject: { type: 'vibes-charge', id: row.id }, payload });
+ return { charge: presentCharge(updated), replay: false, transaction_id: txn.id };
+ });
+}
+
+/** The opaque payer_ref: random, stable per (app, person), never derived from the person's id. */
+async function payerRef(ctx, appId, subject) {
+ const db = ctx.db;
+ const existing = await db.prepare('SELECT payer_ref FROM vibes_payers WHERE app_id = ? AND subject = ?').get(appId, subject);
+ if (existing) return existing.payer_ref;
+ const ref = `pr_${crypto.randomBytes(16).toString('hex')}`;
+ await db.prepare(`INSERT INTO vibes_payers (app_id, subject, payer_ref, created_at) VALUES (?, ?, ?, ?::timestamptz)
+ ON CONFLICT (app_id, subject) DO NOTHING`).run(appId, subject, ref, iso(ctx.now()));
+ const row = await db.prepare('SELECT payer_ref FROM vibes_payers WHERE app_id = ? AND subject = ?').get(appId, subject);
+ if (!row) throw new BillingError(500, 'billing.payer_ref', 'could not record the payer reference');
+ return row.payer_ref;
+}
+
+/** How many Vibes the person has already paid in apps in the last 24 h (refunded charges do not count). */
+async function spentToday(db, subject, now) {
+ const row = await db.prepare(`SELECT COALESCE(SUM(amount), 0)::text AS n FROM vibes_charges
+ WHERE payer_subject = ? AND status = 'paid' AND paid_at > ?::timestamptz`).get(subject, iso(now - DAY_MS));
+ return num(row.n);
+}
+
+/** Pay a pending charge: locked, status-checked, once, never below zero. Returns the person's view of it. */
+async function payCharge(ctx, subject, id, body) {
+ const person = userSubject(subject, 'subject');
+ const cid = chargeId(id);
+ const key = idempotencyKey(body && body.idempotency_key);
+ await assertNotFrozen(ctx);
+
+ const out = await money(ctx.db, async () => {
+ const db = ctx.db;
+ const now = ctx.now();
+ const row = await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE id = ? FOR UPDATE').get(cid);
+ if (!row) fail(404, 'billing.charge_not_found', 'no such payment link');
+ if (row.status === 'paid' && row.payer_subject === person) {
+ return { charge: presentCharge(row), vibes_credit: await balance(db, payerAccount(isSandbox(row), row.app_id, person)), replay: true };
+ }
+ if (row.status === 'paid') fail(409, 'billing.charge_not_pending', 'this charge was already paid by another person');
+ if (row.status !== 'pending') fail(409, 'billing.charge_not_pending', `this charge is ${row.status}`);
+ if (new Date(row.expires_at).getTime() <= now) {
+ // Commit the expiry (it is a state change, not a refusal side effect); answer the 409 outside the transaction.
+ const expired = await db.prepare(`UPDATE vibes_charges SET status = 'expired' WHERE id = ? AND status = 'pending' RETURNING *, amount::text AS amount_text`).get(row.id);
+ return { expired: true, expires_at: isoOf(expired ? expired.expires_at : row.expires_at) };
+ }
+ const sandbox = isSandbox(row);
+ const amount = num(row.amount_text);
+ if (sandbox) assertVibesSandbox(ctx.config);
+ else {
+ assertVibesPay(ctx.config);
+ assertAuthority(ctx.config);
+ const daily = Number(ctx.config.vibes.dailyAppSpendVibesPerPerson) || 0;
+ if (daily <= 0) fail(403, 'billing.vibes_limit_exceeded', 'the daily app spend is closed by the owner');
+ const spent = await spentToday(db, person, now);
+ if (spent + amount > daily) {
+ fail(403, 'billing.vibes_limit_exceeded', `this payment would pass the daily app spend of ${daily} Vibes`, { daily_limit_vibes: daily, spent_vibes: spent, amount });
+ }
+ }
+ const from = payerAccount(sandbox, row.app_id, person);
+ const to = projectAccount(sandbox, row.app_id, row.project_id);
+ const have = await balance(db, from);
+ if (have < amount) fail(402, 'billing.insufficient_vibes', `you have ${have} Vibes, not the ${amount} this charge asks`, { available: have, required: amount });
+ const ref = await payerRef(ctx, row.app_id, person);
+ const { txn } = await post(ctx, {
+ type: 'vibes_charge', idempotencyKey: `vibes:charge:${row.id}`, test: sandbox,
+ actor: { type: 'user', id: person }, fromSubject: person, toSubject: null,
+ entries: [entry(from, -amount), entry(to, amount)],
+ metadata: {
+ vibes_charge: 'pay', charge_id: row.id, app_id: row.app_id, project_id: row.project_id,
+ amount, description: row.description, payer_ref: ref,
+ },
+ });
+ const updated = await db.prepare(`UPDATE vibes_charges SET status = 'paid', paid_at = ?::timestamptz,
+ payer_subject = ?, payer_ref = ?, pay_idempotency_key = ? WHERE id = ? RETURNING *, amount::text AS amount_text`).get(
+ iso(now), person, ref, key, row.id);
+ const payload = webhookPayload(updated, 'paid');
+ await enqueueWebhook(ctx, updated, 'billing.vibes.charge.paid', payload);
+ await enqueue(ctx, { event_type: 'billing.vibes.charge.paid', subject: { type: 'vibes-charge', id: row.id }, payload });
+ return { charge: presentCharge(updated), vibes_credit: await balance(db, from), replay: false, transaction_id: txn.id };
+ });
+ if (out.expired) fail(409, 'billing.charge_expired', 'this payment link has expired', { expires_at: out.expires_at });
+ return out;
+}
+
+/** The pay sheet's read: the charge and app, the item, the person's Vibes and whether they can pay it now. */
+async function personCharge(ctx, subject, id) {
+ const person = userSubject(subject, 'subject');
+ const row = await findCharge(ctx.db, chargeId(id));
+ if (!row) fail(404, 'billing.charge_not_found', 'no such payment link');
+ const sandbox = isSandbox(row);
+ const amount = num(row.amount_text);
+ const credit = await balance(ctx.db, payerAccount(sandbox, row.app_id, person));
+ const now = ctx.now();
+ const expired = row.status === 'pending' && new Date(row.expires_at).getTime() <= now;
+ let dailyRemaining = null;
+ let canPay = row.status === 'pending' && !expired;
+ if (sandbox) canPay = canPay && ctx.config.vibes.sandbox === true;
+ else {
+ canPay = canPay && ctx.config.vibes.pay === true && ctx.config.authority === 'billing';
+ const daily = Number(ctx.config.vibes.dailyAppSpendVibesPerPerson) || 0;
+ if (daily <= 0) canPay = false;
+ else {
+ const spent = await spentToday(ctx.db, person, now);
+ dailyRemaining = Math.max(0, daily - spent);
+ canPay = canPay && dailyRemaining >= amount;
+ }
+ }
+ canPay = canPay && credit >= amount;
+ let app = { id: row.app_id, name: null, icon: null };
+ try { app = await ctx.apps.app(row.project_id, row.app_id); } catch { /* the app id is still enough for the sheet */ }
+ return {
+ charge: presentCharge(row),
+ app,
+ vibes_credit: credit,
+ shortfall_vibes: Math.max(0, amount - credit),
+ can_pay: Boolean(canPay),
+ expired,
+ daily_remaining_vibes: dailyRemaining,
+ };
+}
+
+/** Cancel a pending charge (a close, no money movement). Paid or refunded charges are never cancelled. */
+async function cancelCharge(ctx, subject, id) {
+ const person = userSubject(subject, 'subject');
+ const cid = chargeId(id);
+ await assertNotFrozen(ctx);
+ return await money(ctx.db, async () => {
+ const db = ctx.db;
+ const row = await db.prepare('SELECT *, amount::text AS amount_text FROM vibes_charges WHERE id = ? FOR UPDATE').get(cid);
+ if (!row) fail(404, 'billing.charge_not_found', 'no such payment link');
+ if (row.status === 'cancelled') return { charge: presentCharge(row), replay: true };
+ if (row.status !== 'pending') fail(409, 'billing.charge_not_pending', `this charge is ${row.status}`);
+ const now = ctx.now();
+ if (new Date(row.expires_at).getTime() <= now) {
+ const expired = await db.prepare(`UPDATE vibes_charges SET status = 'expired' WHERE id = ? AND status = 'pending' RETURNING *, amount::text AS amount_text`).get(row.id);
+ return { charge: presentCharge(expired), replay: false, expired: true };
+ }
+ const updated = await db.prepare(`UPDATE vibes_charges SET status = 'cancelled', cancelled_at = ?::timestamptz
+ WHERE id = ? AND status = 'pending' RETURNING *, amount::text AS amount_text`).get(iso(now), row.id);
+ return { charge: presentCharge(updated), replay: false };
+ });
+}
+
+/** A sandbox app's own test grant: mint sandbox Vibes into the payer's pocket for that app. */
+async function sandboxGrant(ctx, principal, body) {
+ const app = requireApp(principal);
+ if (app.env !== 'sandbox') fail(403, 'billing.sandbox_app_only', 'only a sandbox app may grant sandbox Vibes');
+ assertVibesSandbox(ctx.config);
+ await assertNotFrozen(ctx);
+ const b = body == null ? {} : body;
+ const subject = userSubject(b.subject, 'subject');
+ const amount = positiveInt(b.amount, 'amount', MAX_VIBES);
+ return await money(ctx.db, async () => {
+ const db = ctx.db;
+ const now = ctx.now();
+ const nowIso = iso(now);
+ const issuer = A.vibesSandboxIssuer();
+ const target = A.vibesSandbox(app.appId, subject);
+ const issuerId = await accountId(db, issuer, nowIso);
+ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(issuerId);
+ const targetId = await accountId(db, target, nowIso);
+ await db.prepare('SELECT 1 FROM accounts WHERE id = ? FOR UPDATE').get(targetId);
+ const grantId = prefixedId('vg', now);
+ const { txn } = await post(ctx, {
+ type: 'vibes_sandbox_grant', idempotencyKey: `vibes:sandbox:${grantId}`, test: true,
+ actor: { type: 'app', id: app.appId }, toSubject: subject,
+ entries: [entry(issuer, -amount), entry(target, amount)],
+ metadata: { vibes: 'sandbox_grant', grant_id: grantId, app_id: app.appId, project_id: app.projectId, amount },
+ });
+ return { grant_id: grantId, amount, vibes_credit: await balance(db, target), transaction_id: txn.id };
+ });
+}
+
+/** GET /api/v1/vibes/me: the person who delegated this app (billing.vibes-wallet@1). */
+async function walletForApp(ctx, principal) {
+ const app = requireApp(principal);
+ if (app.env === 'sandbox') fail(403, 'billing.sandbox_refused', 'a sandbox app reads no real Vibes');
+ const person = userSubject(app.onBehalfOf, 'on_behalf_of');
+ const out = {
+ subject: { type: 'user', id: person }, currency: 'vibes-bits',
+ credit: await balance(ctx.db, A.credit(person)),
+ payable: await balance(ctx.db, A.payable(person)),
+ bits_per_usd: ctx.rates.bitsPerUsd,
+ };
+ const v = validate('billing.vibes-wallet@1', out);
+ if (!v.valid) throw new BillingError(500, 'billing.wallet_invalid', `the wallet does not match its contract: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
+ return out;
+}
+
+module.exports = {
+ createCharge, getChargeForApp, refundCharge, payCharge, personCharge, cancelCharge, sandboxGrant, walletForApp,
+ presentCharge, webhookPayload, findCharge, CHARGE_ID_RE,
+};
@@ -60,6 +60,7 @@ function createCcbill(cfg) {
@@ -60,6 +60,7 @@ function createCcbill(cfg) {
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 };
const receipt = { provider: 'ccbill', receiptRef: `ccbill:${txId}`, paidCents: Math.round(Number(price) * 100) };
const receipt = { provider: 'ccbill', receiptRef: `ccbill:${txId}`, paidCents: Math.round(Number(price) * 100) };
+ if (intent.kind === 'credits_topup') return { effect: 'credits_topup', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
if (receipt.paidCents + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription sale — review', review: true };
if (receipt.paidCents + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription sale — review', review: true };
return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };
return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };
@@ -28,6 +28,7 @@ const crypto = require('crypto');
@@ -28,6 +28,7 @@ const crypto = require('crypto');
const { iso, BillingError, money } = 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 topups = require('../ops/topups');
const transfers = require('../ops/transfers');
const transfers = require('../ops/transfers');
const subscriptions = require('../ops/subscriptions');
const subscriptions = require('../ops/subscriptions');
const reversals = require('../ops/reversals');
const reversals = require('../ops/reversals');
@@ -68,6 +69,12 @@ async function applyPlan(ctx, plan, row) {
@@ -68,6 +69,12 @@ async function applyPlan(ctx, plan, row) {
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': await plan.apply(ctx); return { effect: 'updated', reason: plan.reason };
case 'update': await plan.apply(ctx); return { effect: 'updated', reason: plan.reason };
case 'purchase': return settled(await purchases.settle(ctx, withEvent(plan.args)));
case 'purchase': return settled(await purchases.settle(ctx, withEvent(plan.args)));
+ case 'credits_topup': {
+ // One shared plan for every provider. Off or unconfigured: recorded for review, no money moves.
+ const r = await topups.settle(ctx, withEvent(plan.args));
+ if (r.skipped) return { effect: 'none', reason: r.skipped, review: true };
+ return settled(r);
+ }
case 'subscription': return settled(await subscriptions.pay(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 'tip': return settled(await transfers.fromReceipt(ctx, withEvent(plan.args)));
case 'external': return await external.record(ctx, withEvent(plan.args));
case 'external': return await external.record(ctx, withEvent(plan.args));
@@ -49,6 +49,7 @@ function createNowpayments(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -49,6 +49,7 @@ function createNowpayments(cfg, { fetchImpl = globalThis.fetch } = {}) {
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) };
+ if (intent.kind === 'credits_topup') return { effect: 'credits_topup', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
if (receipt.paidCents + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription — review', review: true };
if (receipt.paidCents + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription — review', review: true };
return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };
return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };
@@ -58,6 +58,7 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -58,6 +58,7 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
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' };
const receipt = { provider: 'paypal', receiptRef: `paypal:${capture.id}`, paidCents: paid };
const receipt = { provider: 'paypal', receiptRef: `paypal:${capture.id}`, paidCents: paid };
+ if (intent.kind === 'credits_topup') return { effect: 'credits_topup', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
if (paid + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription capture — review', review: true };
if (paid + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription capture — review', review: true };
return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };
return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };
@@ -12,13 +12,14 @@
@@ -12,13 +12,14 @@
*
*
* donation.completed, by the checkout ref we minted into app_ref (appExternalRef):
* donation.completed, by the checkout ref we minted into app_ref (appExternalRef):
* pcorder:<intent> Vibes purchase on the site account → CREDIT for the buyer
* pcorder:<intent> Vibes purchase on the site account → CREDIT for the buyer
+ * pccredit:<intent> Balance top-up on the site account → the shared credits_topup plan
* pcsub:<intent> subscription: route 'site' → MONEY (share to the creator, fee +
* pcsub:<intent> subscription: route 'site' → MONEY (share to the creator, fee +
* rest platform revenue); route 'direct' → EXTERNAL (entitlement
* rest platform revenue); route 'direct' → EXTERNAL (entitlement
* only, the streamer holds the money). Underpaid: site → a plain
* only, the streamer holds the money). Underpaid: site → a plain
* site-routed tip to the streamer; direct → EXTERNAL tip, no effect.
* site-routed tip to the streamer; direct → EXTERNAL tip, no effect.
* pcdon:<streamer>:<donor> site-routed tip for a creator → MONEY into their payable
* pcdon:<streamer>:<donor> site-routed tip for a creator → MONEY into their payable
* (legacy links carry Live user ids; they are resolved to subjects)
* (legacy links carry Live user ids; they are resolved to subjects)
- * (pcorder, site pcsub and pcdon count only when the tip was paid to POWERCHAT_SITE_USERNAME:
+ * (pcorder, pccredit, site pcsub and pcdon count only when the tip was paid to POWERCHAT_SITE_USERNAME:
* the ref is buyer-editable in the fallback tip link; from any other account they are held)
* the ref is buyer-editable in the fallback tip link; from any other account they are held)
* anything else a tip on a streamer's own PowerChat → EXTERNAL: no journal entry;
* anything else a tip on a streamer's own PowerChat → EXTERNAL: no journal entry;
* stored in external_receipts and, once Billing is the authority,
* stored in external_receipts and, once Billing is the authority,
@@ -104,7 +105,7 @@ function createPowerchat(cfg, { network } = {}) {
@@ -104,7 +105,7 @@ function createPowerchat(cfg, { network } = {}) {
const meta = { powerchat_event: paymentId, donor_name: data.donorName ? String(data.donorName).slice(0, 80) : null, app_ref: ref || null };
const meta = { powerchat_event: paymentId, donor_name: data.donorName ? String(data.donorName).slice(0, 80) : null, app_ref: ref || null };
const host = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
const host = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
// Money only reaches OpenVibe when it was paid to the SITE account. app_ref rides in the
// Money only reaches OpenVibe when it was paid to the SITE account. app_ref rides in the
- // buyer-editable fallback tip link (?app_ref=…), so a creator could put `pcorder:` / `pcdon:` /
+ // buyer-editable fallback tip link (?app_ref=…), so a creator could put `pcorder:` / `pccredit:` / `pcdon:` /
// a site `pcsub:` ref on a tip to their OWN PowerChat (money they keep) and have Billing mint
// a site `pcsub:` ref on a tip to their OWN PowerChat (money they keep) and have Billing mint
// credit or cash-out-able payable for it. Site-routed refs from any other account are held.
// credit or cash-out-able payable for it. Site-routed refs from any other account are held.
const onSite = !!cfg.siteUsername && host === cfg.siteUsername;
const onSite = !!cfg.siteUsername && host === cfg.siteUsername;
@@ -131,6 +132,13 @@ function createPowerchat(cfg, { network } = {}) {
@@ -131,6 +132,13 @@ function createPowerchat(cfg, { network } = {}) {
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(/^pccredit:(.+)$/))) {
+ if (!onSite) return offSite('Balance top-up');
+ const intent = await intents.find(ctx.db, m[1]);
+ if (!intent || intent.kind !== 'credits_topup') return { effect: 'none', reason: `Balance top-up ${ref} has no credits_topup intent` };
+ if (cents < 1) return { effect: 'none', reason: 'zero-amount Balance top-up' };
+ return { effect: 'credits_topup', args: { provider: 'powerchat', receiptRef, paidCents: cents, intentId: intent.id, idempotencyKey: key, actor, ...(isTest ? { test: true } : {}) } };
+ }
if ((m = ref.match(/^pcsub:(.+)$/))) {
if ((m = ref.match(/^pcsub:(.+)$/))) {
const intent = await 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` };
@@ -67,6 +67,14 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
@@ -67,6 +67,14 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
return { effect: 'update', reason: 'linked Stripe subscription', apply: async (c) => await 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}` };
+ // A Balance top-up (D9) maps to the one shared credits_topup plan; the shared settle credits usd_credit
+ // instead of Vibes bits. It can never fall through to a purchase or subscription.
+ if (intent.kind === 'credits_topup') {
+ return { effect: 'credits_topup', args: {
+ provider: 'stripe', receiptRef: `stripe:${obj.payment_intent || obj.id}`, paidCents: obj.amount_total,
+ intentId: intent.id, 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 } };
return { effect: 'purchase', args: { provider: 'stripe', receiptRef: `stripe:${obj.payment_intent || obj.id}`, intentId: intent.id, paidCents: obj.amount_total, idempotencyKey: key, actor } };
}
}
case 'invoice.paid':
case 'invoice.paid':
@@ -28,7 +28,8 @@
@@ -28,7 +28,8 @@
* every period revoked for a refund or chargeback has the reversal of its payment
* every period revoked for a refund or chargeback has the reversal of its payment
* Warnings (reported, do not fail): negative user balances, unprocessed/rejected events, events and
* Warnings (reported, do not fail): negative user balances, unprocessed/rejected events, events and
* transactions flagged for review (chargebacks on donated credit, unattributed site tips, EXTERNAL
* transactions flagged for review (chargebacks on donated credit, unattributed site tips, EXTERNAL
- * tips on unmapped accounts), import holds. Totals exclude test transactions (ADR-012 rule 9).
+ * tips on unmapped accounts), credit debts from refunded top-ups already spent, import holds. Totals
+ * exclude test transactions (ADR-012 rule 9).
*
*
* Runs on demand (API, console, scripts/reconcile.js, after an import) and on a schedule
* Runs on demand (API, console, scripts/reconcile.js, after an import) and on a schedule
* (runScheduled, server/index.js); every run is stored in reconciliation_runs with its trigger.
* (runScheduled, server/index.js); every run is stored in reconciliation_runs with its trigger.
@@ -45,17 +46,17 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -45,17 +46,17 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
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 = 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 sums = await db.prepare('SELECT a.currency, SUM(e.amount)::text 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) => String(s.total) === '0'), { per_currency: sums.map((s) => ({ currency: s.currency, sum: s.total, entries: s.n })) });
- 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
+ const unbalanced = await db.prepare(`SELECT e.txn_id, a.currency, SUM(e.amount)::text 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 = 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
+ const cache = await db.prepare(`SELECT a.id, a.kind, a.owner_subject AS owner, a.currency, COALESCE(b.balance, 0)::text AS cached,
+ COALESCE((SELECT SUM(amount) FROM ledger_entries WHERE account_id = a.id), 0)::text 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) => String(r.cached) !== String(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 = await db.prepare(`SELECT t.id, json_extract(t.metadata, '$.rates.bits_per_usd')::float8 AS rate,
const fxRows = await db.prepare(`SELECT t.id, json_extract(t.metadata, '$.rates.bits_per_usd')::float8 AS rate,
@@ -86,12 +87,18 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -86,12 +87,18 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
const rcpt = await db.prepare(`SELECT t.id, t.type, t.provider, t.receipt_ref, p.id AS event, p.provider AS event_provider,
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,
json_extract(t.metadata, '$.paid_cents')::bigint AS paid_cents, json_extract(t.metadata, '$.cents')::bigint AS reversed_cents,
+ json_extract(t.metadata, '$.credit') AS credit_kind,
(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)::bigint 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();
+ // A Balance top-up and its reversal move nano-USD through the provider clearing (1 cent = 10,000,000 nano-USD),
+ // not usd-cents; the paid or reversed cents metadata is still the receipt-side truth.
+ const NANO_PER_CENT = 10_000_000;
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;
+ if (r.credit_kind === 'topup') return !r.receipt_ref || (r.entries > 0 && r.clearing !== -Number(r.paid_cents) * NANO_PER_CENT);
+ if (r.credit_kind === 'topup_refund') return r.entries > 0 && r.clearing !== Number(r.reversed_cents) * NANO_PER_CENT;
if (SETTLING.includes(r.type)) return !r.receipt_ref || (r.entries > 0 && r.clearing !== -Number(r.paid_cents));
if (SETTLING.includes(r.type)) return !r.receipt_ref || (r.entries > 0 && r.clearing !== -Number(r.paid_cents));
if (REVERSING.includes(r.type)) return r.entries > 0 && r.clearing !== Number(r.reversed_cents);
if (REVERSING.includes(r.type)) return r.entries > 0 && r.clearing !== Number(r.reversed_cents);
return false;
return false;
@@ -134,8 +141,8 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -134,8 +141,8 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
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: 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', 'promo_credit') AND b.balance < 0`).all(),
+ negative_balances: await db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
+ WHERE a.kind IN ('user_credit', 'creator_payable', 'project_payable', 'project_payable_sandbox', 'payouts_pending', 'promo_credit', 'usd_credit') AND b.balance < 0`).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(),
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())
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) })),
@@ -143,6 +150,9 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -143,6 +150,9 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
.all()).map((r) => ({ ...r, result: JSON.parse(r.result) })),
.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")
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) })),
.all()).map((r) => ({ ...r, metadata: JSON.parse(r.metadata) })),
+ // A refunded top-up whose credit was already spent: the person owes it, and it is never written off silently.
+ credit_debts: await db.prepare('SELECT id, subject, project_id, provider, transaction_id, topup_txn_id, amount_nano_usd::text AS amount_nano_usd, reason, created_at FROM credit_topup_debts ORDER BY created_at')
+ .all(),
import_holds: await db.prepare('SELECT live_user_id, owner, reason, credit_bits, payable_bits FROM import_holds WHERE resolved_subject IS NULL').all(),
import_holds: await db.prepare('SELECT live_user_id, owner, reason, credit_bits, payable_bits FROM import_holds WHERE resolved_subject IS NULL').all(),
};
};
@@ -157,8 +167,12 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -157,8 +167,12 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
chargeback_loss_cents: await sumKind('chargeback_loss', 'usd-cents'),
chargeback_loss_cents: await sumKind('chargeback_loss', 'usd-cents'),
credit_outstanding_bits: await sumKind('user_credit', 'vibes-bits', false),
credit_outstanding_bits: await sumKind('user_credit', 'vibes-bits', false),
payable_outstanding_bits: await sumKind('creator_payable', 'vibes-bits', false),
payable_outstanding_bits: await sumKind('creator_payable', 'vibes-bits', false),
+ project_payable_outstanding_bits: await sumKind('project_payable', 'vibes-bits', false),
payouts_pending_bits: await sumKind('payouts_pending', 'vibes-bits', false),
payouts_pending_bits: await sumKind('payouts_pending', 'vibes-bits', false),
import_adjustment_bits: await sumKind('import_adjustment', 'vibes-bits', false),
import_adjustment_bits: await sumKind('import_adjustment', 'vibes-bits', false),
+ credit_outstanding_nano_usd: (await db.prepare("SELECT COALESCE(SUM(b.balance), 0)::text AS n FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'usd_credit'").get()).n,
+ // The spent part of refunded top-ups, reported as money the person owes (never netted out of the balance).
+ credit_topup_debt_nano_usd: (await db.prepare('SELECT COALESCE(SUM(amount_nano_usd), 0)::text AS n FROM credit_topup_debts').get()).n,
external_receipts: (await db.prepare('SELECT COUNT(*) AS n FROM external_receipts WHERE test = 0').get()).n,
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_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,
external_cents: (await db.prepare('SELECT COALESCE(SUM(amount_cents), 0)::bigint AS n FROM external_receipts WHERE test = 0').get()).n,
@@ -39,7 +39,10 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourc
@@ -39,7 +39,10 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourc
try {
try {
const missing = await uncovered(d, {
const missing = await uncovered(d, {
tables: TABLES,
tables: TABLES,
- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Billing applied"},
+ exempt: {
+ "account_data_events": "the ledger of requests itself: which export and deletion Billing applied",
+ "config_snapshots": "the owner-settings journal: the acting operator and key names, a configuration history rather than the account's own data",
+ },
});
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
} finally { await d.close(); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+/**
+ * GET /api/me/activity (D12, Network's /wallet): the signed-in person's own Balance and Vibes movements, newest
+ * first, with plain labels and exact integer amounts. A from-Balance Vibes purchase has two rows sharing one id.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/app');
+const { post } = require('../server/ledger');
+const { entry, A } = require('../server/ops/common');
+const { A_INF } = require('../server/ops/credits');
+const balances = require('../server/ops/balance');
+const transfers = require('../server/ops/transfers');
+
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const MARKET = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPZ';
+const key = (p) => `${p}:${crypto.randomBytes(8).toString('hex')}`;
+
+(async () => {
+ console.log('activity');
+ const t = await boot({ env: { BILLING_CREDITS: '1', BILLING_VIBES_FROM_BALANCE: '1' } });
+ const alice = t.user(1, 'alice');
+ const bob = t.user(2, 'bob');
+ t.network.addMembership(PRJ, alice.id, 'owner', false, 'Studio');
+ const aliceToken = t.network.signUser({ subject_id: alice.id, username: 'alice' });
+ const bobToken = t.network.signUser({ subject_id: bob.id, username: 'bob' });
+
+ const fund = (amount) => post(t.ctx, {
+ type: 'credit', idempotencyKey: key('fund'), test: true, actor: { type: 'service', id: 'billing' }, toSubject: alice.id,
+ provider: 'stripe',
+ entries: [entry(A_INF.clearing('stripe'), -BigInt(amount)), entry(A_INF.credit(alice.id, null), BigInt(amount))],
+ metadata: { credit: 'topup', amount_nano_usd: String(amount) },
+ });
+ const get = async (token, q = '') => {
+ const r = await fetch(`${t.base}/api/me/activity${q}`, { headers: { Authorization: `Bearer ${token}` } });
+ const text = await r.text();
+ return { status: r.status, text, body: text ? JSON.parse(text) : null };
+ };
+
+ const topup = await fund('3000000000');
+ const moved = await balances.transfer(t.ctx, { subject: alice.id, toProjectId: PRJ, amountNanoUsd: '300000000', idempotencyKey: key('tr') });
+ const got = await balances.buyVibes(t.ctx, { subject: alice.id, vibes: 100, idempotencyKey: key('vb') });
+ const tip = await transfers.create(t.ctx, { from: alice.id, to: bob.id, amount: 25, idempotencyKey: key('tip'), actor: { type: 'service', id: 'tips' } });
+ await transfers.refund(t.ctx, { txnId: tip.txn.id, idempotencyKey: key('refund') });
+ const hold = await require('../server/ops/credits').createHold(t.ctx, {
+ subject: { type: 'user', id: alice.id }, max_nano_usd: '100000', rate_version: 'rc-activity',
+ request_fingerprint: 'fp-activity', idempotency_key: key('hold'),
+ });
+ await require('../server/ops/credits').settleHold(t.ctx, {
+ hold_id: hold.hold.hold_id, idempotency_key: key('settle'),
+ usage: { input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 1000 },
+ retail: { version: 'rc-activity', nano_usd_per_m: { input: '1000', cached_read: '0', cache_write: '0', output: '3000' }, per_request_nano_usd: '100' },
+ supply_cost_nano_usd: '0', byok: false,
+ attempts: [{ deployment_id: 'own:qwen/qwen3-4b:q4_k_m@3f2a1c9', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString() }],
+ });
+ const paid = await post(t.ctx, {
+ type: 'purchase', idempotencyKey: key('app'), test: true, actor: { type: 'service', id: 'billing' }, toSubject: alice.id,
+ entries: [entry(A.credit(alice.id), -50), entry(A.credit(MARKET), 50)],
+ metadata: { vibes_charge_id: 'vch_01JAB2C3D4E5F6G7H8J9K0MNPQ', app_name: 'Sticker Shop', description: 'Neon cats' },
+ });
+
+ await check('the caller\'s own rows carry exact amounts and plain labels', async () => {
+ const cors = await fetch(`${t.base}/api/me/activity`, { headers: { Authorization: `Bearer ${aliceToken}`, Origin: 'https://openvibe.network' } });
+ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.network');
+ assert.strictEqual(cors.headers.get('cache-control'), 'private, no-store');
+ const r = await get(aliceToken);
+ assert.strictEqual(r.status, 200, r.text);
+ const rows = r.body.activity;
+ const byLabel = (l) => rows.filter((x) => x.label === l);
+ assert.strictEqual(byLabel('Added money')[0].detail, 'Stripe');
+ assert.strictEqual(byLabel('Added money')[0].amount, '3000000000');
+ assert.strictEqual(byLabel('Moved to Studio')[0].amount, '-300000000');
+ assert.strictEqual(byLabel('Tipped @bob')[0].amount, -25);
+ assert.strictEqual(byLabel('Refund')[0].amount, 25);
+ assert.strictEqual(byLabel('Paid Sticker Shop')[0].amount, -50);
+ assert.strictEqual(byLabel('Paid Sticker Shop')[0].detail, 'Neon cats');
+ const ai = byLabel('AI usage')[0];
+ assert.strictEqual(ai.kind, 'balance');
+ assert.ok(ai.detail.includes('qwen/qwen3-4b:q4_k_m@3f2a1c9'), ai.detail);
+ assert.ok(ai.detail.includes('1 request'), ai.detail);
+ assert.strictEqual(ai.amount, '-104'); // 1000 input @ 1000/m = 1; 1000 output @ 3000/m = 3; +100 per request
+ assert.ok(rows.every((x) => x.kind === 'balance' || x.kind === 'vibes'));
+ assert.strictEqual(rows.length, 8);
+ });
+
+ await check('Get Vibes with Balance has two rows sharing the transaction id', async () => {
+ const r = await get(aliceToken);
+ const pair = r.body.activity.filter((x) => x.id === got.transaction_id);
+ assert.strictEqual(pair.length, 2, JSON.stringify(pair));
+ assert.deepStrictEqual(pair.map((x) => x.kind).sort(), ['balance', 'vibes']);
+ const bal = pair.find((x) => x.kind === 'balance');
+ const vib = pair.find((x) => x.kind === 'vibes');
+ assert.strictEqual(bal.label, 'Got Vibes');
+ assert.strictEqual(bal.amount, '-1000000000');
+ assert.strictEqual(vib.amount, 100);
+ assert.ok(pair.every((x) => x.id === got.transaction_id));
+ });
+
+ await check('only the caller\'s rows, another token sees none of them', async () => {
+ const mine = await get(aliceToken);
+ const theirs = await get(bobToken);
+ assert.strictEqual(theirs.status, 200, theirs.text);
+ const myIds = new Set(mine.body.activity.map((x) => x.id));
+ for (const id of [topup.txn.id, moved.transaction_id, got.transaction_id, tip.txn.id, paid.txn.id]) assert.ok(myIds.has(id), id);
+ assert.ok(!theirs.body.activity.some((x) => myIds.has(x.id)), 'bob sees none of alice\'s transactions');
+ assert.ok(!theirs.text.includes(alice.id), 'no other person\'s subject id');
+ assert.ok(!mine.text.includes(bob.id), 'a tip names the creator by public name only');
+ assert.ok(!/usr_[0-9A-HJKMNP-TV-Z]{26}/.test(mine.text), 'no subject ids in the activity text');
+ });
+
+ await check('pagination uses the last transaction id as the cursor', async () => {
+ const first = await get(aliceToken, '?limit=2');
+ assert.strictEqual(first.status, 200, first.text);
+ assert.ok(first.body.activity.length <= 4);
+ assert.ok(first.body.next_before);
+ const next = await get(aliceToken, `?limit=2&before=${encodeURIComponent(first.body.next_before)}`);
+ assert.strictEqual(next.status, 200, next.text);
+ const firstIds = new Set(first.body.activity.map((x) => x.id));
+ assert.ok(!next.body.activity.some((x) => firstIds.has(x.id)), 'pages do not overlap');
+ assert.strictEqual((await get(aliceToken, '?before=txn_nope')).status, 422);
+ assert.strictEqual((await fetch(`${t.base}/api/me/activity`)).status, 401);
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,209 @@
@@ -0,0 +1,209 @@
+'use strict';
+/**
+ * Balance (D12): personal and project accounts, GET /api/me/balance (and the /api/me/credits alias), transfers
+ * between them, and getting Vibes with Balance. Money is integer nano-USD; every write is idempotent and every
+ * journal stays balanced.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/app');
+const inference = require('../server/ops/credits');
+const { post } = require('../server/ledger');
+const { validate } = require('openvibe-contracts');
+
+const INF = ['billing.credit.hold.create', 'billing.credit.hold.settle', 'billing.credit.hold.release'];
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const PRJ2 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNP2';
+const PRJ3 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNP3';
+const PRJ4 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNP4';
+const prjBody = (subject, max, over = {}) => ({
+ idempotency_key: `hold:${crypto.randomBytes(8).toString('hex')}`,
+ subject: { type: 'user', id: subject.id },
+ max_nano_usd: String(max),
+ rate_version: 'rc-v1',
+ request_fingerprint: crypto.randomBytes(8).toString('hex'),
+ ...over,
+});
+
+async function assertJournalBalances(db, label) {
+ const rows = await db.prepare(`SELECT a.currency, SUM(e.amount)::text AS total FROM ledger_entries e
+ JOIN accounts a ON a.id = e.account_id GROUP BY a.currency`).all();
+ for (const r of rows) assert.strictEqual(String(r.total), '0', `journal not balanced for ${r.currency} ${label}: ${r.total}`);
+}
+
+(async () => {
+ console.log('Balance');
+ const t = await boot({ env: { BILLING_CREDITS: '1', BILLING_VIBES_FROM_BALANCE: '1' } });
+ // A real top-up booked the way checkout funding books one: money arrives at a provider and lands in an account.
+ const fund = (subjectId, projectId, amount) => post(t.ctx, {
+ type: 'credit', idempotencyKey: `test:fund:${crypto.randomBytes(8).toString('hex')}`, test: true,
+ actor: { type: 'service', id: 'billing' }, toSubject: subjectId,
+ entries: [
+ { kind: 'provider_clearing', owner: 'stripe', currency: 'nano-usd', amount: -BigInt(amount) },
+ { ...inference.A_INF.credit(subjectId, projectId), amount: BigInt(amount) },
+ ],
+ metadata: { credit: 'test_top_up' },
+ });
+ const balance = (subjectId, projectId) => inference.A_INF.credit(subjectId, projectId);
+ const create = (body, opts = {}) => t.call('POST', '/internal/credits/holds', { body, cap: INF, sub: 'svc:ai', ...opts });
+ const alice = t.user(1);
+ const token = t.network.signUser({ subject_id: alice.id, username: 'alice' });
+ const who = (u) => t.network.signUser({ subject_id: u.id, username: 'u' });
+
+ await check('a personal hold spends user:<usr_id>; a project hold spends project:<prj_id>', async () => {
+ await fund(alice.id, null, '100000');
+ await fund(null, PRJ, '200000');
+ const personal = await create(prjBody(alice, '40000', { project_id: undefined }));
+ assert.strictEqual(personal.status, 201, personal.text);
+ assert.strictEqual(personal.json.project_id, null);
+ const row = await t.db.prepare('SELECT subject_type, project_id, account_id FROM credit_holds WHERE id = ?').get(personal.json.hold_id);
+ assert.strictEqual(row.subject_type, 'user');
+ assert.strictEqual(row.project_id, null);
+ const acct = await t.db.prepare('SELECT owner_subject FROM accounts WHERE id = ?').get(row.account_id);
+ assert.strictEqual(acct.owner_subject, `user:${alice.id}`);
+ assert.strictEqual(await t.db.prepare('SELECT balance::text AS b FROM account_balances WHERE account_id = ?').get(row.account_id).then((x) => x.b), '100000');
+
+ const bob = t.user(2);
+ const project = await create(prjBody(bob, '50000', { project_id: PRJ }));
+ assert.strictEqual(project.status, 201, project.text);
+ const prow = await t.db.prepare('SELECT account_id FROM credit_holds WHERE id = ?').get(project.json.hold_id);
+ const pacct = await t.db.prepare('SELECT owner_subject FROM accounts WHERE id = ?').get(prow.account_id);
+ assert.strictEqual(pacct.owner_subject, `project:${PRJ}`);
+ assert.strictEqual((await t.db.prepare('SELECT balance::text AS b FROM account_balances WHERE account_id = ?').get(prow.account_id)).b, '200000');
+ await assertJournalBalances(t.db, 'after holds');
+ });
+
+ await check('GET /api/me/balance has the contract shape and only the projects the person administers', async () => {
+ t.network.addMembership(PRJ, alice.id, 'admin');
+ t.network.addMembership(PRJ2, alice.id, 'owner');
+ t.network.addMembership(PRJ3, alice.id, 'viewer');
+ t.network.addMembership(PRJ4, alice.id, 'owner', true);
+ const r = await fetch(`${t.base}/api/me/balance`, { headers: { Authorization: `Bearer ${token}`, Origin: 'https://openvibe.network' } });
+ assert.strictEqual(r.status, 200, await r.clone().text());
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.network');
+ const doc = await r.json();
+ const personalValid = validate('billing.balance@1', doc.personal);
+ assert.ok(personalValid.valid, JSON.stringify(personalValid.errors));
+ for (const p of doc.projects) {
+ const valid = validate('billing.balance@1', p);
+ assert.ok(valid.valid, JSON.stringify(valid.errors));
+ }
+ assert.deepStrictEqual(doc.personal.owner, { type: 'user', id: alice.id });
+ assert.strictEqual(typeof doc.personal.balance_nano_usd, 'string');
+ assert.deepStrictEqual(doc.projects.map((p) => p.owner.id), [PRJ, PRJ2].sort());
+ assert.ok(doc.projects.every((p) => p.owner.type === 'project' && typeof p.available_nano_usd === 'string'));
+ const alias = await (await fetch(`${t.base}/api/me/credits`, { headers: { Authorization: `Bearer ${token}` } })).json();
+ assert.deepStrictEqual(alias, doc, '/api/me/credits is the same answer');
+ assert.strictEqual((await fetch(`${t.base}/api/me/balance`)).status, 401);
+ await assertJournalBalances(t.db, 'after reads');
+ });
+
+ await check('a transfer is atomic and idempotent, and refuses a short Balance or a non-admin', async () => {
+ const dana = t.user(3);
+ t.network.addMembership(PRJ, dana.id, 'admin');
+ await fund(dana.id, null, '100000');
+ const post1 = (body, tok = who(dana)) => t.call('POST', '/api/me/balance/transfers', { body, token: tok });
+ const key = `tr:${crypto.randomBytes(8).toString('hex')}`;
+ const bad = await post1({ to_project_id: 'nope', amount_nano_usd: 5, idempotency_key: 'short' });
+ assert.strictEqual(bad.status, 422, bad.text);
+ assert.strictEqual(bad.json.code, 'billing.invalid_request');
+ const first = await post1({ to_project_id: PRJ, amount_nano_usd: '30000', idempotency_key: key });
+ assert.strictEqual(first.status, 201, first.text);
+ assert.strictEqual(first.json.from.balance_nano_usd, '70000');
+ assert.strictEqual(first.json.to.balance_nano_usd, String(200000 + 30000));
+ const replay = await post1({ to_project_id: PRJ, amount_nano_usd: '30000', idempotency_key: key });
+ assert.strictEqual(replay.status, 200, replay.text);
+ assert.strictEqual(replay.json.transfer_id, first.json.transfer_id);
+ assert.strictEqual(replay.json.from.balance_nano_usd, '70000', 'the replay moves nothing');
+ const one = await t.db.prepare('SELECT COUNT(*)::int AS n FROM transactions WHERE idempotency_key = ?').get(`balance:transfer:${key}`);
+ assert.strictEqual(one.n, 1);
+ const reused = await post1({ to_project_id: PRJ, amount_nano_usd: '1000', idempotency_key: key });
+ assert.strictEqual(reused.status, 409, reused.text);
+ const short = await post1({ to_project_id: PRJ, amount_nano_usd: '999999', idempotency_key: `tr:${crypto.randomBytes(8).toString('hex')}` });
+ assert.strictEqual(short.status, 402, short.text);
+ const eve = t.user(4);
+ t.network.addMembership(PRJ3, eve.id, 'viewer');
+ await fund(eve.id, null, '100000');
+ const denied = await post1({ to_project_id: PRJ3, amount_nano_usd: '1000', idempotency_key: `tr:${crypto.randomBytes(8).toString('hex')}` }, who(eve));
+ assert.strictEqual(denied.status, 403, denied.text);
+ assert.strictEqual(denied.json.code, 'billing.not_project_admin');
+ // Two different keys for all the money: exactly one wins, the other is short, never below zero.
+ const d2 = t.user(5);
+ t.network.addMembership(PRJ2, d2.id, 'owner');
+ await fund(d2.id, null, '10000');
+ const results = await Promise.all([
+ post1({ to_project_id: PRJ2, amount_nano_usd: '10000', idempotency_key: `tr:${crypto.randomBytes(8).toString('hex')}` }, who(d2)),
+ post1({ to_project_id: PRJ2, amount_nano_usd: '10000', idempotency_key: `tr:${crypto.randomBytes(8).toString('hex')}` }, who(d2)),
+ ]);
+ assert.strictEqual(results.filter((x) => x.status === 201).length, 1, JSON.stringify(results.map((x) => [x.status, x.json && x.json.code])));
+ const neg = await t.db.prepare("SELECT COUNT(*)::int AS n FROM account_balances b JOIN accounts a ON a.id = b.account_id WHERE a.kind IN ('usd_credit', 'usd_credit_sandbox') AND b.balance < 0").get();
+ assert.strictEqual(neg.n, 0, 'no account below zero');
+ // The same key at the same moment: one transfer, both answers the same id.
+ const gina = t.user(8);
+ t.network.addMembership(PRJ, gina.id, 'owner');
+ await fund(gina.id, null, '5000');
+ const sameKey = `tr:${crypto.randomBytes(8).toString('hex')}`;
+ const pair = await Promise.all([
+ post1({ to_project_id: PRJ, amount_nano_usd: '1000', idempotency_key: sameKey }, who(gina)),
+ post1({ to_project_id: PRJ, amount_nano_usd: '1000', idempotency_key: sameKey }, who(gina)),
+ ]);
+ assert.deepStrictEqual(pair.map((x) => x.status).sort(), [200, 201], JSON.stringify(pair.map((x) => [x.status, x.json])));
+ assert.strictEqual(pair[0].json.transfer_id, pair[1].json.transfer_id);
+ assert.strictEqual((await t.db.prepare('SELECT COUNT(*)::int AS n FROM transactions WHERE idempotency_key = ?').get(`balance:transfer:${sameKey}`)).n, 1);
+ assert.strictEqual((await t.db.prepare('SELECT balance::text AS b FROM account_balances b JOIN accounts a ON a.id = b.account_id WHERE a.owner_subject = ?').get(`user:${gina.id}`)).b, '4000');
+ await assertJournalBalances(t.db, 'after transfers');
+ });
+
+ await check('Vibes from Balance books exactly: USD to platform_revenue, Vibes minted as a pack', async () => {
+ const fred = t.user(6);
+ await fund(fred.id, null, '1000000000');
+ const buy = (body, tok = who(fred)) => t.call('POST', '/api/me/vibes/from-balance', { body, token: tok });
+ const key = `vb:${crypto.randomBytes(8).toString('hex')}`;
+ const r = await buy({ vibes: 100, idempotency_key: key });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.price_nano_usd, '1000000000', '100 Vibes at 100 per USD = $1 exactly');
+ assert.strictEqual(r.json.balance.balance_nano_usd, '0');
+ assert.strictEqual(r.json.vibes_credit, 100);
+ const txn = await t.db.prepare('SELECT * FROM transactions WHERE idempotency_key = ?').get(`balance:vibes:${key}`);
+ const entries = await t.db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, e.amount::text AS amount FROM ledger_entries e
+ JOIN accounts a ON a.id = e.account_id WHERE e.txn_id = ? ORDER BY a.currency, a.kind`).all(txn.id);
+ assert.deepStrictEqual(entries.map((e) => [e.kind, e.owner, e.currency, e.amount]).sort(), [
+ ['fx_conversion', null, 'vibes-bits', '-100'],
+ ['user_credit', fred.id, 'vibes-bits', '100'],
+ ['platform_revenue', null, 'nano-usd', '1000000000'],
+ ['usd_credit', `user:${fred.id}`, 'nano-usd', '-1000000000'],
+ ].sort());
+ const replay = await buy({ vibes: 100, idempotency_key: key });
+ assert.strictEqual(replay.status, 200, replay.text);
+ assert.strictEqual(replay.json.purchase_id, r.json.purchase_id);
+ const again = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE idempotency_key = ?").get(`balance:vibes:${key}`);
+ assert.strictEqual(again.n, 1);
+ const different = await buy({ vibes: 200, idempotency_key: key });
+ assert.strictEqual(different.status, 409, different.text);
+ const short = await buy({ vibes: 100, idempotency_key: `vb:${crypto.randomBytes(8).toString('hex')}` });
+ assert.strictEqual(short.status, 402, short.text);
+ await assertJournalBalances(t.db, 'after Vibes from Balance');
+ });
+
+ await check('billing.vibes is served by the D11 store with its danger keys', async () => {
+ const r = await t.call('GET', '/internal/config/billing.vibes', { cap: ['billing.config.manage'] });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.namespace, 'billing.vibes');
+ for (const k of ['vibes_pay', 'max_charge_vibes', 'daily_app_spend_vibes_per_person', 'from_balance']) assert.ok(r.json.schema.properties[k], `${k} is in the schema`);
+ assert.strictEqual(r.json.schema.properties.vibes_pay['x-danger'], true);
+ assert.strictEqual(r.json.schema.properties.from_balance['x-danger'], true);
+ });
+
+ await check('the disabled switch refuses Get Vibes with Balance', async () => {
+ const off = await boot({ env: { BILLING_CREDITS: '1' } });
+ try {
+ const u = off.user(7);
+ const r = await off.call('POST', '/api/me/vibes/from-balance', { body: { vibes: 100, idempotency_key: 'vb:off:0001' }, token: off.network.signUser({ subject_id: u.id, username: 'off' }) });
+ assert.strictEqual(r.status, 404, r.text);
+ assert.strictEqual(r.json.code, 'billing.vibes_from_balance_disabled');
+ } finally { await off.close(); }
+ await assertJournalBalances(t.db, 'at the end');
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
/**
/**
* Billing's capability declarations are the estate's: every `docs/capabilities-proposal/<id>.json` is
* Billing's capability declarations are the estate's: every `docs/capabilities-proposal/<id>.json` is
* the capability of that id in the pinned openvibe-contracts, it is listed by that package's billing
* the capability of that id in the pinned openvibe-contracts, it is listed by that package's billing
- * service manifest, and each `implementedBy` is a real route in `server/api/v1.js`.
+ * service manifest, and each `implementedBy` is a real route in `server/api/v1.js`, the Credits
+ * router (`server/api/internal.js`) or the owner-settings router (`server/internal-config.js`).
*
*
* This is the check a Network grant needs. A default grant naming a capability that exists nowhere in
* This is the check a Network grant needs. A default grant naming a capability that exists nowhere in
* the estate is inert — `auth.needs()` never matches it and a producer's posts 401 — so a capability
* the estate is inert — `auth.needs()` never matches it and a producer's posts 401 — so a capability
@@ -20,6 +21,10 @@ const proposals = fs.readdirSync(proposalDir).filter((f) => f.endsWith('.json'))
@@ -20,6 +21,10 @@ const proposals = fs.readdirSync(proposalDir).filter((f) => f.endsWith('.json'))
.map((f) => JSON.parse(fs.readFileSync(path.join(proposalDir, f), 'utf8')));
.map((f) => JSON.parse(fs.readFileSync(path.join(proposalDir, f), 'utf8')));
const service = JSON.parse(fs.readFileSync(path.join(contractsDir, 'manifests', 'services', 'billing.json'), 'utf8'));
const service = JSON.parse(fs.readFileSync(path.join(contractsDir, 'manifests', 'services', 'billing.json'), 'utf8'));
const routes = fs.readFileSync(path.join(root, 'server', 'api', 'v1.js'), 'utf8');
const routes = fs.readFileSync(path.join(root, 'server', 'api', 'v1.js'), 'utf8');
+// The Credits capabilities (D9) are served by the /internal/credits router (server/api/internal.js), not /api/v1.
+const internalRoutes = fs.readFileSync(path.join(root, 'server', 'api', 'internal.js'), 'utf8');
+// The owner-settings capability (D11) is served by /internal/config (server/internal-config.js).
+const configRoutes = fs.readFileSync(path.join(root, 'server', 'internal-config.js'), 'utf8');
assert.ok(proposals.length > 0, 'docs/capabilities-proposal has no capability declarations');
assert.ok(proposals.length > 0, 'docs/capabilities-proposal has no capability declarations');
@@ -36,9 +41,13 @@ for (const p of proposals) {
@@ -36,9 +41,13 @@ for (const p of proposals) {
for (const impl of p.implementedBy) {
for (const impl of p.implementedBy) {
// 'POST /api/v1/usage' or 'GET|POST /api/v1/admin/freeze'.
// 'POST /api/v1/usage' or 'GET|POST /api/v1/admin/freeze'.
const [methods, url] = impl.split(' ');
const [methods, url] = impl.split(' ');
- const route = url.replace(/^\/api\/v1/, '');
+ const credits = /^\/internal\/credits(\/|$)/.test(url);
+ const config = /^\/internal\/config(\/|$)/.test(url);
+ const route = credits ? url.replace(/^\/internal\/credits/, '') : config ? url : url.replace(/^\/api\/v1/, '');
+ const source = credits ? internalRoutes : config ? configRoutes : routes;
+ const where = credits ? 'server/api/internal.js' : config ? 'server/internal-config.js' : 'server/api/v1.js';
for (const m of methods.split('|')) {
for (const m of methods.split('|')) {
- assert.ok(routes.includes(`r.${m.toLowerCase()}('${route}'`), `${p.id}: ${impl} is not a route in server/api/v1.js`);
+ assert.ok(source.includes(`r.${m.toLowerCase()}('${route}'`), `${p.id}: ${impl} is not a route in ${where}`);
}
}
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,333 @@
@@ -0,0 +1,333 @@
+'use strict';
+/**
+ * Credits top-up (D9): a person pays USD through Billing's multi-purpose provider pipeline (PowerChat, Stripe,
+ * PayPal, CCBill, NOWPayments) and the one shared credits_topup plan credits their (subject, project_id) usd_credit
+ * account at nano-USD precision. The fee the owner set goes to platform_revenue, replay credits once, and a refund
+ * after partial spend leaves the balance at 0 with the spent part recorded as a debt. Everything is behind
+ * BILLING_CREDITS_TOPUP; the terms come only from topupTerms(ctx), and nothing is configured by default.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { startProviderStub } = require('./helpers/stubs');
+
+const INF = ['billing.credit.hold.create', 'billing.credit.hold.extend', 'billing.credit.hold.settle', 'billing.credit.hold.release'];
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const ZERO_RETAIL = { version: 'rc-topup', nano_usd_per_m: { input: '0', cached_read: '0', cache_write: '0', output: '0' }, per_request_nano_usd: '4000000000' };
+const evt = (id, type, object) => ({ id, type, data: { object } });
+
+async function assertJournalBalances(db, label) {
+ const rows = await db.prepare(`SELECT a.currency, SUM(e.amount)::text AS total FROM ledger_entries e
+ JOIN accounts a ON a.id = e.account_id GROUP BY a.currency`).all();
+ for (const r of rows) assert.strictEqual(String(r.total), '0', `journal not balanced for ${r.currency} ${label}: ${r.total}`);
+}
+
+const nanoBalance = async (t, kind) => (await t.db.prepare(`SELECT COALESCE(SUM(b.balance), 0)::text AS n FROM accounts a
+ JOIN account_balances b ON b.account_id = a.id WHERE a.kind = ? AND a.currency = 'nano-usd'`).get(kind)).n;
+
+(async () => {
+ console.log('credits top-up');
+ const pp = await startProviderStub();
+ const t = await boot({
+ stripe: true,
+ env: {
+ BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1',
+ BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000,2500', BILLING_CREDITS_TOPUP_FEE_BPS: '250',
+ PAYPAL_CLIENT_ID: 'pp', PAYPAL_CLIENT_SECRET: 'pps', PAYPAL_WEBHOOK_ID: 'WH-1', PAYPAL_API_BASE: pp.url,
+ },
+ });
+ const alice = t.user(201);
+ t.network.addMembership(PRJ, alice.id, 'owner');
+ const aliceToken = t.network.signUser({ subject_id: alice.id, username: 'alice' });
+ const start = (body, token = aliceToken) => t.call('POST', '/api/me/credits/topups', { body, token });
+ const read = async (token = aliceToken) => (await fetch(`${t.base}/api/me/credits?project_id=${PRJ}`, { headers: { Authorization: `Bearer ${token}` } })).json();
+ const pcDonation = (eventId, cents, ref) => ({ type: 'donation.completed', streamer: { id: 'pc1', username: 'openvibe' }, data: { eventId, amountUsdCents: cents, appExternalRef: ref } });
+ const paypalHook = async (event) => {
+ const r = await fetch(`${t.base}/webhooks/paypal`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'paypal-transmission-sig': 'good', 'paypal-transmission-id': 'x', 'paypal-auth-algo': 'SHA256withRSA' },
+ body: JSON.stringify(event),
+ });
+ return { status: r.status, json: await r.json() };
+ };
+
+ await check('topupTerms is the one reader of the terms: env today, no defaults, invalid refuses', async () => {
+ const { topupTerms } = require('../server/ops/credits');
+ assert.deepStrictEqual(topupTerms(t.ctx), { enabled: true, amountsCents: [1000, 2500], feeBps: 250, configured: true, problem: null });
+ const off = topupTerms({ env: {} });
+ assert.deepStrictEqual([off.enabled, off.configured, off.amountsCents, off.feeBps], [false, false, [], null]);
+ const bad = topupTerms({ env: { BILLING_CREDITS_TOPUP: '1', BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000,abc', BILLING_CREDITS_TOPUP_FEE_BPS: '250' } });
+ assert.strictEqual(bad.configured, false);
+ assert.match(bad.problem, /invalid amount/);
+ const zero = topupTerms({ env: { BILLING_CREDITS_TOPUP: '1', BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000', BILLING_CREDITS_TOPUP_FEE_BPS: '0' } });
+ assert.deepStrictEqual([zero.configured, zero.feeBps], [true, 0], '0 bps is a configured fee');
+ });
+
+ let topupIntent;
+ await check('Stripe: a person starts an allowed top-up; the existing checkout charges the amount plus the fee', async () => {
+ const r = await start({ provider: 'stripe', project_id: PRJ, amount_cents: 1000 });
+ assert.strictEqual(r.status, 201, r.text);
+ topupIntent = r.json.intent;
+ assert.strictEqual(topupIntent.kind, 'credits_topup');
+ assert.strictEqual(topupIntent.project_id, PRJ);
+ assert.strictEqual(topupIntent.amount_cents, 1025, 'the provider charges the top-up plus the 2.5% fee');
+ assert.strictEqual(topupIntent.fee_cents, 25);
+ assert.strictEqual(topupIntent.topup.amount_nano_usd, '10000000000');
+ assert.match(r.json.checkout_url, /checkout\.stripe\.test/);
+ const call = t.stripe.calls[t.stripe.calls.length - 1];
+ assert.strictEqual(call.url, '/checkout/sessions');
+ assert.strictEqual(call.body['metadata[intent_id]'], topupIntent.id);
+ assert.strictEqual(call.body['line_items[0][price_data][unit_amount]'], '1025');
+ assert.strictEqual(call.body.mode, 'payment');
+ });
+
+ await check('an amount the owner did not list is 422 and opens no checkout', async () => {
+ const before = t.stripe.calls.length;
+ const r = await start({ provider: 'stripe', project_id: PRJ, amount_cents: 999 });
+ assert.strictEqual(r.status, 422, r.text);
+ assert.strictEqual(r.json.code, 'billing.amount_not_allowed');
+ assert.strictEqual(t.stripe.calls.length, before);
+ const n = await t.db.prepare("SELECT COUNT(*)::int AS n FROM payment_intents WHERE kind = 'credits_topup'").get();
+ assert.strictEqual(n.n, 1);
+ });
+
+ await check('Stripe: the paid webhook credits exactly the amount in nano-USD and books the fee to platform_revenue', async () => {
+ const r = await t.stripeEvent(evt('evt_topup_1', 'checkout.session.completed', {
+ id: 'cs_topup_1', mode: 'payment', payment_status: 'paid', payment_intent: 'pi_topup_1', amount_total: 1025,
+ metadata: { intent_id: topupIntent.id },
+ }));
+ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
+ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
+ const s = await read();
+ assert.strictEqual(s.balance_nano_usd, '10000000000');
+ assert.strictEqual(s.topups.length, 1);
+ assert.strictEqual(s.topups[0].status, 'settled');
+ assert.strictEqual(s.topups[0].amount_nano_usd, '10000000000');
+ assert.strictEqual(s.topups[0].fee_nano_usd, '250000000');
+ assert.strictEqual(await nanoBalance(t, 'platform_revenue'), '250000000', 'the fee is revenue');
+ assert.strictEqual(await nanoBalance(t, 'provider_clearing'), '-10250000000', 'the provider is holding the paid cents');
+ await assertJournalBalances(t.db, 'after the paid top-up');
+ });
+
+ await check('Stripe: a redelivery, and a second provider event for the same payment, credit exactly once', async () => {
+ const paid = evt('evt_topup_1', 'checkout.session.completed', {
+ id: 'cs_topup_1', mode: 'payment', payment_status: 'paid', payment_intent: 'pi_topup_1', amount_total: 1025,
+ metadata: { intent_id: topupIntent.id },
+ });
+ const dup = await t.stripeEvent(paid);
+ assert.strictEqual(dup.json.duplicate, true);
+ const other = await t.stripeEvent(evt('evt_topup_1b', 'checkout.session.completed', {
+ id: 'cs_topup_1', mode: 'payment', payment_status: 'paid', payment_intent: 'pi_topup_1', amount_total: 1025,
+ metadata: { intent_id: topupIntent.id },
+ }));
+ assert.strictEqual(other.status, 200, JSON.stringify(other.json));
+ assert.strictEqual(other.json.result.effect, 'duplicate_receipt', JSON.stringify(other.json));
+ const n = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE json_extract(metadata, '$.credit') = 'topup'").get();
+ assert.strictEqual(n.n, 1);
+ assert.strictEqual((await read()).balance_nano_usd, '10000000000');
+ });
+
+ await check('Stripe: a refund after partial spend debits what remains, leaves the balance at 0 and records the debt', async () => {
+ // Spend 4.00 USD of the 10.00 USD through the inference subledger.
+ const hold = await t.call('POST', '/internal/credits/holds', {
+ cap: INF, sub: 'svc:ai',
+ body: { idempotency_key: 'topup-spend-hold', subject: { type: 'user', id: alice.id }, project_id: PRJ, max_nano_usd: '4000000000', rate_version: 'rc-topup', request_fingerprint: 'topup-spend-fp' },
+ });
+ assert.strictEqual(hold.status, 201, hold.text);
+ const settled = await t.call('POST', `/internal/credits/holds/${hold.json.hold_id}/settle`, {
+ cap: INF, sub: 'svc:ai',
+ body: {
+ idempotency_key: 'topup-spend-settle', usage: { input_tokens: 0, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 0 },
+ retail: ZERO_RETAIL, supply_cost_nano_usd: '0', byok: false, attempts: [],
+ },
+ });
+ assert.strictEqual(settled.status, 201, settled.text);
+ assert.strictEqual(settled.json.charge_nano_usd, '4000000000');
+ assert.strictEqual((await read()).balance_nano_usd, '6000000000');
+
+ const r = await t.stripeEvent(evt('evt_topup_refund', 'charge.refunded', { id: 'ch_topup_1', payment_intent: 'pi_topup_1', amount_refunded: 1025 }));
+ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
+ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
+
+ const s = await read();
+ assert.strictEqual(s.balance_nano_usd, '0', 'the account never goes below zero');
+ assert.strictEqual(s.available_nano_usd, '0');
+ assert.strictEqual(s.topups[0].refunded_nano_usd, '6000000000');
+ assert.strictEqual(s.topups[0].debt_nano_usd, '4000000000', 'the spent part is a debt');
+ assert.strictEqual(s.topups[0].status, 'refunded_with_debt');
+ const debts = await t.db.prepare('SELECT * FROM credit_topup_debts').all();
+ assert.strictEqual(debts.length, 1);
+ assert.strictEqual(String(debts[0].amount_nano_usd), '4000000000');
+ assert.strictEqual(await nanoBalance(t, 'platform_revenue'), '4000000000', 'the fee is returned; the collected charge stays revenue');
+ assert.strictEqual(await nanoBalance(t, 'credit_topup_debt'), '-4000000000');
+ await assertJournalBalances(t.db, 'after the top-up refund');
+
+ const rec = await t.assertReconciled('after the top-up refund');
+ assert.strictEqual(rec.warnings.credit_debts.length, 1, 'reconciliation reports the debt');
+ assert.strictEqual(rec.totals.credit_topup_debt_nano_usd, '4000000000');
+ });
+
+ await check('PowerChat: a site-account tip carrying pccredit:<intent> settles through the shared plan, once', async () => {
+ const bob = t.user(202);
+ t.network.addMembership(PRJ, bob.id, 'owner');
+ const bobToken = t.network.signUser({ subject_id: bob.id, username: 'bob' });
+ const r = await start({ provider: 'powerchat', project_id: PRJ, amount_cents: 1000 }, bobToken);
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.checkout_url, null, 'PowerChat has no hosted checkout URL');
+ assert.strictEqual(r.json.intent.checkout_ref, `pccredit:${r.json.intent.id}`);
+ const donation = pcDonation('don-cred-pc', 1025, r.json.intent.checkout_ref);
+ const first = await t.powerchat(donation, { deliveryId: 'dlv-cred-pc' });
+ assert.strictEqual(first.status, 200, JSON.stringify(first));
+ assert.strictEqual(first.json.result.effect, 'settled', JSON.stringify(first));
+ assert.strictEqual((await read(bobToken)).balance_nano_usd, '10000000000');
+ const dup = await t.powerchat(donation, { deliveryId: 'dlv-cred-pc' });
+ assert.strictEqual(dup.json.duplicate, true);
+ const other = await t.powerchat(donation, { deliveryId: 'dlv-cred-pc-retry' });
+ assert.strictEqual(other.json.result.effect, 'duplicate_receipt', JSON.stringify(other));
+
+ const refund = await t.powerchat({
+ type: 'donation.refunded', streamer: { id: 'pc1', username: 'openvibe' },
+ data: { eventId: 'rf-cred-pc', originalEventId: 'don-cred-pc', refundedUsdCents: 1025 },
+ });
+ assert.strictEqual(refund.status, 200, JSON.stringify(refund));
+ assert.strictEqual(refund.json.result.effect, 'settled', JSON.stringify(refund));
+ const s = await read(bobToken);
+ assert.strictEqual(s.balance_nano_usd, '0', 'a full refund of unspent credit leaves nothing');
+ assert.strictEqual(s.topups[0].status, 'refunded');
+ assert.strictEqual(s.topups[0].debt_nano_usd, '0');
+ });
+
+ await check('PayPal: the capture completes the shared plan; a replay and a second event for it credit once', async () => {
+ const carol = t.user(203);
+ t.network.addMembership(PRJ, carol.id, 'owner');
+ const carolToken = t.network.signUser({ subject_id: carol.id, username: 'carol' });
+ const r = await start({ provider: 'paypal', project_id: PRJ, amount_cents: 1000 }, carolToken);
+ assert.strictEqual(r.status, 201, r.text);
+ assert.match(r.json.checkout_url, /paypal\.test/);
+ const order = pp.calls.find((c) => c.url === '/v2/checkout/orders');
+ const orderBody = JSON.parse(order.raw);
+ assert.strictEqual(orderBody.purchase_units[0].custom_id, r.json.intent.id);
+ assert.strictEqual(orderBody.purchase_units[0].amount.value, '10.25');
+
+ const event = {
+ id: 'WH-TOPUP-1', event_type: 'PAYMENT.CAPTURE.COMPLETED',
+ resource: { id: 'CAP-TOPUP', custom_id: r.json.intent.id, amount: { currency_code: 'USD', value: '10.25' } },
+ };
+ const paid = await paypalHook(event);
+ assert.strictEqual(paid.status, 200, JSON.stringify(paid));
+ assert.strictEqual(paid.json.result.effect, 'settled', JSON.stringify(paid));
+ assert.strictEqual((await read(carolToken)).balance_nano_usd, '10000000000');
+ const dup = await paypalHook(event);
+ assert.strictEqual(dup.json.duplicate, true);
+ const other = await paypalHook({ ...event, id: 'WH-TOPUP-1b' });
+ assert.strictEqual(other.json.result.effect, 'duplicate_receipt', JSON.stringify(other));
+
+ const refund = await paypalHook({
+ id: 'WH-TOPUP-RF', event_type: 'PAYMENT.CAPTURE.REFUNDED',
+ resource: { id: 'RF-TOPUP', amount: { currency_code: 'USD', value: '10.25' }, links: [{ rel: 'up', href: 'https://api.paypal.test/v2/payments/captures/CAP-TOPUP' }] },
+ });
+ assert.strictEqual(refund.status, 200, JSON.stringify(refund));
+ assert.strictEqual(refund.json.result.effect, 'settled', JSON.stringify(refund));
+ const s = await read(carolToken);
+ assert.strictEqual(s.balance_nano_usd, '0');
+ assert.strictEqual(s.topups[0].status, 'refunded');
+ await assertJournalBalances(t.db, 'after every provider top-up');
+ await t.assertReconciled('after powerchat and paypal top-ups');
+ });
+
+ await check('a paid event while the top-up switch is off is recorded for review and moves no money', async () => {
+ // The owner sets the terms live (no env pin): turn the top-up on, open a checkout, then turn it off
+ // before the provider's paid delivery arrives.
+ const off = await boot({ stripe: true, env: { BILLING_CREDITS: '1' } });
+ const dave = off.user(204);
+ off.network.addMembership(PRJ, dave.id, 'owner');
+ const daveToken = off.network.signUser({ subject_id: dave.id, username: 'dave' });
+ const actor = { type: 'user', id: dave.id };
+ try {
+ await off.ctx.configStore.apply({ topup: true, topup_amounts_cents: [1000], topup_fee_bps: 250 }, { actor, reason: 'top-up test terms' });
+ const r = await off.call('POST', '/api/me/credits/topups', { token: daveToken, body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 } });
+ assert.strictEqual(r.status, 201, r.text);
+ await off.ctx.configStore.apply({ topup: false }, { merge: true, actor, reason: 'switch the top-up off' });
+ const paid = await off.stripeEvent(evt('evt_topup_off', 'checkout.session.completed', {
+ id: 'cs_topup_off', mode: 'payment', payment_status: 'paid', payment_intent: 'pi_topup_off', amount_total: 1025,
+ metadata: { intent_id: r.json.intent.id },
+ }));
+ assert.strictEqual(paid.status, 200, JSON.stringify(paid));
+ assert.strictEqual(paid.json.result.effect, 'none', JSON.stringify(paid.json));
+ assert.strictEqual(paid.json.result.review, true);
+ const statement = await (await fetch(`${off.base}/api/me/credits?project_id=${PRJ}`, { headers: { Authorization: `Bearer ${daveToken}` } })).json();
+ assert.strictEqual(statement.topups.length, 0, 'no credit and no charge');
+ const txns = await off.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE json_extract(metadata, '$.intent_id') = ?").get(r.json.intent.id);
+ assert.strictEqual(txns.n, 0);
+ await off.assertReconciled('after a refused paid top-up');
+ } finally { await off.close(); }
+ });
+
+ await check('the economy freeze refuses a top-up before any intent or checkout', async () => {
+ await require('../server/ops/admin').setFreeze(t.ctx, { on: true, reason: 'top-up test' });
+ const before = t.stripe.calls.length;
+ const intentsBefore = (await t.db.prepare("SELECT COUNT(*)::int AS n FROM payment_intents WHERE kind = 'credits_topup'").get()).n;
+ const r = await start({ provider: 'stripe', project_id: PRJ, amount_cents: 1000 });
+ assert.strictEqual(r.status, 503, r.text);
+ assert.strictEqual(r.json.code, 'billing.frozen');
+ assert.strictEqual(t.stripe.calls.length, before);
+ const intentsAfter = (await t.db.prepare("SELECT COUNT(*)::int AS n FROM payment_intents WHERE kind = 'credits_topup'").get()).n;
+ assert.strictEqual(intentsAfter, intentsBefore, 'no intent while frozen');
+ await require('../server/ops/admin').setFreeze(t.ctx, { on: false });
+ });
+
+ await t.close();
+ await pp.close();
+
+ await check('unset amounts or fee refuses with 503 billing.topup_unconfigured', async () => {
+ const none = await boot({ env: { BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1' } });
+ const u = none.user(202);
+ const r = await none.call('POST', '/api/me/credits/topups', {
+ token: none.network.signUser({ subject_id: u.id, username: 'u' }),
+ body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 },
+ });
+ assert.strictEqual(r.status, 503, r.text);
+ assert.strictEqual(r.json.code, 'billing.topup_unconfigured');
+ await none.close();
+
+ const bad = await boot({ env: { BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1', BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000', BILLING_CREDITS_TOPUP_FEE_BPS: 'abc' } });
+ const v = bad.user(203);
+ const n = await bad.call('POST', '/api/me/credits/topups', {
+ token: bad.network.signUser({ subject_id: v.id, username: 'v' }),
+ body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 },
+ });
+ assert.strictEqual(n.status, 503, n.text);
+ assert.strictEqual(n.json.code, 'billing.topup_unconfigured');
+ await bad.close();
+ });
+
+ await check('with BILLING_CREDITS_TOPUP off nothing opens: 404, no intent, no checkout; the statement still reads', async () => {
+ const off = await boot({ stripe: true, env: { BILLING_CREDITS: '1' } });
+ const w = off.user(204);
+ off.network.addMembership(PRJ, w.id, 'owner');
+ const token = off.network.signUser({ subject_id: w.id, username: 'w' });
+ const r = await off.call('POST', '/api/me/credits/topups', { token, body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 } });
+ assert.strictEqual(r.status, 404, r.text);
+ assert.strictEqual(r.json.code, 'billing.topup_disabled');
+ assert.strictEqual(off.stripe.calls.length, 0);
+ const n = await off.db.prepare("SELECT COUNT(*)::int AS n FROM payment_intents WHERE kind = 'credits_topup'").get();
+ assert.strictEqual(n.n, 0);
+ const s = await (await fetch(`${off.base}/api/me/credits?project_id=${PRJ}`, { headers: { Authorization: `Bearer ${token}` } })).json();
+ assert.deepStrictEqual(s.topups, []);
+ await off.close();
+ });
+
+ await check('a fee of 0 is allowed (it is set, not defaulted)', async () => {
+ const free = await boot({ stripe: true, env: { BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1', BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000', BILLING_CREDITS_TOPUP_FEE_BPS: '0' } });
+ const x = free.user(205);
+ const r = await free.call('POST', '/api/me/credits/topups', {
+ token: free.network.signUser({ subject_id: x.id, username: 'x' }),
+ body: { provider: 'stripe', project_id: PRJ, amount_cents: 1000 },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.intent.amount_cents, 1000);
+ assert.strictEqual(r.json.intent.fee_cents, 0);
+ await free.close();
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,365 @@
@@ -0,0 +1,365 @@
+'use strict';
+/**
+ * Credits: the prepaid USD balance for metered services (AI Exchange D1, D2; migrations/0011_inference_subledger.sql, ops/inference.js,
+ * api/internal.js): holds, extends, settlements and releases in nano-USD, behind BILLING_CREDITS.
+ * Sandbox grants are behind BILLING_CREDITS_SANDBOX and land in the person's own sandbox account, which only a hold
+ * marked sandbox spends (D10). Real credit comes from a top-up; until checkout funding exists, `fund()` below books
+ * one straight through the ledger. Nothing here touches chargeFor or Vibes bits.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/app');
+const inference = require('../server/ops/credits');
+const { post } = require('../server/ledger');
+
+const INF = ['billing.credit.hold.create', 'billing.credit.hold.extend', 'billing.credit.hold.settle', 'billing.credit.hold.release'];
+const SBX = ['billing.credit.sandbox.grant'];
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+
+function retail(over = {}) {
+ return {
+ version: 'rc-v1',
+ nano_usd_per_m: { input: '1000', cached_read: '500', cache_write: '2000', output: '3000' },
+ per_request_nano_usd: '100',
+ ...over,
+ };
+}
+
+const RATE_CARD = retail();
+
+function usage(u = {}) {
+ return { input_tokens: 0, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 0, ...u };
+}
+
+function settleBody(u, over = {}) {
+ return {
+ idempotency_key: `settle:${crypto.randomBytes(8).toString('hex')}`,
+ usage: usage(u),
+ retail: RATE_CARD,
+ supply_cost_nano_usd: '0',
+ byok: false,
+ attempts: [],
+ ...over,
+ };
+}
+
+function holdBody(subject, max, over = {}) {
+ return {
+ idempotency_key: `hold:${crypto.randomBytes(8).toString('hex')}`,
+ subject: { type: 'user', id: subject.id },
+ max_nano_usd: String(max),
+ rate_version: 'rc-v1',
+ request_fingerprint: crypto.randomBytes(8).toString('hex'),
+ // No project means the person's own Balance (D12); a project hold passes project_id.
+ ...over,
+ };
+}
+
+async function assertJournalBalances(db, label) {
+ const rows = await db.prepare(`SELECT a.currency, SUM(e.amount)::text AS total FROM ledger_entries e
+ JOIN accounts a ON a.id = e.account_id GROUP BY a.currency`).all();
+ for (const r of rows) {
+ assert.strictEqual(String(r.total), '0', `journal not balanced for ${r.currency} ${label}: ${r.total}`);
+ }
+}
+
+(async () => {
+ console.log('credits ledger');
+ const t = await boot({ env: { BILLING_CREDITS: '1', BILLING_CREDITS_SANDBOX: '1' } });
+ const create = (body, opts = {}) => t.call('POST', '/internal/credits/holds', { body, cap: INF, sub: 'svc:ai', ...opts });
+ const extend = (id, add, opts = {}) => t.call('POST', `/internal/credits/holds/${id}/extend`, { body: { add_nano_usd: String(add) }, cap: INF, sub: 'svc:ai', ...opts });
+ const settle = (id, body, opts = {}) => t.call('POST', `/internal/credits/holds/${id}/settle`, { body, cap: INF, sub: 'svc:ai', ...opts });
+ const release = (id, opts = {}) => t.call('POST', `/internal/credits/holds/${id}/release`, { body: {}, cap: INF, sub: 'svc:ai', ...opts });
+ const grant = (body, opts = {}) => t.call('POST', '/internal/credits/sandbox/grants', { body, cap: SBX, sub: 'svc:ai', ...opts });
+ // A real top-up, booked the way checkout funding will book one (money received at the provider -> the person's Credits).
+ const fund = (user, amount, projectId = null) => post(t.ctx, {
+ type: 'credit', idempotencyKey: `test:fund:${crypto.randomBytes(8).toString('hex')}`, test: true,
+ actor: { type: 'service', id: 'billing' }, toSubject: user.id,
+ entries: [
+ { kind: 'provider_clearing', owner: 'stripe', currency: 'nano-usd', amount: -BigInt(amount) },
+ { ...inference.A_INF.credit(user.id, projectId), amount: BigInt(amount) },
+ ],
+ metadata: { credit: 'test_top_up' },
+ });
+
+ const alice = t.user(1);
+
+ await check('sandbox grant creates an inference credit account and is marked test', async () => {
+ const r = await grant({ subject: { type: 'user', id: alice.id }, project_id: PRJ, amount_nano_usd: '100000' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.amount_nano_usd, '100000');
+ assert.strictEqual(r.json.balance_nano_usd, '100000');
+ const txn = await t.db.prepare("SELECT * FROM transactions WHERE idempotency_key = ?").get(`credit:sandbox:${r.json.grant_id}`);
+ assert.strictEqual(txn.test, 1);
+ const entries = (await t.db.prepare('SELECT a.kind, a.owner_subject AS owner, a.currency, e.amount::text AS amount FROM ledger_entries e JOIN accounts a ON a.id = e.account_id WHERE e.txn_id = ?').all(txn.id));
+ assert.deepStrictEqual(entries.map((e) => [e.kind, e.owner, e.currency, e.amount]).sort(),
+ [['usd_credit_sandbox', null, 'nano-usd', '-100000'], ['usd_credit_sandbox', `project:${PRJ}`, 'nano-usd', '100000']]);
+ await assertJournalBalances(t.db, 'after sandbox grant');
+ });
+
+ await check('create hold returns 201 with held, available and lease; 402 when balance is short', async () => {
+ await fund(alice, '100000');
+ const r = await create(holdBody(alice, 50000));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.match(r.json.hold_id, /^hld_/);
+ assert.strictEqual(r.json.held_nano_usd, '50000');
+ assert.strictEqual(r.json.available_nano_usd, '50000');
+ assert.ok(new Date(r.json.lease_expires_at).getTime() > Date.now());
+ const poor = t.user(2);
+ const denied = await create(holdBody(poor, 1));
+ assert.strictEqual(denied.status, 402, denied.text);
+ assert.strictEqual(denied.json.code, 'billing.insufficient_funds');
+ });
+
+ await check('same idempotency key + same fingerprint replays the same hold; different fingerprint is 409', async () => {
+ const bob = t.user(3);
+ await fund(bob, '10000');
+ const body = holdBody(bob, 5000);
+ const first = await create(body);
+ assert.strictEqual(first.status, 201, first.text);
+ const replay = await create(body);
+ assert.strictEqual(replay.status, 200, replay.text);
+ assert.strictEqual(replay.json.hold_id, first.json.hold_id);
+ const changed = await create({ ...body, request_fingerprint: 'different-fp' });
+ assert.strictEqual(changed.status, 409, changed.text);
+ assert.strictEqual(changed.json.code, 'billing.hold_key_reused');
+ });
+
+ await check('extend grows the hold; cannot extend beyond available balance', async () => {
+ const carol = t.user(4);
+ await fund(carol, '20000');
+ const h = await create(holdBody(carol, 5000));
+ const ext = await extend(h.json.hold_id, 5000);
+ assert.strictEqual(ext.status, 200, ext.text);
+ assert.strictEqual(ext.json.held_nano_usd, '10000');
+ const over = await extend(h.json.hold_id, 20000);
+ assert.strictEqual(over.status, 402, over.text);
+ assert.strictEqual(over.json.code, 'billing.insufficient_funds');
+ });
+
+ await check('4-component rounding: ceil once per component per request matches worked example', async () => {
+ // input 1,500,000 tokens @ 1000/m = ceil(1500000*1000/1e6) = 1500 nano
+ // cached_read 2,000,000 @ 500/m = ceil(2000000*500/1e6) = 1000 nano
+ // cache_write 1 @ 2000/m = ceil(1*2000/1e6) = 1 nano
+ // output 1 @ 3000/m = ceil(1*3000/1e6) = 1 nano
+ // per_request = 100
+ // total = 2602 nano
+ const dave = t.user(5);
+ await fund(dave, '10000000');
+ const h = await create(holdBody(dave, 10000000));
+ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 1500000, cached_read_tokens: 2000000, cache_write_tokens: 1, output_tokens: 1 }));
+ assert.strictEqual(s.status, 201, s.text);
+ assert.strictEqual(s.json.charge_nano_usd, '2602');
+ assert.strictEqual(s.json.released_nano_usd, '9997398');
+ });
+
+ await check('BYOK charges 0 regardless of usage', async () => {
+ const erin = t.user(6);
+ await fund(erin, '100000');
+ const h = await create(holdBody(erin, 50000));
+ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 1000000, output_tokens: 1000000 }, { byok: true }));
+ assert.strictEqual(s.status, 201, s.text);
+ assert.strictEqual(s.json.charge_nano_usd, '0');
+ assert.strictEqual(s.json.released_nano_usd, '50000');
+ });
+
+ await check('settle replay charges once and releases the rest exactly once', async () => {
+ const frank = t.user(7);
+ await fund(frank, '100000');
+ const h = await create(holdBody(frank, 50000));
+ const body = settleBody({ input_tokens: 1000, output_tokens: 1000 });
+ const s1 = await settle(h.json.hold_id, body);
+ assert.strictEqual(s1.status, 201, s1.text);
+ // 1000 input @ 1000/m = 1; 1000 output @ 3000/m = 3; per_request 100 => 104
+ assert.strictEqual(s1.json.charge_nano_usd, '104');
+ assert.strictEqual(s1.json.released_nano_usd, '49896');
+ const s2 = await settle(h.json.hold_id, body);
+ assert.strictEqual(s2.status, 200, s2.text);
+ assert.strictEqual(s2.json.settlement_id, s1.json.settlement_id);
+ assert.strictEqual(s2.json.journal_id, s1.json.journal_id);
+ const txns = await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE type = 'credit' AND idempotency_key = ?").get(`credit:settle:${body.idempotency_key}`);
+ assert.strictEqual(Number(txns.n), 1);
+ });
+
+ await check('aborted stream charges only partial usage and releases the unused hold', async () => {
+ const gina = t.user(8);
+ await fund(gina, '100000');
+ const h = await create(holdBody(gina, 50000));
+ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 100, output_tokens: 0 }));
+ assert.strictEqual(s.status, 201, s.text);
+ // 100 input @ 1000/m = ceil(100*1000/1e6) = 1; per_request 100 => 101
+ assert.strictEqual(s.json.charge_nano_usd, '101');
+ assert.strictEqual(s.json.released_nano_usd, '49899');
+ const bal = await t.db.prepare("SELECT b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'usd_credit' AND a.owner_subject = ?").get(`user:${gina.id}`);
+ assert.strictEqual(String(bal.balance), '99899'); // Balance reduced by the charge; the rest of the hold is released
+ });
+
+ await check('release returns the held amount and is idempotent', async () => {
+ const hank = t.user(9);
+ await fund(hank, '100000');
+ const h = await create(holdBody(hank, 30000));
+ const r1 = await release(h.json.hold_id);
+ assert.strictEqual(r1.status, 200, r1.text);
+ assert.strictEqual(r1.json.released_nano_usd, '30000');
+ const r2 = await release(h.json.hold_id);
+ assert.strictEqual(r2.status, 200, r2.text);
+ assert.strictEqual(r2.json.released_nano_usd, '30000');
+ const row = await t.db.prepare('SELECT status FROM credit_holds WHERE id = ?').get(h.json.hold_id);
+ assert.strictEqual(row.status, 'released');
+ });
+
+ await check('expired lease is released by the sweeper', async () => {
+ const ivy = t.user(10);
+ await fund(ivy, '100000');
+ const h = await create(holdBody(ivy, 10000));
+ t.clock.offset += 600 * 1000; // default lease is 300s; advance past it
+ const out = await inference.sweepExpired(t.ctx);
+ assert.ok(out.released >= 1, `expected at least 1 release, got ${out.released}; the hold answered ${h.status} ${JSON.stringify(h.json).slice(0, 300)}`);
+ const row = await t.db.prepare('SELECT status FROM credit_holds WHERE id = ?').get(h.json.hold_id);
+ assert.strictEqual(row.status, 'expired');
+ });
+
+ await check('100 concurrent holds on a balance that fits 7 succeed exactly 7 times and never go negative', async () => {
+ const jack = t.user(11);
+ await fund(jack, '70000');
+ const key = `concurrent:${crypto.randomBytes(8).toString('hex')}`;
+ const holds = await Promise.all(Array.from({ length: 100 }, (_, i) => create({
+ idempotency_key: `${key}:${i}`,
+ subject: { type: 'user', id: jack.id },
+ max_nano_usd: '10000',
+ rate_version: 'rc-v1',
+ request_fingerprint: `fp-${i}`,
+ })));
+ const succeeded = holds.filter((r) => r.status === 201).length;
+ assert.strictEqual(succeeded, 7, `expected 7 holds, got ${succeeded}`);
+ const bal = await t.db.prepare("SELECT b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'usd_credit' AND a.owner_subject = ?").get(`user:${jack.id}`);
+ assert.strictEqual(String(bal.balance), '70000');
+ const held = await t.db.prepare("SELECT COALESCE(SUM(held_nano_usd), 0)::text AS held FROM credit_holds WHERE account_id = (SELECT id FROM accounts WHERE kind = 'usd_credit' AND owner_subject = ?) AND status IN ('active', 'extended')").get(`user:${jack.id}`);
+ assert.strictEqual(String(held.held), '70000');
+ assert.ok(holds.every((r) => r.status === 201 || r.json.code === 'billing.insufficient_funds'));
+ });
+
+ await check('usage beyond the hold takes the free balance, never below zero, and records the shortfall', async () => {
+ const omar = t.user(15);
+ await fund(omar, '3000');
+ const h = await create(holdBody(omar, 1000));
+ // The request turned out to cost 5,000 nano (a per-request fee here), against 1,000 held and 3,000 funded in all.
+ const rate = retail({ nano_usd_per_m: { input: '0', cached_read: '0', cache_write: '0', output: '0' }, per_request_nano_usd: '5000' });
+ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 0, output_tokens: 0 }, { retail: rate }));
+ assert.strictEqual(s.status, 201, s.text);
+ assert.strictEqual(s.json.charge_nano_usd, '3000', 'collected: the hold and the free balance');
+ assert.strictEqual(s.json.shortfall_nano_usd, '2000', 'owed, not collected, and recorded');
+ assert.strictEqual(s.json.released_nano_usd, '0');
+ const bal = await t.db.prepare("SELECT b.balance::text AS balance FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'usd_credit' AND a.owner_subject = ?").get(`user:${omar.id}`);
+ assert.strictEqual(String(bal.balance), '0', 'never below zero');
+ });
+
+ await check('a person reads their own Balance statement: balance, held, available, charges without content', async () => {
+ const lena = t.user(13);
+ t.network.addMembership(PRJ, lena.id, 'owner');
+ await fund(lena, '100000', PRJ);
+ const open = await create(holdBody(lena, 20000, { project_id: PRJ }));
+ const done = await create(holdBody(lena, 10000, { project_id: PRJ }));
+ await settle(done.json.hold_id, settleBody({ input_tokens: 1000, output_tokens: 1000 }, { attempts: [{ deployment_id: 'own:qwen/qwen3-4b:q4_k_m@3f2a1c9', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString() }] }));
+ const token = t.network.signUser({ subject_id: lena.id, username: 'lena' });
+ const read = (q, tok = token) => fetch(`${t.base}/api/me/credits${q}`, { headers: tok ? { Authorization: `Bearer ${tok}` } : {} });
+ const r = await read(`?project_id=${PRJ}`);
+ assert.strictEqual(r.status, 200, await r.clone().text());
+ const doc = await r.json();
+ assert.strictEqual(doc.balance_nano_usd, String(100000 - 104));
+ assert.strictEqual(doc.held_nano_usd, '20000', 'the open hold is held');
+ assert.strictEqual(doc.available_nano_usd, String(100000 - 104 - 20000));
+ assert.strictEqual(doc.charges.length, 1);
+ assert.strictEqual(doc.charges[0].charge_nano_usd, '104');
+ assert.strictEqual(doc.charges[0].model, 'qwen/qwen3-4b:q4_k_m@3f2a1c9');
+ assert.strictEqual(doc.charges[0].service, 'ai');
+ assert.ok(open.json.hold_id);
+ assert.strictEqual((await read(`?project_id=${PRJ}`, null)).status, 401, 'a token is needed');
+ assert.strictEqual((await read('?project_id=nope')).status, 422);
+ const other = t.network.signUser({ subject_id: t.user(14).id, username: 'mo' });
+ const theirs = await read(`?project_id=${PRJ}`, other);
+ assert.strictEqual(theirs.status, 403, 'a person who does not administer the project reads no statement');
+ });
+
+ await check('a body that is not the contract\'s is refused with 422 before anything is held', async () => {
+ const kim = t.user(14);
+ await fund(kim, '100000');
+ const before = await t.db.prepare('SELECT COUNT(*)::int AS n FROM credit_holds').get();
+ const bad = await create({ ...holdBody(kim, 1000), max_nano_usd: 1000 }); // a number, not a decimal string
+ assert.strictEqual(bad.status, 422, JSON.stringify(bad.json));
+ assert.strictEqual(bad.json.code, 'billing.invalid_request');
+ const extra = await create({ ...holdBody(kim, 1000), surprise: true });
+ assert.strictEqual(extra.status, 422, 'an unknown field is refused');
+ const after = await t.db.prepare('SELECT COUNT(*)::int AS n FROM credit_holds').get();
+ assert.strictEqual(after.n, before.n, 'nothing was held');
+ const ok = await create(holdBody(kim, 1000));
+ const settled = await settle(ok.json.hold_id, settleBody({ output_tokens: 10 }, { attempts: [{ provider: 'own', model: 'x' }] }));
+ assert.strictEqual(settled.status, 422, 'attempts carry the contract fields only (deployment_id, provider_access, times)');
+ });
+
+ await check('sandbox Balance pays only a sandbox hold, and its charge returns to the issuer (D10)', async () => {
+ const sam = t.user(15);
+ const SAM_PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPS';
+ t.network.addMembership(SAM_PRJ, sam.id, 'owner');
+ await grant({ subject: { type: 'user', id: sam.id }, project_id: SAM_PRJ, amount_nano_usd: '50000' });
+ const real = await create(holdBody(sam, 1000, { project_id: SAM_PRJ }));
+ assert.strictEqual(real.status, 402, 'a real hold never spends sandbox Balance');
+ const revenueBefore = await t.db.prepare("SELECT COALESCE(SUM(b.balance), 0)::text AS v FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'platform_revenue' AND a.currency = 'nano-usd'").get();
+ const boxed = await create(holdBody(sam, 10000, { project_id: SAM_PRJ, sandbox: true }));
+ assert.strictEqual(boxed.status, 201, boxed.text);
+ const done2 = await settle(boxed.json.hold_id, settleBody({ input_tokens: 1000, output_tokens: 1000 }));
+ assert.strictEqual(done2.status, 201, done2.text);
+ const revenueAfter = await t.db.prepare("SELECT COALESCE(SUM(b.balance), 0)::text AS v FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'platform_revenue' AND a.currency = 'nano-usd'").get();
+ assert.strictEqual(revenueAfter.v, revenueBefore.v, 'a sandbox charge is not revenue');
+ const token = t.network.signUser({ subject_id: sam.id, username: 'sam' });
+ const doc = await (await fetch(`${t.base}/api/me/credits?project_id=${SAM_PRJ}`, { headers: { Authorization: `Bearer ${token}` } })).json();
+ assert.strictEqual(doc.balance_nano_usd, '0', 'the real Balance does not include sandbox Balance');
+ assert.strictEqual(doc.sandbox.balance_nano_usd, String(50000 - 104), 'the sandbox balance is shown apart');
+ assert.strictEqual(doc.charges.length, 0, 'sandbox charges are not on the real statement');
+ });
+
+ await check('each settlement announces billing.credit.settled for aggregates, with no subject or project', async () => {
+ const tom = t.user(16);
+ await fund(tom, '100000');
+ const h = await create(holdBody(tom, 10000));
+ const at = new Date().toISOString();
+ const s2 = await settle(h.json.hold_id, settleBody({ input_tokens: 1000, output_tokens: 1000 }, {
+ attempts: [{ deployment_id: 'own:qwen/qwen3-4b:q4_k_m@3f2a1c9', provider_access: 'OWN_DEPLOYMENT', started_at: at, ended_at: at }],
+ }));
+ assert.strictEqual(s2.status, 201, s2.text);
+ const ev = await t.db.prepare("SELECT envelope FROM service_outbox WHERE envelope->>'event_type' = 'billing.credit.settled' AND envelope->'payload'->>'hold_id' = ?").get(h.json.hold_id);
+ assert.ok(ev, 'the event is in the outbox, in the settle transaction');
+ const env = typeof ev.envelope === 'string' ? JSON.parse(ev.envelope) : ev.envelope;
+ const pl = env.payload;
+ assert.strictEqual(pl.deployment_id, 'own:qwen/qwen3-4b:q4_k_m@3f2a1c9');
+ assert.strictEqual(pl.provider_access, 'OWN_DEPLOYMENT');
+ assert.strictEqual(pl.charge_nano_usd, '104');
+ assert.strictEqual(pl.sandbox, false);
+ for (const k of ['subject', 'project_id', 'account_id']) assert.ok(!(k in pl), `no ${k} in the event`);
+ const v = require('openvibe-contracts').validate('billing.credit.settled@1', pl);
+ assert.ok(v.valid, `the payload is the contract's: ${JSON.stringify(v.errors)}`);
+ });
+
+ await check('the journal balances after all inference operations', async () => {
+ await assertJournalBalances(t.db, 'end of inference tests');
+ });
+
+ await check('the old chargeFor amplification for tiny usage does not affect the subledger', async () => {
+ // Old chargeFor: 1 token at $0.000001/m (1 nano/token) with bitsPerUsd=100 rounds up to 1 bit = $0.01.
+ // Subledger: 1 token at 1 nano/m = 1 nano-USD = $0.000000001, with no per-component minimum.
+ const kate = t.user(12);
+ await fund(kate, '1000');
+ const h = await create(holdBody(kate, 1000));
+ const rate = retail({ nano_usd_per_m: { input: '1', cached_read: '1', cache_write: '1', output: '1' }, per_request_nano_usd: '0' });
+ const s = await settle(h.json.hold_id, settleBody({ input_tokens: 1, output_tokens: 0 }, { retail: rate }));
+ assert.strictEqual(s.json.charge_nano_usd, '1');
+ // chargeFor would have returned 1 bit = 100 nano-USD equivalent
+ const { chargeFor } = require('../server/ops/rating');
+ const old = chargeFor({ id: 'old', provider: 'x', metric: 'token', unit_size: 1000000, unit_price_usd: 0.000001 }, 1, { bitsPerUsd: 100 });
+ assert.strictEqual(old, 1); // 1 bit
+ assert.strictEqual(Number(s.json.charge_nano_usd), 1); // 1 nano, 100x smaller in USD terms than 1 bit
+ });
+
+ await t.close();
+ done();
+})();
@@ -40,7 +40,7 @@ async function boot(opts = {}) {
@@ -40,7 +40,7 @@ async function boot(opts = {}) {
// npm run test:pg: the per-person limits count in the Valkey container (a prefix of this app's own), as in production.
// 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
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;
? 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, accountSend: opts.accountSend });
+ const app = await createApp({ config, db, env, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow, valkey, accountSend: opts.accountSend });
await app.locals.keys.refresh();
await app.locals.keys.refresh();
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}`;
@@ -93,7 +93,7 @@ async function boot(opts = {}) {
@@ -93,7 +93,7 @@ async function boot(opts = {}) {
return r.json;
return r.json;
}
}
- const user = (liveId) => ({ type: 'user', id: network.addUser(liveId) });
+ const user = (liveId, username = null) => ({ type: 'user', id: network.addUser(liveId, username) });
return {
return {
app, base, call, ctx, db: ctx.db, config, clock, network, stripe, powerchat, stripeEvent, assertReconciled, balances, user, logs, dir,
app, base, call, ctx, db: ctx.db, config, clock, network, stripe, powerchat, stripeEvent, assertReconciled, balances, user, logs, dir,
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
*
*
* startNetwork() JWKS, client-credentials token endpoint (scope → cap), resolve-batch
* startNetwork() JWKS, client-credentials token endpoint (scope → cap), resolve-batch
* (insists on a service token for openvibe.network holding identity.subject.resolve),
* (insists on a service token for openvibe.network holding identity.subject.resolve),
- * and SSO: authorize() issues a PKCE-bound code, /oauth/token redeems it, /oauth/revoke
+ * project memberships (/internal/projects?subject= and /internal/projects/:id/members/:subject,
+ * network.project.member.read), and SSO: authorize() issues a PKCE-bound code, /oauth/token redeems it
* startStripe() /checkout/sessions and /subscriptions/:id, recording calls; `fail` makes it 500
* startStripe() /checkout/sessions and /subscriptions/:id, recording calls; `fail` makes it 500
* startEvents() POST /api/v1/events recording batches and the bearer tokens it saw
* startEvents() POST /api/v1/events recording batches and the bearer tokens it saw
*/
*/
@@ -27,6 +28,10 @@ async function startNetwork() {
@@ -27,6 +28,10 @@ async function startNetwork() {
const legacy = {}; // live user id → subject id
const legacy = {}; // live user id → subject id
const resolveCalls = [];
const resolveCalls = [];
const grants = [];
const grants = [];
+ const memberships = []; // { project_id, subject, role, archived, name }
+ const publicNames = new Map(); // subject id -> username (the public name only)
+ const projects = []; // { id, name, owner, archived_at }
+ const apps = []; // { id, project_id, name, environment, icon }
let issuer = 'http://network.test';
let issuer = 'http://network.test';
const state = { down: false };
const state = { down: false };
@@ -51,11 +56,44 @@ async function startNetwork() {
@@ -51,11 +56,44 @@ async function startNetwork() {
iss: issuer, aud: ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'], iat: now, exp: now + 86400,
iss: issuer, aud: ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'], iat: now, exp: now + 86400,
}, privatePem);
}, privatePem);
}
}
- function addUser(liveId) {
+ function addUser(liveId, username = null) {
const subject = ids.newId('user');
const subject = ids.newId('user');
if (liveId != null) legacy[String(liveId)] = subject;
if (liveId != null) legacy[String(liveId)] = subject;
+ if (username) publicNames.set(subject, username);
return subject;
return subject;
}
}
+ /** Record a project membership; the D12 reads return it. */
+ function addMembership(projectId, subject, role = 'owner', archived = false, name = null) {
+ const existing = memberships.find((m) => m.project_id === projectId && m.subject === subject);
+ if (existing) Object.assign(existing, { role, archived, name });
+ else memberships.push({ project_id: projectId, subject, role, archived, name });
+ return memberships[memberships.length - 1];
+ }
+ /** A developer project as GET /internal/projects/:id answers it (name, owner, archived_at). */
+ function addProject({ id, name = 'A project', owner = null, archived_at = null }) {
+ const existing = projects.find((p) => p.id === id);
+ const row = { id, name, owner, archived_at };
+ if (existing) Object.assign(existing, row); else projects.push(row);
+ return row;
+ }
+ /** A dev_apps row as the project view carries it. */
+ function addApp({ id, projectId, name = 'An app', environment = 'production', icon = null }) {
+ const existing = apps.find((a) => a.id === id);
+ const row = { id, project_id: projectId, name, environment, icon };
+ if (existing) Object.assign(existing, row); else apps.push(row);
+ return row;
+ }
+ /** A Network app token: sub app:app_<ULID>, project_id, env and approved grants. */
+ function signApp({ appId, projectId, env = 'production', cap = [], onBehalfOf = null, expSec = 300 }) {
+ const now = Math.floor(Date.now() / 1000);
+ const claims = {
+ iss: issuer, sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.billing'], cap,
+ ns: [projectId, `app.${projectId}.*`], project_id: projectId, env,
+ ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
+ iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex'),
+ };
+ return serviceAuth.signServiceToken(claims, privatePem);
+ }
const server = http.createServer(async (req, res) => {
const server = http.createServer(async (req, res) => {
const raw = await readBody(req);
const raw = await readBody(req);
@@ -77,6 +115,35 @@ async function startNetwork() {
@@ -77,6 +115,35 @@ async function startNetwork() {
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
if (req.url === '/oauth/revoke' && req.method === 'POST') { revoked.push(JSON.parse(raw || '{}').token); return send(res, 200, { revoked: true }); }
if (req.url === '/oauth/revoke' && req.method === 'POST') { revoked.push(JSON.parse(raw || '{}').token); return send(res, 200, { revoked: true }); }
+ if (req.url.startsWith('/internal/projects') && req.method === 'GET') {
+ const auth = String(req.headers.authorization || '');
+ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ if (!v.ok) return send(res, 401, { code: v.code });
+ const caps = v.claims.cap || [];
+ if (!caps.includes('network.project.member.read') && !caps.includes('network.project.read')) return send(res, 403, { code: 'capability.denied' });
+ const u = new URL(req.url, issuer);
+ if (u.pathname === '/internal/projects') {
+ if (!caps.includes('network.project.member.read')) return send(res, 403, { code: 'capability.denied' });
+ const subject = u.searchParams.get('subject');
+ return send(res, 200, { projects: memberships.filter((m) => m.subject === subject).map((m) => ({ ...m })) });
+ }
+ const view = u.pathname.match(/^\/internal\/projects\/([^/]+)$/);
+ if (view) {
+ if (!(v.claims.cap || []).includes('network.project.read')) return send(res, 403, { code: 'capability.denied' });
+ const project = projects.find((p) => p.id === decodeURIComponent(view[1]));
+ if (!project) return send(res, 404, { code: 'project.not_found' });
+ return send(res, 200, {
+ project: { id: project.id, name: project.name, owner: project.owner ? { type: 'user', id: project.owner } : null, archived_at: project.archived_at },
+ apps: apps.filter((a) => a.project_id === project.id).map((a) => ({ id: a.id, name: a.name, environment: a.environment, ...(a.icon ? { icon: a.icon } : {}) })),
+ });
+ }
+ const m = u.pathname.match(/^\/internal\/projects\/([^/]+)\/members\/([^/]+)$/);
+ if (!m) return send(res, 404, { error: 'not found' });
+ if (!caps.includes('network.project.member.read')) return send(res, 403, { code: 'capability.denied' });
+ const found = memberships.find((x) => x.project_id === decodeURIComponent(m[1]) && x.subject === decodeURIComponent(m[2]));
+ if (!found) return send(res, 404, { code: 'project.member_not_found' });
+ return send(res, 200, { project_id: found.project_id, subject: found.subject, role: found.role, archived: found.archived, added_at: null });
+ }
if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
const auth = String(req.headers.authorization || '');
const auth = String(req.headers.authorization || '');
const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
@@ -86,9 +153,17 @@ async function startNetwork() {
@@ -86,9 +153,17 @@ async function startNetwork() {
const body = JSON.parse(raw || '{}');
const body = JSON.parse(raw || '{}');
resolveCalls.push(body);
resolveCalls.push(body);
const results = {};
const results = {};
- for (const id of body.ids || []) {
- const s = body.system === 'live' ? legacy[String(id)] : null;
- results[String(id)] = s ? { subject: { type: 'user', id: s }, username: `u${id}` } : null;
+ if (Array.isArray(body.subject_ids)) {
+ for (const id of body.subject_ids) {
+ results[String(id)] = publicNames.has(String(id))
+ ? { subject: { type: 'user', id: String(id) }, username: publicNames.get(String(id)), display_name: publicNames.get(String(id)) }
+ : null;
+ }
+ } else {
+ for (const id of body.ids || []) {
+ const s = body.system === 'live' ? legacy[String(id)] : null;
+ results[String(id)] = s ? { subject: { type: 'user', id: s }, username: `u${id}` } : null;
+ }
}
}
return send(res, 200, { results });
return send(res, 200, { results });
}
}
@@ -98,7 +173,7 @@ async function startNetwork() {
@@ -98,7 +173,7 @@ async function startNetwork() {
issuer = url;
issuer = url;
// A person's Network session token, as the browser holds it (the shared navbar sends it).
// A person's Network session token, as the browser holds it (the shared navbar sends it).
const signUser = (u) => userToken({ role: 'user', username: 'someone', nowMs: Date.now(), ...u });
const signUser = (u) => userToken({ role: 'user', username: 'someone', nowMs: Date.now(), ...u });
- return { url, publicPem, signService, signUser, addUser, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };
+ return { url, publicPem, signService, signApp, signUser, addUser, addMembership, addProject, addApp, memberships, projects, apps, publicNames, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };
}
}
async function startStripe() {
async function startStripe() {
@@ -136,4 +211,30 @@ async function startEvents() {
@@ -136,4 +211,30 @@ async function startEvents() {
return { url, batches, tokens, close: () => new Promise((r) => server.close(r)) };
return { url, batches, tokens, close: () => new Promise((r) => server.close(r)) };
}
}
-module.exports = { startNetwork, startStripe, startEvents };
+/** PayPal/CCBill/NOWPayments test API: token, signature verification, checkout/capture and an invoice. */
+async function startProviderStub() {
+ const calls = [];
+ const server = http.createServer(async (req, res) => {
+ const raw = await readBody(req);
+ calls.push({ method: req.method, url: req.url, raw });
+ const json = (s, o) => send(res, s, o);
+ if (req.url === '/v1/oauth2/token') return json(200, { access_token: 'pp-token' });
+ if (req.url === '/v1/notifications/verify-webhook-signature') {
+ const body = JSON.parse(raw);
+ return json(200, { verification_status: body.transmission_sig === 'good' ? 'SUCCESS' : 'FAILURE' });
+ }
+ if (req.url === '/v2/checkout/orders') return json(201, { id: 'PPORDER1', links: [{ rel: 'approve', href: 'https://paypal.test/approve/PPORDER1' }] });
+ const cap = req.url.match(/^\/v2\/checkout\/orders\/([^/]+)\/capture$/);
+ if (cap) {
+ const custom = calls.find((c) => c.url === '/v2/checkout/orders');
+ const intentId = JSON.parse(custom.raw).purchase_units[0].custom_id;
+ return json(201, { status: 'COMPLETED', purchase_units: [{ payments: { captures: [{ id: 'CAP-9', custom_id: intentId, amount: { currency_code: 'USD', value: '13.00' } }] } }] });
+ }
+ if (req.url === '/invoice') return json(200, { id: 777, invoice_url: 'https://nowpayments.test/invoice/777' });
+ json(404, {});
+ });
+ const url = await listen(server);
+ return { url, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+module.exports = { startNetwork, startStripe, startEvents, startProviderStub };
@@ -23,5 +23,29 @@ assert.ok(tls.indexOf(m[0]) < tls.indexOf('location / {'), 'before the console c
@@ -23,5 +23,29 @@ assert.ok(tls.indexOf(m[0]) < tls.indexOf('location / {'), 'before the console c
const me = tls.match(/location = \/api\/me\/vibes \{([^}]*)\}/);
const me = tls.match(/location = \/api\/me\/vibes \{([^}]*)\}/);
assert.ok(me && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(me[1]) && /limit_req /.test(me[1]), 'a rate-limited location for /api/me/vibes');
assert.ok(me && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(me[1]) && /limit_req /.test(me[1]), 'a rate-limited location for /api/me/vibes');
assert.ok(tls.indexOf(me[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
assert.ok(tls.indexOf(me[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
+const balance = tls.match(/location = \/api\/me\/balance \{([^}]*)\}/);
+assert.ok(balance && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(balance[1]) && /limit_req /.test(balance[1]), 'a rate-limited location for /api/me/balance');
+assert.ok(tls.indexOf(balance[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
+const transfers = tls.match(/location = \/api\/me\/balance\/transfers \{([^}]*)\}/);
+assert.ok(transfers && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(transfers[1]) && /limit_req /.test(transfers[1]), 'a rate-limited location for POST /api/me/balance/transfers');
+assert.ok(tls.indexOf(transfers[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
+const fromBalance = tls.match(/location = \/api\/me\/vibes\/from-balance \{([^}]*)\}/);
+assert.ok(fromBalance && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(fromBalance[1]) && /limit_req /.test(fromBalance[1]), 'a rate-limited location for POST /api/me/vibes/from-balance');
+assert.ok(tls.indexOf(fromBalance[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
+const activity = tls.match(/location = \/api\/me\/activity \{([^}]*)\}/);
+assert.ok(activity && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(activity[1]) && /limit_req /.test(activity[1]), 'a rate-limited location for /api/me/activity');
+assert.ok(tls.indexOf(activity[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
+const charge = tls.match(/location ~ \^\/api\/me\/vibes\/charges\/vch_\[0-9A-HJKMNP-TV-Z\]\{26\}\$ \{([^}]*)\}/);
+assert.ok(charge && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(charge[1]) && /limit_req /.test(charge[1]), 'a rate-limited location for GET /api/me/vibes/charges/:id');
+assert.ok(tls.indexOf(charge[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
+const chargeAct = tls.match(/location ~ \^\/api\/me\/vibes\/charges\/vch_\[0-9A-HJKMNP-TV-Z\]\{26\}\/\(pay\|cancel\)\$ \{([^}]*)\}/);
+assert.ok(chargeAct && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(chargeAct[1]) && /limit_req /.test(chargeAct[1]), 'a rate-limited location for POST pay/cancel');
+assert.ok(tls.indexOf(chargeAct[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
+const credits = tls.match(/location = \/api\/me\/credits \{([^}]*)\}/);
+assert.ok(credits && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(credits[1]) && /limit_req /.test(credits[1]), 'a rate-limited location for /api/me/credits');
+assert.ok(tls.indexOf(credits[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
+const topups = tls.match(/location = \/api\/me\/credits\/topups \{([^}]*)\}/);
+assert.ok(topups && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(topups[1]) && /limit_req /.test(topups[1]), 'a rate-limited location for POST /api/me/credits/topups');
+assert.ok(tls.indexOf(topups[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
assert.match(tls, /location \^~ \/api\/v1 \{ deny all; \}/);
assert.match(tls, /location \^~ \/api\/v1 \{ deny all; \}/);
-console.log('nginx vhost: the policy is indexable, the rest noindex; /api/me/vibes public, /api/v1 loopback only');
+console.log('nginx vhost: the policy is indexable, the rest noindex; /api/me/vibes, /api/me/balance, /api/me/balance/transfers, /api/me/vibes/from-balance, /api/me/activity, /api/me/credits and /api/me/credits/topups public, /api/v1 loopback only');
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
+'use strict';
+/**
+ * D11 owner settings: Billing's `billing.credits` namespace through openvibe-shared/config.
+ *
+ * The routes live at /internal/config behind Network's service token and `billing.config.manage`; the owner
+ * is the X-OV-Subject usr_ id and a danger write needs X-OV-Fresh: 1. The env pins keep today's variables
+ * winning; an owner change applies live to the Credits switches, the top-up terms and the hold lease.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/app');
+
+const CAP = ['billing.config.manage'];
+const NAMESPACE = 'billing.credits';
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const KEK = 'k1:' + crypto.randomBytes(32).toString('base64');
+
+(async () => {
+ console.log('owner settings (billing.credits)');
+ const t = await boot();
+ const owner = t.user(301);
+ t.network.addMembership(PRJ, owner.id, 'owner');
+ const ownerToken = t.network.signUser({ subject_id: owner.id, username: 'owner' });
+ const headers = (fresh = false, extra = {}) => ({ 'x-ov-subject': owner.id, ...(fresh ? { 'x-ov-fresh': '1' } : {}), ...extra });
+ const get = (cap = CAP, extra = {}) => t.call('GET', '/internal/config', { cap, headers: extra });
+ const apply = (values, { fresh = true, cap = CAP, merge = false } = {}) =>
+ t.call('POST', `/internal/config/${NAMESPACE}`, { cap, headers: headers(fresh), body: { values, merge, reason: 'owner settings test' } });
+ const rollback = (to, fresh = true) =>
+ t.call('POST', `/internal/config/${NAMESPACE}/rollback`, { cap: CAP, headers: headers(fresh), body: { to, reason: 'owner settings test' } });
+ const summary = async () => (await get()).json.namespaces[0];
+
+ await check('the namespace and its schema are served, sealed:false with no KEK and no values until the owner sets them', async () => {
+ const r = await get();
+ assert.strictEqual(r.status, 200, r.text);
+ const ns = r.json.namespaces[0];
+ assert.strictEqual(ns.namespace, NAMESPACE);
+ assert.strictEqual(ns.service, 'billing');
+ assert.strictEqual(ns.revision, null);
+ assert.strictEqual(ns.sealed, false);
+ assert.strictEqual(ns.kek, null);
+ const p = ns.schema.properties;
+ assert.deepStrictEqual(Object.keys(p).sort(), ['credits', 'hold_lease_seconds', 'sandbox', 'topup', 'topup_amounts_cents', 'topup_fee_bps']);
+ for (const k of Object.keys(p)) {
+ assert.strictEqual(p[k]['x-group'], 'Balance', `${k}: x-group`);
+ assert.ok(p[k].title && p[k].description, `${k}: title and description`);
+ }
+ assert.deepStrictEqual([p.credits['x-env'], p.credits['x-danger']], ['BILLING_CREDITS', true]);
+ assert.deepStrictEqual([p.sandbox['x-env'], p.sandbox['x-danger']], ['BILLING_CREDITS_SANDBOX', undefined]);
+ assert.deepStrictEqual([p.topup['x-env'], p.topup['x-danger']], ['BILLING_CREDITS_TOPUP', true]);
+ assert.deepStrictEqual([p.topup_amounts_cents.type, p.topup_amounts_cents.items.type, p.topup_amounts_cents['x-env'], p.topup_amounts_cents['x-danger']], ['array', 'integer', 'BILLING_CREDITS_TOPUP_AMOUNTS_CENTS', true]);
+ assert.deepStrictEqual([p.topup_fee_bps.minimum, p.topup_fee_bps.maximum, p.topup_fee_bps['x-danger']], [0, 10_000, true]);
+ assert.strictEqual(p.hold_lease_seconds['x-env'], 'BILLING_INFERENCE_LEASE_SECONDS');
+ // The brief's x-unit is "seconds"; Shared 4.110.0 only accepts nano_usd, ms and bps, so the unit is in the title.
+ assert.strictEqual(p.hold_lease_seconds['x-unit'], undefined);
+ assert.strictEqual(t.ctx.config.credits.enabled, false);
+ const { topupTerms } = require('../server/ops/credits');
+ assert.strictEqual(topupTerms(t.ctx).configured, false);
+ });
+
+ await check('a caller without billing.config.manage gets 403, and an unsigned one 401', async () => {
+ const denied = await get(['billing.balance.read']);
+ assert.strictEqual(denied.status, 403, denied.text);
+ assert.strictEqual(denied.json.code, 'capability.denied');
+ const missing = await t.call('GET', '/internal/config', { token: null });
+ assert.strictEqual(missing.status, 401, missing.text);
+ const write = await t.call('POST', `/internal/config/${NAMESPACE}`, { cap: ['billing.balance.read'], headers: headers(true), body: { values: { credits: true }, reason: 'x' } });
+ assert.strictEqual(write.status, 403, write.text);
+ });
+
+ await check('a stale call to a danger key is 403 config.fresh_required and changes nothing', async () => {
+ const r = await apply({ credits: true }, { fresh: false });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'config.fresh_required');
+ assert.strictEqual(t.ctx.config.credits.enabled, false);
+ });
+
+ await check('an owner change applies live and its audit line names the keys and actor, never a value', async () => {
+ const r = await apply({ credits: true, topup: true, topup_amounts_cents: [1000, 2500], topup_fee_bps: 250 });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.revision, 1);
+ assert.strictEqual(t.ctx.config.credits.enabled, true, 'the config object is updated without a restart');
+ const { topupTerms } = require('../server/ops/credits');
+ assert.deepStrictEqual(topupTerms(t.ctx), { enabled: true, amountsCents: [1000, 2500], feeBps: 250, configured: true, problem: null });
+ const ns = await summary();
+ assert.strictEqual(ns.revision, 1);
+ assert.deepStrictEqual(ns.values.topup_amounts_cents, [1000, 2500]);
+ const credit = await fetch(`${t.base}/api/me/credits?project_id=${PRJ}`, { headers: { Authorization: `Bearer ${ownerToken}` } });
+ assert.strictEqual(credit.status, 200, await credit.text());
+ const audit = await t.db.prepare("SELECT * FROM staff_audit WHERE action = 'config.change' ORDER BY seq DESC LIMIT 1").get();
+ assert.ok(audit, 'no audit line');
+ assert.strictEqual(audit.actor_subject, owner.id);
+ assert.strictEqual(audit.target_id, NAMESPACE);
+ const detail = JSON.parse(audit.detail);
+ assert.strictEqual(detail.service, 'billing');
+ assert.strictEqual(detail.namespace, NAMESPACE);
+ assert.strictEqual(detail.revision, 1);
+ assert.deepStrictEqual([...detail.keys].sort(), ['credits', 'topup', 'topup_amounts_cents', 'topup_fee_bps']);
+ assert.ok(!audit.detail.includes('250') && !audit.detail.includes('1000'), 'the audit line must not carry a value');
+ });
+
+ await check('a non-danger key needs no fresh sign-in, a danger key does', async () => {
+ const lease = await apply({ hold_lease_seconds: 120 }, { fresh: false, merge: true });
+ assert.strictEqual(lease.status, 200, lease.text);
+ assert.strictEqual(t.ctx.config.credits.leaseSeconds, 120);
+ const stale = await apply({ topup_fee_bps: 300 }, { fresh: false, merge: true });
+ assert.strictEqual(stale.status, 403, stale.text);
+ assert.strictEqual(stale.json.code, 'config.fresh_required');
+ assert.strictEqual((await apply({ topup_fee_bps: 300 }, { merge: true })).status, 200);
+ assert.strictEqual(require('../server/ops/credits').topupTerms(t.ctx).feeBps, 300);
+ });
+
+ await check('history and a rollback apply the earlier terms live', async () => {
+ const before = require('../server/ops/credits').topupTerms(t.ctx).feeBps;
+ await apply({ topup_fee_bps: 400 }, { merge: true });
+ assert.strictEqual(require('../server/ops/credits').topupTerms(t.ctx).feeBps, 400);
+ const h = await t.call('GET', `/internal/config/${NAMESPACE}/history`, { cap: CAP });
+ assert.strictEqual(h.status, 200, h.text);
+ const target = h.json.snapshots.find((s) => s.values && s.values.topup_fee_bps === before && s.state !== 'active');
+ assert.ok(target, 'no earlier revision with the old fee');
+ const back = await rollback(target.revision);
+ assert.strictEqual(back.status, 200, back.text);
+ assert.strictEqual(require('../server/ops/credits').topupTerms(t.ctx).feeBps, before);
+ });
+
+ await check('the env pins win and an owner write to a pinned key is 409 config.pinned_in_env', async () => {
+ const pinned = await boot({
+ env: {
+ BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1',
+ BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000, 2500', BILLING_CREDITS_TOPUP_FEE_BPS: '250', BILLING_INFERENCE_LEASE_SECONDS: '600',
+ },
+ });
+ try {
+ const o = pinned.user(302);
+ const h = (fresh) => ({ 'x-ov-subject': o.id, ...(fresh ? { 'x-ov-fresh': '1' } : {}) });
+ const ns = (await pinned.call('GET', '/internal/config', { cap: CAP })).json.namespaces[0];
+ assert.strictEqual(ns.values.credits, true);
+ assert.strictEqual(ns.values.topup, true);
+ assert.deepStrictEqual(ns.values.topup_amounts_cents, [1000, 2500]);
+ assert.strictEqual(ns.values.topup_fee_bps, 250);
+ assert.strictEqual(ns.values.hold_lease_seconds, 600);
+ assert.deepStrictEqual(ns.pins.topup, { env: 'BILLING_CREDITS_TOPUP', pinned: true });
+ const terms = require('../server/ops/credits').topupTerms(pinned.ctx);
+ assert.deepStrictEqual([terms.enabled, terms.amountsCents, terms.feeBps, terms.configured], [true, [1000, 2500], 250, true]);
+ const r = await pinned.call('POST', `/internal/config/${NAMESPACE}`, { cap: CAP, headers: h(true), body: { values: { topup: false }, reason: 'x' } });
+ assert.strictEqual(r.status, 409, r.text);
+ assert.strictEqual(r.json.code, 'config.pinned_in_env');
+ const lease = await pinned.call('POST', `/internal/config/${NAMESPACE}`, { cap: CAP, headers: h(true), body: { values: { hold_lease_seconds: 120 }, reason: 'x' } });
+ assert.strictEqual(lease.status, 409, lease.text);
+ assert.strictEqual(pinned.ctx.config.credits.leaseSeconds, 600);
+ } finally { await pinned.close(); }
+ });
+
+ await check('the old boolean forms still pin, and an empty variable means unset so the owner can set it', async () => {
+ const legacy = await boot({ env: { BILLING_CREDITS: 'yes', BILLING_CREDITS_SANDBOX: '' } });
+ try {
+ const o = legacy.user(303);
+ const h = { 'x-ov-subject': o.id, 'x-ov-fresh': '1' };
+ const ns = (await legacy.call('GET', '/internal/config', { cap: CAP })).json.namespaces[0];
+ assert.strictEqual(ns.values.credits, true);
+ assert.ok(ns.pins.credits && ns.pins.credits.pinned === true);
+ assert.strictEqual(ns.pins.sandbox, undefined, 'an empty env variable is unset, not a pin');
+ const r = await legacy.call('POST', `/internal/config/${NAMESPACE}`, { cap: CAP, headers: h, body: { values: { sandbox: true }, merge: true, reason: 'x' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(legacy.ctx.config.credits.sandbox, true);
+ } finally { await legacy.close(); }
+ });
+
+ await check('a secret route is write-only and this namespace has no secret keys: 422, and no value is echoed', async () => {
+ const r = await t.call('PUT', `/internal/config/${NAMESPACE}/secrets/topup_fee_bps`, { cap: CAP, headers: headers(true), body: { value: 777, reason: 'x' } });
+ assert.strictEqual(r.status, 422, r.text);
+ assert.strictEqual(r.json.code, 'config.not_secret');
+ assert.ok(!r.text.includes('777'));
+ assert.ok(!t.logs.join('\n').includes('777'), 'a refused secret must not reach a log line');
+ const ns = await summary();
+ assert.notStrictEqual(ns.values.topup_fee_bps, 777);
+ assert.ok(!JSON.stringify(ns.secrets || {}).includes('777'));
+ });
+
+ await check('with a KEK the namespace reports sealed:true and reseals without breaking', async () => {
+ const sealed = await boot({ env: { OV_CONFIG_KEKS: KEK, OV_CONFIG_KEK_CURRENT: 'k1' } });
+ try {
+ const ns = (await sealed.call('GET', '/internal/config', { cap: CAP })).json.namespaces[0];
+ assert.strictEqual(ns.sealed, true);
+ assert.strictEqual(ns.kek, 'k1');
+ assert.strictEqual(sealed.ctx.configStore.sealed, true);
+ const report = await sealed.ctx.configStore.reseal();
+ assert.strictEqual(report.kek, 'k1');
+ assert.deepStrictEqual(report.errors, []);
+ } finally { await sealed.close(); }
+ });
+
+ await t.close();
+ done();
+})();
@@ -4,40 +4,11 @@
@@ -4,40 +4,11 @@
* a stub provider API: verification, settlement exactly once, and refunds/chargebacks as reversals.
* a stub provider API: verification, settlement exactly once, and refunds/chargebacks as reversals.
*/
*/
const assert = require('assert');
const assert = require('assert');
-const http = require('http');
const crypto = require('crypto');
const crypto = require('crypto');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
+const { startProviderStub } = require('./helpers/stubs');
const { sortObject } = require('../server/providers/nowpayments');
const { sortObject } = require('../server/providers/nowpayments');
-async function startProviderStub() {
- const calls = [];
- const server = http.createServer((req, res) => {
- const chunks = [];
- req.on('data', (c) => chunks.push(c));
- req.on('end', () => {
- const raw = Buffer.concat(chunks).toString('utf8');
- calls.push({ method: req.method, url: req.url, raw });
- const json = (s, o) => { res.writeHead(s, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
- if (req.url === '/v1/oauth2/token') return json(200, { access_token: 'pp-token' });
- if (req.url === '/v1/notifications/verify-webhook-signature') {
- const body = JSON.parse(raw);
- return json(200, { verification_status: body.transmission_sig === 'good' ? 'SUCCESS' : 'FAILURE' });
- }
- if (req.url === '/v2/checkout/orders') return json(201, { id: 'PPORDER1', links: [{ rel: 'approve', href: 'https://paypal.test/approve/PPORDER1' }] });
- const cap = req.url.match(/^\/v2\/checkout\/orders\/([^/]+)\/capture$/);
- if (cap) {
- const custom = calls.find((c) => c.url === '/v2/checkout/orders');
- const intentId = JSON.parse(custom.raw).purchase_units[0].custom_id;
- return json(201, { status: 'COMPLETED', purchase_units: [{ payments: { captures: [{ id: 'CAP-9', custom_id: intentId, amount: { currency_code: 'USD', value: '13.00' } }] } }] });
- }
- if (req.url === '/invoice') return json(200, { id: 777, invoice_url: 'https://nowpayments.test/invoice/777' });
- json(404, {});
- });
- });
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
- return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
-}
-
(async () => {
(async () => {
const stub = await startProviderStub();
const stub = await startProviderStub();
const t = await boot({
const t = await boot({
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+// D12 public words: every page, doc and error says "Balance", never "Credits". The wire names stay as they are
+// (`usd_credit`, `/internal/credits`, `billing.credit.*`), so this test reads text and string literals only.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
+const docFiles = ['README.md', 'STATUS.json', 'docs/balance.md', 'docs/vibes-pay.md', 'docs/service-manifest-proposal.json', 'deploy/nginx/billing.openvibe.network.conf'];
+const textFiles = ['server/policy.js', 'server/console/views.js', 'server/config-store.js', 'server/api/me.js', 'server/ops/credits.js', 'server/ops/topups.js', 'server/ops/balance.js', 'server/ops/activity.js', 'server/ops/intents.js', 'server/ops/reversals.js', 'server/api/internal.js'];
+
+for (const f of docFiles) assert.ok(!/Credits/.test(read(f)), `${f} has no "Credits"`);
+for (const f of textFiles) {
+ const text = read(f);
+ // A quoted public string: "Credits", 'Credits', `Credits…` (an identifier such as createCreditsTopup is not text).
+ const quoted = text.match(/(["'`])(?:[^"'`\n]*?)Credits(?:[^"'`\n]*?)\1/);
+ assert.ok(!quoted, `${f} has no quoted "Credits" (found ${quoted && quoted[0]})`);
+}
+for (const f of docFiles) assert.ok(!/earn(?:ed|s)?\s+vibes/i.test(read(f)), `${f} says Vibes are bought, never earned`);
+assert.ok(/Balance/.test(read('docs/balance.md')), 'the Balance doc says Balance');
+console.log('public words: Balance in every public text and string; no "Credits", no "earn Vibes"');
new file mode 100644
new file mode 100644
@@ -0,0 +1,404 @@
@@ -0,0 +1,404 @@
+'use strict';
+/**
+ * Vibes Pay (D12 step 4): app charges, the pay sheet, the project wallet, signed webhooks, refunds, limits,
+ * sandbox Vibes and the journal. Money is whole Vibes; a charge moves once and never below zero.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const { boot, fund, check, done } = require('./helpers/app');
+const { validate } = require('openvibe-contracts');
+const { createVibesClient } = require('openvibe-sdk/vibes');
+const vibesWebhooks = require('../server/ops/vibes-webhooks');
+
+const APP = 'app_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const APP2 = 'app_01JAB2C3D4E5F6G7H8J9K0MNP2';
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const PRJ2 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNP2';
+const KEK = 'k1:' + crypto.randomBytes(32).toString('base64');
+const CAP = ['billing.vibes.charge', 'billing.vibes.read'];
+// The released SDK's webhook verifier, exactly as an app uses it: openvibe-sdk/vibes webhooks.verify.
+const sdkVibes = createVibesClient({ appToken: 'test-app-token', billingUrl: 'https://billing.example' });
+const verify = (raw, header, secret, now) => sdkVibes.webhooks.verify(raw, header, secret, { now });
+
+function startHook() {
+ const received = [];
+ const state = { failStatus: 0 };
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const delivery = { headers: req.headers, raw: Buffer.concat(chunks).toString('utf8'), failed: Boolean(state.failStatus) };
+ received.push(delivery);
+ if (delivery.failed) { res.writeHead(state.failStatus); res.end('no'); return; }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end('{"ok":true}');
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${server.address().port}/hook`, received, state,
+ close: () => new Promise((r) => server.close(r)),
+ })));
+}
+
+function signatureTime(header) {
+ const parts = Object.fromEntries(String(header || '').split(',').map((p) => p.split('=')));
+ return Number(parts.t);
+}
+
+async function journalBalanced(db, label) {
+ const rows = await db.prepare(`SELECT a.currency, SUM(e.amount)::text AS total FROM ledger_entries e
+ JOIN accounts a ON a.id = e.account_id GROUP BY a.currency`).all();
+ for (const r of rows) assert.strictEqual(String(r.total), '0', `journal not balanced for ${r.currency} ${label}: ${r.total}`);
+}
+
+(async () => {
+ console.log('Vibes Pay');
+ const hook = await startHook();
+ const t = await boot({
+ env: {
+ BILLING_AUTHORITY: 'billing',
+ BILLING_VIBES_PAY: '1',
+ BILLING_VIBES_MAX_CHARGE: '10000',
+ BILLING_VIBES_DAILY_APP_SPEND: '10000',
+ BILLING_VIBES_SANDBOX: '1',
+ BILLING_VIBES_CHARGE_TTL_SECONDS: '1800',
+ OV_CONFIG_KEKS: KEK, OV_CONFIG_KEK_CURRENT: 'k1',
+ },
+ });
+ try {
+ const payer = t.user(401);
+ const other = t.user(402);
+ t.network.addProject({ id: PRJ, name: 'Neon Studio', owner: payer.id });
+ t.network.addProject({ id: PRJ2, name: 'Sandbox Studio', owner: payer.id });
+ t.network.addMembership(PRJ, payer.id, 'owner', false, 'Neon Studio');
+ t.network.addApp({ id: APP, projectId: PRJ, name: 'Neon Cats', environment: 'production', icon: 'https://openvibe.media/assets/neon.png' });
+ t.network.addApp({ id: APP2, projectId: PRJ2, name: 'Sandbox Cats', environment: 'sandbox' });
+ const appToken = t.network.signApp({ appId: APP, projectId: PRJ, env: 'production', cap: CAP });
+ const app2Token = t.network.signApp({ appId: APP2, projectId: PRJ2, env: 'sandbox', cap: CAP });
+ const payerToken = t.network.signUser({ subject_id: payer.id, username: 'payer' });
+ const otherToken = t.network.signUser({ subject_id: other.id, username: 'other' });
+ await fund(t, payer, 1000);
+ const appHeaders = (token = appToken) => ({ token });
+ const create = (body, token = appToken) => t.call('POST', '/api/v1/vibes/charges', { token, body });
+ const charge = async (body, token = appToken) => {
+ const r = await create(body, token);
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json;
+ };
+ const pay = (id, token = payerToken, key = `pay_${crypto.randomBytes(8).toString('hex')}`) =>
+ t.call('POST', `/api/me/vibes/charges/${id}/pay`, { token, body: { idempotency_key: key }, key: null });
+ const cancel = (id, token = payerToken) => t.call('POST', `/api/me/vibes/charges/${id}/cancel`, { token, body: {}, key: null });
+ const viewOf = async (id, token = payerToken) => await t.call('GET', `/api/me/vibes/charges/${id}`, { token });
+ const balanceOf = async (account) => Number(String((await t.db.prepare(`SELECT COALESCE(b.balance, 0)::text AS b FROM accounts a
+ LEFT 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(account.kind, account.owner, account.currency)).b));
+ const payerAcct = { kind: 'user_credit', owner: payer.id, currency: 'vibes-bits' };
+ const projectAcct = { kind: 'project_payable', owner: `project:${PRJ}`, currency: 'vibes-bits' };
+
+ let secret;
+ await check('the app stores its webhook url and a sealed secret; the marker is all a read returns', async () => {
+ const put = await t.call('PUT', '/api/v1/vibes/webhook', { token: appToken, body: { url: hook.url } });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual(put.json.sealed, true);
+ assert.strictEqual(put.json.secret_set, true);
+ assert.ok(put.json.shown_once && typeof put.json.secret === 'string' && put.json.secret.length >= 16, put.text);
+ secret = put.json.secret;
+ const get = await t.call('GET', '/api/v1/vibes/webhook', { token: appToken });
+ assert.strictEqual(get.status, 200, get.text);
+ assert.strictEqual(get.json.secret, undefined, 'a read never returns the secret');
+ assert.strictEqual(get.json.secret_hint, secret.slice(-4));
+ assert.strictEqual(get.json.url, hook.url);
+ const row = await t.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = ? ORDER BY revision DESC LIMIT 1").get(`billing.app.${APP.toLowerCase()}`);
+ assert.ok(row, 'the app webhook has a D11 revision');
+ assert.ok(!String(row.values_json).includes(secret), 'the sealed store carries no plaintext secret');
+ const otherApp = await t.call('GET', '/api/v1/vibes/webhook', { token: app2Token });
+ assert.strictEqual(otherApp.status, 200, otherApp.text);
+ assert.strictEqual(otherApp.json.url, null, 'another app sees its own empty settings');
+ });
+
+ let paidCharge;
+ await check('create is idempotent on the app reference, and a different body under it conflicts', async () => {
+ const body = { amount: 50, description: 'Sticker pack: Neon cats', reference: 'order_123', return_url: 'https://myapp.example/thanks' };
+ const r = await create(body);
+ assert.strictEqual(r.status, 201, r.text);
+ paidCharge = r.json;
+ const valid = validate('billing.vibes-charge@1', paidCharge);
+ assert.ok(valid.valid, JSON.stringify(valid.errors));
+ assert.strictEqual(paidCharge.status, 'pending');
+ assert.match(paidCharge.pay_url, /^https:\/\/openvibe\.network\/pay\/vch_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(paidCharge.sandbox, false);
+ const replay = await create(body);
+ assert.strictEqual(replay.status, 200, replay.text);
+ assert.strictEqual(replay.json.id, paidCharge.id);
+ const conflict = await create({ ...body, amount: 51 });
+ assert.strictEqual(conflict.status, 409, conflict.text);
+ assert.strictEqual(conflict.json.code, 'billing.reference_reused');
+ const notMine = await t.call('GET', `/api/v1/vibes/charges/${paidCharge.id}`, { token: app2Token });
+ assert.strictEqual(notMine.status, 404, notMine.text);
+ });
+
+ await check('the pay sheet shows the app from Network, the item, the price and the person\'s Vibes', async () => {
+ const r = await viewOf(paidCharge.id);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(validate('billing.vibes-charge@1', r.json.charge).valid);
+ assert.deepStrictEqual(r.json.app, { id: APP, name: 'Neon Cats', icon: 'https://openvibe.media/assets/neon.png' });
+ assert.strictEqual(r.json.charge.description, 'Sticker pack: Neon cats');
+ assert.strictEqual(r.json.vibes_credit, 1000);
+ assert.strictEqual(r.json.shortfall_vibes, 0);
+ assert.strictEqual(r.json.can_pay, true);
+ assert.strictEqual((await t.call('GET', `/api/me/vibes/charges/${paidCharge.id}`)).status, 401);
+ assert.strictEqual((await t.call('GET', '/api/me/vibes/charges/vch_01JAB2C3D4E5F6G7H8J9K0MNPQ')).status, 401);
+ const cors = await fetch(`${t.base}/api/me/vibes/charges/${paidCharge.id}`, { headers: { Authorization: `Bearer ${payerToken}`, Origin: 'https://openvibe.network' } });
+ assert.strictEqual(cors.status, 200);
+ assert.strictEqual(cors.headers.get('access-control-allow-origin'), 'https://openvibe.network');
+ assert.strictEqual(cors.headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('a person-delegated app reads only that person\'s Vibes (billing.vibes.read)', async () => {
+ const delegated = t.network.signApp({ appId: APP, projectId: PRJ, env: 'production', cap: ['billing.vibes.read'], onBehalfOf: payer.id });
+ const r = await t.call('GET', '/api/v1/vibes/me', { token: delegated });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(validate('billing.vibes-wallet@1', r.json).valid, JSON.stringify(r.json));
+ assert.deepStrictEqual(r.json.subject, { type: 'user', id: payer.id });
+ assert.strictEqual(r.json.credit, 1000);
+ const noPerson = t.network.signApp({ appId: APP, projectId: PRJ, env: 'production', cap: ['billing.vibes.read'] });
+ assert.strictEqual((await t.call('GET', '/api/v1/vibes/me', { token: noPerson })).status, 422);
+ const sandbox = t.network.signApp({ appId: APP2, projectId: PRJ2, env: 'sandbox', cap: ['billing.vibes.read'], onBehalfOf: payer.id });
+ const refused = await t.call('GET', '/api/v1/vibes/me', { token: sandbox });
+ assert.strictEqual(refused.status, 403, refused.text);
+ assert.strictEqual(refused.json.code, 'billing.sandbox_refused');
+ });
+
+ await check('the person pays once, atomically: 50 Vibes move to the project wallet', async () => {
+ const r = await pay(paidCharge.id, payerToken, `pay_first_${crypto.randomBytes(8).toString('hex')}`);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.charge.status, 'paid');
+ assert.ok(r.json.charge.paid_at);
+ assert.strictEqual(r.json.vibes_credit, 950);
+ assert.strictEqual(await balanceOf(payerAcct), 950);
+ assert.strictEqual(await balanceOf(projectAcct), 50);
+ const rows = await t.db.prepare("SELECT id FROM transactions WHERE type = 'vibes_charge'").all();
+ assert.strictEqual(rows.length, 1);
+ await journalBalanced(t.db, 'after a payment');
+ });
+
+ await check('the app gets a signed paid webhook with no person id or email, and the event too', async () => {
+ const out = await vibesWebhooks.deliverDue(t.ctx);
+ assert.strictEqual(out.delivered, 1, JSON.stringify(out));
+ assert.strictEqual(hook.received.length, 1);
+ const delivery = hook.received[0];
+ assert.ok(verify(delivery.raw, delivery.headers['x-openvibe-signature-v2'], secret, t.ctx.now()), 'openvibe-sdk/vibes verifies the HMAC-SHA256 signature');
+ assert.strictEqual(delivery.headers['x-openvibe-event'], 'billing.vibes.charge.paid');
+ const body = JSON.parse(delivery.raw);
+ const valid = validate('billing.vibes.charge.paid@1', body);
+ assert.ok(valid.valid, JSON.stringify(valid.errors));
+ assert.strictEqual(body.status, 'paid');
+ assert.strictEqual(body.amount, 50);
+ assert.match(body.payer_ref, /^pr_[0-9a-f]{32}$/);
+ assert.ok(!delivery.raw.includes(payer.id), 'no person id in the webhook');
+ assert.ok(!delivery.raw.includes('usr_'), 'no subject id in the webhook');
+ assert.ok(!delivery.raw.includes('@'), 'no email in the webhook');
+ const ev = await t.db.prepare("SELECT envelope FROM service_outbox WHERE envelope #>> '{event_type}' = 'billing.vibes.charge.paid'").get();
+ assert.ok(ev, 'the first-party event is in the outbox');
+ const envelope = typeof ev.envelope === 'string' ? JSON.parse(ev.envelope) : ev.envelope;
+ assert.strictEqual(envelope.payload.payer_ref, body.payer_ref);
+ assert.ok(!JSON.stringify(envelope).includes(payer.id));
+ });
+
+ await check('a double pay pays once: a replay for the same person, a conflict for another', async () => {
+ const same = await pay(paidCharge.id, payerToken, `pay_second_${crypto.randomBytes(8).toString('hex')}`);
+ assert.strictEqual(same.status, 200, same.text);
+ assert.strictEqual(same.json.replay, true);
+ assert.strictEqual(same.json.charge.status, 'paid');
+ const count = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE type = 'vibes_charge'").get();
+ assert.strictEqual(count.n, 1);
+ assert.strictEqual(await balanceOf(payerAcct), 950);
+ assert.strictEqual(await balanceOf(projectAcct), 50);
+ const rival = await pay(paidCharge.id, otherToken);
+ assert.strictEqual(rival.status, 409, rival.text);
+ assert.strictEqual(rival.json.code, 'billing.charge_not_pending');
+ assert.strictEqual((await cancel(paidCharge.id)).status, 409);
+ });
+
+ await check('a refund returns the Vibes once, from the project wallet, and emits the refunded webhook', async () => {
+ const r = await t.call('POST', `/api/v1/vibes/charges/${paidCharge.id}/refund`, { token: appToken, body: { reason: 'bought twice' }, key: null });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.status, 'refunded');
+ assert.ok(r.json.refunded_at);
+ assert.strictEqual(await balanceOf(payerAcct), 1000);
+ assert.strictEqual(await balanceOf(projectAcct), 0);
+ const again = await t.call('POST', `/api/v1/vibes/charges/${paidCharge.id}/refund`, { token: appToken, body: {}, key: null });
+ assert.strictEqual(again.status, 200, again.text);
+ assert.strictEqual(again.json.status, 'refunded');
+ const refunds = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE type = 'vibes_refund'").get();
+ assert.strictEqual(refunds.n, 1);
+ const out = await vibesWebhooks.deliverDue(t.ctx);
+ assert.strictEqual(out.delivered, 1, JSON.stringify(out));
+ const delivery = hook.received[hook.received.length - 1];
+ assert.ok(verify(delivery.raw, delivery.headers['x-openvibe-signature-v2'], secret, t.ctx.now()));
+ const body = JSON.parse(delivery.raw);
+ assert.ok(validate('billing.vibes.charge.refunded@1', body).valid);
+ assert.strictEqual(body.status, 'refunded');
+ assert.strictEqual(body.payer_ref, JSON.parse(hook.received[0].raw).payer_ref, 'the payer reference is stable per app');
+ assert.ok(!delivery.raw.includes(payer.id));
+ await journalBalanced(t.db, 'after a refund');
+ });
+
+ await check('an expired charge cannot be paid and never moves money', async () => {
+ const c = await charge({ amount: 10, description: 'Late sticker', reference: 'order_expired' });
+ t.clock.offset += 31 * 60 * 1000;
+ const r = await pay(c.id);
+ assert.strictEqual(r.status, 409, r.text);
+ assert.strictEqual(r.json.code, 'billing.charge_expired');
+ const row = await t.db.prepare('SELECT status FROM vibes_charges WHERE id = ?').get(c.id);
+ assert.strictEqual(row.status, 'expired');
+ const count = await t.db.prepare("SELECT COUNT(*)::int AS n FROM transactions WHERE idempotency_key = ?").get(`vibes:charge:${c.id}`);
+ assert.strictEqual(count.n, 0);
+ });
+
+ await check('short of Vibes is 402 and the charge stays pending', async () => {
+ const c = await charge({ amount: 1001, description: 'Too much', reference: 'order_short' });
+ const r = await pay(c.id);
+ assert.strictEqual(r.status, 402, r.text);
+ assert.strictEqual(r.json.code, 'billing.insufficient_vibes');
+ assert.strictEqual(r.json.details.required, 1001);
+ const row = await t.db.prepare('SELECT status FROM vibes_charges WHERE id = ?').get(c.id);
+ assert.strictEqual(row.status, 'pending');
+ });
+
+ await check('limit: the largest single charge and the daily app spend are owner settings', async () => {
+ const tooBig = await create({ amount: 10001, description: 'Over the top', reference: 'order_big' });
+ assert.strictEqual(tooBig.status, 403, tooBig.text);
+ assert.strictEqual(tooBig.json.code, 'billing.vibes_limit_exceeded');
+ await fund(t, payer, 20000);
+ // The boot caps the day at 10,000; spend it with one allowed charge, then a little one is refused.
+ const big = await charge({ amount: 10000, description: 'One big one', reference: 'order_big_ok' });
+ assert.strictEqual((await pay(big.id)).status, 200);
+ const little = await charge({ amount: 1, description: 'One more', reference: 'order_over_daily' });
+ const r = await pay(little.id);
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'billing.vibes_limit_exceeded');
+ // Paying a big allowed charge back leaves the daily spend net of the refund.
+ const refund = await t.call('POST', `/api/v1/vibes/charges/${big.id}/refund`, { token: appToken, body: {}, key: null });
+ assert.strictEqual(refund.status, 201, refund.text);
+ });
+
+ await check('a webhook that fails is retried with backoff and delivered later', async () => {
+ await vibesWebhooks.deliverDue(t.ctx); // drain earlier deliveries so this check sees only its own row
+ hook.state.failStatus = 500;
+ const c = await charge({ amount: 5, description: 'Retry me', reference: 'order_retry' });
+ assert.strictEqual((await pay(c.id)).status, 200);
+ const first = await vibesWebhooks.deliverDue(t.ctx);
+ assert.strictEqual(first.failed, 1, JSON.stringify(first));
+ const row = await t.db.prepare('SELECT * FROM vibes_webhook_outbox WHERE charge_id = ?').get(c.id);
+ assert.strictEqual(Number(row.attempts), 1);
+ assert.strictEqual(Number(row.last_status), 500);
+ assert.ok(row.delivered_at == null);
+ assert.ok(new Date(row.next_attempt_at).getTime() > t.ctx.now(), 'the retry is scheduled in the future');
+ const firstTry = hook.received[hook.received.length - 1];
+ assert.ok(firstTry.failed);
+ assert.ok(verify(firstTry.raw, firstTry.headers['x-openvibe-signature-v2'], secret, t.ctx.now()), 'the failed attempt was signed too');
+ hook.state.failStatus = 0;
+ t.clock.offset += 2000; // a retry is a new delivery attempt: a fresh t, still inside the 300 s tolerance
+ await t.db.prepare('UPDATE vibes_webhook_outbox SET next_attempt_at = ?::timestamptz WHERE charge_id = ?').run(new Date(t.ctx.now() - 1000).toISOString(), c.id);
+ const second = await vibesWebhooks.deliverDue(t.ctx);
+ assert.strictEqual(second.delivered, 1, JSON.stringify(second));
+ const done = await t.db.prepare('SELECT * FROM vibes_webhook_outbox WHERE charge_id = ?').get(c.id);
+ assert.ok(done.delivered_at);
+ assert.strictEqual(Number(done.attempts), 2);
+ const retry = hook.received[hook.received.length - 1];
+ assert.ok(!retry.failed);
+ assert.ok(verify(retry.raw, retry.headers['x-openvibe-signature-v2'], secret, t.ctx.now()), 'the retried delivery verifies with openvibe-sdk/vibes');
+ assert.ok(signatureTime(retry.headers['x-openvibe-signature-v2']) > signatureTime(firstTry.headers['x-openvibe-signature-v2']), 'the retry carries a fresh t');
+ });
+
+ await check('the project owner cashes out the project wallet through the existing cashout path', async () => {
+ const c = await charge({ amount: 500, description: 'Cashout item', reference: 'order_cashout' });
+ assert.strictEqual((await pay(c.id)).status, 200);
+ const before = await balanceOf(projectAcct);
+ const request = (body, cap = ['billing.cashout.request'], key) => t.call('POST', '/api/v1/cashouts', { cap, body, ...(key ? { key } : {}) });
+ const body = {
+ subject: payer.id, project_id: PRJ, amount: 500,
+ payout_method: { type: 'paypal', address: '[email protected]' },
+ };
+ const r = await request(body);
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.cashout.project_id, PRJ, 'the cashout names the project');
+ assert.strictEqual(await balanceOf(projectAcct), before - 500);
+ const pending = Number((await t.db.prepare(`SELECT b.balance::text AS b FROM accounts a JOIN account_balances b ON b.account_id = a.id
+ WHERE a.kind = 'payouts_pending' AND a.owner_subject = ?`).get(payer.id)).b);
+ assert.strictEqual(pending, 500);
+ const otherBody = { ...body, subject: other.id };
+ const denied = await request(otherBody);
+ assert.strictEqual(denied.status, 403, denied.text);
+ assert.strictEqual(denied.json.code, 'billing.not_project_owner');
+ await journalBalanced(t.db, 'after a project cashout');
+ });
+
+ await check('the economy freeze refuses app charges and payments, reads still answer', async () => {
+ const c = await charge({ amount: 5, description: 'Before freeze', reference: 'order_before_freeze' });
+ await t.call('POST', '/api/v1/admin/freeze', { key: null, body: { on: true, reason: 'Vibes Pay test' } });
+ const frozen = await create({ amount: 5, description: 'Frozen', reference: 'order_frozen' });
+ assert.strictEqual(frozen.status, 503, frozen.text);
+ assert.strictEqual(frozen.json.code, 'billing.frozen');
+ const r = await pay(c.id);
+ assert.strictEqual(r.status, 503, r.text);
+ assert.strictEqual(r.json.code, 'billing.frozen');
+ assert.strictEqual((await viewOf(c.id)).status, 200);
+ await t.call('POST', '/api/v1/admin/freeze', { key: null, body: { on: false } });
+ });
+
+ await check('sandbox: test grants only, test Vibes only, never a real account', async () => {
+ const realBefore = await balanceOf(payerAcct);
+ const projectBefore = await balanceOf(projectAcct);
+ const c = await charge({ amount: 30, description: 'Sandbox sticker', reference: 'sandbox_1' }, app2Token);
+ assert.strictEqual(c.sandbox, true);
+ const short = await pay(c.id);
+ assert.strictEqual(short.status, 402, short.text);
+ const grant = await t.call('POST', '/api/v1/vibes/sandbox/grants', {
+ token: app2Token, body: { subject: { type: 'user', id: payer.id }, amount: 100 }, key: null,
+ });
+ assert.strictEqual(grant.status, 201, grant.text);
+ assert.strictEqual(grant.json.vibes_credit, 100);
+ const view = await viewOf(c.id);
+ assert.strictEqual(view.json.vibes_credit, 100);
+ assert.strictEqual(view.json.can_pay, true);
+ const paid = await pay(c.id);
+ assert.strictEqual(paid.status, 200, paid.text);
+ assert.strictEqual(paid.json.charge.status, 'paid');
+ assert.strictEqual(paid.json.vibes_credit, 70);
+ assert.strictEqual(await balanceOf(payerAcct), realBefore, 'real Vibes were not touched');
+ assert.strictEqual(await balanceOf(projectAcct), projectBefore, 'the real project wallet was not touched');
+ const row = await t.db.prepare(`SELECT t.test, a.kind, b.balance::text AS balance FROM transactions t
+ JOIN ledger_entries e ON e.txn_id = t.id JOIN accounts a ON a.id = e.account_id JOIN account_balances b ON b.account_id = a.id
+ WHERE t.idempotency_key = ? AND a.kind = 'project_payable_sandbox'`).get(`vibes:charge:${c.id}`);
+ assert.ok(row && Number(row.test) === 1 && row.balance === '30', JSON.stringify(row));
+ // A production app cannot mint sandbox Vibes.
+ const refused = await t.call('POST', '/api/v1/vibes/sandbox/grants', { token: appToken, body: { subject: { type: 'user', id: payer.id }, amount: 10 }, key: null });
+ assert.strictEqual(refused.status, 403, refused.text);
+ assert.strictEqual(refused.json.code, 'billing.sandbox_app_only');
+ // A person with only sandbox Vibes cannot pay a real charge.
+ const onlySandbox = t.user(403);
+ const onlyToken = t.network.signUser({ subject_id: onlySandbox.id, username: 'sandbox' });
+ await t.call('POST', '/api/v1/vibes/sandbox/grants', { token: app2Token, body: { subject: { type: 'user', id: onlySandbox.id }, amount: 500 }, key: null });
+ const real = await charge({ amount: 10, description: 'Real item', reference: 'real_after_sandbox' });
+ const denied = await pay(real.id, onlyToken);
+ assert.strictEqual(denied.status, 402, denied.text);
+ // A sandbox refund returns test Vibes to the test pocket.
+ const refund = await t.call('POST', `/api/v1/vibes/charges/${c.id}/refund`, { token: app2Token, body: {}, key: null });
+ assert.strictEqual(refund.status, 201, refund.text);
+ const after = await t.call('GET', '/api/v1/vibes/charges/' + c.id, { token: app2Token });
+ assert.strictEqual(after.json.status, 'refunded');
+ });
+
+ await check('every journal transaction balances and reconciliation passes', async () => {
+ await journalBalanced(t.db, 'at the end');
+ const rec = await t.assertReconciled('after Vibes Pay');
+ assert.ok(rec.ok);
+ });
+ } finally {
+ await hook.close();
+ await t.close();
+ }
+ done();
+})();