openvibers/openvibe.billing Commits

Vibes Pay (D12): app charges, the pay sheet, the project wallet and signed webhooks (#191)

OP OpenVibers committed 8 days ago · 16089d00ae · 67 files, +5661 −148

* OpenVibe.Billing: high-precision inference subledger (AI Exchange D2)

- Add migration 0011 for inference_credit accounts, holds, settlements and sandbox grants in nano-USD BIGINT.
- Ledger support for BigInt nano-USD amounts alongside existing bits/cents.
- Internal /internal/inference/* endpoints for hold, extend, settle, release and sandbox grant.
- Lease sweeper timer for expired holds.
- Sandbox grants gated by BILLING_INFERENCE_SANDBOX; whole feature by BILLING_INFERENCE_SUBLEDGER (off).
- Tests: concurrency, idempotency, rounding, BYOK, expiry, journal balance, chargeFor amplification comparison.

* Credits (D9): the subledger is Billing's general prepaid USD credit, and the review's money fixes

D9 (owner 11-03): two currencies. Vibes stay the social one; Credits are the topped-up USD
balance metered developer services spend (AI first, compute next). Renamed before
anything ran: credit_holds / credit_settlements / credit_sandbox_grants, account kinds
usd_credit and usd_credit_sandbox, transaction type credit, /internal/credits/*,
billing.credit.* capabilities, BILLING_CREDITS / BILLING_CREDITS_SANDBOX flags, and a
service column on holds (ai, run, ...).

Review fixes:
- a concurrent create with the same key replays (re-checked after the account lock);
- the first hold on a new account cannot collide on the account key (ON CONFLICT);
- a settle key reused for another hold is 409, and two concurrent settles replay;
- usage beyond the hold is taken from the account's free balance, never below zero, and
  what cannot be collected is recorded as shortfall_nano_usd for reconciliation;
- the sweeper alone decides expiry (a late settle on an unswept hold still bills);
- token counts must be safe integers (no BigInt throw);
- available_nano_usd is what the account has left, not the hold;
- the widened CHECKs are added NOT VALID then VALIDATEd (no scan under an exclusive lock);
- the three tables are in the account export (kept as money records on deletion).

* Credits: one capability per operation (billing.credit.hold.{create,extend,settle,release}), as Contracts v0.284.0 defines them

* openvibe-contracts v0.284.0 (was v0.222.0): the billing.credit-* schemas and capabilities

* GET /api/me/credits?project_id=: a person's Credits statement (balance, held, available, the last charges)

Their own account only (the subject is the token's), counts, prices and model ids, never
any content; off with BILLING_CREDITS. For Services' project Credits page.

* docs/credits.md: how a request is paid, the invariants and their tests, the old amplification, the launch runbook

* Credits: test the shortfall path (the free balance taken, the rest recorded, never below zero)

* Credits: a rate-limited public location for GET /api/me/credits

A person's own Credits statement is the second public /api path beside /api/me/vibes, before the /api 404 and
under the same console rate limit. It answers 404 while BILLING_CREDITS is off.

* Credits: the internal routes validate the contract's request bodies

Holds, extensions, settlements and sandbox grants are checked against their openvibe-contracts v0.284.0 schemas
before anything is held or charged; another shape is 422 billing.invalid_request. The statement reads the model
from the attempt's deployment_id, the field the contract carries.

* WIP: sandbox credit apart (D10) and billing.credit.settled (needs openvibe-contracts v0.287.0)

* Merge remote-tracking branch 'origin/main' into review/aix-p2

# Conflicts:
#	README.md

* Credits: sandbox credit apart (D10), billing.credit.settled, openvibe-contracts v0.287.0

- A sandbox grant now lands in the person's own usd_credit_sandbox account for the project; only a hold with
  sandbox: true spends it, and its charge returns to the issuer, never platform_revenue. Before, a grant minted
  into usd_credit, so granted test credit could have paid for real supply.
- Each settlement announces billing.credit.settled@1 in the same transaction, for aggregates (Report): the
  answering deployment, provider_access, usage, the charge collected, supply cost and shortfall; no subject,
  project or account.
- The statement shows sandbox credit apart; the five Credits capabilities are declared; docs/credits.md says
  how sandbox credit is kept apart and how a staging canary is funded.

* Credits top-up (D9): sell Credits through the existing checkout, behind BILLING_CREDITS_TOPUP (off)

A signed-in person starts a top-up for one of their projects with one of the owner's
configured amounts; the existing provider checkout charges the amount plus the owner's
fee. The paid webhook credits usd_credit at 1 cent = 10,000,000 nano-USD and books the
fee to platform_revenue, idempotent on the provider event, the receipt and the intent.
A refund or chargeback debits what remains (never below zero) and records the spent part
as a debt that reconciliation reports. GET /api/me/credits lists top-ups beside charges.

No default amount and no default fee: with either unset or invalid the route refuses
503 billing.topup_unconfigured; flag off creates nothing.

* Credits top-up: refuse while the economy is frozen, before any intent or checkout

* Credits top-up: one shared plan through every provider; terms read only by topupTerms()

The owner (11-04): Credits are bought through Billing's universal provider system, the same
pipeline as a Vibes purchase. Every adapter now maps its verified delivery to the one shared
credits_topup plan in providers/index.js applyPlan; no adapter owns Credits logic. PowerChat
carries a top-up on the site account as app_ref=pccredit:<intent>, like pcorder for purchases;
its response has checkout_ref and no hosted URL. The other adapters settle their existing paid
webhook. All five are listed in docs/credits.md.

topupTerms(ctx) in ops/credits.js is now the only reader of the amounts, the fee and the
top-up switch (env today; D11 moves it to billing.credits). config.js no longer parses them.
The test covers Stripe, PayPal and PowerChat settlement, replay and refunds, plus the one
terms function.

* D11 owner settings: billing.credits through openvibe-shared/config

* Balance (D12): person and project accounts, /api/me/balance, transfers and Vibes from Balance

* Balance activity: GET /api/me/activity for the wallet

* Activity test: CORS and no-store on the person route

* Vibes Pay (D12): app charges, the pay sheet, the project wallet and signed webhooks

* Vibes Pay: verify webhooks with openvibe-sdk 0.84.0 and pin contracts 0.289.1

Changes

ddiff --git a/.env.example b/.env.example
iindex 7779044..4968d14 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex d239123..a7fabd8 100644
---- a/README.md
++++ b/README.md
@@@ -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`, unit
same transaction and verified by reconciliation. Funds checks run inside the transaction that moves
the money, and every money transaction is SERIALIZABLE (`ledger.js` `money()`, retried on a
serialization failure), so two movements that each passed a check cannot both commit.
++- **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
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`.
@@@ -119,7 +129,22 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
settles 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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex ac653e8..b7242e4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/deploy/nginx/billing.openvibe.network.conf b/deploy/nginx/billing.openvibe.network.conf
iindex 49094aa..71ed072 100644
---- a/deploy/nginx/billing.openvibe.network.conf
++++ b/deploy/nginx/billing.openvibe.network.conf
@@@ -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.
ddiff --git a/docs/balance.md b/docs/balance.md
nnew file mode 100644
iindex 0000000..d6c4d04
---- /dev/null
++++ b/docs/balance.md
@@@ -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.
ddiff --git a/docs/capabilities-proposal/billing.config.manage.json b/docs/capabilities-proposal/billing.config.manage.json
nnew file mode 100644
iindex 0000000..100c264
---- /dev/null
++++ b/docs/capabilities-proposal/billing.config.manage.json
@@@ -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"
++}
ddiff --git a/docs/capabilities-proposal/billing.credit.hold.create.json b/docs/capabilities-proposal/billing.credit.hold.create.json
nnew file mode 100644
iindex 0000000..8185ba7
---- /dev/null
++++ b/docs/capabilities-proposal/billing.credit.hold.create.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.credit.hold.extend.json b/docs/capabilities-proposal/billing.credit.hold.extend.json
nnew file mode 100644
iindex 0000000..aed143e
---- /dev/null
++++ b/docs/capabilities-proposal/billing.credit.hold.extend.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.credit.hold.release.json b/docs/capabilities-proposal/billing.credit.hold.release.json
nnew file mode 100644
iindex 0000000..0332739
---- /dev/null
++++ b/docs/capabilities-proposal/billing.credit.hold.release.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.credit.hold.settle.json b/docs/capabilities-proposal/billing.credit.hold.settle.json
nnew file mode 100644
iindex 0000000..ad38de7
---- /dev/null
++++ b/docs/capabilities-proposal/billing.credit.hold.settle.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.credit.sandbox.grant.json b/docs/capabilities-proposal/billing.credit.sandbox.grant.json
nnew file mode 100644
iindex 0000000..5087a84
---- /dev/null
++++ b/docs/capabilities-proposal/billing.credit.sandbox.grant.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.vibes.charge.json b/docs/capabilities-proposal/billing.vibes.charge.json
nnew file mode 100644
iindex 0000000..73cc90e
---- /dev/null
++++ b/docs/capabilities-proposal/billing.vibes.charge.json
@@@ -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"
++}
ddiff --git a/docs/capabilities-proposal/billing.vibes.read.json b/docs/capabilities-proposal/billing.vibes.read.json
nnew file mode 100644
iindex 0000000..3924398
---- /dev/null
++++ b/docs/capabilities-proposal/billing.vibes.read.json
@@@ -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"
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex 5c3db69..8c98832 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -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."
}
ddiff --git a/docs/vibes-pay.md b/docs/vibes-pay.md
nnew file mode 100644
iindex 0000000..9d7ed75
---- /dev/null
++++ b/docs/vibes-pay.md
@@@ -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.
ddiff --git a/migrations/0011_credits.sql b/migrations/0011_credits.sql
nnew file mode 100644
iindex 0000000..e0d2326
---- /dev/null
++++ b/migrations/0011_credits.sql
@@@ -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
++);
ddiff --git a/migrations/0012_credits_topup.sql b/migrations/0012_credits_topup.sql
nnew file mode 100644
iindex 0000000..1774435
---- /dev/null
++++ b/migrations/0012_credits_topup.sql
@@@ -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);
ddiff --git a/migrations/0013_config.sql b/migrations/0013_config.sql
nnew file mode 100644
iindex 0000000..4d2bccf
---- /dev/null
++++ b/migrations/0013_config.sql
@@@ -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
++);
ddiff --git a/migrations/0014_balance.sql b/migrations/0014_balance.sql
nnew file mode 100644
iindex 0000000..283a387
---- /dev/null
++++ b/migrations/0014_balance.sql
@@@ -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;
ddiff --git a/migrations/0015_vibes_pay.sql b/migrations/0015_vibes_pay.sql
nnew file mode 100644
iindex 0000000..b55566f
---- /dev/null
++++ b/migrations/0015_vibes_pay.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 2b5106e..7b13239 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 6b3d6f2..19fcaa3 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 26c63a3..0d27804 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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);
}
},
});
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
iindex 2f6530c..1c133e4 100644
---- a/server/api/actor-limits.js
++++ b/server/api/actor-limits.js
@@@ -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. */
ddiff --git a/server/api/auth.js b/server/api/auth.js
iindex d1c23e4..25ae960 100644
---- a/server/api/auth.js
++++ b/server/api/auth.js
@@@ -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 };
ddiff --git a/server/api/internal.js b/server/api/internal.js
nnew file mode 100644
iindex 0000000..1b07c6f
---- /dev/null
++++ b/server/api/internal.js
@@@ -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 };
ddiff --git a/server/api/me.js b/server/api/me.js
iindex 8f5c7c7..3885151 100644
---- a/server/api/me.js
++++ b/server/api/me.js
@@@ -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 };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 4dbcd12..f5e099d 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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) => {
ddiff --git a/server/app.js b/server/app.js
iindex ef2683f..065564a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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'));
ddiff --git a/server/config-store.js b/server/config-store.js
nnew file mode 100644
iindex 0000000..51d0455
---- /dev/null
++++ b/server/config-store.js
@@@ -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,
++};
ddiff --git a/server/config.js b/server/config.js
iindex d980f40..967b983 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/console/queries.js b/server/console/queries.js
iindex af3040a..bc1ed54 100644
---- a/server/console/queries.js
++++ b/server/console/queries.js
@@@ -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'),
ddiff --git a/server/db.js b/server/db.js
iindex 56a0bbf..5b6533a 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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');
ddiff --git a/server/index.js b/server/index.js
iindex 03ee028..4d7d87d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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-sdk
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();
}
ddiff --git a/server/internal-config.js b/server/internal-config.js
nnew file mode 100644
iindex 0000000..bc5b2b9
---- /dev/null
++++ b/server/internal-config.js
@@@ -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 };
ddiff --git a/server/ledger.js b/server/ledger.js
iindex 99bdfdd..0a84c9f 100644
---- a/server/ledger.js
++++ b/server/ledger.js
@@@ -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.id
WHERE a.kind = ? AND a.owner_subject IS NOT DISTINCT FROM ? AND a.currency = ?`).get(kind, owner, currency);
-- return row ? row.balance : 0;
++ return 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 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);
++ 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 == 0
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;
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 })),
};
}
ddiff --git a/server/network.js b/server/network.js
iindex 9110516..245881e 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -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 };
ddiff --git a/server/ops/activity.js b/server/ops/activity.js
nnew file mode 100644
iindex 0000000..dc70ee9
---- /dev/null
++++ b/server/ops/activity.js
@@@ -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 };
ddiff --git a/server/ops/balance.js b/server/ops/balance.js
nnew file mode 100644
iindex 0000000..527e414
---- /dev/null
++++ b/server/ops/balance.js
@@@ -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 };
ddiff --git a/server/ops/cashouts.js b/server/ops/cashouts.js
iindex 2971a21..52edd12 100644
---- a/server/ops/cashouts.js
++++ b/server/ops/cashouts.js
@@@ -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 casho
function 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 };
ddiff --git a/server/ops/common.js b/server/ops/common.js
iindex 7f6fb69..1e5047e 100644
---- a/server/ops/common.js
++++ b/server/ops/common.js
@@@ -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 }),
ddiff --git a/server/ops/credits.js b/server/ops/credits.js
nnew file mode 100644
iindex 0000000..7a4358a
---- /dev/null
++++ b/server/ops/credits.js
@@@ -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,
++};
ddiff --git a/server/ops/intents.js b/server/ops/intents.js
iindex ad2dc2f..649104c 100644
---- a/server/ops/intents.js
++++ b/server/ops/intents.js
@@@ -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 };
ddiff --git a/server/ops/reversals.js b/server/ops/reversals.js
iindex 387b834..8e7cd12 100644
---- a/server/ops/reversals.js
++++ b/server/ops/reversals.js
@@@ -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, {
ddiff --git a/server/ops/topups.js b/server/ops/topups.js
nnew file mode 100644
iindex 0000000..b8dd0be
---- /dev/null
++++ b/server/ops/topups.js
@@@ -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 };
ddiff --git a/server/ops/vibes-webhooks.js b/server/ops/vibes-webhooks.js
nnew file mode 100644
iindex 0000000..f4705cc
---- /dev/null
++++ b/server/ops/vibes-webhooks.js
@@@ -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 };
ddiff --git a/server/ops/vibes.js b/server/ops/vibes.js
nnew file mode 100644
iindex 0000000..14d5928
---- /dev/null
++++ b/server/ops/vibes.js
@@@ -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,
++};
ddiff --git a/server/providers/ccbill.js b/server/providers/ccbill.js
iindex 57a3414..390aa3b 100644
---- a/server/providers/ccbill.js
++++ b/server/providers/ccbill.js
@@@ -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 } };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 8893122..8bcfbaa 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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));
ddiff --git a/server/providers/nowpayments.js b/server/providers/nowpayments.js
iindex bff40a6..19e899b 100644
---- a/server/providers/nowpayments.js
++++ b/server/providers/nowpayments.js
@@@ -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 } };
ddiff --git a/server/providers/paypal.js b/server/providers/paypal.js
iindex 291b9cd..ba64f9b 100644
---- a/server/providers/paypal.js
++++ b/server/providers/paypal.js
@@@ -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 } };
ddiff --git a/server/providers/powerchat.js b/server/providers/powerchat.js
iindex 310a74a..02e867b 100644
---- a/server/providers/powerchat.js
++++ b/server/providers/powerchat.js
@@@ -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` };
ddiff --git a/server/providers/stripe.js b/server/providers/stripe.js
iindex f91f303..26ab673 100644
---- a/server/providers/stripe.js
++++ b/server/providers/stripe.js
@@@ -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':
ddiff --git a/server/reconcile.js b/server/reconcile.js
iindex 16e7dec..04edc94 100644
---- a/server/reconcile.js
++++ b/server/reconcile.js
@@@ -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_id
GROUP BY e.txn_id, a.currency HAVING SUM(e.amount) <> 0 LIMIT 100`).all();
check('journal.txn_balanced', !unbalanced.length, unbalanced.length ? { offenders: unbalanced } : null);
-- const cache = 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();
-- 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_id
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();
++ // 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,
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 1098500..b086d2e 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -39,7 +39,10 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourc
try {
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(); }
ddiff --git a/test/activity.test.js b/test/activity.test.js
nnew file mode 100644
iindex 0000000..6cf0058
---- /dev/null
++++ b/test/activity.test.js
@@@ -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); });
ddiff --git a/test/balance.test.js b/test/balance.test.js
nnew file mode 100644
iindex 0000000..31e5ac9
---- /dev/null
++++ b/test/balance.test.js
@@@ -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); });
ddiff --git a/test/capabilities.test.js b/test/capabilities.test.js
iindex 954683c..c776500 100644
---- a/test/capabilities.test.js
++++ b/test/capabilities.test.js
@@@ -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}`);
}
}
}
ddiff --git a/test/credits-topup.test.js b/test/credits-topup.test.js
nnew file mode 100644
iindex 0000000..0531c8c
---- /dev/null
++++ b/test/credits-topup.test.js
@@@ -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();
++})();
ddiff --git a/test/credits.test.js b/test/credits.test.js
nnew file mode 100644
iindex 0000000..cb1a6e9
---- /dev/null
++++ b/test/credits.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 9207075..a6c315f 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -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,
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex ef2b5ee..21b0d93 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -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 id
const 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 };
ddiff --git a/test/nginx-vhost.test.js b/test/nginx-vhost.test.js
iindex 5cea188..0fac359 100644
---- a/test/nginx-vhost.test.js
++++ b/test/nginx-vhost.test.js
@@@ -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 \{([^}]*)\}/);
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');
ddiff --git a/test/owner-settings.test.js b/test/owner-settings.test.js
nnew file mode 100644
iindex 0000000..8dd3fd8
---- /dev/null
++++ b/test/owner-settings.test.js
@@@ -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();
++})();
ddiff --git a/test/providers.test.js b/test/providers.test.js
iindex b2260c9..d7d5bf5 100644
---- a/test/providers.test.js
++++ b/test/providers.test.js
@@@ -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({
ddiff --git a/test/public-words.test.js b/test/public-words.test.js
nnew file mode 100644
iindex 0000000..9d90973
---- /dev/null
++++ b/test/public-words.test.js
@@@ -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"');
ddiff --git a/test/vibes-pay.test.js b/test/vibes-pay.test.js
nnew file mode 100644
iindex 0000000..a71c852
---- /dev/null
++++ b/test/vibes-pay.test.js
@@@ -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();
++})();