openvibers/openvibe.codes Commits

Docs: /docs/billing renders the billing policy from OpenVibe.Billing's /policy.json (WS-K task 10): currencies, purchase tiers with the creator value and what OpenVibe keeps, tips, the subscription split and fee, cashout minimum and hold; cached 5 min, a problem (502) and no numbers when Billing does not answer; in the docs index and the sitemap

OP OpenVibers committed 26 Sept 2026 · e86ba956ea · 3 files, +107 −1


Changes

ddiff --git a/server/http/docs.js b/server/http/docs.js
iindex 68b7ffa..974c56f 100644
---- a/server/http/docs.js
++++ b/server/http/docs.js
@@@ -14,6 +14,7 @@
* helpers, from the installed openvibe-shared and openvibe-sdk, with a live sample
* /docs/events event types from the service manifests
* /docs/services the Network's registry with health as reported (never invented)
++ * /docs/billing the billing policy as OpenVibe.Billing's /policy.json states it (never restated)
* /docs/sdk[/:module] SDK reference from its .d.ts files
* /docs/adr/:id ADRs as published in openvibe-contracts
*/
@@@ -52,6 +53,7 @@ function createDocsRoutes(ctx) {
<li><a href="/docs/events"><strong>Events</strong></a><span>${docs.events.length} event types services declare they produce</span></li>
<li><a href="/docs/services"><strong>Services</strong></a><span>the registry as OpenVibe.Network reports it, with health</span></li>
<li><a href="/docs/tools"><strong>Tools API</strong></a><span>every OpenVibe tool you can call from code, from the live registry</span></li>
++<li><a href="/docs/billing"><strong>Billing policy</strong></a><span>prices, the creator split, fees, holds and cashouts, live from OpenVibe.Billing</span></li>
<li><a href="/docs/sdk"><strong>SDK</strong></a><span>${docs.sdk.length} modules of openvibe-sdk from their type definitions</span></li>
<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li>
</ul>
@@@ -357,6 +359,54 @@ ${families.map((f) => html`<h2>${f}</h2>${table(['Tool', 'Runs as', 'Access', 'I
});
});
++ // ── Billing policy (live from OpenVibe.Billing, WS-K task 10) ──
++ // The numbers are Billing's /policy.json (read from the rates the ledger charges); nothing is
++ // restated here. Billing not answering shows a problem, never remembered or invented numbers.
++ const BILLING_URL = (process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600').replace(/\/$/, '');
++ let billingCache = { at: 0, policy: null };
++ r.get('/billing', async (req, res) => {
++ let policy = billingCache.policy; let problem = null;
++ if (!policy || Date.now() - billingCache.at > 300_000) {
++ try {
++ const out = await fetch(`${BILLING_URL}/policy.json`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
++ if (!out.ok) throw new Error(`Billing answered ${out.status}`);
++ policy = await out.json();
++ billingCache = { at: Date.now(), policy };
++ } catch (err) { problem = { code: 'codes.billing_unavailable', detail: err.message }; }
++ }
++ const usd = (c) => `$${(c / 100).toFixed(2)}`;
++ const n = (x) => Number(x).toLocaleString('en-US');
++ const p = policy;
++ send(res, problem && !p ? 502 : 200, {
++ index: true, cache: 'public, max-age=300', viewer: req.viewer, config, path: req.originalUrl,
++ title: 'Billing policy',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Billing' }],
++ body: html`<h1>Billing policy</h1>
++<p>How money moves on OpenVibe, as <a href="https://billing.openvibe.network/policy">billing.openvibe.network/policy</a> states it for everyone. The numbers below are Billing's <a href="https://billing.openvibe.network/policy.json"><code>/policy.json</code></a>, read from the rates the ledger charges, fetched at most every 5 minutes. Apps that take payments or show balances should link to that page rather than restate it.</p>
++${problem && !p ? problemBox(problem, { title: 'The billing policy could not be read' }) : html`
++<p>Ledger: <strong>${p.authority === 'billing' ? 'OpenVibe Billing' : 'openvibe.live'}</strong> (<code>authority: ${p.authority}</code>). Wording last changed ${p.wording_date}.</p>
++<h2>Currencies</h2>
++${table(['Currency', 'Bought with money', 'Withdrawable'], [
++ ['Vibes', 'yes', `only Vibes received; ${usd(p.currencies.vibes.cash_value_per_100_cents)} per 100`],
++ ['OpenCoins', 'no', 'never'],
++ ['Channel points', 'no', 'never'],
++])}
++<h2>Buying Vibes</h2>
++${table(['Vibes in one purchase', 'Price per 100', 'Creator value per 100', 'OpenVibe keeps'], p.purchase.tiers.map((t) => [
++ `${n(t.from)}${t.to == null ? ' or more' : `–${n(t.to)}`}`, usd(t.price_per_100_cents), usd(t.creator_value_per_100_cents), `${t.openvibe_keeps_pct}%`,
++]))}
++<p>One purchase: ${n(p.purchase.min_vibes)} to ${n(p.purchase.max_vibes)} Vibes. Tips reach the creator in full (${p.tips.creator_receives_pct}%).</p>
++<h2>Subscriptions and cashouts</h2>
++${table(['Rule', 'Value'], [
++ ['Subscription', `${usd(p.subscription.price_cents)} for ${n(p.subscription.period_days)} days`],
++ ['Creator share (paid through OpenVibe’s PowerChat)', `${p.subscription.creator_share_pct}% (${usd(p.subscription.creator_share_cents)})`],
++ ['Site routing fee, on top', `${p.subscription.site_route_fee_pct}% (${usd(p.subscription.site_route_fee_cents)})`],
++ ['Minimum cashout', `${n(p.cashout.min_vibes)} Vibes (${usd(p.cashout.min_cents)})`],
++ ['Cashout hold, then review', `${n(p.cashout.hold_days)} days`],
++])}`}`,
++ });
++ });
++
// ── SDK ─────────────────────────────────────────────────
r.get('/sdk', (req, res) => {
page(req, res, {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a5a2e63..f443389 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -271,7 +271,7 @@ ${signedIn ? releaseActions(req, rel) : ''}
].join('\n'));
});
r.get('/sitemap.xml', (req, res) => {
-- const urls = ['/', '/docs', '/docs/api', '/docs/updates', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/services', '/docs/tools', '/docs/sdk', '/oauth', '/tools/webhooks', '/manifests/validate',
++ const urls = ['/', '/docs', '/docs/api', '/docs/updates', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/services', '/docs/billing', '/docs/tools', '/docs/sdk', '/oauth', '/tools/webhooks', '/manifests/validate',
'/policy', '/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency',
...governance.filter((g) => !g.draft).map((g) => `/policy/${g.slug}`),
...docs.contracts.map((c) => `/docs/contracts/${c.id}`), ...docs.capabilities.map((c) => `/docs/capabilities/${c.id}`),
ddiff --git a/test/docs-billing.test.js b/test/docs-billing.test.js
nnew file mode 100644
iindex 0000000..9211d91
---- /dev/null
++++ b/test/docs-billing.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * /docs/billing (WS-K task 10) renders OpenVibe.Billing's /policy.json and nothing of its own: the
++ * numbers on the page are the ones Billing answered, and Billing not answering is a 502 with a
++ * problem box, never numbers from anywhere else.
++ */
++const assert = require('assert');
++const http = require('http');
++
++const POLICY = {
++ authority: 'live', wording_date: '2026-09-26',
++ currencies: { vibes: { bought_with_money: true, cash_value_per_100_cents: 100, withdrawable: 'only Vibes you received' }, opencoins: { bought_with_money: false, withdrawable: false }, channel_points: { bought_with_money: false, withdrawable: false } },
++ purchase: { min_vibes: 100, max_vibes: 10000000, tiers: [{ from: 100, to: 499, price_per_100_cents: 150, creator_value_per_100_cents: 100, openvibe_keeps_pct: 33.3 }, { from: 500, to: null, price_per_100_cents: 137, creator_value_per_100_cents: 100, openvibe_keeps_pct: 27 }] },
++ tips: { creator_receives_pct: 100 },
++ subscription: { price_cents: 499, period_days: 31, creator_share_pct: 70, creator_share_cents: 349, site_route_fee_pct: 10, site_route_fee_cents: 50 },
++ cashout: { min_vibes: 500, min_cents: 500, hold_days: 14 },
++};
++
++(async () => {
++ let up = true;
++ const billing = http.createServer((req, res) => {
++ if (!up) { res.statusCode = 503; return res.end('{}'); }
++ if (req.url !== '/policy.json') { res.statusCode = 404; return res.end(); }
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify(POLICY));
++ });
++ await new Promise((r) => billing.listen(0, '127.0.0.1', r));
++ process.env.OV_BILLING_INTERNAL_URL = `http://127.0.0.1:${billing.address().port}`;
++ const { boot, check, done } = require('./helpers/boot');
++ const t = await boot();
++
++ await check('the page shows what Billing answered', async () => {
++ const r = await t.get('/docs/billing');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ for (const s of ['$1.37', '27%', '500 or more', '$4.99 for 31 days', '70% ($3.49)', '10% ($0.50)', '500 Vibes ($5.00)', '14 days', 'openvibe.live', 'billing.openvibe.network/policy']) {
++ assert.ok(r.text.includes(s), `the page says ${s}`);
++ }
++ assert.ok((await t.get('/docs')).text.includes('href="/docs/billing"'), 'the docs index links it');
++ });
++
++ await check('Billing down on a cold cache: a problem, no numbers', async () => {
++ up = false;
++ // A fresh process has no cache; drop this one's by booting a second app.
++ for (const k of Object.keys(require.cache)) if (k.includes('/server/http/docs.js')) delete require.cache[k];
++ const t2 = await boot();
++ const r = await t2.get('/docs/billing');
++ assert.strictEqual(r.status, 502);
++ assert.ok(r.text.includes('The billing policy could not be read'));
++ assert.ok(!r.text.includes('$4.99'), 'no numbers without Billing');
++ await t2.close();
++ });
++
++ await t.close();
++ billing.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });