openvibers/openvibe.vip Commits

Checkout stays closed while VIP_CHECKOUT_PROVIDERS is empty (the setting until Billing's cutover): plan pages say so, the API refuses; README: before and after the Billing cutover

OP OpenVibers committed 23 Sept 2026 · a2c630ddec · 5 files, +35 −3


Changes

ddiff --git a/.env.example b/.env.example
iindex 0ec7200..9cb06be 100644
---- a/.env.example
++++ b/.env.example
@@@ -28,8 +28,9 @@ VIP_STAFF_ROLES=admin
BILLING_URL=http://127.0.0.1:4600
# BILLING_AUDIENCE=openvibe.billing
# VIP_BILLING_TIMEOUT_MS=5000
--# Providers offered on plan pages (must be enabled on Billing); `credit` pays from Vibes credit
--VIP_CHECKOUT_PROVIDERS=powerchat,credit
++# Providers offered on plan pages (must be enabled on Billing); `credit` pays from Vibes credit.
++# Leave EMPTY until Billing is authoritative (Wave 8 cutover): checkout is then closed.
++VIP_CHECKOUT_PROVIDERS=
# Turns Billing's PowerChat checkout_ref into a link: {ref} and {cents}
VIP_POWERCHAT_LINK_TEMPLATE=
# VIP_BILLING_RATES_TTL_MS=300000
ddiff --git a/README.md b/README.md
iindex a214144..c366b7c 100644
---- a/README.md
++++ b/README.md
@@@ -232,6 +232,21 @@ Idempotent (keys in `vip_migration_maps`), never overwrites a creator's later ed
on a later run; `--dry-run` rolls everything back; exit 1 when Billing could not be read. Live's
`sub_price_usd`/share/fee are reported for reference only — Billing prices.
++## Before and after the Billing cutover
++
++Billing runs in shadow mode until the Wave 8 cutover (Live stays authoritative for money,
++`BILLING_AUTHORITY=live`). Until then:
++
++- keep **checkout closed** (`VIP_CHECKOUT_PROVIDERS=` empty): a subscription sold through Billing
++ now would be one Live does not know about. Plan pages say joining is not open yet; the API answers
++ `422 vip.checkout.provider_unavailable`;
++- VIP can run read-only against Billing's shadow entitlements (imported from Live), which are only as
++ current as Billing's last import; new Live subscriptions do not reach Billing (or VIP) before the cutover;
++- Billing publishes its events only when its own `EVENTS_URL` is set.
++
++After the cutover: run `scripts/import-live.js` against the final Live snapshot (after Billing's
++final import), `scripts/subscribe.js`, then open checkout (`VIP_CHECKOUT_PROVIDERS=powerchat,credit`).
++
## Security notes
- Every API route checks one capability (services) or ownership (users); a presented token is judged
ddiff --git a/server/config.js b/server/config.js
iindex 1c458ea..47c37aa 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -58,7 +58,8 @@ function loadConfig(env = process.env) {
timeoutMs: int(env.VIP_BILLING_TIMEOUT_MS, 5000),
// Checkout providers offered on the plan pages (each must be enabled on Billing too).
// `credit` pays from the member's Vibes credit through Billing's subscription API.
-- providers: list(env.VIP_CHECKOUT_PROVIDERS || 'powerchat,credit'),
++ // Empty (set but blank) closes checkout — the setting until Billing is authoritative.
++ providers: list(env.VIP_CHECKOUT_PROVIDERS != null ? env.VIP_CHECKOUT_PROVIDERS : 'powerchat,credit'),
// Billing answers a PowerChat intent with a checkout_ref (pcsub:…) instead of a URL. When
// set, this template turns it into a link: {ref} and {cents} are filled in.
powerchatLinkTemplate: env.VIP_POWERCHAT_LINK_TEMPLATE || '',
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex b19b1dc..266d693 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -71,6 +71,7 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You jo
? '<p class="muted">This plan is not sold through Billing yet.</p>'
: isOwner ? '<p class="muted">This is your plan.</p>'
: member && member.status === 'active' ? ''
++ : !providers.length ? '<p class="muted">Joining through OpenVibe.Billing is not open yet.</p>'
: viewer
? `<form method="post" action="/${encodeURIComponent(creator.username)}/join" class="join">${csrf(token)}<input type="hidden" name="plan_id" value="${esc(p.id)}">
<label>Pay with <select name="provider">${providers.map((x) => `<option value="${esc(x)}">${esc(x === 'credit' ? 'your Vibes credit' : x === 'powerchat' ? 'PowerChat' : x)}</option>`).join('')}</select></label>
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 6aa3b5e..41696eb 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -227,5 +227,19 @@ const { test, run } = harness('api');
assert.strictEqual(bad.status, 401);
});
++ test('checkout closed (VIP_CHECKOUT_PROVIDERS empty, before the Billing cutover): pages say so, the API refuses', async () => {
++ const t2 = await boot({ env: { VIP_CHECKOUT_PROVIDERS: '' } });
++ try {
++ const c = t2.network.newUser('uma', { role: 'streamer' });
++ const p = (await t2.call('POST', '/api/v1/plans', { user: c, body: { name: 'Club', publish: true } })).json.plan;
++ const page = await t2.page('/uma', { user: t2.network.newUser('vic') });
++ assert.match(page.text, /Joining through OpenVibe\.Billing is not open yet/);
++ assert.doesNotMatch(page.text, /<select name="provider">/);
++ const r = await t2.call('POST', '/api/v1/checkout', { user: t2.network.newUser('wes'), body: { plan_id: p.id, provider: 'powerchat' } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json.code, 'vip.checkout.provider_unavailable');
++ } finally { await t2.close(); }
++ });
++
await run().finally(() => t.close());
})();