openvibers/openvibe.billing Commits
Subscription tiers priced by Billing (contracts 0.201.0) (#76)
Changes
@@@ -71,6 +71,10 @@ BILLING_SESSION_SECRET=#BILLING_MIN_PURCHASE_BITS=100#BILLING_MAX_BITS=10000000#BILLING_SUB_PRICE_CENTS=499++# Creator-priced subscription tiers (GET/PUT /api/v1/tiers): price bounds in cents and tiers per creator (max 10).++#BILLING_TIER_MIN_CENTS=100++#BILLING_TIER_MAX_CENTS=50000++#BILLING_MAX_TIERS=10#BILLING_SUB_SHARE_PCT=70#BILLING_SITE_ROUTE_FEE_PCT=10#BILLING_SUB_PERIOD_DAYS=31@@@ -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.200.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.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- **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)@@@ -194,7 +194,7 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30|---|---|---|| `GET /api/health`, `/api/ready` | — | liveness / readiness (DB + Network key) || `GET /api/v1/rates` | — | prices, packages, sub price/share/fee, cashout rules, enabled providers |--| `POST /api/v1/intents` | `billing.intent.create` | start a checkout `{provider, kind: purchase\|subscription, subject, bits \| streamer, route?, auto_renew?}`; amount priced here; returns the provider checkout URL, or `checkout_ref` (`pcorder:`/`pcsub:`) for PowerChat |++| `POST /api/v1/intents` | `billing.intent.create` | start a checkout `{provider, kind: purchase\|subscription, subject, bits \| streamer, tier?, route?, auto_renew?}`; amount priced here (a subscription at the creator's tier price); returns the provider checkout URL, or `checkout_ref` (`pcorder:`/`pcsub:`) for PowerChat || `GET /api/v1/intents/:id`, `POST …/:id/capture` | `billing.intent.create` | read; capture an approved PayPal order || `POST /api/v1/purchases/settle` | `billing.ledger.admin` | settle a verified provider receipt `{provider, provider_ref, amount_cents, subject \| intent_id, bits?}` || `POST /api/v1/transfers` | `billing.transfer.create` | tip/donation `{from, to, amount, kind?, target? (EntityRef), message?}`; self-dealing refused by subject |@@@ -204,7 +204,9 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30| `GET /api/v1/cashouts[?status&subject]`, `GET …/:id` | `billing.cashout.manage` (`…request` for one) | list / read || `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, auto_renew?}` (share credited every period); `source: receipt` also needs `billing.ledger.admin` |++| `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/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 || `POST /api/v1/subscriptions/:id/refund` | `billing.ledger.admin` | staff refund of one whole period paid from credit `{transaction_id \| period_end, reason}` → 201 `{transaction, subscription, entitlement}`; the period is revoked (the subscription is shortened, or ends with its last period); a period already refunded or charged back → 200 `{transaction: null, noop: "already_reversed", reversed_by}`; a receipt-paid period → 422 `billing.not_refundable` (its provider reverses it) || `GET /api/v1/subscriptions[?subscriber&streamer&status]`, `GET …/:id` | `billing.entitlement.check` | read |@@@ -472,7 +474,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.200.0++- openvibe-contracts: v0.201.0- openvibe-sdk: v0.50.1- openvibe-shared: v4.3.0<!-- versions:end -->@@@ -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.200.0 (openvibe-sdk v0.28.0, openvibe-shared v2.13.0)",++ "contracts": "openvibe-contracts v0.201.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.",nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "billing.tier.manage",++ "version": "1.0",++ "owner": "billing",++ "status": "active",++ "visibility": "internal",++ "sensitive": true,++ "description": "Read and set the monthly prices of a creator's subscription tiers, for the product where the creator sells them (OpenVibe.VIP), within Billing's bounds. Billing keeps the price and charges it: new subscriptions to a tier pay its price, running ones renew at the price they were bought at.",++ "permissions": [++ "billing:subscriptions"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "ledger-write",++ "events": [],++ "implementedBy": [++ "GET /api/v1/tiers/:streamer",++ "PUT /api/v1/tiers/:streamer/:tier"++ ],++ "inputSchema": "billing.tier-request@1",++ "outputSchema": "billing.tier-result@1"++}nnew file mode 100644@@@ -0,0 +1,15 @@++-- phase: expand++-- Creator-priced subscription tiers (openvibe-contracts 0.201.0, billing.tier.manage; server/ops/tiers.js): the monthly++-- price of each tier a creator sells, set through the product that sells them (OpenVibe.VIP) and charged here (ADR-012:++-- Billing prices, products never do). Tier 1 without a row keeps BILLING_SUB_PRICE_CENTS, so nothing changes for a++-- creator who sets no tiers. `archived` stops new subscriptions to a tier; running ones renew at their own price_cents.++CREATE TABLE subscription_tiers (++ streamer text COLLATE "C" NOT NULL,++ tier integer NOT NULL CHECK (tier BETWEEN 1 AND 10),++ price_cents bigint NOT NULL CHECK (price_cents > 0),++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),++ updated_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (streamer, tier)++);@@@ -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.200.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.201.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.200.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0",-- "integrity": "sha512-w0e+JgfhB2rYe2yfPHhE+0klyFEJE72iwd8u+o41E2uV4H0Hh+zEP0+0FfQDbeni5eV+lj3WmojTtDaKOfSBpA==",++ "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==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -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.200.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.201.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"@@@ -29,6 +29,7 @@ const external = require('../ops/external');const usage = require('../ops/usage');const promo = require('../ops/promo');const rating = require('../ops/rating');++const tiers = require('../ops/tiers');const CAP = {intent: 'billing.intent.create',@@@ -38,6 +39,7 @@ const CAP = {cashoutManage: 'billing.cashout.manage',subscription: 'billing.subscription.manage',entitlement: 'billing.entitlement.check',++ tier: 'billing.tier.manage',admin: 'billing.ledger.admin',usage: 'billing.usage.record',};@@@ -66,7 +68,11 @@ function v1Router({ ctx, auth, adapters, limits }) {res.json({currency: 'vibes-bits', bits_per_usd: rt.bitsPerUsd, price_tiers: rt.priceTiers, min_purchase_bits: rt.minPurchaseBits,packages: [100, 500, 1000, 2500, 5000, 10000, 25000].map((bits) => ({ bits, price_cents: rt.priceCents(bits) })),-- subscription: { price_cents: rt.subPriceCents, streamer_share_pct: rt.subSharePct, site_route_fee_pct: rt.siteRouteFeePct, period_days: rt.subPeriodDays },++ subscription: {++ price_cents: rt.subPriceCents, streamer_share_pct: rt.subSharePct, site_route_fee_pct: rt.siteRouteFeePct, period_days: rt.subPeriodDays,++ // Creators may price their own tiers (GET/PUT /api/v1/tiers); tier 1 without a price is price_cents.++ tiers: { min_price_cents: rt.tierMinCents, max_price_cents: rt.tierMaxCents, max_tiers: rt.maxTiers },++ },cashout: { min_bits: rt.minCashoutBits, escrow_days: rt.escrowDays },providers: Object.fromEntries(Object.values(adapters).map((a) => [a.name, a.enabled])),});@@@ -188,10 +194,22 @@ function v1Router({ ctx, auth, adapters, limits }) {}const out = await subscriptions.pay(ctx, {subscriber: userSubject(b.subscriber, 'subscriber'), streamer: userSubject(b.streamer, 'streamer'), source,-- priceCents: b.price_cents, autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),++ // A tier is priced by Billing (ops/tiers.js); an explicit price_cents only applies without one.++ tier: b.tier, priceCents: b.tier != null ? undefined : b.price_cents,++ autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),});res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });}, limits.budget('billing.subscription.create', person('subscriber'))));++ // ── 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) => {++ res.json(await tiers.list(ctx, pathSubject(req.params.streamer)));++ }));++ r.put('/tiers/:streamer/:tier', ...write(CAP.tier, async (req, res) => {++ const b = req.body || {};++ const tier = await tiers.set(ctx, { streamer: pathSubject(req.params.streamer), tier: req.params.tier, priceCents: b.price_cents, status: b.status, actor: actorOf(req) });++ res.json({ tier });++ }));r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {const list = await subscriptions.list(db, {subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,@@@ -105,6 +105,11 @@ function loadConfig(env = process.env) {minPurchaseBits: int(env.BILLING_MIN_PURCHASE_BITS, 100),maxBits: int(env.BILLING_MAX_BITS, 10_000_000),subPriceCents: int(env.BILLING_SUB_PRICE_CENTS, 499),++ // Creator-priced subscription tiers (ops/tiers.js): the bounds a creator's monthly price must stay within,++ // and how many tiers one creator may sell. Tier 1 without a price keeps subPriceCents.++ tierMinCents: int(env.BILLING_TIER_MIN_CENTS, 100),++ tierMaxCents: int(env.BILLING_TIER_MAX_CENTS, 50_000),++ maxTiers: Math.min(10, Math.max(1, int(env.BILLING_MAX_TIERS, 10))),subSharePct: num(env.BILLING_SUB_SHARE_PCT, 70), // streamer share of a subsiteRouteFeePct: num(env.BILLING_SITE_ROUTE_FEE_PCT, 10), // PowerChat site-route feesubPeriodDays: int(env.BILLING_SUB_PERIOD_DAYS, 31),@@@ -5,10 +5,13 @@* rates (never taken from the client), and the provider reference is unique once set.** kind purchase bits → price under the tiers-- * kind subscription sub price, plus the site-route fee when PowerChat routes through the site++ * kind subscription the creator's tier price (ops/tiers.js; tier 1 by default), plus the site-route fee when++ * PowerChat routes through the site. The tier rides in metadata.tier, so every provider's++ * settlement (subscriptions.pay with intentId) grants that tier.*/const { prefixedId, iso } = require('../ledger');const { fail, positiveInt, userSubject } = require('./common');++const tiers = require('./tiers');function parse(row) {return row ? { ...row, auto_renew: !!row.auto_renew, metadata: JSON.parse(row.metadata || '{}') } : null;@@@ -25,6 +28,17 @@ async function findByProviderRef(db, provider, ref) {return parse(await db.prepare('SELECT * FROM payment_intents WHERE provider = ? AND provider_ref = ?').get(provider, ref));}++/**++ * One membership per member and creator, at one tier: while a period runs, a checkout for another tier is refused++ * (the member cancels and joins the other tier when it ends). The same tier may be renewed early, as before.++ */++async function refuseTierChange(db, subject, streamer, tier, ms) {++ const sub = await db.prepare('SELECT tier, status, current_period_end FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(subject, streamer);++ if (!sub || Number(sub.tier || 1) === tier) return;++ const running = (sub.status === 'active' || sub.status === 'past_due') && sub.current_period_end && Date.parse(sub.current_period_end) > ms;++ if (running) fail(409, 'billing.tier_change_unsupported', `this member has tier ${sub.tier} until ${sub.current_period_end}; cancel it and join tier ${tier} when it ends`);++}++async function create(ctx, input) {const { db, rates } = ctx;const provider = String(input.provider || '').toLowerCase();@@@ -44,7 +58,9 @@ async function create(ctx, input) {} else if (kind === 'subscription') {row.streamer_subject = userSubject(input.streamer, 'streamer');if (row.streamer_subject === subject) fail(422, 'billing.self_dealing', 'you cannot subscribe to yourself');-- const base = rates.subPriceCents;++ row.tier = tiers.tierOf(rates, input.tier);++ await refuseTierChange(db, subject, row.streamer_subject, row.tier, ms);++ const base = await tiers.priceFor(ctx, row.streamer_subject, row.tier);row.route = input.route === 'direct' ? 'direct' : (provider === 'powerchat' ? 'site' : null);row.fee_cents = row.route === 'site' ? rates.siteFeeCents(base) : 0;row.amount_cents = base + row.fee_cents;@@@ -60,6 +76,7 @@ async function create(ctx, input) {}row.metadata = { rates: rates.snapshot({ price_tiers: kind === 'purchase' ? rates.priceTiers : undefined, sub_share_pct: rates.subSharePct, site_route_fee_pct: rates.siteRouteFeePct }) };if (row.receiving_account) row.metadata.receiving_account = row.receiving_account;++ if (row.tier) Object.assign(row.metadata, { tier: row.tier, tier_price_cents: row.amount_cents - row.fee_cents });await db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route,auto_renew, status, metadata, created_at, updated_at)VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)`).run(row.id, provider, kind, subject, row.streamer_subject,@@@ -103,6 +120,7 @@ function present(i) {subject: { type: 'user', id: i.subject }, streamer: i.streamer_subject ? { type: 'user', id: i.streamer_subject } : null,amount_cents: i.amount_cents, fee_cents: i.fee_cents, bits: i.bits, route: i.route, auto_renew: i.auto_renew,status: i.status, settled_txn: i.settled_txn || null, created_at: i.created_at,++ ...(i.kind === 'subscription' ? { tier: Number((i.metadata && i.metadata.tier) || 1) } : {}),// PowerChat checkouts carry this in app_ref; the webhook echoes it back.checkout_ref: i.provider === 'powerchat' ? `${i.kind === 'purchase' ? 'pcorder' : 'pcsub'}:${i.id}` : undefined,};@@@ -26,6 +26,7 @@ const { enqueue } = require('../outbox');const { A, MAX_RECEIPT_CENTS, entry, receiptEntries, fail, positiveInt } = require('./common');const { summary } = require('./purchases');const intents = require('./intents');++const tiers = require('./tiers');const KIND = 'channel_subscription';@@@ -63,7 +64,7 @@ async function entitlement(db, subject, streamer, ms) {const sub = await findPair(db, subject, streamer);return {subject: { type: 'user', id: subject }, streamer: { type: 'user', id: streamer }, kind: KIND,-- active: !!expires, expires_at: expires || null,++ active: !!expires, expires_at: expires || null, tier: sub ? Number(sub.tier) || 1 : null,// past_due: when the sweep stops retrying the renewal. The grace grants no access (active stays false).grace_until: sub && sub.status === 'past_due' ? sub.grace_until || null : null,subscription: sub ? { id: sub.id, status: sub.status, auto_renew: sub.auto_renew, cancel_at_period_end: sub.cancel_at_period_end, provider: sub.provider } : null,@@@ -77,7 +78,7 @@ async function activeEntitlements(db, subject, ms) {}/**-- * Pay one period. input: { subscriber, streamer, source: 'credit'|'receipt', priceCents?, autoRenew?,++ * Pay one period. input: { subscriber, streamer, source: 'credit'|'receipt', tier?, priceCents?, autoRenew?,* receipt?: { provider, receiptRef, paidCents, feeCents, route, providerRef, test, periodEnd },* intentId?, idempotencyKey, actor, sourceEventId, renewal? }*/@@@ -88,15 +89,31 @@ async function pay(ctx, input) {return await money(db, async () => {const existingTxn = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);if (existingTxn) return await result(db, await getTxn(db, existingTxn.id), ctx, true);-- if (input.intentId) intents.refuseIfSettledInLive(await intents.find(db, input.intentId));++ const intent = input.intentId ? await intents.find(db, input.intentId) : null;++ if (intent) intents.refuseIfSettledInLive(intent);const r = input.receipt;if (r) {const dup = await db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(r.receiptRef);if (dup) return { ...await result(db, await getTxn(db, dup.id), ctx, true), duplicateReceipt: true };}const ms = ctx.now();-- const priceCents = input.priceCents != null ? positiveInt(input.priceCents, 'price_cents', 10_000_000) : rates.subPriceCents;const prior = await findPair(db, subscriber, streamer);++ // The tier (ops/tiers.js): the caller's, else the checkout's (a provider settlement), else the running one's.++ const meta0 = (intent && intent.metadata) || {};++ const tier = input.tier != null ? tiers.tierOf(rates, input.tier) : Number(meta0.tier || (prior && prior.tier) || 1);++ const sameTier = !prior || Number(prior.tier || 1) === tier;++ let priceCents;++ if (input.priceCents != null) {++ priceCents = positiveInt(input.priceCents, 'price_cents', 10_000_000); // a renewal keeps the price it was bought at++ } else if (input.source === 'credit') {++ // One membership per member and creator at one tier: no switching tiers while a paid period runs.++ const running = prior && (prior.status === 'active' || prior.status === 'past_due') && prior.current_period_end && Date.parse(prior.current_period_end) > ms;++ if (running && !sameTier) fail(409, 'billing.tier_change_unsupported', `this member has tier ${prior.tier} until ${prior.current_period_end}; cancel it and join tier ${tier} when it ends`);++ priceCents = await tiers.priceFor(ctx, streamer, tier);++ } else {++ // A provider receipt: what the checkout priced the tier at (the money arrived; it is never refused here).++ priceCents = Number(meta0.tier_price_cents) > 0 ? Number(meta0.tier_price_cents) : (sameTier && prior && prior.price_cents) || rates.subPriceCents;++ }let entries = [];let provider = 'credit';@@@ -107,7 +124,7 @@ async function pay(ctx, input) {const share = Math.min(cost, rates.subShareBits(priceCents));await requireFunds(db, A.credit(subscriber), cost, 'credit');entries = [entry(A.credit(subscriber), -cost), entry(A.payable(streamer), share), entry(A.revenue('vibes-bits'), cost - share)];-- meta = { source: 'credit', price_cents: priceCents, cost_bits: cost, share_bits: share, sub_share_pct: rates.subSharePct };++ meta = { source: 'credit', tier, price_cents: priceCents, cost_bits: cost, share_bits: share, sub_share_pct: rates.subSharePct };} else if (input.source === 'receipt' && r) {provider = r.provider;route = r.route || null;@@@ -115,12 +132,12 @@ async function pay(ctx, input) {const fee = Math.max(0, Math.round(Number(r.feeCents) || 0));if (route === 'direct') {// EXTERNAL: the streamer holds the money already; nothing touches the journal.-- meta = { source: 'external', route, paid_cents: paid, external: true };++ meta = { source: 'external', tier, route, paid_cents: paid, external: true };} else {const base = Math.max(0, paid - fee);const share = rates.subShareBits(base);entries = receiptEntries(rates, { provider, paidCents: paid, bits: share, target: A.payable(streamer) });-- meta = { source: 'receipt', route, paid_cents: paid, fee_cents: fee, share_base_cents: base, share_bits: share, sub_share_pct: rates.subSharePct };++ meta = { source: 'receipt', tier, route, paid_cents: paid, fee_cents: fee, share_base_cents: base, share_bits: share, sub_share_pct: rates.subSharePct };}} else {fail(422, 'billing.invalid_input', "source must be 'credit' or 'receipt' (with a receipt)");@@@ -151,15 +168,15 @@ async function pay(ctx, input) {let sub;if (prior) {await db.prepare(`UPDATE subscriptions SET provider = ?, provider_ref = COALESCE(?, provider_ref), route = ?, status = 'active', auto_renew = ?,-- cancel_at_period_end = 0, price_cents = ?, current_period_end = ?, grace_until = NULL, renewal_failed_at = NULL, renewal_attempts = 0,++ cancel_at_period_end = 0, tier = ?, price_cents = ?, current_period_end = ?, grace_until = NULL, renewal_failed_at = NULL, renewal_attempts = 0,updated_at = ? WHERE id = ?`)-- .run(provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, prior.id);++ .run(provider, r ? r.providerRef || null : null, route, autoRenew, tier, priceCents, iso(end), nowIso, prior.id);sub = await find(db, prior.id);} else {const id = prefixedId('sub', ms);await db.prepare(`INSERT INTO subscriptions (id, subscriber, streamer, tier, provider, provider_ref, route, status, auto_renew, cancel_at_period_end,-- price_cents, current_period_end, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?, ?, 'active', ?, 0, ?, ?, ?, ?)`)-- .run(id, subscriber, streamer, provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, nowIso);++ price_cents, current_period_end, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, 0, ?, ?, ?, ?)`)++ .run(id, subscriber, streamer, tier, provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, nowIso);sub = await find(db, id);}await db.prepare(`INSERT INTO entitlements (id, subject, kind, scope, subscription_id, starts_at, ends_at, source_txn, created_at)nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++++/**++ * Creator-priced subscription tiers (openvibe-contracts 0.201.0, billing.tier.manage). The product where a creator++ * sells memberships (OpenVibe.VIP) sets each tier's monthly price; Billing keeps it and charges it (ADR-012: Billing++ * prices, products never do).++ *++ * tier 1 without a row the site's default subscription price (rates.subPriceCents): nothing changes for a++ * creator who sets no tiers++ * another tier without one not for sale (billing.tier_unknown)++ * archived no new subscriptions (billing.tier_archived); running ones renew at their own price++ *++ * A price stays within rates.tierMinCents..tierMaxCents; a creator sells at most rates.maxTiers tiers.++ */++const { iso } = require('../ledger');++const { fail, positiveInt, userSubject } = require('./common');++++const STATUSES = ['active', 'archived'];++++function present(row) {++ return row ? { tier: Number(row.tier), price_cents: Number(row.price_cents), status: row.status, updated_at: row.updated_at } : null;++}++++/** A tier number from a body or path (1 when absent), within the configured maximum. */++function tierOf(rates, v, { required = false } = {}) {++ if (v == null || v === '') {++ if (required) fail(422, 'billing.invalid_input', 'tier is required');++ return 1;++ }++ const n = Number(v);++ if (!Number.isSafeInteger(n) || n < 1 || n > rates.maxTiers) fail(422, 'billing.invalid_input', `tier must be an integer from 1 to ${rates.maxTiers}`);++ return n;++}++++async function find(db, streamer, tier) {++ return await db.prepare('SELECT * FROM subscription_tiers WHERE streamer = ? AND tier = ?').get(streamer, tier);++}++++async function list(ctx, streamerRef) {++ const streamer = userSubject(streamerRef, 'streamer');++ const rows = await ctx.db.prepare('SELECT * FROM subscription_tiers WHERE streamer = ? ORDER BY tier').all(streamer);++ const r = ctx.rates;++ return {++ streamer: { type: 'user', id: streamer }, tiers: rows.map(present),++ default_price_cents: r.subPriceCents, min_price_cents: r.tierMinCents, max_price_cents: r.tierMaxCents, max_tiers: r.maxTiers,++ };++}++++/** The monthly price of a new subscription to `tier` of `streamer`, or a refusal when that tier is not for sale. */++async function priceFor(ctx, streamer, tier) {++ const row = await find(ctx.db, streamer, tier);++ if (row) {++ if (row.status !== 'active') fail(409, 'billing.tier_archived', `tier ${tier} of this creator is no longer sold`);++ return Number(row.price_cents);++ }++ if (tier === 1) return ctx.rates.subPriceCents;++ fail(422, 'billing.tier_unknown', `this creator sells no tier ${tier}`);++}++++/** Set one tier's price (and status). Returns the tier as presented. */++async function set(ctx, { streamer: streamerRef, tier: tierValue, priceCents, status, actor }) {++ const { db, rates } = ctx;++ const streamer = userSubject(streamerRef, 'streamer');++ const tier = tierOf(rates, tierValue, { required: true });++ const price = positiveInt(priceCents, 'price_cents', rates.tierMaxCents);++ if (price < rates.tierMinCents) fail(422, 'billing.invalid_amount', `price_cents must be at least ${rates.tierMinCents}`);++ const st = status == null || status === '' ? 'active' : String(status);++ if (!STATUSES.includes(st)) fail(422, 'billing.invalid_input', `status must be one of ${STATUSES.join(', ')}`);++ const at = iso(ctx.now());++ const by = actor ? (actor.on_behalf_of && actor.on_behalf_of.id) || actor.principal || null : null;++ await db.prepare(`INSERT INTO subscription_tiers (streamer, tier, price_cents, status, updated_by, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (streamer, tier) DO UPDATE SET price_cents = excluded.price_cents, status = excluded.status,++ updated_by = excluded.updated_by, updated_at = excluded.updated_at`).run(streamer, tier, price, st, by, at, at);++ return present(await find(db, streamer, tier));++}++++module.exports = { list, set, priceFor, tierOf, find, present, STATUSES };@@@ -14,7 +14,7 @@ const express = require('express');const TERMS_URL = 'https://openvibe.network/terms';const CONTACT = '[email protected]';// The date the wording last changed. Rate changes show up on their own (the numbers are live).--const WORDING_DATE = '2026-09-26';++const WORDING_DATE = '2026-10-17';const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));const usd = (cents) => `$${(cents / 100).toFixed(2)}`;@@@ -53,6 +53,7 @@ function policyData(rates, { authority = 'live' } = {}) {creator_share_cents: Math.floor((subCents * rates.subSharePct) / 100),site_route_fee_pct: rates.siteRouteFeePct,site_route_fee_cents: Math.round((subCents * rates.siteRouteFeePct) / 100),++ tier_min_price_cents: rates.tierMinCents, tier_max_price_cents: rates.tierMaxCents, max_tiers: rates.maxTiers,},cashout: { min_vibes: rates.minCashoutBits, min_cents: Math.round(rates.minCashoutBits * perVibeValueCents), hold_days: rates.escrowDays },};@@@ -107,7 +108,7 @@ dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:12px 0<p>When you send Vibes, the creator receives all of them: nothing is taken when a tip is sent. A tip paid through OpenVibe's PowerChat account for a creator without their own PowerChat is credited to the creator in full, one Vibe per cent.</p><h2>Subscriptions</h2>--<p>A subscription is <strong>${usd(s.price_cents)}</strong> for ${num(s.period_days)} days.</p>++<p>A subscription is <strong>${usd(s.price_cents)}</strong> for ${num(s.period_days)} days, unless the creator sets their own price for a tier (from ${usd(s.tier_min_price_cents)} to ${usd(s.tier_max_price_cents)}). The split below is the same at any price, and a subscription renews at the price it was bought at.</p><ul><li>Paid through the creator's own PowerChat: the creator is paid directly by PowerChat, and OpenVibe adds no fee.</li><li>Paid through OpenVibe's PowerChat account: a routing fee of ${pct(s.site_route_fee_pct)} (${usd(s.site_route_fee_cents)}) is added on top of the price, and the creator receives ${pct(s.creator_share_pct)} of the price (${usd(s.creator_share_cents)}) as Vibes they can cash out. The routing fee is not part of the split.</li>@@@ -15,7 +15,7 @@ const { policyData } = require('../server/policy');], 'tiers start at the minimum purchase and cover every amount once');assert.deepStrictEqual(d.purchase.tiers.map((x) => x.openvibe_keeps_pct), [33.3, 28.6, 23.1, 19.4, 16.7, 13, 9.1]);assert.ok(d.purchase.tiers.every((x) => x.creator_value_per_100_cents === 100), 'a creator gets $1.00 per 100 Vibes at every tier');-- assert.deepStrictEqual(d.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 });++ assert.deepStrictEqual(d.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, tier_min_price_cents: 100, tier_max_price_cents: 50000, max_tiers: 10 });assert.deepStrictEqual(d.cashout, { min_vibes: 500, min_cents: 500, hold_days: 14 });assert.strictEqual(d.currencies.opencoins.withdrawable, false, 'OpenCoins are never withdrawable');assert.strictEqual(d.currencies.channel_points.withdrawable, false);nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * Creator-priced subscription tiers (openvibe-contracts 0.201.0, billing.tier.manage; server/ops/tiers.js): the product++ * that sells memberships sets each tier's monthly price within Billing's bounds, and Billing charges it on a credit++ * period, a PowerChat checkout (the tier rides on the intent) and renewals (at the price bought at). Tier 1 without a++ * price stays the site price; an unknown or archived tier is not for sale; no switching tiers mid-period.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, fund, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ env: { BILLING_RENEWAL_GRACE_DAYS: '0' } });++ const creator = t.user(41);++ const fan = t.user(42);++ const other = t.user(43);++ console.log('subscription tiers');++ const put = (tier, body, cap = ['billing.tier.manage']) => t.call('PUT', `/api/v1/tiers/${creator.id}/${tier}`, { cap, body, headers: { 'Idempotency-Key': `tier-${crypto.randomUUID()}` } });++ const list = (cap = ['billing.tier.manage']) => t.call('GET', `/api/v1/tiers/${creator.id}`, { cap });++++ await check('no tiers yet: tier 1 is the site price; the bounds are published', async () => {++ const r = await list(['billing.intent.create']);++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json.tiers, []);++ assert.deepStrictEqual([r.json.default_price_cents, r.json.min_price_cents, r.json.max_price_cents, r.json.max_tiers], [499, 100, 50000, 10]);++ const rates = await t.call('GET', '/api/v1/rates', { token: null });++ assert.deepStrictEqual(rates.json.subscription.tiers, { min_price_cents: 100, max_price_cents: 50000, max_tiers: 10 });++ });++++ await check('setting a price needs billing.tier.manage, an Idempotency-Key and a price within the bounds', async () => {++ assert.strictEqual((await put(2, { price_cents: 1500 }, ['billing.intent.create'])).status, 403);++ const noKey = await t.call('PUT', `/api/v1/tiers/${creator.id}/2`, { cap: ['billing.tier.manage'], body: { price_cents: 1500 } });++ assert.strictEqual(noKey.status, 400, noKey.text);++ assert.strictEqual((await put(2, { price_cents: 99 })).json.code, 'billing.invalid_amount');++ assert.strictEqual((await put(2, { price_cents: 50001 })).json.code, 'billing.invalid_amount');++ assert.strictEqual((await put(11, { price_cents: 1500 })).json.code, 'billing.invalid_input');++ assert.strictEqual((await put(2, { price_cents: 1500, status: 'paused' })).json.code, 'billing.invalid_input');++ const ok = await put(2, { price_cents: 1500 });++ assert.strictEqual(ok.status, 200, ok.text);++ assert.deepStrictEqual([ok.json.tier.tier, ok.json.tier.price_cents, ok.json.tier.status], [2, 1500, 'active']);++ assert.strictEqual((await put(1, { price_cents: 300 })).status, 200);++ assert.deepStrictEqual((await list()).json.tiers.map((x) => [x.tier, x.price_cents]), [[1, 300], [2, 1500]]);++ });++++ await check('a credit period costs the tier price; the share follows it; the entitlement names the tier', async () => {++ await fund(t, fan, 3000);++ const r = await t.call('POST', '/api/v1/subscriptions', { cap: ['billing.subscription.manage'], body: { subscriber: fan, streamer: creator, source: 'credit', tier: 2 } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.subscription.tier, 2);++ assert.strictEqual(r.json.subscription.price_cents, 1500);++ const m = r.json.transaction.metadata;++ assert.deepStrictEqual([m.tier, m.cost_bits, m.share_bits], [2, 1500, 1050]);++ assert.strictEqual(r.json.entitlement.tier, 2);++ const e = await t.call('GET', `/api/v1/entitlements/${fan.id}?streamer=${creator.id}`, { cap: ['billing.entitlement.check'] });++ assert.deepStrictEqual([e.json.active, e.json.tier], [true, 2]);++ await t.assertReconciled('after a tier-2 period');++ });++++ await check('no switching tiers while a period runs (credit or checkout); the same tier renews early as before', async () => {++ const sw = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: fan, streamer: creator, source: 'credit', tier: 1 } });++ assert.strictEqual(sw.status, 409, sw.text);++ assert.strictEqual(sw.json.code, 'billing.tier_change_unsupported');++ const ck = await t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'subscription', subject: fan, streamer: creator, tier: 1 } });++ assert.strictEqual(ck.json.code, 'billing.tier_change_unsupported');++ const again = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: fan, streamer: creator, source: 'credit', tier: 2 } });++ assert.strictEqual(again.status, 201, again.text);++ assert.strictEqual(again.json.transaction.metadata.renewal, true);++ });++++ await check('a PowerChat checkout is priced by the tier and grants it on settlement', async () => {++ const i = await t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'subscription', subject: other, streamer: creator, tier: 2, route: 'site' } });++ assert.strictEqual(i.status, 201, i.text);++ assert.deepStrictEqual([i.json.intent.tier, i.json.intent.amount_cents, i.json.intent.fee_cents], [2, 1650, 150]);++ const r = await t.powerchat({ type: 'donation.completed', streamer: { id: 'pc1', username: 'openvibe' }, data: { eventId: `don-${crypto.randomUUID()}`, amountUsdCents: 1650, appExternalRef: i.json.intent.checkout_ref } });++ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));++ const e = await t.call('GET', `/api/v1/entitlements/${other.id}?streamer=${creator.id}`);++ assert.deepStrictEqual([e.json.active, e.json.tier], [true, 2]);++ const s = await t.call('GET', `/api/v1/subscriptions/${e.json.subscription.id}`);++ assert.deepStrictEqual([s.json.subscription.tier, s.json.subscription.price_cents], [2, 1500]);++ await t.assertReconciled('after a tier-2 checkout');++ });++++ await check('unknown and archived tiers are not for sale; tier 1 keeps its price', async () => {++ const nobody = t.user(44);++ await fund(t, nobody, 2000);++ const unknown = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: nobody, streamer: creator, source: 'credit', tier: 3 } });++ assert.strictEqual(unknown.json.code, 'billing.tier_unknown');++ assert.strictEqual((await put(2, { price_cents: 1500, status: 'archived' })).status, 200);++ const archived = await t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'subscription', subject: nobody, streamer: creator, tier: 2 } });++ assert.strictEqual(archived.json.code, 'billing.tier_archived');++ const one = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: nobody, streamer: creator, source: 'credit' } });++ assert.strictEqual(one.status, 201, one.text);++ assert.deepStrictEqual([one.json.subscription.tier, one.json.subscription.price_cents, one.json.transaction.metadata.cost_bits], [1, 300, 300]);++ });++++ await check('a renewal keeps the tier and the price it was bought at, even after the creator changes it', async () => {++ assert.strictEqual((await put(2, { price_cents: 2500 })).status, 200);++ const sub = (await t.call('GET', `/api/v1/subscriptions?subscriber=${fan.id}&streamer=${creator.id}`)).json.subscriptions[0];++ await fund(t, fan, 1500);++ t.clock.offset = Date.parse(sub.current_period_end) - Date.now() + 1000;++ const { sweep } = require('../server/ops/subscriptions');++ const out = await sweep(t.ctx);++ assert.ok(out.renewed.includes(sub.id), JSON.stringify(out));++ const after = (await t.call('GET', `/api/v1/subscriptions/${sub.id}`)).json.subscription;++ assert.deepStrictEqual([after.tier, after.price_cents], [2, 1500]);++ await t.assertReconciled('after a tier-2 renewal');++ });++++ await t.close();++ done();++})();
@@ -71,6 +71,10 @@ BILLING_SESSION_SECRET=
@@ -71,6 +71,10 @@ BILLING_SESSION_SECRET=
#BILLING_MIN_PURCHASE_BITS=100
#BILLING_MIN_PURCHASE_BITS=100
#BILLING_MAX_BITS=10000000
#BILLING_MAX_BITS=10000000
#BILLING_SUB_PRICE_CENTS=499
#BILLING_SUB_PRICE_CENTS=499
+# Creator-priced subscription tiers (GET/PUT /api/v1/tiers): price bounds in cents and tiers per creator (max 10).
+#BILLING_TIER_MIN_CENTS=100
+#BILLING_TIER_MAX_CENTS=50000
+#BILLING_MAX_TIERS=10
#BILLING_SUB_SHARE_PCT=70
#BILLING_SUB_SHARE_PCT=70
#BILLING_SITE_ROUTE_FEE_PCT=10
#BILLING_SITE_ROUTE_FEE_PCT=10
#BILLING_SUB_PERIOD_DAYS=31
#BILLING_SUB_PERIOD_DAYS=31
@@ -64,7 +64,7 @@ promo write changes both in one transaction.
@@ -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.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)
- 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)
- the payment providers whose secrets are configured (none in production today)
-- `openvibe-contracts` v0.200.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.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
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
- **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)
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
@@ -194,7 +194,7 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30
@@ -194,7 +194,7 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30
|---|---|---|
|---|---|---|
| `GET /api/health`, `/api/ready` | — | liveness / readiness (DB + Network key) |
| `GET /api/health`, `/api/ready` | — | liveness / readiness (DB + Network key) |
| `GET /api/v1/rates` | — | prices, packages, sub price/share/fee, cashout rules, enabled providers |
| `GET /api/v1/rates` | — | prices, packages, sub price/share/fee, cashout rules, enabled providers |
-| `POST /api/v1/intents` | `billing.intent.create` | start a checkout `{provider, kind: purchase\|subscription, subject, bits \| streamer, route?, auto_renew?}`; amount priced here; returns the provider checkout URL, or `checkout_ref` (`pcorder:`/`pcsub:`) for PowerChat |
+| `POST /api/v1/intents` | `billing.intent.create` | start a checkout `{provider, kind: purchase\|subscription, subject, bits \| streamer, tier?, route?, auto_renew?}`; amount priced here (a subscription at the creator's tier price); returns the provider checkout URL, or `checkout_ref` (`pcorder:`/`pcsub:`) for PowerChat |
| `GET /api/v1/intents/:id`, `POST …/:id/capture` | `billing.intent.create` | read; capture an approved PayPal order |
| `GET /api/v1/intents/:id`, `POST …/:id/capture` | `billing.intent.create` | read; capture an approved PayPal order |
| `POST /api/v1/purchases/settle` | `billing.ledger.admin` | settle a verified provider receipt `{provider, provider_ref, amount_cents, subject \| intent_id, bits?}` |
| `POST /api/v1/purchases/settle` | `billing.ledger.admin` | settle a verified provider receipt `{provider, provider_ref, amount_cents, subject \| intent_id, bits?}` |
| `POST /api/v1/transfers` | `billing.transfer.create` | tip/donation `{from, to, amount, kind?, target? (EntityRef), message?}`; self-dealing refused by subject |
| `POST /api/v1/transfers` | `billing.transfer.create` | tip/donation `{from, to, amount, kind?, target? (EntityRef), message?}`; self-dealing refused by subject |
@@ -204,7 +204,9 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30
@@ -204,7 +204,9 @@ Contracts service manifests name (never a wildcard), never cached, limited to 30
| `GET /api/v1/cashouts[?status&subject]`, `GET …/:id` | `billing.cashout.manage` (`…request` for one) | list / read |
| `GET /api/v1/cashouts[?status&subject]`, `GET …/:id` | `billing.cashout.manage` (`…request` for one) | list / read |
| `POST /api/v1/cashouts/:id/approve` | `billing.cashout.manage` | `{payout_reference (required), payout_provider?}`; refused before the escrow ends |
| `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/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, auto_renew?}` (share credited every period); `source: receipt` also needs `billing.ledger.admin` |
+| `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/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 |
| `POST /api/v1/subscriptions/:id/cancel` | `billing.subscription.manage` | cancel at period end; Stripe is cancelled at Stripe first |
| `POST /api/v1/subscriptions/:id/refund` | `billing.ledger.admin` | staff refund of one whole period paid from credit `{transaction_id \| period_end, reason}` → 201 `{transaction, subscription, entitlement}`; the period is revoked (the subscription is shortened, or ends with its last period); a period already refunded or charged back → 200 `{transaction: null, noop: "already_reversed", reversed_by}`; a receipt-paid period → 422 `billing.not_refundable` (its provider reverses it) |
| `POST /api/v1/subscriptions/:id/refund` | `billing.ledger.admin` | staff refund of one whole period paid from credit `{transaction_id \| period_end, reason}` → 201 `{transaction, subscription, entitlement}`; the period is revoked (the subscription is shortened, or ends with its last period); a period already refunded or charged back → 200 `{transaction: null, noop: "already_reversed", reversed_by}`; a receipt-paid period → 422 `billing.not_refundable` (its provider reverses it) |
| `GET /api/v1/subscriptions[?subscriber&streamer&status]`, `GET …/:id` | `billing.entitlement.check` | read |
| `GET /api/v1/subscriptions[?subscriber&streamer&status]`, `GET …/:id` | `billing.entitlement.check` | read |
@@ -472,7 +474,7 @@ PowerChat purchase settling end to end after the cutover.
@@ -472,7 +474,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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.200.0
+- openvibe-contracts: v0.201.0
- openvibe-sdk: v0.50.1
- openvibe-sdk: v0.50.1
- openvibe-shared: v4.3.0
- openvibe-shared: v4.3.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"authoritative": true,
"authoritative": true,
"deployed": 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).",
"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.200.0 (openvibe-sdk v0.28.0, openvibe-shared v2.13.0)",
+ "contracts": "openvibe-contracts v0.201.0 (openvibe-sdk v0.28.0, openvibe-shared v2.13.0)",
"sdk": "openvibe-sdk v0.50.1",
"sdk": "openvibe-sdk v0.50.1",
"shared": "openvibe-shared v4.3.0",
"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.",
"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.",
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "billing.tier.manage",
+ "version": "1.0",
+ "owner": "billing",
+ "status": "active",
+ "visibility": "internal",
+ "sensitive": true,
+ "description": "Read and set the monthly prices of a creator's subscription tiers, for the product where the creator sells them (OpenVibe.VIP), within Billing's bounds. Billing keeps the price and charges it: new subscriptions to a tier pay its price, running ones renew at the price they were bought at.",
+ "permissions": [
+ "billing:subscriptions"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "ledger-write",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/tiers/:streamer",
+ "PUT /api/v1/tiers/:streamer/:tier"
+ ],
+ "inputSchema": "billing.tier-request@1",
+ "outputSchema": "billing.tier-result@1"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+-- phase: expand
+-- Creator-priced subscription tiers (openvibe-contracts 0.201.0, billing.tier.manage; server/ops/tiers.js): the monthly
+-- price of each tier a creator sells, set through the product that sells them (OpenVibe.VIP) and charged here (ADR-012:
+-- Billing prices, products never do). Tier 1 without a row keeps BILLING_SUB_PRICE_CENTS, so nothing changes for a
+-- creator who sets no tiers. `archived` stops new subscriptions to a tier; running ones renew at their own price_cents.
+CREATE TABLE subscription_tiers (
+ streamer text COLLATE "C" NOT NULL,
+ tier integer NOT NULL CHECK (tier BETWEEN 1 AND 10),
+ price_cents bigint NOT NULL CHECK (price_cents > 0),
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
+ updated_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (streamer, tier)
+);
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.201.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.3.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -828,9 +828,9 @@
@@ -828,9 +828,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.200.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0",
- "integrity": "sha512-w0e+JgfhB2rYe2yfPHhE+0klyFEJE72iwd8u+o41E2uV4H0Hh+zEP0+0FfQDbeni5eV+lj3WmojTtDaKOfSBpA==",
+ "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==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.200.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.201.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.3.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -29,6 +29,7 @@ const external = require('../ops/external');
@@ -29,6 +29,7 @@ const external = require('../ops/external');
const usage = require('../ops/usage');
const usage = require('../ops/usage');
const promo = require('../ops/promo');
const promo = require('../ops/promo');
const rating = require('../ops/rating');
const rating = require('../ops/rating');
+const tiers = require('../ops/tiers');
const CAP = {
const CAP = {
intent: 'billing.intent.create',
intent: 'billing.intent.create',
@@ -38,6 +39,7 @@ const CAP = {
@@ -38,6 +39,7 @@ const CAP = {
cashoutManage: 'billing.cashout.manage',
cashoutManage: 'billing.cashout.manage',
subscription: 'billing.subscription.manage',
subscription: 'billing.subscription.manage',
entitlement: 'billing.entitlement.check',
entitlement: 'billing.entitlement.check',
+ tier: 'billing.tier.manage',
admin: 'billing.ledger.admin',
admin: 'billing.ledger.admin',
usage: 'billing.usage.record',
usage: 'billing.usage.record',
};
};
@@ -66,7 +68,11 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -66,7 +68,11 @@ function v1Router({ ctx, auth, adapters, limits }) {
res.json({
res.json({
currency: 'vibes-bits', bits_per_usd: rt.bitsPerUsd, price_tiers: rt.priceTiers, min_purchase_bits: rt.minPurchaseBits,
currency: 'vibes-bits', bits_per_usd: rt.bitsPerUsd, price_tiers: rt.priceTiers, min_purchase_bits: rt.minPurchaseBits,
packages: [100, 500, 1000, 2500, 5000, 10000, 25000].map((bits) => ({ bits, price_cents: rt.priceCents(bits) })),
packages: [100, 500, 1000, 2500, 5000, 10000, 25000].map((bits) => ({ bits, price_cents: rt.priceCents(bits) })),
- subscription: { price_cents: rt.subPriceCents, streamer_share_pct: rt.subSharePct, site_route_fee_pct: rt.siteRouteFeePct, period_days: rt.subPeriodDays },
+ subscription: {
+ price_cents: rt.subPriceCents, streamer_share_pct: rt.subSharePct, site_route_fee_pct: rt.siteRouteFeePct, period_days: rt.subPeriodDays,
+ // Creators may price their own tiers (GET/PUT /api/v1/tiers); tier 1 without a price is price_cents.
+ tiers: { min_price_cents: rt.tierMinCents, max_price_cents: rt.tierMaxCents, max_tiers: rt.maxTiers },
+ },
cashout: { min_bits: rt.minCashoutBits, escrow_days: rt.escrowDays },
cashout: { min_bits: rt.minCashoutBits, escrow_days: rt.escrowDays },
providers: Object.fromEntries(Object.values(adapters).map((a) => [a.name, a.enabled])),
providers: Object.fromEntries(Object.values(adapters).map((a) => [a.name, a.enabled])),
});
});
@@ -188,10 +194,22 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -188,10 +194,22 @@ function v1Router({ ctx, auth, adapters, limits }) {
}
}
const out = await subscriptions.pay(ctx, {
const out = await subscriptions.pay(ctx, {
subscriber: userSubject(b.subscriber, 'subscriber'), streamer: userSubject(b.streamer, 'streamer'), source,
subscriber: userSubject(b.subscriber, 'subscriber'), streamer: userSubject(b.streamer, 'streamer'), source,
- priceCents: b.price_cents, autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
+ // A tier is priced by Billing (ops/tiers.js); an explicit price_cents only applies without one.
+ tier: b.tier, priceCents: b.tier != null ? undefined : b.price_cents,
+ autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
});
});
res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });
res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });
}, limits.budget('billing.subscription.create', person('subscriber'))));
}, limits.budget('billing.subscription.create', person('subscriber'))));
+ // ── 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) => {
+ res.json(await tiers.list(ctx, pathSubject(req.params.streamer)));
+ }));
+ r.put('/tiers/:streamer/:tier', ...write(CAP.tier, async (req, res) => {
+ const b = req.body || {};
+ const tier = await tiers.set(ctx, { streamer: pathSubject(req.params.streamer), tier: req.params.tier, priceCents: b.price_cents, status: b.status, actor: actorOf(req) });
+ res.json({ tier });
+ }));
r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {
r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], async (req, res) => {
const list = await subscriptions.list(db, {
const list = await subscriptions.list(db, {
subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
@@ -105,6 +105,11 @@ function loadConfig(env = process.env) {
@@ -105,6 +105,11 @@ function loadConfig(env = process.env) {
minPurchaseBits: int(env.BILLING_MIN_PURCHASE_BITS, 100),
minPurchaseBits: int(env.BILLING_MIN_PURCHASE_BITS, 100),
maxBits: int(env.BILLING_MAX_BITS, 10_000_000),
maxBits: int(env.BILLING_MAX_BITS, 10_000_000),
subPriceCents: int(env.BILLING_SUB_PRICE_CENTS, 499),
subPriceCents: int(env.BILLING_SUB_PRICE_CENTS, 499),
+ // Creator-priced subscription tiers (ops/tiers.js): the bounds a creator's monthly price must stay within,
+ // and how many tiers one creator may sell. Tier 1 without a price keeps subPriceCents.
+ tierMinCents: int(env.BILLING_TIER_MIN_CENTS, 100),
+ tierMaxCents: int(env.BILLING_TIER_MAX_CENTS, 50_000),
+ maxTiers: Math.min(10, Math.max(1, int(env.BILLING_MAX_TIERS, 10))),
subSharePct: num(env.BILLING_SUB_SHARE_PCT, 70), // streamer share of a sub
subSharePct: num(env.BILLING_SUB_SHARE_PCT, 70), // streamer share of a sub
siteRouteFeePct: num(env.BILLING_SITE_ROUTE_FEE_PCT, 10), // PowerChat site-route fee
siteRouteFeePct: num(env.BILLING_SITE_ROUTE_FEE_PCT, 10), // PowerChat site-route fee
subPeriodDays: int(env.BILLING_SUB_PERIOD_DAYS, 31),
subPeriodDays: int(env.BILLING_SUB_PERIOD_DAYS, 31),
@@ -5,10 +5,13 @@
@@ -5,10 +5,13 @@
* rates (never taken from the client), and the provider reference is unique once set.
* rates (never taken from the client), and the provider reference is unique once set.
*
*
* kind purchase bits → price under the tiers
* kind purchase bits → price under the tiers
- * kind subscription sub price, plus the site-route fee when PowerChat routes through the site
+ * kind subscription the creator's tier price (ops/tiers.js; tier 1 by default), plus the site-route fee when
+ * PowerChat routes through the site. The tier rides in metadata.tier, so every provider's
+ * settlement (subscriptions.pay with intentId) grants that tier.
*/
*/
const { prefixedId, iso } = require('../ledger');
const { prefixedId, iso } = require('../ledger');
const { fail, positiveInt, userSubject } = require('./common');
const { fail, positiveInt, userSubject } = require('./common');
+const tiers = require('./tiers');
function parse(row) {
function parse(row) {
return row ? { ...row, auto_renew: !!row.auto_renew, metadata: JSON.parse(row.metadata || '{}') } : null;
return row ? { ...row, auto_renew: !!row.auto_renew, metadata: JSON.parse(row.metadata || '{}') } : null;
@@ -25,6 +28,17 @@ async function findByProviderRef(db, provider, ref) {
@@ -25,6 +28,17 @@ async function findByProviderRef(db, provider, ref) {
return parse(await db.prepare('SELECT * FROM payment_intents WHERE provider = ? AND provider_ref = ?').get(provider, ref));
return parse(await db.prepare('SELECT * FROM payment_intents WHERE provider = ? AND provider_ref = ?').get(provider, ref));
}
}
+/**
+ * One membership per member and creator, at one tier: while a period runs, a checkout for another tier is refused
+ * (the member cancels and joins the other tier when it ends). The same tier may be renewed early, as before.
+ */
+async function refuseTierChange(db, subject, streamer, tier, ms) {
+ const sub = await db.prepare('SELECT tier, status, current_period_end FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(subject, streamer);
+ if (!sub || Number(sub.tier || 1) === tier) return;
+ const running = (sub.status === 'active' || sub.status === 'past_due') && sub.current_period_end && Date.parse(sub.current_period_end) > ms;
+ if (running) fail(409, 'billing.tier_change_unsupported', `this member has tier ${sub.tier} until ${sub.current_period_end}; cancel it and join tier ${tier} when it ends`);
+}
+
async function create(ctx, input) {
async function create(ctx, input) {
const { db, rates } = ctx;
const { db, rates } = ctx;
const provider = String(input.provider || '').toLowerCase();
const provider = String(input.provider || '').toLowerCase();
@@ -44,7 +58,9 @@ async function create(ctx, input) {
@@ -44,7 +58,9 @@ async function create(ctx, input) {
} else if (kind === 'subscription') {
} else if (kind === 'subscription') {
row.streamer_subject = userSubject(input.streamer, 'streamer');
row.streamer_subject = userSubject(input.streamer, 'streamer');
if (row.streamer_subject === subject) fail(422, 'billing.self_dealing', 'you cannot subscribe to yourself');
if (row.streamer_subject === subject) fail(422, 'billing.self_dealing', 'you cannot subscribe to yourself');
- const base = rates.subPriceCents;
+ row.tier = tiers.tierOf(rates, input.tier);
+ await refuseTierChange(db, subject, row.streamer_subject, row.tier, ms);
+ const base = await tiers.priceFor(ctx, row.streamer_subject, row.tier);
row.route = input.route === 'direct' ? 'direct' : (provider === 'powerchat' ? 'site' : null);
row.route = input.route === 'direct' ? 'direct' : (provider === 'powerchat' ? 'site' : null);
row.fee_cents = row.route === 'site' ? rates.siteFeeCents(base) : 0;
row.fee_cents = row.route === 'site' ? rates.siteFeeCents(base) : 0;
row.amount_cents = base + row.fee_cents;
row.amount_cents = base + row.fee_cents;
@@ -60,6 +76,7 @@ async function create(ctx, input) {
@@ -60,6 +76,7 @@ async function create(ctx, input) {
}
}
row.metadata = { rates: rates.snapshot({ price_tiers: kind === 'purchase' ? rates.priceTiers : undefined, sub_share_pct: rates.subSharePct, site_route_fee_pct: rates.siteRouteFeePct }) };
row.metadata = { rates: rates.snapshot({ price_tiers: kind === 'purchase' ? rates.priceTiers : undefined, sub_share_pct: rates.subSharePct, site_route_fee_pct: rates.siteRouteFeePct }) };
if (row.receiving_account) row.metadata.receiving_account = row.receiving_account;
if (row.receiving_account) row.metadata.receiving_account = row.receiving_account;
+ if (row.tier) Object.assign(row.metadata, { tier: row.tier, tier_price_cents: row.amount_cents - row.fee_cents });
await db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route,
await db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route,
auto_renew, status, metadata, created_at, updated_at)
auto_renew, status, metadata, created_at, updated_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)`).run(row.id, provider, kind, subject, row.streamer_subject,
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)`).run(row.id, provider, kind, subject, row.streamer_subject,
@@ -103,6 +120,7 @@ function present(i) {
@@ -103,6 +120,7 @@ function present(i) {
subject: { type: 'user', id: i.subject }, streamer: i.streamer_subject ? { type: 'user', id: i.streamer_subject } : null,
subject: { type: 'user', id: i.subject }, streamer: i.streamer_subject ? { type: 'user', id: i.streamer_subject } : null,
amount_cents: i.amount_cents, fee_cents: i.fee_cents, bits: i.bits, route: i.route, auto_renew: i.auto_renew,
amount_cents: i.amount_cents, fee_cents: i.fee_cents, bits: i.bits, route: i.route, auto_renew: i.auto_renew,
status: i.status, settled_txn: i.settled_txn || null, created_at: i.created_at,
status: i.status, settled_txn: i.settled_txn || null, created_at: i.created_at,
+ ...(i.kind === 'subscription' ? { tier: Number((i.metadata && i.metadata.tier) || 1) } : {}),
// PowerChat checkouts carry this in app_ref; the webhook echoes it back.
// PowerChat checkouts carry this in app_ref; the webhook echoes it back.
checkout_ref: i.provider === 'powerchat' ? `${i.kind === 'purchase' ? 'pcorder' : 'pcsub'}:${i.id}` : undefined,
checkout_ref: i.provider === 'powerchat' ? `${i.kind === 'purchase' ? 'pcorder' : 'pcsub'}:${i.id}` : undefined,
};
};
@@ -26,6 +26,7 @@ const { enqueue } = require('../outbox');
@@ -26,6 +26,7 @@ const { enqueue } = require('../outbox');
const { A, MAX_RECEIPT_CENTS, entry, receiptEntries, fail, positiveInt } = require('./common');
const { A, MAX_RECEIPT_CENTS, entry, receiptEntries, fail, positiveInt } = require('./common');
const { summary } = require('./purchases');
const { summary } = require('./purchases');
const intents = require('./intents');
const intents = require('./intents');
+const tiers = require('./tiers');
const KIND = 'channel_subscription';
const KIND = 'channel_subscription';
@@ -63,7 +64,7 @@ async function entitlement(db, subject, streamer, ms) {
@@ -63,7 +64,7 @@ async function entitlement(db, subject, streamer, ms) {
const sub = await findPair(db, subject, streamer);
const sub = await findPair(db, subject, streamer);
return {
return {
subject: { type: 'user', id: subject }, streamer: { type: 'user', id: streamer }, kind: KIND,
subject: { type: 'user', id: subject }, streamer: { type: 'user', id: streamer }, kind: KIND,
- active: !!expires, expires_at: expires || null,
+ active: !!expires, expires_at: expires || null, tier: sub ? Number(sub.tier) || 1 : null,
// past_due: when the sweep stops retrying the renewal. The grace grants no access (active stays false).
// past_due: when the sweep stops retrying the renewal. The grace grants no access (active stays false).
grace_until: sub && sub.status === 'past_due' ? sub.grace_until || null : null,
grace_until: sub && sub.status === 'past_due' ? sub.grace_until || null : null,
subscription: sub ? { id: sub.id, status: sub.status, auto_renew: sub.auto_renew, cancel_at_period_end: sub.cancel_at_period_end, provider: sub.provider } : null,
subscription: sub ? { id: sub.id, status: sub.status, auto_renew: sub.auto_renew, cancel_at_period_end: sub.cancel_at_period_end, provider: sub.provider } : null,
@@ -77,7 +78,7 @@ async function activeEntitlements(db, subject, ms) {
@@ -77,7 +78,7 @@ async function activeEntitlements(db, subject, ms) {
}
}
/**
/**
- * Pay one period. input: { subscriber, streamer, source: 'credit'|'receipt', priceCents?, autoRenew?,
+ * Pay one period. input: { subscriber, streamer, source: 'credit'|'receipt', tier?, priceCents?, autoRenew?,
* receipt?: { provider, receiptRef, paidCents, feeCents, route, providerRef, test, periodEnd },
* receipt?: { provider, receiptRef, paidCents, feeCents, route, providerRef, test, periodEnd },
* intentId?, idempotencyKey, actor, sourceEventId, renewal? }
* intentId?, idempotencyKey, actor, sourceEventId, renewal? }
*/
*/
@@ -88,15 +89,31 @@ async function pay(ctx, input) {
@@ -88,15 +89,31 @@ async function pay(ctx, input) {
return await money(db, async () => {
return await money(db, async () => {
const existingTxn = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
const existingTxn = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
if (existingTxn) return await result(db, await getTxn(db, existingTxn.id), ctx, true);
if (existingTxn) return await result(db, await getTxn(db, existingTxn.id), ctx, true);
- if (input.intentId) intents.refuseIfSettledInLive(await intents.find(db, input.intentId));
+ const intent = input.intentId ? await intents.find(db, input.intentId) : null;
+ if (intent) intents.refuseIfSettledInLive(intent);
const r = input.receipt;
const r = input.receipt;
if (r) {
if (r) {
const dup = await db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(r.receiptRef);
const dup = await db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(r.receiptRef);
if (dup) return { ...await result(db, await getTxn(db, dup.id), ctx, true), duplicateReceipt: true };
if (dup) return { ...await result(db, await getTxn(db, dup.id), ctx, true), duplicateReceipt: true };
}
}
const ms = ctx.now();
const ms = ctx.now();
- const priceCents = input.priceCents != null ? positiveInt(input.priceCents, 'price_cents', 10_000_000) : rates.subPriceCents;
const prior = await findPair(db, subscriber, streamer);
const prior = await findPair(db, subscriber, streamer);
+ // The tier (ops/tiers.js): the caller's, else the checkout's (a provider settlement), else the running one's.
+ const meta0 = (intent && intent.metadata) || {};
+ const tier = input.tier != null ? tiers.tierOf(rates, input.tier) : Number(meta0.tier || (prior && prior.tier) || 1);
+ const sameTier = !prior || Number(prior.tier || 1) === tier;
+ let priceCents;
+ if (input.priceCents != null) {
+ priceCents = positiveInt(input.priceCents, 'price_cents', 10_000_000); // a renewal keeps the price it was bought at
+ } else if (input.source === 'credit') {
+ // One membership per member and creator at one tier: no switching tiers while a paid period runs.
+ const running = prior && (prior.status === 'active' || prior.status === 'past_due') && prior.current_period_end && Date.parse(prior.current_period_end) > ms;
+ if (running && !sameTier) fail(409, 'billing.tier_change_unsupported', `this member has tier ${prior.tier} until ${prior.current_period_end}; cancel it and join tier ${tier} when it ends`);
+ priceCents = await tiers.priceFor(ctx, streamer, tier);
+ } else {
+ // A provider receipt: what the checkout priced the tier at (the money arrived; it is never refused here).
+ priceCents = Number(meta0.tier_price_cents) > 0 ? Number(meta0.tier_price_cents) : (sameTier && prior && prior.price_cents) || rates.subPriceCents;
+ }
let entries = [];
let entries = [];
let provider = 'credit';
let provider = 'credit';
@@ -107,7 +124,7 @@ async function pay(ctx, input) {
@@ -107,7 +124,7 @@ async function pay(ctx, input) {
const share = Math.min(cost, rates.subShareBits(priceCents));
const share = Math.min(cost, rates.subShareBits(priceCents));
await requireFunds(db, A.credit(subscriber), cost, 'credit');
await requireFunds(db, A.credit(subscriber), cost, 'credit');
entries = [entry(A.credit(subscriber), -cost), entry(A.payable(streamer), share), entry(A.revenue('vibes-bits'), cost - share)];
entries = [entry(A.credit(subscriber), -cost), entry(A.payable(streamer), share), entry(A.revenue('vibes-bits'), cost - share)];
- meta = { source: 'credit', price_cents: priceCents, cost_bits: cost, share_bits: share, sub_share_pct: rates.subSharePct };
+ meta = { source: 'credit', tier, price_cents: priceCents, cost_bits: cost, share_bits: share, sub_share_pct: rates.subSharePct };
} else if (input.source === 'receipt' && r) {
} else if (input.source === 'receipt' && r) {
provider = r.provider;
provider = r.provider;
route = r.route || null;
route = r.route || null;
@@ -115,12 +132,12 @@ async function pay(ctx, input) {
@@ -115,12 +132,12 @@ async function pay(ctx, input) {
const fee = Math.max(0, Math.round(Number(r.feeCents) || 0));
const fee = Math.max(0, Math.round(Number(r.feeCents) || 0));
if (route === 'direct') {
if (route === 'direct') {
// EXTERNAL: the streamer holds the money already; nothing touches the journal.
// EXTERNAL: the streamer holds the money already; nothing touches the journal.
- meta = { source: 'external', route, paid_cents: paid, external: true };
+ meta = { source: 'external', tier, route, paid_cents: paid, external: true };
} else {
} else {
const base = Math.max(0, paid - fee);
const base = Math.max(0, paid - fee);
const share = rates.subShareBits(base);
const share = rates.subShareBits(base);
entries = receiptEntries(rates, { provider, paidCents: paid, bits: share, target: A.payable(streamer) });
entries = receiptEntries(rates, { provider, paidCents: paid, bits: share, target: A.payable(streamer) });
- meta = { source: 'receipt', route, paid_cents: paid, fee_cents: fee, share_base_cents: base, share_bits: share, sub_share_pct: rates.subSharePct };
+ meta = { source: 'receipt', tier, route, paid_cents: paid, fee_cents: fee, share_base_cents: base, share_bits: share, sub_share_pct: rates.subSharePct };
}
}
} else {
} else {
fail(422, 'billing.invalid_input', "source must be 'credit' or 'receipt' (with a receipt)");
fail(422, 'billing.invalid_input', "source must be 'credit' or 'receipt' (with a receipt)");
@@ -151,15 +168,15 @@ async function pay(ctx, input) {
@@ -151,15 +168,15 @@ async function pay(ctx, input) {
let sub;
let sub;
if (prior) {
if (prior) {
await db.prepare(`UPDATE subscriptions SET provider = ?, provider_ref = COALESCE(?, provider_ref), route = ?, status = 'active', auto_renew = ?,
await db.prepare(`UPDATE subscriptions SET provider = ?, provider_ref = COALESCE(?, provider_ref), route = ?, status = 'active', auto_renew = ?,
- cancel_at_period_end = 0, price_cents = ?, current_period_end = ?, grace_until = NULL, renewal_failed_at = NULL, renewal_attempts = 0,
+ cancel_at_period_end = 0, tier = ?, price_cents = ?, current_period_end = ?, grace_until = NULL, renewal_failed_at = NULL, renewal_attempts = 0,
updated_at = ? WHERE id = ?`)
updated_at = ? WHERE id = ?`)
- .run(provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, prior.id);
+ .run(provider, r ? r.providerRef || null : null, route, autoRenew, tier, priceCents, iso(end), nowIso, prior.id);
sub = await find(db, prior.id);
sub = await find(db, prior.id);
} else {
} else {
const id = prefixedId('sub', ms);
const id = prefixedId('sub', ms);
await db.prepare(`INSERT INTO subscriptions (id, subscriber, streamer, tier, provider, provider_ref, route, status, auto_renew, cancel_at_period_end,
await db.prepare(`INSERT INTO subscriptions (id, subscriber, streamer, tier, provider, provider_ref, route, status, auto_renew, cancel_at_period_end,
- price_cents, current_period_end, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?, ?, 'active', ?, 0, ?, ?, ?, ?)`)
- .run(id, subscriber, streamer, provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, nowIso);
+ price_cents, current_period_end, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, 0, ?, ?, ?, ?)`)
+ .run(id, subscriber, streamer, tier, provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, nowIso);
sub = await find(db, id);
sub = await find(db, id);
}
}
await db.prepare(`INSERT INTO entitlements (id, subject, kind, scope, subscription_id, starts_at, ends_at, source_txn, created_at)
await db.prepare(`INSERT INTO entitlements (id, subject, kind, scope, subscription_id, starts_at, ends_at, source_txn, created_at)
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+
+/**
+ * Creator-priced subscription tiers (openvibe-contracts 0.201.0, billing.tier.manage). The product where a creator
+ * sells memberships (OpenVibe.VIP) sets each tier's monthly price; Billing keeps it and charges it (ADR-012: Billing
+ * prices, products never do).
+ *
+ * tier 1 without a row the site's default subscription price (rates.subPriceCents): nothing changes for a
+ * creator who sets no tiers
+ * another tier without one not for sale (billing.tier_unknown)
+ * archived no new subscriptions (billing.tier_archived); running ones renew at their own price
+ *
+ * A price stays within rates.tierMinCents..tierMaxCents; a creator sells at most rates.maxTiers tiers.
+ */
+const { iso } = require('../ledger');
+const { fail, positiveInt, userSubject } = require('./common');
+
+const STATUSES = ['active', 'archived'];
+
+function present(row) {
+ return row ? { tier: Number(row.tier), price_cents: Number(row.price_cents), status: row.status, updated_at: row.updated_at } : null;
+}
+
+/** A tier number from a body or path (1 when absent), within the configured maximum. */
+function tierOf(rates, v, { required = false } = {}) {
+ if (v == null || v === '') {
+ if (required) fail(422, 'billing.invalid_input', 'tier is required');
+ return 1;
+ }
+ const n = Number(v);
+ if (!Number.isSafeInteger(n) || n < 1 || n > rates.maxTiers) fail(422, 'billing.invalid_input', `tier must be an integer from 1 to ${rates.maxTiers}`);
+ return n;
+}
+
+async function find(db, streamer, tier) {
+ return await db.prepare('SELECT * FROM subscription_tiers WHERE streamer = ? AND tier = ?').get(streamer, tier);
+}
+
+async function list(ctx, streamerRef) {
+ const streamer = userSubject(streamerRef, 'streamer');
+ const rows = await ctx.db.prepare('SELECT * FROM subscription_tiers WHERE streamer = ? ORDER BY tier').all(streamer);
+ const r = ctx.rates;
+ return {
+ streamer: { type: 'user', id: streamer }, tiers: rows.map(present),
+ default_price_cents: r.subPriceCents, min_price_cents: r.tierMinCents, max_price_cents: r.tierMaxCents, max_tiers: r.maxTiers,
+ };
+}
+
+/** The monthly price of a new subscription to `tier` of `streamer`, or a refusal when that tier is not for sale. */
+async function priceFor(ctx, streamer, tier) {
+ const row = await find(ctx.db, streamer, tier);
+ if (row) {
+ if (row.status !== 'active') fail(409, 'billing.tier_archived', `tier ${tier} of this creator is no longer sold`);
+ return Number(row.price_cents);
+ }
+ if (tier === 1) return ctx.rates.subPriceCents;
+ fail(422, 'billing.tier_unknown', `this creator sells no tier ${tier}`);
+}
+
+/** Set one tier's price (and status). Returns the tier as presented. */
+async function set(ctx, { streamer: streamerRef, tier: tierValue, priceCents, status, actor }) {
+ const { db, rates } = ctx;
+ const streamer = userSubject(streamerRef, 'streamer');
+ const tier = tierOf(rates, tierValue, { required: true });
+ const price = positiveInt(priceCents, 'price_cents', rates.tierMaxCents);
+ if (price < rates.tierMinCents) fail(422, 'billing.invalid_amount', `price_cents must be at least ${rates.tierMinCents}`);
+ const st = status == null || status === '' ? 'active' : String(status);
+ if (!STATUSES.includes(st)) fail(422, 'billing.invalid_input', `status must be one of ${STATUSES.join(', ')}`);
+ const at = iso(ctx.now());
+ const by = actor ? (actor.on_behalf_of && actor.on_behalf_of.id) || actor.principal || null : null;
+ await db.prepare(`INSERT INTO subscription_tiers (streamer, tier, price_cents, status, updated_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (streamer, tier) DO UPDATE SET price_cents = excluded.price_cents, status = excluded.status,
+ updated_by = excluded.updated_by, updated_at = excluded.updated_at`).run(streamer, tier, price, st, by, at, at);
+ return present(await find(db, streamer, tier));
+}
+
+module.exports = { list, set, priceFor, tierOf, find, present, STATUSES };
@@ -14,7 +14,7 @@ const express = require('express');
@@ -14,7 +14,7 @@ const express = require('express');
const TERMS_URL = 'https://openvibe.network/terms';
const TERMS_URL = 'https://openvibe.network/terms';
const CONTACT = '[email protected]';
const CONTACT = '[email protected]';
// The date the wording last changed. Rate changes show up on their own (the numbers are live).
// The date the wording last changed. Rate changes show up on their own (the numbers are live).
-const WORDING_DATE = '2026-09-26';
+const WORDING_DATE = '2026-10-17';
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const usd = (cents) => `$${(cents / 100).toFixed(2)}`;
const usd = (cents) => `$${(cents / 100).toFixed(2)}`;
@@ -53,6 +53,7 @@ function policyData(rates, { authority = 'live' } = {}) {
@@ -53,6 +53,7 @@ function policyData(rates, { authority = 'live' } = {}) {
creator_share_cents: Math.floor((subCents * rates.subSharePct) / 100),
creator_share_cents: Math.floor((subCents * rates.subSharePct) / 100),
site_route_fee_pct: rates.siteRouteFeePct,
site_route_fee_pct: rates.siteRouteFeePct,
site_route_fee_cents: Math.round((subCents * rates.siteRouteFeePct) / 100),
site_route_fee_cents: Math.round((subCents * rates.siteRouteFeePct) / 100),
+ tier_min_price_cents: rates.tierMinCents, tier_max_price_cents: rates.tierMaxCents, max_tiers: rates.maxTiers,
},
},
cashout: { min_vibes: rates.minCashoutBits, min_cents: Math.round(rates.minCashoutBits * perVibeValueCents), hold_days: rates.escrowDays },
cashout: { min_vibes: rates.minCashoutBits, min_cents: Math.round(rates.minCashoutBits * perVibeValueCents), hold_days: rates.escrowDays },
};
};
@@ -107,7 +108,7 @@ dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:12px 0
@@ -107,7 +108,7 @@ dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:12px 0
<p>When you send Vibes, the creator receives all of them: nothing is taken when a tip is sent. A tip paid through OpenVibe's PowerChat account for a creator without their own PowerChat is credited to the creator in full, one Vibe per cent.</p>
<p>When you send Vibes, the creator receives all of them: nothing is taken when a tip is sent. A tip paid through OpenVibe's PowerChat account for a creator without their own PowerChat is credited to the creator in full, one Vibe per cent.</p>
<h2>Subscriptions</h2>
<h2>Subscriptions</h2>
-<p>A subscription is <strong>${usd(s.price_cents)}</strong> for ${num(s.period_days)} days.</p>
+<p>A subscription is <strong>${usd(s.price_cents)}</strong> for ${num(s.period_days)} days, unless the creator sets their own price for a tier (from ${usd(s.tier_min_price_cents)} to ${usd(s.tier_max_price_cents)}). The split below is the same at any price, and a subscription renews at the price it was bought at.</p>
<ul>
<ul>
<li>Paid through the creator's own PowerChat: the creator is paid directly by PowerChat, and OpenVibe adds no fee.</li>
<li>Paid through the creator's own PowerChat: the creator is paid directly by PowerChat, and OpenVibe adds no fee.</li>
<li>Paid through OpenVibe's PowerChat account: a routing fee of ${pct(s.site_route_fee_pct)} (${usd(s.site_route_fee_cents)}) is added on top of the price, and the creator receives ${pct(s.creator_share_pct)} of the price (${usd(s.creator_share_cents)}) as Vibes they can cash out. The routing fee is not part of the split.</li>
<li>Paid through OpenVibe's PowerChat account: a routing fee of ${pct(s.site_route_fee_pct)} (${usd(s.site_route_fee_cents)}) is added on top of the price, and the creator receives ${pct(s.creator_share_pct)} of the price (${usd(s.creator_share_cents)}) as Vibes they can cash out. The routing fee is not part of the split.</li>
@@ -15,7 +15,7 @@ const { policyData } = require('../server/policy');
@@ -15,7 +15,7 @@ const { policyData } = require('../server/policy');
], 'tiers start at the minimum purchase and cover every amount once');
], 'tiers start at the minimum purchase and cover every amount once');
assert.deepStrictEqual(d.purchase.tiers.map((x) => x.openvibe_keeps_pct), [33.3, 28.6, 23.1, 19.4, 16.7, 13, 9.1]);
assert.deepStrictEqual(d.purchase.tiers.map((x) => x.openvibe_keeps_pct), [33.3, 28.6, 23.1, 19.4, 16.7, 13, 9.1]);
assert.ok(d.purchase.tiers.every((x) => x.creator_value_per_100_cents === 100), 'a creator gets $1.00 per 100 Vibes at every tier');
assert.ok(d.purchase.tiers.every((x) => x.creator_value_per_100_cents === 100), 'a creator gets $1.00 per 100 Vibes at every tier');
- assert.deepStrictEqual(d.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 });
+ assert.deepStrictEqual(d.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, tier_min_price_cents: 100, tier_max_price_cents: 50000, max_tiers: 10 });
assert.deepStrictEqual(d.cashout, { min_vibes: 500, min_cents: 500, hold_days: 14 });
assert.deepStrictEqual(d.cashout, { min_vibes: 500, min_cents: 500, hold_days: 14 });
assert.strictEqual(d.currencies.opencoins.withdrawable, false, 'OpenCoins are never withdrawable');
assert.strictEqual(d.currencies.opencoins.withdrawable, false, 'OpenCoins are never withdrawable');
assert.strictEqual(d.currencies.channel_points.withdrawable, false);
assert.strictEqual(d.currencies.channel_points.withdrawable, false);
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * Creator-priced subscription tiers (openvibe-contracts 0.201.0, billing.tier.manage; server/ops/tiers.js): the product
+ * that sells memberships sets each tier's monthly price within Billing's bounds, and Billing charges it on a credit
+ * period, a PowerChat checkout (the tier rides on the intent) and renewals (at the price bought at). Tier 1 without a
+ * price stays the site price; an unknown or archived tier is not for sale; no switching tiers mid-period.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, fund, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ env: { BILLING_RENEWAL_GRACE_DAYS: '0' } });
+ const creator = t.user(41);
+ const fan = t.user(42);
+ const other = t.user(43);
+ console.log('subscription tiers');
+ const put = (tier, body, cap = ['billing.tier.manage']) => t.call('PUT', `/api/v1/tiers/${creator.id}/${tier}`, { cap, body, headers: { 'Idempotency-Key': `tier-${crypto.randomUUID()}` } });
+ const list = (cap = ['billing.tier.manage']) => t.call('GET', `/api/v1/tiers/${creator.id}`, { cap });
+
+ await check('no tiers yet: tier 1 is the site price; the bounds are published', async () => {
+ const r = await list(['billing.intent.create']);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json.tiers, []);
+ assert.deepStrictEqual([r.json.default_price_cents, r.json.min_price_cents, r.json.max_price_cents, r.json.max_tiers], [499, 100, 50000, 10]);
+ const rates = await t.call('GET', '/api/v1/rates', { token: null });
+ assert.deepStrictEqual(rates.json.subscription.tiers, { min_price_cents: 100, max_price_cents: 50000, max_tiers: 10 });
+ });
+
+ await check('setting a price needs billing.tier.manage, an Idempotency-Key and a price within the bounds', async () => {
+ assert.strictEqual((await put(2, { price_cents: 1500 }, ['billing.intent.create'])).status, 403);
+ const noKey = await t.call('PUT', `/api/v1/tiers/${creator.id}/2`, { cap: ['billing.tier.manage'], body: { price_cents: 1500 } });
+ assert.strictEqual(noKey.status, 400, noKey.text);
+ assert.strictEqual((await put(2, { price_cents: 99 })).json.code, 'billing.invalid_amount');
+ assert.strictEqual((await put(2, { price_cents: 50001 })).json.code, 'billing.invalid_amount');
+ assert.strictEqual((await put(11, { price_cents: 1500 })).json.code, 'billing.invalid_input');
+ assert.strictEqual((await put(2, { price_cents: 1500, status: 'paused' })).json.code, 'billing.invalid_input');
+ const ok = await put(2, { price_cents: 1500 });
+ assert.strictEqual(ok.status, 200, ok.text);
+ assert.deepStrictEqual([ok.json.tier.tier, ok.json.tier.price_cents, ok.json.tier.status], [2, 1500, 'active']);
+ assert.strictEqual((await put(1, { price_cents: 300 })).status, 200);
+ assert.deepStrictEqual((await list()).json.tiers.map((x) => [x.tier, x.price_cents]), [[1, 300], [2, 1500]]);
+ });
+
+ await check('a credit period costs the tier price; the share follows it; the entitlement names the tier', async () => {
+ await fund(t, fan, 3000);
+ const r = await t.call('POST', '/api/v1/subscriptions', { cap: ['billing.subscription.manage'], body: { subscriber: fan, streamer: creator, source: 'credit', tier: 2 } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.subscription.tier, 2);
+ assert.strictEqual(r.json.subscription.price_cents, 1500);
+ const m = r.json.transaction.metadata;
+ assert.deepStrictEqual([m.tier, m.cost_bits, m.share_bits], [2, 1500, 1050]);
+ assert.strictEqual(r.json.entitlement.tier, 2);
+ const e = await t.call('GET', `/api/v1/entitlements/${fan.id}?streamer=${creator.id}`, { cap: ['billing.entitlement.check'] });
+ assert.deepStrictEqual([e.json.active, e.json.tier], [true, 2]);
+ await t.assertReconciled('after a tier-2 period');
+ });
+
+ await check('no switching tiers while a period runs (credit or checkout); the same tier renews early as before', async () => {
+ const sw = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: fan, streamer: creator, source: 'credit', tier: 1 } });
+ assert.strictEqual(sw.status, 409, sw.text);
+ assert.strictEqual(sw.json.code, 'billing.tier_change_unsupported');
+ const ck = await t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'subscription', subject: fan, streamer: creator, tier: 1 } });
+ assert.strictEqual(ck.json.code, 'billing.tier_change_unsupported');
+ const again = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: fan, streamer: creator, source: 'credit', tier: 2 } });
+ assert.strictEqual(again.status, 201, again.text);
+ assert.strictEqual(again.json.transaction.metadata.renewal, true);
+ });
+
+ await check('a PowerChat checkout is priced by the tier and grants it on settlement', async () => {
+ const i = await t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'subscription', subject: other, streamer: creator, tier: 2, route: 'site' } });
+ assert.strictEqual(i.status, 201, i.text);
+ assert.deepStrictEqual([i.json.intent.tier, i.json.intent.amount_cents, i.json.intent.fee_cents], [2, 1650, 150]);
+ const r = await t.powerchat({ type: 'donation.completed', streamer: { id: 'pc1', username: 'openvibe' }, data: { eventId: `don-${crypto.randomUUID()}`, amountUsdCents: 1650, appExternalRef: i.json.intent.checkout_ref } });
+ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
+ const e = await t.call('GET', `/api/v1/entitlements/${other.id}?streamer=${creator.id}`);
+ assert.deepStrictEqual([e.json.active, e.json.tier], [true, 2]);
+ const s = await t.call('GET', `/api/v1/subscriptions/${e.json.subscription.id}`);
+ assert.deepStrictEqual([s.json.subscription.tier, s.json.subscription.price_cents], [2, 1500]);
+ await t.assertReconciled('after a tier-2 checkout');
+ });
+
+ await check('unknown and archived tiers are not for sale; tier 1 keeps its price', async () => {
+ const nobody = t.user(44);
+ await fund(t, nobody, 2000);
+ const unknown = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: nobody, streamer: creator, source: 'credit', tier: 3 } });
+ assert.strictEqual(unknown.json.code, 'billing.tier_unknown');
+ assert.strictEqual((await put(2, { price_cents: 1500, status: 'archived' })).status, 200);
+ const archived = await t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'subscription', subject: nobody, streamer: creator, tier: 2 } });
+ assert.strictEqual(archived.json.code, 'billing.tier_archived');
+ const one = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: nobody, streamer: creator, source: 'credit' } });
+ assert.strictEqual(one.status, 201, one.text);
+ assert.deepStrictEqual([one.json.subscription.tier, one.json.subscription.price_cents, one.json.transaction.metadata.cost_bits], [1, 300, 300]);
+ });
+
+ await check('a renewal keeps the tier and the price it was bought at, even after the creator changes it', async () => {
+ assert.strictEqual((await put(2, { price_cents: 2500 })).status, 200);
+ const sub = (await t.call('GET', `/api/v1/subscriptions?subscriber=${fan.id}&streamer=${creator.id}`)).json.subscriptions[0];
+ await fund(t, fan, 1500);
+ t.clock.offset = Date.parse(sub.current_period_end) - Date.now() + 1000;
+ const { sweep } = require('../server/ops/subscriptions');
+ const out = await sweep(t.ctx);
+ assert.ok(out.renewed.includes(sub.id), JSON.stringify(out));
+ const after = (await t.call('GET', `/api/v1/subscriptions/${sub.id}`)).json.subscription;
+ assert.deepStrictEqual([after.tier, after.price_cents], [2, 1500]);
+ await t.assertReconciled('after a tier-2 renewal');
+ });
+
+ await t.close();
+ done();
+})();