openvibers/openvibe.vip Commits
Route VIP's Billing client through openvibe-sdk/commerce
Changes
@@@ -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",@@@ -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"@@@ -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"},@@@ -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(),};}
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"deployed": 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.",
"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)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"roadmap": "Wave 10",
"roadmap": "Wave 10",
"works": [
"works": [
@@ -20,8 +20,9 @@
@@ -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)",
"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)",
"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)",
"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)",
"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)"
"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": [
"not_yet": [
@@ -30,7 +31,7 @@
@@ -30,7 +31,7 @@
"Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product",
"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)"
"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": [
"features": [
"plans with immutable versions, perks and product bindings",
"plans with immutable versions, perks and product bindings",
"entitlement projection from Billing events with valid_until, grace and a Billing fallback; unknown never authorizes",
"entitlement projection from Billing events with valid_until, grace and a Billing fallback; unknown never authorizes",
@@ -14,8 +14,8 @@
@@ -14,8 +14,8 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1117,9 +1117,9 @@
@@ -1117,9 +1117,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1133,9 +1133,8 @@
@@ -1133,9 +1133,8 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -31,8 +31,8 @@
@@ -31,8 +31,8 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -9,26 +9,19 @@
@@ -9,26 +9,19 @@
* billing.subscription.manage POST /api/v1/subscriptions pay a period from the member's credit
* 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
* POST /api/v1/subscriptions/:id/cancel cancel at period end
* billing.entitlement.check GET /api/v1/entitlements/:subject?streamer= the authoritative check
* 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 = {
const CAPS = {
intent: 'billing.intent.create',
intent: 'billing.intent.create',
@@ -37,67 +30,26 @@ const CAPS = {
@@ -37,67 +30,26 @@ const CAPS = {
};
};
function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClients = null } = {}) {
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 {
return {
/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */
/** { 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. */
/** { 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. */
/** { 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(),
};
};
}
}