openvibers/openvibe.vip Commits

Route VIP's Billing client through openvibe-sdk/commerce

OP OpenVibers committed 4 Oct 2026 · 1df4c98c5f · 4 files, +36 −84

Asked: Part of a cross-repository initiative (init-t5-step-13-dossier-home-work-1004T2236): T5 step 13 (dossier /home/workstation/openvibe/agents/ds/prep/T5.md section "T5 step 13"; step 11 is merged: VIP #8, Billing #12/#13/#14, so VIP's Billing client surface is settled): ONE commerce module shared by T…
Harness job init-t5-step-13-dossi-2236-s3-230254 (plan T5).

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex b4e47d9..db250a1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"deployed": true,
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 loopback only; https://openvibe.vip still serves the OpenVibe.Sites placeholder. Principal vip; three Events subscriptions for Billing's events (secret rotated 2026-09-24); checkout closed (VIP_CHECKOUT_PROVIDERS empty). On 2026-09-24 production held the Live import: 44 creators, 43 plans (one per creator), 0 memberships while Billing runs in shadow.",
-- "contracts": "openvibe-contracts v0.79.0",
++ "contracts": "openvibe-contracts v0.92.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"roadmap": "Wave 10",
"works": [
@@@ -20,8 +20,9 @@
"reusable membership card (GET /embed/:username/card.json, GET /api/v1/creators/:ref/card), badge (/embed/:username/badge.svg) and embeddable widget (/embed/:username/widget): public data only, no cookies, the widget's CSP frame-ancestors configurable (VIP_WIDGET_FRAME_ANCESTORS), member count from Billing that the creator can hide (not deployed yet)",
"the Live subscription import has run: 43 plans and plan versions, 44 creators, 87 migration maps (production, 2026-09-23)",
"Chat (subscriber badge, vip.entitlement.check), Community (members-only spaces and threads) and Blog (members-only posts) call VIP in production (vip.resource.policy.evaluate grants for Community and Blog)",
-- "14 vip.* capabilities, the service manifest and its events registered in openvibe-contracts v0.19.0 (this repository pins v0.79.0)",
++ "14 vip.* capabilities, the service manifest and its events registered in openvibe-contracts v0.19.0 (this repository pins v0.92.0)",
"per-actor limits on /api/v1 (openvibe-sdk/limits)",
++ "the Billing client (server/billing-client.js) is a thin wrapper over openvibe-sdk/commerce (openvibe-sdk v0.28.0), shared with Tips: same capabilities, idempotency keys and retry-once-on-401",
"renewal/refund convergence via Billing events: a per-period charge ledger (vip_membership_periods, unique per Billing transaction; reversals and reversal-before-grant tombstones), a past_due renewal grace that never authorizes (answer.grace), and a period audit that doubts a paid period without a record (migration 0002; docs/cutover-0002-renewal-periods.md)"
],
"not_yet": [
@@@ -30,7 +31,7 @@
"Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product",
"revocations reach Chat and Community through vip.membership.changed subscriptions (2026-09-24); Blog and Live still converge by the cache TTL (30-60 s)"
],
-- "updated": "2026-09-28",
++ "updated": "2026-10-04",
"features": [
"plans with immutable versions, perks and product bindings",
"entitlement projection from Billing events with valid_until, grace and a Billing fallback; unknown never authorizes",
ddiff --git a/package-lock.json b/package-lock.json
iindex 2e8540a..0c698f8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,8 +14,8 @@
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
},
@@@ -1117,9 +1117,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.79.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",
++ "version": "0.92.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
++ "integrity": "sha512-2Wm1CdsNqIBou+XYAtIiIcQ6GyXWWh5miG81iFYrPvWGLVF1BUjEN4XxQGK2S2bC6TCKz7XQtp5IJx3Mg0vPIw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1133,9 +1133,8 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.26.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==",
++ "version": "0.28.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 2eb27e6..e6ac12d 100644
---- a/package.json
++++ b/package.json
@@@ -31,8 +31,8 @@
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
},
ddiff --git a/server/billing-client.js b/server/billing-client.js
iindex 8ae9ef5..606e9ba 100644
---- a/server/billing-client.js
++++ b/server/billing-client.js
@@@ -9,26 +9,19 @@
* billing.subscription.manage POST /api/v1/subscriptions pay a period from the member's credit
* POST /api/v1/subscriptions/:id/cancel cancel at period end
* billing.entitlement.check GET /api/v1/entitlements/:subject?streamer= the authoritative check
-- * GET /api/v1/subscriptions[?streamer&subscriber&status], /:id
++ * GET /api/v1/subscriptions[?streamer&subscriber&status], /:id, /api/v1/rates
*
-- * Every POST carries an Idempotency-Key derived from VIP's own record, so a retry after a lost
-- * response returns Billing's original result instead of acting twice.
++ * The HTTP shape lives in openvibe-sdk/commerce, shared with Tips: every POST carries the
++ * Idempotency-Key derived from VIP's own record (sent unchanged on the one retry after a 401), so a
++ * retry after a lost response returns Billing's original result instead of acting twice.
*
-- * Errors: a Billing problem+json becomes a BillingCallError with .status/.code; a network failure or
-- * timeout has no status (retryable, and for an entitlement check it means "unknown", never "yes").
++ * Errors: a Billing problem+json becomes a BillingCallError (the SDK's CommerceError) with
++ * .status/.code; a network failure or timeout has no status (retryable, and for an entitlement check
++ * it means "unknown", never "yes").
*/
--const { serviceAuth } = require('openvibe-contracts');
++const { createCommerceClient, CommerceError } = require('openvibe-sdk/commerce');
--class BillingCallError extends Error {
-- constructor(message, { status = null, code = null, body = null } = {}) {
-- super(message);
-- this.name = 'BillingCallError';
-- this.status = status;
-- this.code = code;
-- this.body = body;
-- }
-- get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401 || this.code === 'billing.frozen'; }
--}
++const BillingCallError = CommerceError;
const CAPS = {
intent: 'billing.intent.create',
@@@ -37,67 +30,26 @@ const CAPS = {
};
function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClients = null } = {}) {
-- const base = config.billing.url;
-- const clients = new Map();
-- const tokensFor = (cap) => {
-- if (tokenClients && tokenClients[cap]) return tokenClients[cap];
-- if (!clients.has(cap)) {
-- clients.set(cap, serviceAuth.createTokenClient({
-- tokenUrl: `${config.network.internalUrl}/oauth/token`,
-- clientId: config.oauth.clientId,
-- clientSecret: config.oauth.clientSecret,
-- audience: config.billing.audience,
-- scope: cap,
-- fetchImpl,
-- }));
-- }
-- return clients.get(cap);
-- };
--
-- async function call(cap, method, path, { body, key, traceparent, retried = false } = {}) {
-- let auth;
-- try { auth = await tokensFor(cap).authHeaders(); } catch (e) { throw new BillingCallError(`token (${cap}): ${e.message}`); }
-- const headers = { Accept: 'application/json', ...auth };
-- if (body !== undefined) headers['Content-Type'] = 'application/json';
-- if (key) headers['Idempotency-Key'] = key;
-- if (traceparent) headers.traceparent = traceparent;
-- let res;
-- try {
-- res = await fetchImpl(`${base}${path}`, {
-- method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(config.billing.timeoutMs),
-- });
-- } catch (e) {
-- throw new BillingCallError(`Billing unreachable: ${e.message}`);
-- }
-- if (res.status === 401 && !retried) { tokensFor(cap).invalidate(); return await call(cap, method, path, { body, key, traceparent, retried: true }); }
-- const data = await res.json().catch(() => null);
-- if (!res.ok) {
-- const code = (data && (data.code || data.error)) || `http_${res.status}`;
-- throw new BillingCallError(`Billing ${res.status} ${code}: ${(data && data.detail) || ''}`.trim(), { status: res.status, code, body: data });
-- }
-- return data;
-- }
-- const user = (id) => ({ type: 'user', id });
-- const qs = (o) => { const p = new URLSearchParams(); for (const [k, v] of Object.entries(o)) if (v != null && v !== '') p.set(k, v); const s = p.toString(); return s ? `?${s}` : ''; };
++ const commerce = createCommerceClient(config, {
++ fetchImpl,
++ caps: { intent: CAPS.intent, subscription: CAPS.subscription, entitlement: CAPS.entitlement },
++ tokenClients,
++ });
return {
/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */
-- createIntent: async ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => await call(CAPS.intent, 'POST', '/api/v1/intents', {
-- body: { provider, kind: 'subscription', subject: user(subject), streamer: user(creator), auto_renew: !!autoRenew, success_url: successUrl, cancel_url: cancelUrl },
-- key, traceparent,
++ createIntent: async ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => await commerce.createIntent({
++ provider, kind: 'subscription', subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent,
}),
/** { subscription, entitlement, transaction } — one period paid from the member's credit. */
-- subscribeWithCredit: async ({ subscriber, creator, autoRenew, key, traceparent }) => await call(CAPS.subscription, 'POST', '/api/v1/subscriptions', {
-- body: { subscriber: user(subscriber), streamer: user(creator), source: 'credit', auto_renew: autoRenew !== false },
-- key, traceparent,
-- }),
-- cancelSubscription: async ({ id, key, traceparent }) => await call(CAPS.subscription, 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),
-- getSubscription: async (id) => await call(CAPS.entitlement, 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`),
-- listSubscriptions: async ({ streamer, subscriber, status } = {}) => await call(CAPS.entitlement, 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`),
++ subscribeWithCredit: async ({ subscriber, creator, autoRenew, key, traceparent }) => await commerce.subscribeWithCredit({ subscriber, creator, autoRenew, key, traceparent }),
++ cancelSubscription: async ({ id, key, traceparent }) => await commerce.cancelSubscription({ id, key, traceparent }),
++ getSubscription: async (id) => await commerce.getSubscription(id),
++ listSubscriptions: async ({ streamer, subscriber, status } = {}) => await commerce.listSubscriptions({ streamer, subscriber, status }),
/** { active, expires_at, subscription } — the authoritative answer. */
-- entitlement: async (subject, creator) => await call(CAPS.entitlement, 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`),
-- rates: async () => await call(CAPS.entitlement, 'GET', '/api/v1/rates'),
-- baseUrl: () => base,
++ entitlement: async (subject, creator) => await commerce.entitlement(subject, creator),
++ rates: async () => await commerce.rates(),
++ baseUrl: () => commerce.baseUrl(),
};
}