openvibers/openvibe.billing Commits

Per-person rate limits on Billing's money-creating routes (roadmap WS-R task 4; openvibe-sdk v0.12.0 is now a dependency). Every /api/v1 caller is a first-party service (Live, Tips, VIP) acting for many people, so server/api/actor-limits.js never counts per service. A request counts against the person its body names (the buyer, the payer, the creator, the subscriber), whichever service sends it, and a refund counts against the payer of the transfer it gives back. The limit runs after the capability and freeze checks (a request refused while frozen is not counted) and before the idempotency store and the ledger. A refusal therefore stores nothing, and the same Idempotency-Key works after Retry-After. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one line and counts billing_rate_limited_total{limit,window}. BILLING_LIMITS=off turns every limit off, as a rollback lever that needs no deploy.

OP OpenVibers committed 28 Sept 2026 · 80f97aabbe · 11 files, +317 −15

- POST /intents (checkout): 5 a minute, 30 an hour, since each opens a provider checkout and dozens are card testing.
- POST /transfers: 60 and 1200 per payer, one a second and above Tips' own 20 a minute.
- POST /transfers/:id/refund: 20 and 200 per payer. There cannot be more refunds than transfers, Live's media queue holds 3 requests per person, and a refund Live sees refused stays unrefunded for the streamer's button to retry.
- POST /cashouts (payout request): 3 and 10.
- POST /recycle: 10 and 60.
- POST /subscriptions: 10 and 60 per subscriber.
- GET /transactions?subject= (a person's history): BILLING_LIMITS_MINUTE / BILLING_LIMITS_HOUR, 120 and 3000.

Never limited: the provider webhooks (a refused receipt would be money received and not credited), capturing an approved order, cashout approve and deny, subscription cancel, the freeze, every admin route, the staff console, balance and entitlement reads, health, ready, release.json and metrics. Billing has no /internal routes. test/console.test.js boots with BILLING_LIMITS=off, since it opens four payouts for one creator within a second to fill the staff queue. Every other test runs with the limits on. The test helper takes limitsNow.

test/actor-limits.test.js runs on a fixed limiter clock. A person's sixth checkout in a minute is refused with Retry-After 45, with no intent and no idempotency record stored. The same person through another service is refused while another person passes, and the next minute reopens so the refused key goes through. The 61st transfer moves no money while another payer passes. Refunds are refused after 20 for one payer, whichever service asks. The fourth payout request escrows nothing while another creator passes. History reads are limited per person, while balance reads, webhooks, health, ready, release.json and metrics never are. Refusals are logged without a token and counted, and the ledger still reconciles. npm test 19/19.

Changes

ddiff --git a/.env.example b/.env.example
iindex 261d168..428e00e 100644
---- a/.env.example
++++ b/.env.example
@@@ -36,6 +36,15 @@ BILLING_AUDIENCE=openvibe.billing
OV_OAUTH_CLIENT_ID=billing
OV_OAUTH_CLIENT_SECRET=change-me
++# ── Per-person limits on /api/v1's money routes (server/api/actor-limits.js; README "Per-person limits") ──
++# Counted per person named in the request, whichever service sends it; past a limit 429 rate_limited with
++# Retry-After. MINUTE/HOUR: a person's history reads; checkouts, transfers, refunds, payout requests,
++# recycling and credit subscriptions have fixed, tighter numbers. Webhooks are never limited.
++# BILLING_LIMITS=off counts nobody (rollback without a deploy).
++#BILLING_LIMITS=on
++#BILLING_LIMITS_MINUTE=120
++#BILLING_LIMITS_HOUR=3000
++
# ── Staff console (Network SSO; see README "Staff console") ──
# The Network must list ${BASE_URL}/auth/callback (https://billing.openvibe.network/auth/callback)
# as a redirect URI of OAuth client `billing`.
ddiff --git a/README.md b/README.md
iindex 1236d6e..ff2c722 100644
---- a/README.md
++++ b/README.md
@@@ -129,6 +129,31 @@ The capabilities and the service manifest are released in openvibe-contracts (v0
[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Grants are matched with
contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).
++### Per-person limits
++
++The money-creating routes also limit how often one person can use them: `server/api/actor-limits.js`,
++openvibe-sdk/limits, roadmap WS-R task 4. Every caller is a first-party service acting for many people,
++so nothing is counted per service: a request counts against the person its body names (the buyer, the
++payer, the creator, the subscriber), whichever service sends it; a refund counts against the payer of the
++transfer it gives back. The limit runs after the capability and freeze checks (a request refused while
++frozen is not counted) and before the idempotency store and the ledger, so a refusal stores nothing and
++the same `Idempotency-Key` works after `Retry-After`. Past a limit: `429` problem+json `rate_limited`, one log line and
++`billing_rate_limited_total{limit,window}`. `BILLING_LIMITS=off` turns them all off (a rollback lever).
++
++| Route | Per person, a minute / an hour |
++|---|---|
++| `POST /api/v1/intents` (checkout) | 5 / 30 |
++| `POST /api/v1/transfers` (per payer) | 60 / 1200 |
++| `POST /api/v1/transfers/:id/refund` (per payer of the transfer) | 20 / 200 |
++| `POST /api/v1/cashouts` (payout request) | 3 / 10 |
++| `POST /api/v1/recycle` | 10 / 60 |
++| `POST /api/v1/subscriptions` (per subscriber) | 10 / 60 |
++| `GET /api/v1/transactions?subject=` (history) | `BILLING_LIMITS_MINUTE` / `BILLING_LIMITS_HOUR` (120 / 3000) |
++
++Never limited: the provider webhooks (`/webhooks/*`), capturing an approved order, cashout approve and
++deny, subscription cancel, the freeze, every admin route, the staff console, balance and entitlement
++reads, `/api/health`, `/api/ready`, `/release.json` and `/metrics`. `test/actor-limits.test.js`.
++
## Staff console
Server-rendered pages at the root of `https://billing.openvibe.network` for the people who decide
ddiff --git a/package-lock.json b/package-lock.json
iindex 058e08e..f7dee95 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,6 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"engines": {
@@@ -982,6 +983,15 @@
"node": ">=20"
}
},
++ "node_modules/openvibe-sdk": {
++ "version": "0.12.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
++ "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ }
++ },
"node_modules/openvibe-shared": {
"version": "1.22.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
ddiff --git a/package.json b/package.json
iindex 6675717..c0ad1be 100644
---- a/package.json
++++ b/package.json
@@@ -20,6 +20,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
"engines": {
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
nnew file mode 100644
iindex 0000000..c7f2f9c
---- /dev/null
++++ b/server/api/actor-limits.js
@@@ -0,0 +1,108 @@
++'use strict';
++
++/**
++ * Per-person rate limits on the money-creating routes of /api/v1 (roadmap WS-R task 4;
++ * openvibe-sdk/limits).
++ *
++ * Every /api/v1 caller is a first-party service (Live, Tips, VIP) acting for many people, so a limit is
++ * never counted per service: it is counted per PERSON, the one the request names in its body (the buyer,
++ * the payer, the creator), whichever service sends it. A refund counts against the payer of the transfer
++ * it gives back. A request that names no valid person counts against its principal (it is then refused
++ * 422 by the route anyway). Each limit sits after the capability and freeze checks (a request refused
++ * while frozen is not counted) and before the idempotency store and any ledger work: a refusal stores
++ * nothing, so a retry with the same Idempotency-Key after Retry-After is a fresh attempt. Past a limit:
++ * 429 problem+json `rate_limited` with Retry-After, one log line and billing_rate_limited_total{limit,window}.
++ * Counters live in this process: a restart forgets them. BILLING_LIMITS=off counts nobody (a rollback
++ * lever, no deploy needed).
++ *
++ * Never limited: the provider webhooks (/webhooks/*: a refused receipt would be money received and not
++ * credited), capturing an approved order (the person already paid; bounded by the intents they started),
++ * cashout approve and deny, subscription cancel, the freeze, every admin route, the staff console,
++ * balance and entitlement reads (hot paths the services call for every page and chat message),
++ * /api/health, /api/ready, /release.json and /metrics.
++ */
++const { createActorLimiter } = require('openvibe-sdk/limits');
++const { ids } = require('openvibe-contracts');
++
++/** Each budget per person: a minute, an hour. */
++const BUDGETS = {
++ // Starting a checkout (POST /intents) prices it and opens a provider checkout (Stripe, PayPal…).
++ // 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 },
++ // 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 },
++ // Giving a transfer back to its payer. There cannot be more refunds than the payer's transfers (60 a
++ // minute, above), Live's media queue holds 3 requests per person by default, and a refund Live sees
++ // refused stays unrefunded, so the streamer's refund button asks again.
++ 'billing.transfer.refund': { minute: 20, hour: 200 },
++ // A payout request moves a creator's payable into escrow for staff to pay out: a creator asks now and
++ // then (a replayed Idempotency-Key counts too).
++ 'billing.cashout.request': { minute: 3, hour: 10 },
++ // Recycling payable into spendable credit: a creator's button, a few at a time.
++ '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 },
++};
++
++/** The bare usr_… id from a SubjectRef or a bare id, else null. */
++function personOf(v) {
++ const id = v && typeof v === 'object' ? v.id : v;
++ return typeof id === 'string' && ids.isSubjectId('user', id) && (!v || typeof v !== 'object' || v.type === 'user') ? id : null;
++}
++
++/**
++ * createActorLimits({ config, db, now, registry, log }) → { budget(name, personFrom), history, refund }.
++ * personFrom(req) names the person a request is counted against (or null: its principal).
++ */
++function createActorLimits({ config, db, now = () => Date.now(), registry = null, log = console }) {
++ const refused = registry
++ ? registry.counter({ name: 'billing_rate_limited_total', help: 'Requests refused 429 by a per-person limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++ const enabled = config.actorLimits.enabled;
++
++ const limiter = createActorLimiter({
++ limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
++ now,
++ actor(req) {
++ if (!enabled) return null;
++ const person = req.limitPerson;
++ if (person) return `user:${person}`;
++ return req.principal && req.principal.sub ? req.principal.sub : null;
++ },
++ onLimited(e) {
++ // The actor is a subject id or a principal, never a token or an amount.
++ log.warn(`[Billing] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++
++ /** The budget `name`, counted against personFrom(req). */
++ function budget(name, personFrom) {
++ const own = BUDGETS[name];
++ if (!own) throw new Error(`limits: no budget named ${name}`);
++ const limit = limiter(name, own);
++ return function billingActorLimit(req, res, next) {
++ try { req.limitPerson = personFrom(req); } catch { req.limitPerson = null; }
++ return limit(req, res, next);
++ };
++ }
++
++ /** A person's history (GET /transactions?subject=): BILLING_LIMITS_MINUTE / BILLING_LIMITS_HOUR, 120 and 3000. */
++ const historyLimit = limiter('billing.history.read');
++ function history(req, res, next) {
++ req.limitPerson = personOf(req.query && req.query.subject);
++ return historyLimit(req, res, next);
++ }
++
++ /** The payer of the transfer a refund gives back (one primary-key read), else null. */
++ function payerOf(req) {
++ const row = db.prepare('SELECT from_subject FROM transactions WHERE id = ?').get(String(req.params.id));
++ return row ? personOf(row.from_subject) : null;
++ }
++
++ return { budget, history, payerOf };
++}
++
++module.exports = { createActorLimits, personOf, BUDGETS };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 30eb5cc..ec2ab0c 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -14,6 +14,7 @@ const { getTxn, present: presentTxn, balance, BillingError } = require('../ledge
const { A, userSubject, positiveInt, isFrozen, fail } = require('../ops/common');
const { idempotent } = require('./idempotency');
const { actorOf } = require('./auth');
++const { personOf } = require('./actor-limits');
const purchases = require('../ops/purchases');
const transfers = require('../ops/transfers');
const cashouts = require('../ops/cashouts');
@@@ -35,16 +36,21 @@ const CAP = {
admin: 'billing.ledger.admin',
};
--function v1Router({ ctx, auth, adapters }) {
++function v1Router({ ctx, auth, adapters, limits }) {
const r = express.Router();
const { db } = ctx;
const idem = idempotent(db, ctx.now);
const notFrozen = (req, res, next) => (isFrozen(db)
? http.sendProblem(res, 503, 'billing.frozen', { detail: 'the economy is frozen: writes are refused, reads are served', ctx: req.ov })
: next());
-- /** A mutating route: capability → freeze → idempotency → handler. */
-- const write = (cap, handler) => [auth.needs(cap), notFrozen, idem, wrap(handler)];
-- const read = (cap, handler) => [auth.needs(cap), wrap(handler)];
++ /**
++ * A mutating route: capability → freeze → per-person limit (when given) → idempotency → handler.
++ * A limit refusal reaches neither the idempotency store nor the ledger; a frozen refusal is not counted.
++ */
++ const write = (cap, handler, limit) => [auth.needs(cap), notFrozen, ...(limit ? [limit] : []), idem, wrap(handler)];
++ const read = (cap, handler, limit) => [auth.needs(cap), ...(limit ? [limit] : []), wrap(handler)];
++ // Who a money route is counted against (api/actor-limits.js): the person the body names.
++ const person = (field) => (req) => personOf(req.body && req.body[field]);
const txnOut = (t, extra = {}) => ({ transaction: presentTxn(t), ...extra });
const pathSubject = (v) => userSubject(String(v), 'subject');
@@@ -83,7 +89,7 @@ function v1Router({ ctx, auth, adapters }) {
}
}
res.status(201).json({ intent: intents.present(intents.find(db, intent.id)), checkout_url: checkoutUrl });
-- }));
++ }, limits.budget('billing.intent.create', person('subject'))));
r.get('/intents/:id', ...read(CAP.intent, (req, res) => {
const i = intents.find(db, req.params.id);
if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);
@@@ -119,12 +125,12 @@ function v1Router({ ctx, auth, adapters }) {
message: b.message, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,
});
res.status(201).json(txnOut(out.txn, { balance: { credit: balance(db, A.credit(out.txn.from_subject)) } }));
-- }));
++ }, limits.budget('billing.transfer.create', person('from'))));
r.post('/transfers/:id/refund', ...write(CAP.transfer, (req, res) => {
const b = req.body || {};
const out = transfers.refund(ctx, { txnId: req.params.id, amount: b.amount, reason: b.reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json(txnOut(out.txn));
-- }));
++ }, limits.budget('billing.transfer.refund', limits.payerOf)));
// ── Recycle: creator payable → own spendable credit ──────
r.post('/recycle', ...write(CAP.cashoutRequest, (req, res) => {
@@@ -132,14 +138,14 @@ function v1Router({ ctx, auth, adapters }) {
const subject = userSubject(b.subject);
const out = cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json(txnOut(out.txn, { balance: balanceOf(subject) }));
-- }));
++ }, limits.budget('billing.recycle', person('subject'))));
// ── Cashouts ─────────────────────────────────────────────
r.post('/cashouts', ...write(CAP.cashoutRequest, (req, res) => {
const b = req.body || {};
const out = cashouts.request(ctx, { subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json({ cashout: cashouts.present(out.cashout) });
-- }));
++ }, limits.budget('billing.cashout.request', person('subject'))));
r.get('/cashouts', ...read(CAP.cashoutManage, (req, res) => {
const list = cashouts.list(db, { status: req.query.status, subject: req.query.subject, limit: Number(req.query.limit) || 100 });
res.json({ cashouts: list.map(cashouts.present) });
@@@ -179,7 +185,7 @@ function v1Router({ ctx, auth, adapters }) {
priceCents: b.price_cents, autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
});
res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });
-- }));
++ }, limits.budget('billing.subscription.create', person('subscriber'))));
r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], (req, res) => {
const list = subscriptions.list(db, {
subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
@@@ -230,7 +236,7 @@ function v1Router({ ctx, auth, adapters }) {
const page = rows.slice(0, limit);
const next = rows.length > limit ? Buffer.from(JSON.stringify([page[page.length - 1].created_at, page[page.length - 1].id])).toString('base64url') : null;
res.json({ transactions: page.map((x) => presentTxn(getTxn(db, x.id))), next_cursor: next });
-- }));
++ }, limits.history));
r.get('/transactions/:id', ...read(CAP.balance, (req, res) => {
const t = getTxn(db, req.params.id);
if (!t) fail(404, 'billing.transaction_not_found', `no transaction ${req.params.id}`);
ddiff --git a/server/app.js b/server/app.js
iindex 5014c41..b168c0b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -10,7 +10,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 }) — everything injectable.
++ * createApp({ config, db, keys, identity, adapters, now, fetchImpl, log, limitsNow }) — everything injectable
++ * (limitsNow: the per-person limiter's clock, tests; default the wall clock).
*/
const path = require('path');
const express = require('express');
@@@ -23,6 +24,7 @@ const { createRates } = require('./rates');
const { createKeyProvider, createIdentity } = require('./network');
const { createAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
++const { createActorLimits } = require('./api/actor-limits');
const { webhooksRouter } = require('./api/webhooks');
const { consoleRouter } = require('./console');
const providers = require('./providers');
@@@ -79,7 +81,9 @@ function createApp(opts = {}) {
app.get('/api/ready', readiness.handler);
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
-- app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters }));
++ // Per-person limits on the money-creating routes (api/actor-limits.js); the webhooks above never are.
++ const limits = createActorLimits({ config, db, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
++ app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));
// The billing policy is public and indexable; the staff console and the API are not.
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));
ddiff --git a/server/config.js b/server/config.js
iindex 2f73b0e..6a408d1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -83,6 +83,14 @@ function loadConfig(env = process.env) {
publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
},
audience: env.BILLING_AUDIENCE || 'openvibe.billing',
++ // Per-person limits on /api/v1's money-creating routes (server/api/actor-limits.js, roadmap WS-R
++ // task 4). minute/hour: a person's history reads; the money routes set tighter numbers there.
++ // BILLING_LIMITS=off counts nobody (a rollback lever).
++ actorLimits: {
++ enabled: String(env.BILLING_LIMITS || 'on').toLowerCase() !== 'off',
++ minute: Math.max(1, int(env.BILLING_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.BILLING_LIMITS_HOUR, 3000)),
++ },
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'billing',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..3493448
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,128 @@
++'use strict';
++/**
++ * Per-person limits on the money-creating routes (server/api/actor-limits.js, roadmap WS-R task 4), on a
++ * fixed limiter clock: past its limit one person gets 429 problem+json `rate_limited` with Retry-After,
++ * before the idempotency store and the ledger, whichever service sends the request, while another person
++ * still passes; the window reopens and the same Idempotency-Key then goes through. Checkouts, transfers,
++ * refunds (counted against the payer) and payout requests have their own numbers. Balance reads, the
++ * provider webhooks, health, ready, release.json and metrics are never limited; refusals are logged (no
++ * token) and counted.
++ */
++const assert = require('assert');
++const { boot, fund, check, done } = require('./helpers/app');
++
++(async () => {
++ // 15 s into a minute: the minute window has 45 s left. A person's history reads: 3 a minute.
++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
++ const t = await boot({ limitsNow: () => clock, env: { BILLING_LIMITS_MINUTE: '3', BILLING_LIMITS_HOUR: '100' } });
++ const fan = t.user(31);
++ const other = t.user(32);
++ const creator = t.user(33);
++ const second = t.user(34);
++ console.log('actor limits');
++ const count = (table) => t.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
++ const checkout = (subject, o = {}) => t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'purchase', subject, bits: 500 }, ...o });
++
++ await check('checkouts: 5 a minute per person, then 429 rate_limited with Retry-After; nothing is stored', async () => {
++ for (let i = 0; i < 5; i++) assert.strictEqual((await checkout(fan)).status, 201, `checkout ${i + 1}`);
++ const before = count('payment_intents');
++ const r = await checkout(fan, { key: 'limits-retry-key-1' });
++ assert.strictEqual(r.status, 429, r.text);
++ assert.strictEqual(r.headers.get('retry-after'), '45');
++ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
++ assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(r.json.detail.includes('billing.intent.create'), r.json.detail);
++ assert.strictEqual(count('payment_intents'), before, 'no intent stored');
++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM idempotency_keys WHERE key = 'limits-retry-key-1'").get().n, 0, 'the refusal is not an idempotent answer');
++ });
++
++ await check('counted per person, not per service: another service for the same person is refused, another person passes', async () => {
++ assert.strictEqual((await checkout(fan, { sub: 'svc:tips' })).status, 429, 'Tips speaking for the same person');
++ assert.strictEqual((await checkout(other)).status, 201, 'another person still starts a checkout');
++ });
++
++ await check('the next minute reopens, and the refused Idempotency-Key goes through', async () => {
++ clock += 45 * 1000;
++ const r = await checkout(fan, { key: 'limits-retry-key-1' });
++ assert.strictEqual(r.status, 201, r.text);
++ });
++
++ await check('transfers: 60 a minute per payer; the 61st moves nothing; another payer passes', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ await fund(t, fan, 10000);
++ await fund(t, other, 1000);
++ for (let i = 0; i < 60; i++) {
++ const r = await t.call('POST', '/api/v1/transfers', { body: { from: fan, to: creator, amount: 10 } });
++ if (r.status !== 201) assert.fail(`transfer ${i + 1}: ${r.text}`);
++ }
++ const credit = (await t.balances(fan.id)).credit;
++ const r = await t.call('POST', '/api/v1/transfers', { body: { from: fan, to: creator, amount: 10 } });
++ assert.deepStrictEqual([r.status, r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
++ assert.strictEqual((await t.balances(fan.id)).credit, credit, 'no money moved');
++ assert.strictEqual((await t.call('POST', '/api/v1/transfers', { body: { from: other, to: creator, amount: 10 } })).status, 201);
++ });
++
++ await check('refunds count against the payer: 20 a minute', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 10, 0);
++ const tip = await t.call('POST', '/api/v1/transfers', { body: { from: other, to: creator, amount: 100 } });
++ assert.strictEqual(tip.status, 201, tip.text);
++ const id = tip.json.transaction.id;
++ for (let i = 0; i < 20; i++) {
++ const r = await t.call('POST', `/api/v1/transfers/${id}/refund`, { body: { amount: 1, reason: 'test' }, sub: i % 2 ? 'svc:tips' : 'svc:live' });
++ if (r.status !== 201) assert.fail(`refund ${i + 1}: ${r.text}`);
++ }
++ const r = await t.call('POST', `/api/v1/transfers/${id}/refund`, { body: { amount: 1, reason: 'test' } });
++ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
++ });
++
++ await check('payout requests: 3 a minute per creator; nothing is escrowed past it; another creator passes', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 15, 0);
++ await fund(t, second, 1000);
++ await t.call('POST', '/api/v1/transfers', { body: { from: second, to: creator, amount: 1000 } });
++ const tipSecond = await t.call('POST', '/api/v1/transfers', { body: { from: other, to: second, amount: 800 } });
++ assert.strictEqual(tipSecond.status, 201, tipSecond.text);
++ const cashout = (subject) => t.call('POST', '/api/v1/cashouts', { body: { subject, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });
++ for (let i = 0; i < 3; i++) assert.strictEqual((await cashout(creator)).status, 201, `cashout ${i + 1}`);
++ const before = count('cashouts');
++ const r = await cashout(creator);
++ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
++ assert.strictEqual(count('cashouts'), before, 'nothing escrowed');
++ const ok = await cashout(second);
++ assert.strictEqual(ok.status, 201, ok.text);
++ });
++
++ await check('history reads per person (BILLING_LIMITS_MINUTE); balance reads are never limited', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 20, 0);
++ const history = (who) => t.call('GET', `/api/v1/transactions?subject=${who.id}`, { cap: ['billing.balance.read'] });
++ for (let i = 0; i < 3; i++) assert.strictEqual((await history(fan)).status, 200);
++ assert.strictEqual((await history(fan)).status, 429);
++ assert.strictEqual((await history(other)).status, 200, 'another person still reads');
++ for (let i = 0; i < 8; i++) assert.strictEqual((await t.call('GET', `/api/v1/balances/${fan.id}`, { cap: ['billing.balance.read'] })).status, 200);
++ });
++
++ await check('provider webhooks, health, ready, release.json and metrics are never limited', async () => {
++ for (let i = 0; i < 8; i++) {
++ const d = await t.powerchat({ type: 'donation.completed', streamer: { id: 'pc1', username: 'openvibe' }, data: { eventId: `limits-${i}`, amountUsdCents: 100 } });
++ assert.notStrictEqual(d.status, 429, JSON.stringify(d.json));
++ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).status, 200);
++ assert.notStrictEqual((await t.call('GET', '/api/ready', { token: null })).status, 429);
++ assert.strictEqual((await t.call('GET', '/release.json', { token: null })).status, 200);
++ assert.strictEqual((await t.call('GET', '/metrics', { token: null })).status, 200);
++ }
++ });
++
++ await check('refusals are logged (the person, never a token) and counted in billing_rate_limited_total', async () => {
++ assert.ok(t.logs.includes(`[Billing] limit billing.intent.create: user:${fan.id} refused, over 5 per minute`), t.logs.join('\n'));
++ assert.ok(t.logs.includes(`[Billing] limit billing.transfer.refund: user:${other.id} refused, over 20 per minute`), t.logs.join('\n'));
++ assert.ok(!t.logs.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');
++ const m = (await t.call('GET', '/metrics', { token: null })).text;
++ const lines = m.split('\n').filter((l) => l.includes('billing_rate_limited_total')).join('\n');
++ for (const [name, n] of [['billing.intent.create', 2], ['billing.transfer.create', 1], ['billing.transfer.refund', 1], ['billing.cashout.request', 1], ['billing.history.read', 1]]) {
++ assert.ok(new RegExp(`billing_rate_limited_total\\{limit="${name.replace(/\./g, '\\.')}",window="minute"\\} ${n}`).test(m), `${name}:\n${lines}`);
++ }
++ t.assertReconciled('after the refusals');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/console.test.js b/test/console.test.js
iindex f24cdd1..e7cc216 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -18,7 +18,9 @@ const DAY = 86_400_000;
const staff = network.addUser(); // admin + listed
const listedNonAdmin = network.addUser(); // listed, but Network role 'user'
const unlistedAdmin = network.addUser(); // Network admin, not Billing staff
-- const t = await boot({ network, env: { BASE_URL: 'https://billing.test', BILLING_STAFF_SUBJECTS: `${staff}, ${listedNonAdmin}, not-a-subject`, BILLING_SESSION_SECRET: SESSION_SECRET } });
++ // BILLING_LIMITS=off: this file opens several payouts for one creator within a second to fill the staff
++ // queue, past the per-person cashout limit (3 a minute); test/actor-limits.test.js covers the limits.
++ const t = await boot({ network, env: { BASE_URL: 'https://billing.test', BILLING_STAFF_SUBJECTS: `${staff}, ${listedNonAdmin}, not-a-subject`, BILLING_SESSION_SECRET: SESSION_SECRET, BILLING_LIMITS: 'off' } });
const fan = t.user(21);
const creator = t.user(22);
console.log('staff console');
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 1cc5fb1..9cd3f99 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -35,7 +35,8 @@ async function boot(opts = {}) {
const clock = { offset: 0 };
const logs = [];
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
-- const app = createApp({ config, now: () => Date.now() + clock.offset, log });
++ // opts.limitsNow: the per-person limiter's clock (default the wall clock).
++ const app = createApp({ config, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow });
await app.locals.keys.load();
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;