openvibers/openvibe.billing Commits

GET /api/v1/stats (billing.stats.read) + contracts 0.203.0 (#79)

OP OpenVibers committed 27 days ago · 4500a1f28d · 8 files, +144 −8

* GET /api/v1/stats (billing.stats.read): Vibes bought and subscriptions, windows and daily series, no person in them

* openvibe-contracts v0.203.0 (was v0.201.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.203.0 release.

Changes

ddiff --git a/README.md b/README.md
iindex ed0c26f..859a767 100644
---- a/README.md
++++ b/README.md
@@@ -64,7 +64,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.201.0, `openvibe-sdk` v0.50.1 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`), `openvibe-shared` v4.3.0, pinned by release tarball
++- `openvibe-contracts` v0.203.0, `openvibe-sdk` v0.50.1 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`), `openvibe-shared` v4.3.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)
@@@ -205,6 +205,7 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30
| `POST /api/v1/cashouts/:id/approve` | `billing.cashout.manage` | `{payout_reference (required), payout_provider?}`; refused before the escrow ends |
| `POST /api/v1/cashouts/:id/deny` | `billing.cashout.manage` | reversal back to payable `{reason?}` |
| `POST /api/v1/subscriptions` | `billing.subscription.manage` | start/renew a period `{subscriber, streamer, source: credit, tier?, auto_renew?}` at the tier's price (share credited every period); no switching tiers while a period runs (409 `billing.tier_change_unsupported`); `source: receipt` also needs `billing.ledger.admin` |
++| `GET /api/v1/stats[?days=1..90]` | `billing.stats.read` | network-wide counts with no person in them (Vibes bought: all time, d/w/m/pw windows, a daily series; subscriptions: active now, started in the windows and per day; test and imported rows left out of windows and series), for a product's public stats (Live's home) |
| `GET /api/v1/tiers/:streamer` | `billing.tier.manage`, `billing.intent.create` or `billing.subscription.manage` | a creator's tier prices `{streamer, tiers: [{tier, price_cents, status}], default_price_cents, min_price_cents, max_price_cents, max_tiers}`; tier 1 without a row sells at the default price |
| `PUT /api/v1/tiers/:streamer/:tier` | `billing.tier.manage` | set a tier's monthly price `{price_cents, status?: active\|archived}` within the bounds (`BILLING_TIER_MIN_CENTS`..`BILLING_TIER_MAX_CENTS`, at most `BILLING_MAX_TIERS` tiers); the product that sells memberships (OpenVibe.VIP) calls it for the creator; archived sells no more, running subscriptions renew at the price they were bought at |
| `POST /api/v1/subscriptions/:id/cancel` | `billing.subscription.manage` | cancel at period end; Stripe is cancelled at Stripe first |
@@@ -486,7 +487,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.201.0
++- openvibe-contracts: v0.203.0
- openvibe-sdk: v0.50.1
- openvibe-shared: v4.3.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex b2f9f26..a1fd2d1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"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.201.0 (openvibe-sdk v0.28.0, openvibe-shared v2.13.0)",
++ "contracts": "openvibe-contracts v0.203.0 (openvibe-sdk v0.28.0, openvibe-shared v2.13.0)",
"sdk": "openvibe-sdk v0.50.1",
"shared": "openvibe-shared v4.3.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.",
ddiff --git a/docs/capabilities-proposal/billing.stats.read.json b/docs/capabilities-proposal/billing.stats.read.json
nnew file mode 100644
iindex 0000000..a9e20d1
---- /dev/null
++++ b/docs/capabilities-proposal/billing.stats.read.json
@@@ -0,0 +1,21 @@
++{
++ "id": "billing.stats.read",
++ "version": "1.0",
++ "owner": "billing",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Network-wide money counts with no person in them, for a product's public stats: Vibes bought with money (all time, recent windows and a daily series) and channel subscriptions (active now, started in recent windows and a daily series). Test and imported rows are left out of the windows and series.",
++ "permissions": [
++ "billing:read"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "ledger-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/stats"
++ ],
++ "inputSchema": "common.no-body@1",
++ "outputSchema": "billing.stats-result@1"
++}
ddiff --git a/package-lock.json b/package-lock.json
iindex f176ad8..aeb0d4e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,7 +12,7 @@
"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.201.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.203.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.3.0",
"pg": "^8.23.0"
@@@ -828,9 +828,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.201.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.201.0",
-- "integrity": "sha512-9iWa788aBJ4BOzDgjW67sxxDWuTjp/fmoZFBpwtOJE+6XKNnKxiHwYoAcUdwO5YSzU/+Ga5Z6H16nMA0HFSTgA==",
++ "version": "0.203.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.203.0",
++ "integrity": "sha512-WaOKOWTxbcaBt/7wh4KO3i/boDyFCD4eK5snej5OUtL6HeWoeVJGGNCk/se43wkFdBQW/aFqZHpDqo7jsG6o0w==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex d728f10..e951d10 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"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.201.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.203.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.3.0",
"pg": "^8.23.0"
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex ed0f6c2..4dbcd12 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -30,6 +30,7 @@ const usage = require('../ops/usage');
const promo = require('../ops/promo');
const rating = require('../ops/rating');
const tiers = require('../ops/tiers');
++const { stats } = require('../ops/stats');
const CAP = {
intent: 'billing.intent.create',
@@@ -40,6 +41,7 @@ const CAP = {
subscription: 'billing.subscription.manage',
entitlement: 'billing.entitlement.check',
tier: 'billing.tier.manage',
++ stats: 'billing.stats.read',
admin: 'billing.ledger.admin',
usage: 'billing.usage.record',
};
@@@ -200,6 +202,11 @@ function v1Router({ ctx, auth, adapters, limits }) {
});
res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });
}, limits.budget('billing.subscription.create', person('subscriber'))));
++ // ── Network-wide counts with no person in them (ops/stats.js): a product's public stats ──
++ r.get('/stats', ...read(CAP.stats, async (req, res) => {
++ res.json(await stats(ctx, { days: req.query.days }));
++ }));
++
// ── Subscription tiers (ops/tiers.js): the creator's prices, set by the product that sells them ──
// Anyone who may start a checkout may read what it costs; setting a price is billing.tier.manage.
r.get('/tiers/:streamer', ...read([CAP.tier, CAP.intent, CAP.subscription], async (req, res) => {
ddiff --git a/server/ops/stats.js b/server/ops/stats.js
nnew file mode 100644
iindex 0000000..ecf3977
---- /dev/null
++++ b/server/ops/stats.js
@@@ -0,0 +1,61 @@
++'use strict';
++
++/**
++ * Network-wide money counts with no person in them (openvibe-contracts 0.203.0, billing.stats.read, GET /api/v1/stats),
++ * for a product's public stats: OpenVibe.Live's home counted Vibes bought and channel subscriptions from its own tables,
++ * which stopped at the cutover of 2026-10-17.
++ *
++ * purchases Vibes bought with money: the credit settled purchase transactions put in buyers' accounts.
++ * bits_total is all time (the Vibes imported from Live included); the windows and the daily series
++ * leave out test and imported rows (an imported row is dated by the import, not the purchase).
++ * subscriptions active now (status active, period not over); started = paid first periods (renewals are not new
++ * subscriptions), windows and daily series, test and imported rows left out.
++ *
++ * Windows: d, w, m = the last 1, 7 and 30 days; pw = the 7 days before the last 7. Days are UTC.
++ */
++const { iso } = require('../ledger');
++
++const DAY = 86_400_000;
++
++function windowsOf(rows, now) {
++ const since = (ms) => iso(now - ms);
++ const sum = (from, to = null) => rows.filter((r) => r.at >= from && (to == null || r.at < to)).reduce((a, r) => a + r.value, 0);
++ return { d: sum(since(DAY)), w: sum(since(7 * DAY)), m: sum(since(30 * DAY)), pw: sum(since(14 * DAY), since(7 * DAY)) };
++}
++
++function seriesOf(rows, now, days) {
++ const out = [];
++ for (let i = days - 1; i >= 0; i--) out.push({ day: iso(now - i * DAY).slice(0, 10), value: 0 });
++ const byDay = new Map(out.map((x) => [x.day, x]));
++ for (const r of rows) { const b = byDay.get(r.at.slice(0, 10)); if (b) b.value += r.value; }
++ return out;
++}
++
++async function stats(ctx, { days = 30 } = {}) {
++ const { db } = ctx;
++ const now = ctx.now();
++ const n = Math.min(90, Math.max(1, Math.round(Number(days)) || 30));
++ const since = iso(now - Math.max(n, 30) * DAY);
++
++ const total = await db.prepare(`SELECT COALESCE(SUM(e.amount), 0)::bigint AS bits FROM transactions t
++ JOIN ledger_entries e ON e.txn_id = t.id JOIN accounts a ON a.id = e.account_id
++ WHERE t.type = 'purchase' AND t.test = 0 AND a.kind = 'user_credit' AND e.amount > 0`).get();
++ const purchases = (await db.prepare(`SELECT t.created_at AS at, SUM(e.amount)::bigint AS value FROM transactions t
++ JOIN ledger_entries e ON e.txn_id = t.id JOIN accounts a ON a.id = e.account_id
++ WHERE t.type = 'purchase' AND t.test = 0 AND t.status = 'settled' AND a.kind = 'user_credit' AND e.amount > 0 AND t.created_at >= ?
++ GROUP BY t.id, t.created_at`).all(since)).map((r) => ({ at: r.at, value: Number(r.value) }));
++
++ const active = await db.prepare("SELECT COUNT(*)::bigint AS n FROM subscriptions WHERE status = 'active' AND current_period_end > ?").get(iso(now));
++ const started = (await db.prepare(`SELECT created_at AS at, metadata FROM transactions
++ WHERE type = 'subscription' AND test = 0 AND status = 'settled' AND created_at >= ?`).all(since))
++ .filter((r) => { try { return JSON.parse(r.metadata || '{}').renewal !== true; } catch { return true; } })
++ .map((r) => ({ at: r.at, value: 1 }));
++
++ return {
++ as_of: iso(now),
++ purchases: { bits_total: Number(total.bits), windows: windowsOf(purchases, now), series: seriesOf(purchases, now, n) },
++ subscriptions: { active: Number(active.n), windows: windowsOf(started, now), series: seriesOf(started, now, n) },
++ };
++}
++
++module.exports = { stats };
ddiff --git a/test/stats.test.js b/test/stats.test.js
nnew file mode 100644
iindex 0000000..983a73c
---- /dev/null
++++ b/test/stats.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * GET /api/v1/stats (openvibe-contracts 0.203.0, billing.stats.read; server/ops/stats.js): network-wide counts with no
++ * person in them. Vibes bought (all time, windows, a daily series) and subscriptions (active now, started: renewals are
++ * not new ones), with test rows left out; the capability is required; the answer names nobody.
++ */
++const assert = require('assert');
++const { boot, fund, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ env: { BILLING_RENEWAL_GRACE_DAYS: '0' } });
++ const a = t.user(71);
++ const b = t.user(72);
++ const creator = t.user(73);
++ const get = (q = '', cap = ['billing.stats.read']) => t.call('GET', `/api/v1/stats${q}`, { cap });
++
++ await check('nothing yet: zeros, and a series of the asked length', async () => {
++ const r = await get('?days=7');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.purchases.bits_total, 0);
++ assert.strictEqual(r.json.subscriptions.active, 0);
++ assert.strictEqual(r.json.purchases.series.length, 7);
++ assert.deepStrictEqual(r.json.purchases.windows, { d: 0, w: 0, m: 0, pw: 0 });
++ });
++
++ await check('purchases and a first subscription count; an early renewal does not start a new one', async () => {
++ await fund(t, a, 1500);
++ await fund(t, b, 600);
++ assert.strictEqual((await t.call('POST', '/api/v1/subscriptions', { cap: ['billing.subscription.manage'], body: { subscriber: a, streamer: creator, source: 'credit' } })).status, 201);
++ assert.strictEqual((await t.call('POST', '/api/v1/subscriptions', { cap: ['billing.subscription.manage'], body: { subscriber: a, streamer: creator, source: 'credit' } })).status, 201, 'an early renewal');
++ const r = await get();
++ assert.strictEqual(r.json.purchases.bits_total, 2100);
++ assert.deepStrictEqual(r.json.purchases.windows, { d: 2100, w: 2100, m: 2100, pw: 0 });
++ assert.strictEqual(r.json.purchases.series.at(-1).value, 2100, "today's bucket");
++ assert.strictEqual(r.json.subscriptions.active, 1);
++ assert.deepStrictEqual(r.json.subscriptions.windows, { d: 1, w: 1, m: 1, pw: 0 });
++ assert.ok(!JSON.stringify(r.json).includes('usr_'), 'no person in the answer');
++ });
++
++ await check('the capability is required', async () => {
++ assert.strictEqual((await get('', ['billing.entitlement.check'])).status, 403);
++ });
++
++ await t.close();
++ done();
++})();