openvibers/openvibe.vip Commits
Tiers: one plan per tier (1-10), each priced by the creator on Billing (billing.tier.manage); pages, card and API show each tier's price; checkout buys the plan's tier; Billing's tier files memberships made elsewhere (#68)
Changes
@@@ -109,10 +109,16 @@ The three Events subscriptions exist.often the plan changes; a new purchase after the membership lapsed takes the current version.- Perk rules evaluate the perks of the member's version, not the plan's latest.- Renaming a perk never changes published terms (each version keeps its snapshot).--- Billing sells one channel subscription per (member, creator) today, so a creator has at most **one-- published plan sold through Billing** (a unique index; a second is refused with-- `409 vip.plan.billing_product_taken`). Tiers and network memberships need a Billing product first:-- a network plan (staff) can be published as terms, but checkout answers `409 vip.checkout.unavailable`.++- **Tiers** (since 2026-10-17, openvibe-contracts 0.201.0): a plan sold through Billing is one tier of the creator's++ membership (`billing_tier` 1–10, chosen at creation, else the next free one). A creator publishes at most **one plan++ per tier** (a unique index; a second is refused with `409 vip.plan.billing_product_taken`) and sets each tier's++ monthly price on Billing (`server/domain/prices.js`, `billing.tier.manage`: the dashboard's "Set price", or++ `price_cents` on `POST /plans` / `PATCH /plans/:id`). VIP never keeps a price. Tier 1 without a price sells at++ Billing's default; a higher tier without one cannot be joined. A member holds one tier of a creator at a time++ (Billing refuses a second while a period runs); a subscription made elsewhere (Live's) is tier 1, and Billing's++ `tier` on `billing.entitlement.changed` files a membership made without a checkout under that tier's plan.++ Network memberships still need a Billing product: a network plan (staff) can be published as terms, but checkout++ answers `409 vip.checkout.unavailable`.- Archiving stops new members; existing members keep their terms until Billing ends the membership.### The entitlement projection (Billing is the truth)@@@ -206,8 +212,8 @@ handle or `network`.| `GET /creators/:ref/card` | — (public) | — | the membership card (below), the same as `/embed/:username/card.json` || `GET /plans?creator=&include=drafts,archived` | `vip.plan.list` for drafts | owner | plans with current/draft versions || `GET /plans/:id`, `GET /plans/:id/versions` | `vip.plan.list` for drafts | owner | a plan; its version history (published versions are public) |--| `POST /plans` | `vip.plan.create` (body `creator`) | self / staff for `network` | create (v1), optionally `publish` |--| `PATCH /plans/:id` | `vip.plan.update` | owner | edit → new version |++| `POST /plans` | `vip.plan.create` (body `creator`) | self / staff for `network` | create (v1) at `tier` (else the next free one), optionally `publish` and `price_cents` (the tier's monthly price, set on Billing) |++| `PATCH /plans/:id` | `vip.plan.update` | owner | edit → new version; `price_cents` sets the tier's price on Billing (not a term: no new version) || `POST /plans/:id/publish` | `vip.plan.update` | owner | publish the latest version || `POST /plans/:id/archive` | `vip.plan.archive` | owner | archive || `GET /perks?creator=&network=&retired=` | `vip.perk.list` for retired | owner | perks with product bindings |@@@ -30,11 +30,12 @@],"not_yet": ["no membership sold yet: the first real purchase through Billing (PowerChat or Vibes credit) is the end-to-end proof still due",-- "Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product",-- "revocations reach Chat and Community through vip.membership.changed subscriptions (2026-09-24); Blog and Live still converge by the cache TTL (30-60 s)"++ "revocations reach Chat and Community through vip.membership.changed subscriptions (2026-09-24); Blog and Live still converge by the cache TTL (30-60 s)",++ "no network membership product on Billing yet (OpenVibe Premium sold on openvibe.vip): a network plan is terms only"],"features": ["plans with immutable versions, perks and product bindings",++ "tiers: one plan per tier (1-10), each priced by the creator on Billing (billing.tier.manage); a member holds one tier at a time","entitlement projection from Billing events with valid_until, grace and a Billing fallback; unknown never authorizes","checkout and cancel through OpenVibe.Billing","gated-resource policy evaluation for products (fails closed)",nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- Tiers (owner's ask: creator memberships with tiers; openvibe-contracts 0.201.0): a plan sold through Billing names the++-- tier it is sold as, and Billing prices each tier (billing.tier.manage, ADR-012). A creator now publishes one plan per++-- tier instead of one plan in all. Every existing plan is tier 1, which Billing sells at its default price until the++-- creator sets one, so nothing a member bought changes.++ALTER TABLE vip_plans ADD COLUMN billing_tier integer NOT NULL DEFAULT 1 CHECK (billing_tier BETWEEN 1 AND 10);++CREATE UNIQUE INDEX idx_vip_plans_one_per_tier++ ON vip_plans (creator_id, billing_kind, billing_tier) WHERE status = 'published' AND billing_kind IS NOT NULL;++DROP INDEX idx_vip_plans_one_billing_product;@@@ -38,6 +38,9 @@ form input:not([type=checkbox]):not([type=hidden]), form textarea, form select {form label.check { display: flex; align-items: center; gap: 8px; }form.inline { display: inline-block; margin: 4px 6px 4px 0; }form.join { display: grid; gap: 4px; max-width: 420px; }++form.price-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 8px 0; }++form.price-form label { margin: 0; }++form.price-form input:not([type=hidden]) { width: 9em; }fieldset { border: 1px solid var(--vip-border); border-radius: 8px; margin: 12px 0; }details > summary { cursor: pointer; font-weight: 600; margin: 6px 0; }.notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }@@@ -100,7 +100,12 @@ function v1Router({ domain, apiAuth, limits }) {return userSubject(value, 'subject');}-- const presentPlanFull = async (plan) => ({ ...await plans.present(plan), creator: creators.present(await creators.byId(plan.creator_id)) });++ // Plans as presented, with each tier's price from Billing (domain/prices.js) when the creator sells through it.++ const priced = async (c, list) => (c && c.kind === 'creator' && c.subject ? await domain.prices.withPrices(c.subject, list) : list);++ const presentPlanFull = async (plan) => {++ const c = await creators.byId(plan.creator_id);++ return { ...(await priced(c, [await plans.present(plan)]))[0], creator: creators.present(c) };++ };const trace = (req) => req.ov && req.ov.traceparent;// ── Creators (public) ────────────────────────────────────@@@ -110,7 +115,7 @@ function v1Router({ domain, apiAuth, limits }) {const own = mayManage(req.principal, c, CAP.planList);res.json({creator: creators.present(c),-- plans: (await Promise.all((await plans.list({ creatorId: c.id, includeDrafts: own })).map(async (p) => await plans.present(p)))),++ plans: await priced(c, (await Promise.all((await plans.list({ creatorId: c.id, includeDrafts: own })).map(async (p) => await plans.present(p))))),perks: (await Promise.all((await perks.list({ creatorId: c.id, includeNetwork: false })).map(async (p) => await perks.present(p)))),});}));@@@ -129,7 +134,7 @@ function v1Router({ domain, apiAuth, limits }) {const include = String(req.query.include || '').split(',');const own = mayManage(req.principal, c, CAP.planList);const list = await plans.list({ creatorId: c.id, includeDrafts: own && include.includes('drafts'), includeArchived: own && include.includes('archived') });-- res.json({ creator: creators.present(c), plans: (await Promise.all(list.map(async (p) => await plans.present(p)))) });++ res.json({ creator: creators.present(c), plans: await priced(c, (await Promise.all(list.map(async (p) => await plans.present(p))))) });}));const visiblePlan = async (req, id) => {const plan = await plans.byId(id);@@@ -150,8 +155,9 @@ function v1Router({ domain, apiAuth, limits }) {const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;const plan = await plans.create({creatorId: c.id, slug: b.slug, name: b.name, description: b.description, benefits: b.benefits, perks: b.perks,-- billingKind: b.billing_kind, publish: bool(b.publish), changeNote: b.change_note, actor, traceparent: trace(req),++ billingKind: b.billing_kind, tier: b.tier, publish: bool(b.publish), changeNote: b.change_note, actor, traceparent: trace(req),});++ if (b.price_cents != null && plan.billing_kind) await domain.prices.set(c, plan, b.price_cents, { traceparent: trace(req) });res.status(201).json({ plan: await presentPlanFull(plan) });}));const managedPlan = async (req, cap) => {@@@ -164,6 +170,8 @@ function v1Router({ domain, apiAuth, limits }) {const plan = await managedPlan(req, CAP.planUpdate);const b = req.body || {};const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ // price_cents: the plan's tier price, kept and charged by Billing (not a plan term, so no new version).++ if (b.price_cents != null) await domain.prices.set(await creators.byId(plan.creator_id), plan, b.price_cents, { traceparent: trace(req) });const out = await plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });res.json({ plan: await presentPlanFull(out.plan), version: await plans.presentVersion(out.version), unchanged: out.unchanged });}));@@@ -27,22 +27,27 @@ const CAPS = {intent: 'billing.intent.create',subscription: 'billing.subscription.manage',entitlement: 'billing.entitlement.check',++ tier: 'billing.tier.manage',};function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClients = null } = {}) {const commerce = createCommerceClient(config, {fetchImpl,-- caps: { intent: CAPS.intent, subscription: CAPS.subscription, entitlement: CAPS.entitlement },++ caps: { intent: CAPS.intent, subscription: CAPS.subscription, entitlement: CAPS.entitlement, tier: CAPS.tier },tokenClients,});return {/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */-- createIntent: async ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => await commerce.createIntent({-- provider, kind: 'subscription', subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent,++ createIntent: async ({ provider, subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent }) => await commerce.createIntent({++ provider, kind: 'subscription', subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent,}),-- /** { subscription, entitlement, transaction } — one period paid from the member's credit. */-- subscribeWithCredit: async ({ subscriber, creator, autoRenew, key, traceparent }) => await commerce.subscribeWithCredit({ subscriber, creator, autoRenew, key, traceparent }),++ /** { subscription, entitlement, transaction } — one period paid from the member's credit, at the tier's price. */++ subscribeWithCredit: async ({ subscriber, creator, tier, autoRenew, key, traceparent }) => await commerce.subscribeWithCredit({ subscriber, creator, tier, autoRenew, key, traceparent }),++ /** A creator's tier prices on Billing: { tiers: [{ tier, price_cents, status }], default_price_cents, min/max_price_cents, max_tiers }. */++ listTiers: async (creator) => await commerce.listTiers(creator),++ /** { tier } — set one tier's monthly price (billing.tier.manage). */++ setTier: async ({ creator, tier, priceCents, status, key, traceparent }) => await commerce.setTier({ creator, tier, priceCents, status, key, traceparent }),cancelSubscription: async ({ id, key, traceparent }) => await commerce.cancelSubscription({ id, key, traceparent }),getSubscription: async (id) => await commerce.getSubscription(id),listSubscriptions: async ({ streamer, subscriber, status } = {}) => await commerce.listSubscriptions({ streamer, subscriber, status }),@@@ -4,8 +4,9 @@* The public face of a creator's memberships, reused by other products and other sites (roadmap* §11.3 "reusable membership badge/card/widget endpoints"; rendered by server/web/embeds.js):*-- * card(creator) plan names and terms (the current published version), perks, the join and-- * terms links, Billing's price, the member count, and the badge and widget URLs++ * card(creator) plan names and terms (the current published version), perks, each plan's tier and++ * Billing's price for it (domain/prices.js), the join and terms links, the member++ * count, and the badge and widget URLs* badge(creator) what a member's badge reads: the name of the badge perk in the plan new members* buy ("Subscriber badge" → "Subscriber"), else "Member"*@@@ -20,7 +21,7 @@*/const BADGE_SUFFIX = /\s+badge$/i;--function createCards({ db, config, creators, plans, perks, billing, now, log = console }) {++function createCards({ db, config, creators, plans, perks, billing, prices = null, now, log = console }) {const counts = new Map(); // creator id → { at, count, stale, pending }let rates = { at: -Infinity, value: null };@@@ -61,9 +62,10 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = cconst kindOf = async (perkId) => { const p = await perks.byId(perkId); return p ? p.kind : 'other'; };-- /** The published plans of a creator, each at the version new members buy. */++ /** The published plans of a creator, each at the version new members buy, with its tier's price. */async function publicPlans(c) {-- return (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p)))).filter((p) => p.current_version);++ const list = (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p)))).filter((p) => p.current_version);++ return prices ? await prices.withPrices(c.subject, list) : list;}async function badge(c, list = null) {@@@ -99,14 +101,16 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = creturn {id: p.id, slug: p.slug, name: v.name, description: v.description, benefits: v.benefits, version: v.version,perks: (await Promise.all(v.perks.map(async (x) => ({ key: x.key, name: x.name, kind: await kindOf(x.id) })))),-- purchasable: p.purchasable,++ purchasable: p.purchasable, tier: p.tier,++ price_cents: p.price_cents != null ? p.price_cents : null,join_url: `${u.page}#plan-${encodeURIComponent(p.slug)}`,terms_url: `${u.page}/plans/${encodeURIComponent(p.slug)}`,};}))),join_url: u.page,joining_open: joiningOpen,-- price: sub ? { price_cents: sub.price_cents, period_days: sub.period_days, charged_by: 'OpenVibe.Billing' } : null,++ // The lowest price a new member can join at (one plan per tier, each priced by Billing).++ price: sub ? { price_cents: lowest(list, sub), period_days: sub.period_days, charged_by: 'OpenVibe.Billing' } : null,members: { count: members.count, shown: members.shown, stale: members.stale },badge: { ...await badge(c, list), svg_url: u.badge },widget_url: u.widget,@@@ -114,6 +118,11 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c};}++ function lowest(list, sub) {++ const priced = list.filter((p) => p.purchasable && p.price_cents != null).map((p) => p.price_cents);++ return priced.length ? Math.min(...priced) : sub.price_cents;++ }++return { card, badge, memberCount, publicPlans, urls };}@@@ -24,6 +24,8 @@ function billingProblem(err) {if (code === 'billing.insufficient_funds') fail(409, 'vip.checkout.insufficient_credit', 'your Vibes credit does not cover this membership');if (code === 'billing.provider_disabled' || code === 'billing.invalid_input') fail(409, 'vip.checkout.provider_unavailable', 'that payment method is not available right now');if (code === 'billing.self_dealing') fail(422, 'vip.checkout.self', 'you cannot join your own plan');++ if (code === 'billing.tier_change_unsupported') fail(409, 'vip.checkout.other_tier', 'you already hold another tier of this creator; cancel it and join this one when it ends');++ if (code === 'billing.tier_unknown' || code === 'billing.tier_archived') fail(409, 'vip.checkout.unavailable', 'this plan has no price on Billing yet, so it cannot be joined');if (err.status === 403 || err.status === 401) fail(503, 'vip.billing_unavailable', 'VIP is not allowed to reach Billing for this yet');fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; nothing was charged');}@@@ -60,7 +62,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlementif (p === 'credit') {let out;try {-- out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, autoRenew, key: `vip:checkout:${id}`, traceparent });++ out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew, key: `vip:checkout:${id}`, traceparent });} catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }const sub = out.subscription || {};const ent = out.entitlement || {};@@@ -70,7 +72,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlementawait entitlements.apply({member, creator: creator.subject, active: !!ent.active, expiresAt: ent.expires_at || sub.current_period_end || null,cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id || null, subscriptionStatus: sub.status || null,-- reason: 'granted', source: 'billing_check', asOf: now(), checkoutId: id, traceparent,++ reason: 'granted', source: 'billing_check', asOf: now(), checkoutId: id, tier: ent.tier, traceparent,});const txn = out.transaction && out.transaction.id;if (txn) {@@@ -86,7 +88,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlementlet out;try {out = await billing.createIntent({-- provider: p, subject: member, creator: creator.subject, autoRenew,++ provider: p, subject: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew,successUrl: successUrl || `${config.baseUrl}/me?joined=${encodeURIComponent(creator.username || '')}`,cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,key: `vip:checkout:${id}`, traceparent,@@@ -60,7 +60,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla* Apply what Billing said about (member, creator). Runs in a transaction (joins the caller's).* input: { member, creator, kind?, active, expiresAt, cancelAtPeriodEnd?, subscriptionId?,* subscriptionStatus?, graceUntil?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),-- * origin?, checkoutId?, traceparent? }++ * origin?, checkoutId?, tier? (Billing's, for filing a membership made elsewhere under the plan of that tier), traceparent? }*/async function apply(input) {return await db.tx(async () => {@@@ -97,7 +97,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, plasubscription_status = excluded.subscription_status, grace_until = excluded.grace_until, source = excluded.source, last_reason = excluded.last_reason,billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,valid_until = excluded.valid_until`).run(next);-- const { membership } = await memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });++ const { membership } = await memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId, tier: input.tier });// A first look that finds no membership is not a change anyone needs to hear about.const changed = prev? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end@@@ -10,6 +10,7 @@ const { createEntitlements } = require('./entitlements');const { createPolicies } = require('./policies');const { createCheckout } = require('./checkout');const { createCards } = require('./cards');++const { createPrices } = require('./prices');function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console, indexnow = null }) {const creators = createCreators({ db, now });@@@ -20,9 +21,10 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), logconst entitlements = createEntitlements({ db, now, config, billing, outbox, memberships, plans, log });const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements, periods });-- const cards = createCards({ db, config, creators, plans, perks, billing, now, log });++ const prices = createPrices({ config, billing, now });++ const cards = createCards({ db, config, creators, plans, perks, billing, prices, now, log });const tx = async (fn) => await db.tx(fn);-- return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards };++ return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards, prices };}module.exports = { createDomain };@@@ -27,7 +27,7 @@ function createMemberships({ db, now, creators, plans }) {}/** Record the version a membership is under. Inside the projection's transaction. */-- async function onProjection({ prev, next, reason, origin = null, checkoutId = null }) {++ async function onProjection({ prev, next, reason, origin = null, checkoutId = null, tier = null }) {const creator = await creators.bySubject(next.creator_subject) || await creators.ensure({ subject: next.creator_subject, origin: 'system' });const member = next.member_subject;let m = await get(member, creator.id);@@@ -54,7 +54,7 @@ function createMemberships({ db, now, creators, plans }) {planId = checkout.plan_id; versionId = checkout.plan_version_id; how = 'checkout';await db.prepare("UPDATE vip_checkouts SET status = 'used', billing_subscription_id = COALESCE(billing_subscription_id, ?), updated_at = ? WHERE id = ?").run(next.subscription_id || null, at, checkout.id);} else {-- const plan = await plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind);++ const plan = await plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind, tier || 1);if (plan) { planId = plan.id; versionId = plan.current_version_id; }}if (m) {@@@ -9,15 +9,17 @@* members buy; members who joined earlier keep the version they bought (vip_memberships).** Prices are not plan terms here: Billing prices and charges a subscription, and records what each-- * period cost. A plan says which Billing product it is sold as (billing_kind), and Billing sells one-- * channel subscription per (member, creator) today, so a creator has at most one published plan-- * bound to it. A plan with no Billing product (a network plan today) can be published as terms, but-- * has no checkout until Billing sells it.++ * period cost. A plan says which Billing product it is sold as (billing_kind) and at which tier++ * (billing_tier, 1 to MAX_TIER, fixed when the plan is created): a creator publishes at most one plan per tier,++ * and sets each tier's monthly price on Billing (domain/prices.js, billing.tier.manage). A member holds one++ * tier of a creator at a time. A plan with no Billing product (a network plan today) can be published as++ * terms, but has no checkout until Billing sells it.*/const crypto = require('crypto');const { fail, prefixedId, iso, text, slug, slugify, json } = require('../util');const BILLING_KINDS = ['channel_subscription'];++const MAX_TIER = 10;function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow = null }) {const byId = async (id) => await db.prepare('SELECT * FROM vip_plans WHERE id = ?').get(id) || null;@@@ -55,7 +57,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnowconst at = iso(now());const id = prefixedId('vpv', now());const perkSnap = perkRows.map((p) => ({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator' }));-- const terms = { name, description, benefits, perks: perkSnap, billing_kind: plan.billing_kind, price: plan.billing_kind ? 'set and charged by OpenVibe.Billing' : null };++ const terms = { name, description, benefits, perks: perkSnap, billing_kind: plan.billing_kind, ...(plan.billing_kind ? { tier: Number(plan.billing_tier) || 1 } : {}), price: plan.billing_kind ? 'set and charged by OpenVibe.Billing' : null };terms.digest = crypto.createHash('sha256').update(JSON.stringify([terms.name, terms.description, terms.benefits, terms.perks, terms.billing_kind])).digest('hex').slice(0, 16);await db.prepare(`INSERT INTO vip_plan_versions (id, plan_id, version, name, description, benefits, terms, change_note, created_by, created_at, published_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, plan.id, n, name, description, JSON.stringify(benefits), JSON.stringify(terms), changeNote || null, actor, at, publish ? at : null);@@@ -82,14 +84,26 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow}function publishConflict(e) {-- // PostgreSQL unique_violation (23505) on the one-published-plan-per-billing-kind index of vip_plans.-- if (e && e.code === '23505' && e.table === 'vip_plans' && /\(creator_id, billing_kind\)/.test(e.detail || '')) {-- fail(409, 'vip.plan.billing_product_taken', 'this creator already has a published plan sold as a channel subscription; Billing sells one per member and creator today, so archive the other plan first');++ // PostgreSQL unique_violation (23505) on the one-published-plan-per-tier index of vip_plans.++ if (e && e.code === '23505' && e.table === 'vip_plans' && /\(creator_id, billing_kind, billing_tier\)/.test(e.detail || '')) {++ fail(409, 'vip.plan.billing_product_taken', 'another published plan is already sold at this tier; archive it first, or create this plan at another tier');}throw e;}-- async function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, publish = false, changeNote, actor = null, traceparent }) {++ /** A tier from a form or the API: 1..MAX_TIER; when absent, the creator's lowest tier no live plan uses. */++ async function tierFor(creatorId, value) {++ if (value != null && value !== '') {++ const n = Number(value);++ if (!Number.isSafeInteger(n) || n < 1 || n > MAX_TIER) fail(422, 'vip.invalid_input', `tier must be a whole number from 1 to ${MAX_TIER}`);++ return n;++ }++ const used = new Set((await db.prepare("SELECT billing_tier FROM vip_plans WHERE creator_id = ? AND status <> 'archived' AND billing_kind IS NOT NULL").all(creatorId)).map((r) => Number(r.billing_tier)));++ for (let t = 1; t <= MAX_TIER; t++) if (!used.has(t)) return t;++ fail(409, 'vip.plan.tiers_full', `a creator sells at most ${MAX_TIER} tiers; archive a plan first`);++ }++++ async function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, tier, publish = false, changeNote, actor = null, traceparent }) {const creator = await creators.byId(creatorId);if (!creator) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);const kind = billingKind === undefined ? (creator.kind === 'network' ? null : 'channel_subscription') : (billingKind || null);@@@ -99,12 +113,13 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnowconst planSlug = s ? slug(s) : slugify(cleanName);if (await db.prepare('SELECT 1 FROM vip_plans WHERE creator_id = ? AND slug = ?').get(creatorId, planSlug)) fail(409, 'vip.plan_exists', `a plan ${planSlug} already exists`);const perkRows = await perks.resolveForPlan(creatorId, perkRefs || []);++ const billingTier = kind ? await tierFor(creatorId, tier) : 1;const at = iso(now());const id = prefixedId('vpl', now());try {const plan = await db.tx(async () => {-- await db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, current_version_id, latest_version, created_by, created_at, updated_at)-- VALUES (?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(id, creatorId, planSlug, publish ? 'published' : 'draft', kind, actor, at, at);++ await db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, billing_tier, current_version_id, latest_version, sort, created_by, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, NULL, 0, ?, ?, ?, ?)`).run(id, creatorId, planSlug, publish ? 'published' : 'draft', kind, billingTier, billingTier, actor, at, at);const v = await insertVersion(await byId(id), {name: cleanName, description: text(description, 'description', 2000), benefits: cleanBenefits(benefits) || [],perkRows, changeNote: changeNote || 'created', actor, publish,@@@ -180,8 +195,8 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnowreturn await db.prepare(`SELECT * FROM vip_plans WHERE creator_id = ? AND status IN (${st.map(() => '?').join(',')}) ORDER BY sort, created_at`).all(creatorId, ...st);}-- /** What a new member of this creator buys through Billing's product `kind`. */-- const purchasable = async (creatorId, kind = 'channel_subscription') => await db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND status = 'published'").get(creatorId, kind) || null;++ /** What a new member of this creator buys through Billing's product `kind` at `tier` (a subscription made elsewhere, such as Live's, is tier 1). */++ const purchasable = async (creatorId, kind = 'channel_subscription', tier = 1) => await db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND billing_tier = ? AND status = 'published'").get(creatorId, kind, Number(tier) || 1) || null;async function presentVersion(v) {if (!v) return null;@@@ -198,6 +213,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnowconst latest = await latestVersion(plan.id);return {id: plan.id, creator_id: plan.creator_id, slug: plan.slug, status: plan.status, billing_kind: plan.billing_kind,++ tier: plan.billing_kind ? Number(plan.billing_tier) || 1 : null,purchasable: plan.status === 'published' && !!plan.billing_kind,current_version: await presentVersion(current),draft_version: latest && (!current || latest.version > current.version) ? await presentVersion(latest) : null,@@@ -206,7 +222,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow};}-- return { byId, version, latestVersion, versions, versionPerks, create, update, publish, archive, list, purchasable, present, presentVersion, BILLING_KINDS };++ return { byId, version, latestVersion, versions, versionPerks, create, update, publish, archive, list, purchasable, present, presentVersion, tierFor, BILLING_KINDS, MAX_TIER };}module.exports = { createPlans };nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++++/**++ * A creator's tier prices, kept by OpenVibe.Billing (billing.tier.manage, openvibe-contracts 0.201.0; ADR-012: Billing++ * prices, VIP never does). Each plan sold through Billing names its tier (plans.billing_tier); the creator sets that++ * tier's monthly price here and Billing charges it. Tier 1 without a price is sold at Billing's default price; a++ * higher tier without one is not for sale until the creator prices it.++ *++ * forCreator(subject) { tiers: Map(tier → { price_cents, status }), defaultCents, minCents, maxCents } or++ * null when Billing does not answer (pages then say the price is shown at checkout).++ * Cached per creator (config.billing.ratesTtlMs), dropped when the creator sets one.++ * priceOf(table, tier) the price new members pay for a tier, or null when it is not for sale++ * set(creator, plan, cents) set the plan's tier price on Billing++ */++const { fail, prefixedId } = require('../util');++const { BillingCallError } = require('../billing-client');++++function createPrices({ config, billing, now }) {++ const cache = new Map();++++ async function forCreator(subject) {++ if (!subject) return null;++ const hit = cache.get(subject);++ if (hit && now() - hit.at < config.billing.ratesTtlMs) return hit.value;++ let value = null;++ try {++ const out = await billing.listTiers(subject);++ value = {++ tiers: new Map((out.tiers || []).map((t) => [Number(t.tier), { price_cents: Number(t.price_cents), status: t.status }])),++ defaultCents: Number(out.default_price_cents) || null, minCents: Number(out.min_price_cents) || null, maxCents: Number(out.max_price_cents) || null,++ };++ cache.set(subject, { at: now(), value });++ } catch {++ // Billing did not answer: keep the last answer a little longer rather than show nothing.++ value = hit ? hit.value : null;++ cache.set(subject, { at: now() - config.billing.ratesTtlMs + 30_000, value });++ }++ return value;++ }++++ function priceOf(table, tier) {++ if (!table) return null;++ const t = Number(tier) || 1;++ const row = table.tiers.get(t);++ if (row) return row.status === 'active' ? row.price_cents : null;++ return t === 1 ? table.defaultCents : null;++ }++++ /** Presented plans with `price_cents` (null when unknown or unpriced); a higher tier without a price is not purchasable. */++ async function withPrices(creatorSubject, plans) {++ const table = await forCreator(creatorSubject);++ return plans.map((p) => {++ if (!p || !p.billing_kind) return p;++ const price = priceOf(table, p.tier);++ const unpriced = !!table && price == null;++ return { ...p, price_cents: price, purchasable: p.purchasable && !unpriced };++ });++ }++++ /** The creator sets their plan's tier price (USD cents a month). */++ async function set(creator, plan, priceCents, { traceparent } = {}) {++ if (!plan || !plan.billing_kind) fail(409, 'vip.plan.not_sold', 'this plan is not sold through Billing, so it has no price');++ if (!creator || !creator.subject) fail(409, 'vip.plan.not_sold', 'this creator cannot sell through Billing');++ const cents = Number(priceCents);++ if (!Number.isSafeInteger(cents) || cents <= 0) fail(422, 'vip.invalid_input', 'price must be an amount in dollars and cents, like 4.99');++ try {++ // A fresh key per change: the same price set twice is two calls, and a retry of one call is the same call.++ const out = await billing.setTier({ creator: creator.subject, tier: Number(plan.billing_tier) || 1, priceCents: cents, key: `vip:tier:${prefixedId('vtp', now())}`, traceparent });++ cache.delete(creator.subject);++ return out.tier;++ } catch (e) {++ if (!(e instanceof BillingCallError)) throw e;++ if (e.code === 'billing.invalid_amount' || e.code === 'billing.invalid_input') fail(422, 'vip.invalid_input', (e.body && e.body.detail) || 'Billing refused that price');++ if (e.code === 'billing.frozen') fail(503, 'vip.billing_frozen', 'payments are paused right now; please try again later');++ if (e.status === 403 || e.status === 401) fail(503, 'vip.billing_unavailable', 'VIP is not allowed to set prices on Billing yet');++ fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; the price did not change');++ }++ }++++ /** "$4.99" from cents, and cents from "4.99" / "$4.99" / "5" (null when it is not an amount). */++ const centsFromDollars = (v) => {++ const m = /^\s*\$?\s*(\d{1,6})(?:\.(\d{1,2}))?\s*$/.exec(String(v == null ? '' : v));++ return m ? Number(m[1]) * 100 + Number((m[2] || '0').padEnd(2, '0')) : null;++ };++++ return { forCreator, priceOf, withPrices, set, centsFromDollars };++}++++module.exports = { createPrices };@@@ -63,6 +63,7 @@ function consumerRouter({ domain, config, log = console, accountData = null, accmember, creator, active: !!p.active && !reversed, expiresAt: p.expires_at || null,cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,subscriptionStatus: sub ? sub.status : undefined, graceUntil: p.grace_until || undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,++ tier: p.tier || undefined,});if (reversed) await entitlements.doubt(member, creator);return out.outcome;@@@ -25,10 +25,11 @@ function notice(q) {return '';}--function priceLine(rates) {++function priceLine(rates, cents) {const sub = rates && rates.subscription;-- if (!sub || !sub.price_cents) return '<p class="price muted">OpenVibe.Billing sets the price; it is shown at checkout.</p>';-- return `<p class="price"><b>${money(sub.price_cents)}</b> every ${esc(sub.period_days)} days, charged and renewed by OpenVibe.Billing.</p>`;++ const price = cents != null ? cents : sub && sub.price_cents;++ if (!price) return '<p class="price muted">OpenVibe.Billing charges the price; it is shown at checkout.</p>';++ return `<p class="price"><b>${money(price)}</b> every ${esc((sub && sub.period_days) || 31)} days, charged and renewed by OpenVibe.Billing.</p>`;}function perkList(perks) {@@@ -116,7 +117,7 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You joconst planCards = plans.length ? plans.map((p) => {const v = p.current_version;const join = !p.purchasable-- ? '<p class="muted">This plan is not sold through Billing yet.</p>'++ ? `<p class="muted">${p.billing_kind && p.status === 'published' ? 'This plan has no price yet, so it cannot be joined.' : 'This plan is not sold through Billing yet.'}</p>`: isOwner ? '<p class="muted">This is your plan.</p>': member && member.status === 'active' ? '': !providers.length ? '<p class="muted">Joining through OpenVibe.Billing is not open yet.</p>'@@@ -127,11 +128,11 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You jo<button type="submit">Join ${esc(v.name)}</button></form>`: `<p><a class="button" href="/auth/login?next=${encodeURIComponent(`/${creator.username}`)}">Sign in to join</a></p>`;return `<article class="card plan" id="plan-${esc(p.slug)}">--<h2>${esc(v.name)}</h2>++<h2>${esc(v.name)}${plans.filter((x) => x.billing_kind).length > 1 && p.tier ? ` <span class="pill">Tier ${esc(p.tier)}</span>` : ''}</h2>${v.description ? `<p>${esc(v.description)}</p>` : ''}${benefitList(v.benefits)}${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}--${p.purchasable ? priceLine(rates) : ''}++${p.purchasable ? priceLine(rates, p.price_cents) : ''}${join}<p class="muted small">Version ${esc(v.version)} · published ${fmtDate(v.published_at)} · <a href="/${encodeURIComponent(creator.username)}/plans/${encodeURIComponent(p.slug)}">terms history</a></p></article>`;@@@ -194,7 +195,7 @@ function perkChoices(allPerks, selectedKeys) {return `<fieldset class="perk-choices"><legend>Perks</legend>${allPerks.map((p) => `<label class="check"><input type="checkbox" name="perks" value="${esc(p.key)}"${selectedKeys.includes(p.key) ? ' checked' : ''}> ${esc(p.name)}${p.scope === 'network' ? ' <span class="muted small">(network)</span>' : ''}</label>`).join('')}</fieldset>`;}--function planEditor({ plan, allPerks, token, base }) {++function planEditor({ plan, allPerks, token, base, priceTable }) {const v = plan.draft_version || plan.current_version;const keys = v ? v.perks.map((p) => p.key) : [];const versions = (plan.versions || []).map((x) => `<li>v${esc(x.version)} · ${esc(x.name)} · ${x.published_at ? `published ${fmtDate(x.published_at)}` : 'draft'}${x.change_note ? ` · ${esc(x.change_note)}` : ''}${x.perks.length ? ` · perks: ${x.perks.map((p) => esc(p.key)).join(', ')}` : ''}</li>`).join('');@@@ -203,7 +204,8 @@ function planEditor({ plan, allPerks, token, base }) {plan.status !== 'archived' ? `<form method="post" action="${base}/plans/${esc(plan.id)}/archive" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Archive</button></form>` : '',].join(' ');return `<article class="card plan-editor"><h3>${esc(v ? v.name : plan.slug)} <span class="pill">${esc(plan.status)}</span></h3>--<p class="muted small">${plan.billing_kind ? 'Sold through OpenVibe.Billing as a channel subscription.' : 'Not sold through Billing (terms only).'} ${plan.current_version ? `New members buy v${esc(plan.current_version.version)}.` : ''}</p>++<p class="muted small">${plan.billing_kind ? `Tier ${esc(plan.tier)}, sold through OpenVibe.Billing as a channel subscription${plan.price_cents != null ? ` at <b>${money(plan.price_cents)}</b> a month` : priceTable ? ', with no price yet: set one so members can join' : ''}.` : 'Not sold through Billing (terms only).'} ${plan.current_version ? `New members buy v${esc(plan.current_version.version)}.` : ''}</p>++${plan.billing_kind && plan.status !== 'archived' ? `<form method="post" action="${base}/plans/${esc(plan.id)}/price" class="inline price-form">${csrf(token)}<label>Monthly price ($) <input name="price" inputmode="decimal" required pattern="\\$?[0-9]{1,6}(\\.[0-9]{1,2})?" size="8" value="${plan.price_cents != null ? esc((plan.price_cents / 100).toFixed(2)) : ''}"${priceTable && priceTable.minCents ? ` placeholder="${esc((priceTable.minCents / 100).toFixed(2))} to ${esc((priceTable.maxCents / 100).toFixed(2))}"` : ''}></label> <button type="submit" class="button-quiet">Set price</button></form>` : ''}${plan.status !== 'archived' ? `<details><summary>Edit (creates version ${esc((plan.versions || []).length + 1)})</summary><form method="post" action="${base}/plans/${esc(plan.id)}">${csrf(token)}<label>Name <input name="name" maxlength="80" required value="${esc(v ? v.name : '')}"></label>@@@ -233,7 +235,7 @@ function shareCard({ creator, embeds, hasPlan }) {<label>Card for developers (JSON, open to any site) <input readonly value="${esc(embeds.card)}"></label></section>`;}--function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base, embeds }) {++function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base, embeds, priceTable = null }) {if (!viewer) {return `<section class="card narrow"><h1>Creator dashboard</h1><p>Sign in with your OpenVibe account to offer a membership plan.</p><p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;@@@ -248,14 +250,16 @@ ${isNetwork ? '' : `<section class="card"><h2>Profile</h2><form method="post" ac<label class="check"><input type="checkbox" name="show_member_count" value="1"${creator.show_member_count ? ' checked' : ''}> Show how many members you have on your card and widget</label><button type="submit">Save</button></form></section>${shareCard({ creator, embeds, hasPlan: plans.some((p) => p.status === 'published') })}`}--<section><h2>Plans</h2>${plans.length ? plans.map((p) => planEditor({ plan: p, allPerks, token, base })).join('') : '<p class="muted">No plans yet.</p>'}++<section><h2>Plans</h2>${plans.length ? plans.map((p) => planEditor({ plan: p, allPerks, token, base, priceTable })).join('') : '<p class="muted">No plans yet.</p>'}<details class="card"><summary>New plan</summary><form method="post" action="${base}/plans">${csrf(token)}<label>Name <input name="name" maxlength="80" required></label><label>Description <textarea name="description" maxlength="2000" rows="3"></textarea></label><label>Benefits, one per line <textarea name="benefits" rows="3"></textarea></label>${perkChoices(allPerks, [])}++${isNetwork ? '' : `<label>Tier <select name="tier"><option value="">the next free tier</option>${Array.from({ length: 10 }, (_, i) => `<option value="${i + 1}"${plans.some((p) => p.billing_kind && p.status !== 'archived' && p.tier === i + 1) ? ' disabled' : ''}>Tier ${i + 1}</option>`).join('')}</select></label>++<label>Monthly price ($) <input name="price" inputmode="decimal" pattern="\\$?[0-9]{1,6}(\\.[0-9]{1,2})?" size="8"${priceTable && priceTable.minCents ? ` placeholder="${esc((priceTable.minCents / 100).toFixed(2))} to ${esc((priceTable.maxCents / 100).toFixed(2))}"` : ''}></label>`}<label class="check"><input type="checkbox" name="publish" value="1"> Publish now</label>--<p class="muted small">${isNetwork ? 'Network plans are terms only until Billing sells a network membership.' : 'A published plan is sold through OpenVibe.Billing as your channel subscription; Billing sets the price. One published plan can be sold at a time.'}</p>++<p class="muted small">${isNetwork ? 'Network plans are terms only until Billing sells a network membership.' : `Each plan is one tier of your membership, sold through OpenVibe.Billing as your channel subscription at the price you set${priceTable && priceTable.defaultCents ? ` (tier 1 without a price sells at ${money(priceTable.defaultCents)})` : ''}. A member holds one tier at a time; put everything a tier includes in its perks and benefits.`}</p><button type="submit">Create plan</button></form></details></section><section><h2>Perks</h2>${ownPerks.length ? `<ul class="perk-admin">${ownPerks.map((p) => `<li><details><summary><b>${esc(p.name)}</b> <span class="muted small">${esc(p.key)} · ${esc(p.kind)} · ${esc(p.status)}</span></summary>@@@ -38,7 +38,7 @@ const LLMS_FULL_TTL_MS = 3600 * 1000;function createWebRoutes({ domain, config, layout, userAuth }) {const router = express.Router();-- const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;++ const { creators, plans, perks, memberships, entitlements, policies, checkout, billing, prices } = domain;const forms = createForms({ secret: config.formSecret, now: domain.now });const isStaff = (v) => !!v && v.staff === true;router.use(viewerMiddleware(userAuth));@@@ -52,6 +52,17 @@ function createWebRoutes({ domain, config, layout, userAuth }) {}const send = (res, status, o) => res.status(status).type('html').send(layout.page(o));++ const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;++ /** A price typed in a form ("4.99", "$5"): cents, or null when left blank; anything else is refused. */++ function priceInput(v, { required = false } = {}) {++ if (v == null || String(v).trim() === '') {++ if (required) throw new VipError(422, 'vip.invalid_input', 'Type a monthly price, like 4.99.');++ return null;++ }++ const cents = prices.centsFromDollars(v);++ if (cents == null || cents <= 0) throw new VipError(422, 'vip.invalid_input', 'Type the monthly price in dollars and cents, like 4.99.');++ return cents;++ }const notFound = (req, res) => send(res, 404, { title: 'Not found', robots: 'noindex', viewer: req.viewer, body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) });// Notices ride in the redirect (?ok= / ?error=) and carry an HMAC of their text, so a crafted// link cannot put words of its choosing on a VIP page (e.g. "send your Vibes to …").@@@ -215,7 +226,9 @@ function createWebRoutes({ domain, config, layout, userAuth }) {let creator;try { creator = await dashCreator(req); } catch (e) { return send(res, 403, { title: 'Dashboard', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.errorPage({ status: 403, title: 'Not allowed', message: e.detail }) }); }const isNetwork = creator.kind === 'network';-- const planRows = (await Promise.all((await plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true })).map(async (p) => await plans.present(p, { withVersions: true }))));++ const planList = (await Promise.all((await plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true })).map(async (p) => await plans.present(p, { withVersions: true }))));++ const planRows = isNetwork ? planList : await prices.withPrices(creator.subject, planList);++ const priceTable = isNetwork ? null : await prices.forCreator(creator.subject);const allPerks = (await Promise.all((await perks.list({ creatorId: creator.id })).map(async (p) => await perks.present(p, { withBindings: false }))));const ownPerks = (await Promise.all((await perks.list({ creatorId: creator.id, includeNetwork: false, includeRetired: true })).map(async (p) => await perks.present(p))));let members = [];@@@ -233,7 +246,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {send(res, 200, {title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,body: pages.dashboard({-- viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource,++ viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource, priceTable,rules: isNetwork ? [] : await Promise.all((await policies.list(creator.id)).map(policies.present)), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,}),@@@ -255,8 +268,18 @@ function createWebRoutes({ domain, config, layout, userAuth }) {post(`${base}/plans`, async (req, res, to) => {withAs(req);const c = await dashCreator(req);-- const plan = await plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });-- back(res, to, 'ok', `Plan created${plan.status === 'published' ? ' and published' : ''}.`);++ const cents = priceInput(req.body.price);++ const plan = await plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), tier: req.body.tier || undefined, publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });++ if (cents != null && plan.billing_kind) await prices.set(c, plan, cents, { traceparent: req.ov.traceparent });++ back(res, to, 'ok', `Plan created${plan.status === 'published' ? ' and published' : ''}${cents != null && plan.billing_kind ? ` at ${dollars(cents)} a month` : ''}.`);++ }, opts);++ // The plan's tier price, kept and charged by Billing (domain/prices.js).++ post(`${base}/plans/:id/price`, async (req, res, to) => {++ const plan = await plans.byId(req.params.id);++ const c = await own(req, plan);++ const cents = priceInput(req.body.price, { required: true });++ await prices.set(c, plan, cents, { traceparent: req.ov.traceparent });++ back(res, to, 'ok', `Price set: ${dollars(cents)} a month for new members. Current members keep the price they joined at.`);}, opts);post(`${base}/plans/:id`, async (req, res, to) => {const plan = await plans.byId(req.params.id);@@@ -314,7 +337,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {const c = await creators.byUsername(req.params.username);if (!c || c.status !== 'active') return notFound(req, res);if (c.username !== req.params.username) return res.redirect(301, `/${encodeURIComponent(c.username)}`);-- const list = (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p))));++ const list = await prices.withPrices(c.subject, (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p)))));const isOwner = !!(req.viewer && req.viewer.subject === c.subject);const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;const name = c.display_name || c.username;@@@ -73,7 +73,13 @@ async function startBilling(network, clock) {const periods = []; // { sub_id, subject, scope, starts, ends, revoked, txn }const idem = new Map();const calls = [];-- const state = { down: false, frozen: false, credit: new Map() };++ const state = { down: false, frozen: false, credit: new Map(), tiers: new Map() }; // tiers: creator → Map(tier → { price_cents, status })++ /** Billing's tier price (contracts 0.201.0): a row, else tier 1 at the default 499; [status, code] when not for sale. */++ function tierPrice(creator, tier) {++ const row = (state.tiers.get(creator) || new Map()).get(tier);++ if (row) return row.status === 'active' ? row.price_cents : [409, 'billing.tier_archived'];++ return tier === 1 ? 499 : [422, 'billing.tier_unknown'];++ }const now = () => clock.now();const iso = (ms) => new Date(ms).toISOString();let seq = 0;@@@ -95,12 +101,13 @@ async function startBilling(network, clock) {const s = byPair(subject, scope);return {subject: { type: 'user', id: subject }, streamer: { type: 'user', id: scope }, kind: 'channel_subscription', active: !!exp, expires_at: exp ? iso(exp) : null,++ tier: s ? s.tier || 1 : null,subscription: s ? { id: s.id, status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, provider: s.provider } : null,};}const present = (s) => ({-- id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, tier: 1, provider: s.provider,-- status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, price_cents: 499,++ id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, tier: s.tier || 1, provider: s.provider,++ status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, price_cents: s.price_cents || 499,current_period_end: s.current_period_end, created_at: s.created_at, updated_at: s.updated_at, legacy_live_id: s.legacy_live_id ?? null,});function envelope(type, subject, payload) {@@@ -112,7 +119,7 @@ async function startBilling(network, clock) {const entEvent = (member, creator, reason, extra = {}) => envelope('billing.entitlement.changed', { type: 'entitlement', id: `${member}:channel_subscription:${creator}` }, { ...entitlement(member, creator), reason, ...extra });/** Pay one period (grant or renew). Returns { sub, events }. */-- function pay(member, creator, { provider = 'credit', autoRenew = true, legacyLiveId = null } = {}) {++ function pay(member, creator, { provider = 'credit', autoRenew = true, legacyLiveId = null, tier = null, priceCents = null } = {}) {const t = now();let s = byPair(member, creator);const renewal = !!(s && s.status === 'active');@@@ -124,6 +131,7 @@ async function startBilling(network, clock) {subs.set(s.id, s);}Object.assign(s, { status: 'active', auto_renew: autoRenew, cancel_at_period_end: false, current_period_end: iso(end), updated_at: iso(t) });++ if (tier != null) Object.assign(s, { tier, price_cents: priceCents || s.price_cents || 499 });periods.push({ sub_id: s.id, subject: member, scope: creator, starts: start, ends: end, revoked: false, txn });return { sub: s, txn, events: [envelope('billing.transaction.settled', { type: 'transaction', id: txn }, { transaction_id: txn, type: 'subscription', from_subject: member, to_subject: creator }), entEvent(member, creator, renewal ? 'renewed' : 'granted', { transaction_id: txn })] };}@@@ -182,8 +190,13 @@ async function startBilling(network, clock) {if (replay()) return undefined;if (body.subject.id === body.streamer.id) return send(res, 422, { code: 'billing.self_dealing' });if (!['powerchat', 'stripe'].includes(body.provider)) return send(res, 409, { code: 'billing.provider_disabled' });++ const tier = Number(body.tier || 1);++ const price = tierPrice(body.streamer.id, tier);++ if (Array.isArray(price)) return send(res, price[0], { code: price[1] });++ const held = byPair(body.subject.id, body.streamer.id);++ if (held && held.status === 'active' && (held.tier || 1) !== tier) return send(res, 409, { code: 'billing.tier_change_unsupported' });const id = nextId('pi');-- const intent = { id, provider: body.provider, kind: 'subscription', subject: body.subject, streamer: body.streamer, amount_cents: body.provider === 'powerchat' ? 549 : 499, status: 'created', checkout_ref: body.provider === 'powerchat' ? `pcsub:${id}` : undefined };++ const intent = { id, provider: body.provider, kind: 'subscription', subject: body.subject, streamer: body.streamer, tier, amount_cents: body.provider === 'powerchat' ? Math.round(price * 1.1) : price, status: 'created', checkout_ref: body.provider === 'powerchat' ? `pcsub:${id}` : undefined };return store(201, { intent, checkout_url: body.provider === 'stripe' ? `https://checkout.stripe.test/${id}` : null });}if (p === '/api/v1/subscriptions' && req.method === 'POST') {@@@ -192,10 +205,15 @@ async function startBilling(network, clock) {if (state.frozen) return send(res, 503, { code: 'billing.frozen' });const member = body.subscriber.id; const creator = body.streamer.id;if (member === creator) return send(res, 422, { code: 'billing.self_dealing' });++ const tier = Number(body.tier || 1);++ const price = tierPrice(creator, tier);++ if (Array.isArray(price)) return send(res, price[0], { code: price[1] });++ const held = byPair(member, creator);++ if (held && held.status === 'active' && (held.tier || 1) !== tier) return send(res, 409, { code: 'billing.tier_change_unsupported' });const credit = state.credit.get(member) || 0;-- if (credit < 499) return send(res, 409, { code: 'billing.insufficient_funds', detail: 'not enough credit' });-- state.credit.set(member, credit - 499);-- const out = pay(member, creator, { autoRenew: body.auto_renew !== false });++ if (credit < price) return send(res, 409, { code: 'billing.insufficient_funds', detail: 'not enough credit' });++ state.credit.set(member, credit - price);++ const out = pay(member, creator, { autoRenew: body.auto_renew !== false, tier, priceCents: price });state.lastEvents = out.events;return store(201, { subscription: present(out.sub), entitlement: entitlement(member, creator), transaction: { id: out.txn } });}@@@ -222,6 +240,26 @@ async function startBilling(network, clock) {const list = [...subs.values()].filter((s) => (!q.streamer || s.streamer === q.streamer) && (!q.subscriber || s.subscriber === q.subscriber) && (!q.status || s.status === q.status));return send(res, 200, { subscriptions: list.map(present) });}++ m = p.match(/^\/api\/v1\/tiers\/([^/]+)$/);++ if (m && req.method === 'GET') {++ const e = auth(req, null);++ if (e) return send(res, e[0], e[1]);++ const tiers = [...(state.tiers.get(m[1]) || new Map()).entries()].sort((a, b) => a[0] - b[0]).map(([tier, r]) => ({ tier, price_cents: r.price_cents, status: r.status }));++ return send(res, 200, { streamer: { type: 'user', id: m[1] }, tiers, default_price_cents: 499, min_price_cents: 100, max_price_cents: 50000, max_tiers: 10 });++ }++ m = p.match(/^\/api\/v1\/tiers\/([^/]+)\/(\d+)$/);++ if (m && req.method === 'PUT') {++ if (!need('billing.tier.manage')) return undefined;++ if (!req.headers['idempotency-key']) return send(res, 400, { code: 'idempotency.key_required' });++ if (replay()) return undefined;++ if (state.frozen) return send(res, 503, { code: 'billing.frozen' });++ const cents = Number(body.price_cents);++ if (!Number.isSafeInteger(cents) || cents < 100 || cents > 50000) return send(res, 422, { code: 'billing.invalid_amount', detail: 'price_cents must be from 100 to 50000' });++ if (!state.tiers.has(m[1])) state.tiers.set(m[1], new Map());++ const row = { price_cents: cents, status: body.status || 'active' };++ state.tiers.get(m[1]).set(Number(m[2]), row);++ return store(200, { tier: { tier: Number(m[2]), ...row } });++ }m = p.match(/^\/api\/v1\/entitlements\/([^/]+)$/);if (m && req.method === 'GET') {if (!need('billing.entitlement.check')) return undefined;@@@ -110,11 +110,15 @@ const { test, run } = harness('plans');assert.strictEqual((await t.domain.plans.version(m.plan_version_id)).version, 3);});-- test('Billing sells one channel subscription per creator: a second published plan is refused', async () => {-- const res = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second', publish: true } });++ test('one published plan per tier: a second plan at tier 1 is refused; without a tier it takes the next free one', async () => {++ const res = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second', tier: 1, publish: true } });assert.strictEqual(res.status, 409);assert.strictEqual(res.json.code, 'vip.plan.billing_product_taken');-- const draft = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second' } });++ const next = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Superfan' } });++ assert.strictEqual(next.status, 201, next.text);++ assert.strictEqual(next.json.plan.tier, 2);++ assert.strictEqual((await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Eleven', tier: 11 } })).status, 422);++ const draft = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second', tier: 1 } });assert.strictEqual(draft.status, 201);assert.strictEqual((await t.call('POST', `/api/v1/plans/${draft.json.plan.id}/publish`, { user: creator })).status, 409);assert.strictEqual((await t.call('POST', `/api/v1/plans/${plan.id}/archive`, { user: creator })).status, 200);nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++/**++ * Tiers (owner's ask: creator memberships with tiers; openvibe-contracts 0.201.0). A creator publishes one plan per++ * tier and sets each tier's monthly price on Billing (VIP never keeps a price). Pages, the card and the API show each++ * plan's price; a tier above 1 without a price cannot be joined; a checkout buys the plan's tier; a subscription made++ * elsewhere (Live's, tier 1) is filed under the tier-1 plan, and Billing's tier decides the plan when no checkout did.++ */++const assert = require('assert');++const { boot, harness } = require('./helpers/app');++const { test, run } = harness('tiers');++++(async () => {++ const t = await boot();++ const creator = t.network.newUser('tierra', { role: 'streamer' });++ const fan = t.network.newUser('fanny');++ const live = t.network.newUser('lively');++ let basic;++ let superfan;++++ test('a creator publishes two tiers and prices them on Billing (tier 1 at the default until set)', async () => {++ const one = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Supporter', benefits: ['Badge'], publish: true } });++ assert.strictEqual(one.status, 201, one.text);++ basic = one.json.plan;++ assert.deepStrictEqual([basic.tier, basic.price_cents, basic.purchasable], [1, 499, true], 'tier 1 without a price sells at Billing\'s default');++ const two = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Superfan', benefits: ['Badge', 'Monthly call'], tier: 2, publish: true } });++ assert.strictEqual(two.status, 201, two.text);++ superfan = two.json.plan;++ assert.deepStrictEqual([superfan.tier, superfan.price_cents, superfan.purchasable], [2, null, false], 'tier 2 is not for sale until priced');++ const priced = await t.call('PATCH', `/api/v1/plans/${superfan.id}`, { user: creator, body: { price_cents: 1500 } });++ assert.strictEqual(priced.status, 200, priced.text);++ assert.deepStrictEqual([priced.json.plan.price_cents, priced.json.plan.purchasable, priced.json.unchanged], [1500, true, true], 'a price is not a plan term: no new version');++ assert.deepStrictEqual(t.billing.state.tiers.get(creator.subject).get(2), { price_cents: 1500, status: 'active' });++ const bad = await t.call('PATCH', `/api/v1/plans/${superfan.id}`, { user: creator, body: { price_cents: 50 } });++ assert.strictEqual(bad.status, 422);++ const other = await t.call('PATCH', `/api/v1/plans/${superfan.id}`, { user: fan, body: { price_cents: 100 } });++ assert.strictEqual(other.status, 403, 'only the creator prices their tiers');++ });++++ test('the dashboard sets a price from "$3" and refuses a non-amount', async () => {++ const d = await t.page('/dashboard', { user: creator });++ assert.match(d.text, /Tier 2, sold through OpenVibe\.Billing as a channel subscription at <b>\$15\.00<\/b> a month/);++ const token = t.csrf(d.text);++ const ok = await t.page(`/dashboard/plans/${basic.id}/price`, { user: creator, method: 'POST', form: { _csrf: token, price: '$3' } });++ assert.match(decodeURIComponent(ok.location), /Price set: \$3\.00 a month/);++ assert.strictEqual(t.billing.state.tiers.get(creator.subject).get(1).price_cents, 300);++ const no = await t.page(`/dashboard/plans/${basic.id}/price`, { user: creator, method: 'POST', form: { _csrf: token, price: 'three' } });++ assert.match(no.location, /error=/);++ });++++ test('the creator page and the card show each tier and its price', async () => {++ const page = await t.page(`/${creator.username}`);++ assert.match(page.text, /Supporter <span class="pill">Tier 1<\/span>/);++ assert.match(page.text, /<b>\$3\.00<\/b> every/);++ assert.match(page.text, /<b>\$15\.00<\/b> every/);++ const card = await t.call('GET', `/api/v1/creators/${creator.username}/card`);++ assert.strictEqual(card.status, 200, card.text);++ assert.deepStrictEqual(card.json.card.plans.map((p) => [p.tier, p.price_cents, p.purchasable]), [[1, 300, true], [2, 1500, true]]);++ assert.strictEqual(card.json.card.price.price_cents, 300, 'the card\'s headline price is the lowest tier');++ });++++ test('a checkout buys the plan\'s tier at its price and files the member under that plan', async () => {++ t.billing.state.credit.set(fan.subject, 2000);++ const co = await t.call('POST', '/api/v1/checkout', { user: fan, body: { plan_id: superfan.id, provider: 'credit' } });++ assert.strictEqual(co.status, 201, co.text);++ const sent = t.billing.calls.filter((c) => c.path === '/api/v1/subscriptions' && c.method === 'POST').pop();++ assert.strictEqual(sent.body.tier, 2);++ assert.strictEqual(t.billing.state.credit.get(fan.subject), 500, 'charged the tier-2 price');++ const m = await t.domain.memberships.get(fan.subject, (await t.domain.creators.bySubject(creator.subject)).id);++ assert.strictEqual(m.plan_id, superfan.id);++ const pc = await t.call('POST', '/api/v1/checkout', { user: t.network.newUser('pc'), body: { plan_id: superfan.id, provider: 'powerchat' } });++ assert.strictEqual(pc.status, 201, pc.text);++ const intent = t.billing.calls.filter((c) => c.path === '/api/v1/intents').pop();++ assert.strictEqual(intent.body.tier, 2);++ });++++ test('a subscription made elsewhere is filed under the plan of Billing\'s tier', async () => {++ await t.deliverAll(t.billing.pay(live.subject, creator.subject).events); // Live's subscribe button: tier 1++ const c = await t.domain.creators.bySubject(creator.subject);++ assert.strictEqual((await t.domain.memberships.get(live.subject, c.id)).plan_id, basic.id);++ const vipper = t.network.newUser('vipper');++ await t.deliverAll(t.billing.pay(vipper.subject, creator.subject, { tier: 2, priceCents: 1500 }).events);++ assert.strictEqual((await t.domain.memberships.get(vipper.subject, c.id)).plan_id, superfan.id, 'Billing said tier 2');++ });++++ test('a tier with no price cannot be joined, and says so', async () => {++ const three = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Patron', tier: 3, publish: true } });++ assert.strictEqual(three.status, 201, three.text);++ const page = await t.page(`/${creator.username}`);++ assert.match(page.text, /This plan has no price yet, so it cannot be joined\./);++ const co = await t.call('POST', '/api/v1/checkout', { user: t.network.newUser('early'), body: { plan_id: three.json.plan.id, provider: 'credit' } });++ assert.strictEqual(co.status, 409);++ assert.strictEqual(co.json.code, 'vip.checkout.unavailable');++ });++++ await run();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -109,10 +109,16 @@ The three Events subscriptions exist.
@@ -109,10 +109,16 @@ The three Events subscriptions exist.
often the plan changes; a new purchase after the membership lapsed takes the current version.
often the plan changes; a new purchase after the membership lapsed takes the current version.
- Perk rules evaluate the perks of the member's version, not the plan's latest.
- Perk rules evaluate the perks of the member's version, not the plan's latest.
- Renaming a perk never changes published terms (each version keeps its snapshot).
- Renaming a perk never changes published terms (each version keeps its snapshot).
-- Billing sells one channel subscription per (member, creator) today, so a creator has at most **one
- published plan sold through Billing** (a unique index; a second is refused with
- `409 vip.plan.billing_product_taken`). Tiers and network memberships need a Billing product first:
- a network plan (staff) can be published as terms, but checkout answers `409 vip.checkout.unavailable`.
+- **Tiers** (since 2026-10-17, openvibe-contracts 0.201.0): a plan sold through Billing is one tier of the creator's
+ membership (`billing_tier` 1–10, chosen at creation, else the next free one). A creator publishes at most **one plan
+ per tier** (a unique index; a second is refused with `409 vip.plan.billing_product_taken`) and sets each tier's
+ monthly price on Billing (`server/domain/prices.js`, `billing.tier.manage`: the dashboard's "Set price", or
+ `price_cents` on `POST /plans` / `PATCH /plans/:id`). VIP never keeps a price. Tier 1 without a price sells at
+ Billing's default; a higher tier without one cannot be joined. A member holds one tier of a creator at a time
+ (Billing refuses a second while a period runs); a subscription made elsewhere (Live's) is tier 1, and Billing's
+ `tier` on `billing.entitlement.changed` files a membership made without a checkout under that tier's plan.
+ Network memberships still need a Billing product: a network plan (staff) can be published as terms, but checkout
+ answers `409 vip.checkout.unavailable`.
- Archiving stops new members; existing members keep their terms until Billing ends the membership.
- Archiving stops new members; existing members keep their terms until Billing ends the membership.
### The entitlement projection (Billing is the truth)
### The entitlement projection (Billing is the truth)
@@ -206,8 +212,8 @@ handle or `network`.
@@ -206,8 +212,8 @@ handle or `network`.
| `GET /creators/:ref/card` | — (public) | — | the membership card (below), the same as `/embed/:username/card.json` |
| `GET /creators/:ref/card` | — (public) | — | the membership card (below), the same as `/embed/:username/card.json` |
| `GET /plans?creator=&include=drafts,archived` | `vip.plan.list` for drafts | owner | plans with current/draft versions |
| `GET /plans?creator=&include=drafts,archived` | `vip.plan.list` for drafts | owner | plans with current/draft versions |
| `GET /plans/:id`, `GET /plans/:id/versions` | `vip.plan.list` for drafts | owner | a plan; its version history (published versions are public) |
| `GET /plans/:id`, `GET /plans/:id/versions` | `vip.plan.list` for drafts | owner | a plan; its version history (published versions are public) |
-| `POST /plans` | `vip.plan.create` (body `creator`) | self / staff for `network` | create (v1), optionally `publish` |
-| `PATCH /plans/:id` | `vip.plan.update` | owner | edit → new version |
+| `POST /plans` | `vip.plan.create` (body `creator`) | self / staff for `network` | create (v1) at `tier` (else the next free one), optionally `publish` and `price_cents` (the tier's monthly price, set on Billing) |
+| `PATCH /plans/:id` | `vip.plan.update` | owner | edit → new version; `price_cents` sets the tier's price on Billing (not a term: no new version) |
| `POST /plans/:id/publish` | `vip.plan.update` | owner | publish the latest version |
| `POST /plans/:id/publish` | `vip.plan.update` | owner | publish the latest version |
| `POST /plans/:id/archive` | `vip.plan.archive` | owner | archive |
| `POST /plans/:id/archive` | `vip.plan.archive` | owner | archive |
| `GET /perks?creator=&network=&retired=` | `vip.perk.list` for retired | owner | perks with product bindings |
| `GET /perks?creator=&network=&retired=` | `vip.perk.list` for retired | owner | perks with product bindings |
@@ -30,11 +30,12 @@
@@ -30,11 +30,12 @@
],
],
"not_yet": [
"not_yet": [
"no membership sold yet: the first real purchase through Billing (PowerChat or Vibes credit) is the end-to-end proof still due",
"no membership sold yet: the first real purchase through Billing (PowerChat or Vibes credit) is the end-to-end proof still due",
- "Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product",
- "revocations reach Chat and Community through vip.membership.changed subscriptions (2026-09-24); Blog and Live still converge by the cache TTL (30-60 s)"
+ "revocations reach Chat and Community through vip.membership.changed subscriptions (2026-09-24); Blog and Live still converge by the cache TTL (30-60 s)",
+ "no network membership product on Billing yet (OpenVibe Premium sold on openvibe.vip): a network plan is terms only"
],
],
"features": [
"features": [
"plans with immutable versions, perks and product bindings",
"plans with immutable versions, perks and product bindings",
+ "tiers: one plan per tier (1-10), each priced by the creator on Billing (billing.tier.manage); a member holds one tier at a time",
"entitlement projection from Billing events with valid_until, grace and a Billing fallback; unknown never authorizes",
"entitlement projection from Billing events with valid_until, grace and a Billing fallback; unknown never authorizes",
"checkout and cancel through OpenVibe.Billing",
"checkout and cancel through OpenVibe.Billing",
"gated-resource policy evaluation for products (fails closed)",
"gated-resource policy evaluation for products (fails closed)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- Tiers (owner's ask: creator memberships with tiers; openvibe-contracts 0.201.0): a plan sold through Billing names the
+-- tier it is sold as, and Billing prices each tier (billing.tier.manage, ADR-012). A creator now publishes one plan per
+-- tier instead of one plan in all. Every existing plan is tier 1, which Billing sells at its default price until the
+-- creator sets one, so nothing a member bought changes.
+ALTER TABLE vip_plans ADD COLUMN billing_tier integer NOT NULL DEFAULT 1 CHECK (billing_tier BETWEEN 1 AND 10);
+CREATE UNIQUE INDEX idx_vip_plans_one_per_tier
+ ON vip_plans (creator_id, billing_kind, billing_tier) WHERE status = 'published' AND billing_kind IS NOT NULL;
+DROP INDEX idx_vip_plans_one_billing_product;
@@ -38,6 +38,9 @@ form input:not([type=checkbox]):not([type=hidden]), form textarea, form select {
@@ -38,6 +38,9 @@ form input:not([type=checkbox]):not([type=hidden]), form textarea, form select {
form label.check { display: flex; align-items: center; gap: 8px; }
form label.check { display: flex; align-items: center; gap: 8px; }
form.inline { display: inline-block; margin: 4px 6px 4px 0; }
form.inline { display: inline-block; margin: 4px 6px 4px 0; }
form.join { display: grid; gap: 4px; max-width: 420px; }
form.join { display: grid; gap: 4px; max-width: 420px; }
+form.price-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 8px 0; }
+form.price-form label { margin: 0; }
+form.price-form input:not([type=hidden]) { width: 9em; }
fieldset { border: 1px solid var(--vip-border); border-radius: 8px; margin: 12px 0; }
fieldset { border: 1px solid var(--vip-border); border-radius: 8px; margin: 12px 0; }
details > summary { cursor: pointer; font-weight: 600; margin: 6px 0; }
details > summary { cursor: pointer; font-weight: 600; margin: 6px 0; }
.notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }
.notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }
@@ -100,7 +100,12 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -100,7 +100,12 @@ function v1Router({ domain, apiAuth, limits }) {
return userSubject(value, 'subject');
return userSubject(value, 'subject');
}
}
- const presentPlanFull = async (plan) => ({ ...await plans.present(plan), creator: creators.present(await creators.byId(plan.creator_id)) });
+ // Plans as presented, with each tier's price from Billing (domain/prices.js) when the creator sells through it.
+ const priced = async (c, list) => (c && c.kind === 'creator' && c.subject ? await domain.prices.withPrices(c.subject, list) : list);
+ const presentPlanFull = async (plan) => {
+ const c = await creators.byId(plan.creator_id);
+ return { ...(await priced(c, [await plans.present(plan)]))[0], creator: creators.present(c) };
+ };
const trace = (req) => req.ov && req.ov.traceparent;
const trace = (req) => req.ov && req.ov.traceparent;
// ── Creators (public) ────────────────────────────────────
// ── Creators (public) ────────────────────────────────────
@@ -110,7 +115,7 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -110,7 +115,7 @@ function v1Router({ domain, apiAuth, limits }) {
const own = mayManage(req.principal, c, CAP.planList);
const own = mayManage(req.principal, c, CAP.planList);
res.json({
res.json({
creator: creators.present(c),
creator: creators.present(c),
- plans: (await Promise.all((await plans.list({ creatorId: c.id, includeDrafts: own })).map(async (p) => await plans.present(p)))),
+ plans: await priced(c, (await Promise.all((await plans.list({ creatorId: c.id, includeDrafts: own })).map(async (p) => await plans.present(p))))),
perks: (await Promise.all((await perks.list({ creatorId: c.id, includeNetwork: false })).map(async (p) => await perks.present(p)))),
perks: (await Promise.all((await perks.list({ creatorId: c.id, includeNetwork: false })).map(async (p) => await perks.present(p)))),
});
});
}));
}));
@@ -129,7 +134,7 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -129,7 +134,7 @@ function v1Router({ domain, apiAuth, limits }) {
const include = String(req.query.include || '').split(',');
const include = String(req.query.include || '').split(',');
const own = mayManage(req.principal, c, CAP.planList);
const own = mayManage(req.principal, c, CAP.planList);
const list = await plans.list({ creatorId: c.id, includeDrafts: own && include.includes('drafts'), includeArchived: own && include.includes('archived') });
const list = await plans.list({ creatorId: c.id, includeDrafts: own && include.includes('drafts'), includeArchived: own && include.includes('archived') });
- res.json({ creator: creators.present(c), plans: (await Promise.all(list.map(async (p) => await plans.present(p)))) });
+ res.json({ creator: creators.present(c), plans: await priced(c, (await Promise.all(list.map(async (p) => await plans.present(p))))) });
}));
}));
const visiblePlan = async (req, id) => {
const visiblePlan = async (req, id) => {
const plan = await plans.byId(id);
const plan = await plans.byId(id);
@@ -150,8 +155,9 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -150,8 +155,9 @@ function v1Router({ domain, apiAuth, limits }) {
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const plan = await plans.create({
const plan = await plans.create({
creatorId: c.id, slug: b.slug, name: b.name, description: b.description, benefits: b.benefits, perks: b.perks,
creatorId: c.id, slug: b.slug, name: b.name, description: b.description, benefits: b.benefits, perks: b.perks,
- billingKind: b.billing_kind, publish: bool(b.publish), changeNote: b.change_note, actor, traceparent: trace(req),
+ billingKind: b.billing_kind, tier: b.tier, publish: bool(b.publish), changeNote: b.change_note, actor, traceparent: trace(req),
});
});
+ if (b.price_cents != null && plan.billing_kind) await domain.prices.set(c, plan, b.price_cents, { traceparent: trace(req) });
res.status(201).json({ plan: await presentPlanFull(plan) });
res.status(201).json({ plan: await presentPlanFull(plan) });
}));
}));
const managedPlan = async (req, cap) => {
const managedPlan = async (req, cap) => {
@@ -164,6 +170,8 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -164,6 +170,8 @@ function v1Router({ domain, apiAuth, limits }) {
const plan = await managedPlan(req, CAP.planUpdate);
const plan = await managedPlan(req, CAP.planUpdate);
const b = req.body || {};
const b = req.body || {};
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ // price_cents: the plan's tier price, kept and charged by Billing (not a plan term, so no new version).
+ if (b.price_cents != null) await domain.prices.set(await creators.byId(plan.creator_id), plan, b.price_cents, { traceparent: trace(req) });
const out = await plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });
const out = await plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });
res.json({ plan: await presentPlanFull(out.plan), version: await plans.presentVersion(out.version), unchanged: out.unchanged });
res.json({ plan: await presentPlanFull(out.plan), version: await plans.presentVersion(out.version), unchanged: out.unchanged });
}));
}));
@@ -27,22 +27,27 @@ const CAPS = {
@@ -27,22 +27,27 @@ const CAPS = {
intent: 'billing.intent.create',
intent: 'billing.intent.create',
subscription: 'billing.subscription.manage',
subscription: 'billing.subscription.manage',
entitlement: 'billing.entitlement.check',
entitlement: 'billing.entitlement.check',
+ tier: 'billing.tier.manage',
};
};
function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClients = null } = {}) {
function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClients = null } = {}) {
const commerce = createCommerceClient(config, {
const commerce = createCommerceClient(config, {
fetchImpl,
fetchImpl,
- caps: { intent: CAPS.intent, subscription: CAPS.subscription, entitlement: CAPS.entitlement },
+ caps: { intent: CAPS.intent, subscription: CAPS.subscription, entitlement: CAPS.entitlement, tier: CAPS.tier },
tokenClients,
tokenClients,
});
});
return {
return {
/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */
/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */
- createIntent: async ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => await commerce.createIntent({
- provider, kind: 'subscription', subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent,
+ createIntent: async ({ provider, subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent }) => await commerce.createIntent({
+ provider, kind: 'subscription', subject, creator, tier, autoRenew, successUrl, cancelUrl, key, traceparent,
}),
}),
- /** { subscription, entitlement, transaction } — one period paid from the member's credit. */
- subscribeWithCredit: async ({ subscriber, creator, autoRenew, key, traceparent }) => await commerce.subscribeWithCredit({ subscriber, creator, autoRenew, key, traceparent }),
+ /** { subscription, entitlement, transaction } — one period paid from the member's credit, at the tier's price. */
+ subscribeWithCredit: async ({ subscriber, creator, tier, autoRenew, key, traceparent }) => await commerce.subscribeWithCredit({ subscriber, creator, tier, autoRenew, key, traceparent }),
+ /** A creator's tier prices on Billing: { tiers: [{ tier, price_cents, status }], default_price_cents, min/max_price_cents, max_tiers }. */
+ listTiers: async (creator) => await commerce.listTiers(creator),
+ /** { tier } — set one tier's monthly price (billing.tier.manage). */
+ setTier: async ({ creator, tier, priceCents, status, key, traceparent }) => await commerce.setTier({ creator, tier, priceCents, status, key, traceparent }),
cancelSubscription: async ({ id, key, traceparent }) => await commerce.cancelSubscription({ id, key, traceparent }),
cancelSubscription: async ({ id, key, traceparent }) => await commerce.cancelSubscription({ id, key, traceparent }),
getSubscription: async (id) => await commerce.getSubscription(id),
getSubscription: async (id) => await commerce.getSubscription(id),
listSubscriptions: async ({ streamer, subscriber, status } = {}) => await commerce.listSubscriptions({ streamer, subscriber, status }),
listSubscriptions: async ({ streamer, subscriber, status } = {}) => await commerce.listSubscriptions({ streamer, subscriber, status }),
@@ -4,8 +4,9 @@
@@ -4,8 +4,9 @@
* The public face of a creator's memberships, reused by other products and other sites (roadmap
* The public face of a creator's memberships, reused by other products and other sites (roadmap
* §11.3 "reusable membership badge/card/widget endpoints"; rendered by server/web/embeds.js):
* §11.3 "reusable membership badge/card/widget endpoints"; rendered by server/web/embeds.js):
*
*
- * card(creator) plan names and terms (the current published version), perks, the join and
- * terms links, Billing's price, the member count, and the badge and widget URLs
+ * card(creator) plan names and terms (the current published version), perks, each plan's tier and
+ * Billing's price for it (domain/prices.js), the join and terms links, the member
+ * count, and the badge and widget URLs
* badge(creator) what a member's badge reads: the name of the badge perk in the plan new members
* badge(creator) what a member's badge reads: the name of the badge perk in the plan new members
* buy ("Subscriber badge" → "Subscriber"), else "Member"
* buy ("Subscriber badge" → "Subscriber"), else "Member"
*
*
@@ -20,7 +21,7 @@
@@ -20,7 +21,7 @@
*/
*/
const BADGE_SUFFIX = /\s+badge$/i;
const BADGE_SUFFIX = /\s+badge$/i;
-function createCards({ db, config, creators, plans, perks, billing, now, log = console }) {
+function createCards({ db, config, creators, plans, perks, billing, prices = null, now, log = console }) {
const counts = new Map(); // creator id → { at, count, stale, pending }
const counts = new Map(); // creator id → { at, count, stale, pending }
let rates = { at: -Infinity, value: null };
let rates = { at: -Infinity, value: null };
@@ -61,9 +62,10 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
@@ -61,9 +62,10 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
const kindOf = async (perkId) => { const p = await perks.byId(perkId); return p ? p.kind : 'other'; };
const kindOf = async (perkId) => { const p = await perks.byId(perkId); return p ? p.kind : 'other'; };
- /** The published plans of a creator, each at the version new members buy. */
+ /** The published plans of a creator, each at the version new members buy, with its tier's price. */
async function publicPlans(c) {
async function publicPlans(c) {
- return (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p)))).filter((p) => p.current_version);
+ const list = (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p)))).filter((p) => p.current_version);
+ return prices ? await prices.withPrices(c.subject, list) : list;
}
}
async function badge(c, list = null) {
async function badge(c, list = null) {
@@ -99,14 +101,16 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
@@ -99,14 +101,16 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
return {
return {
id: p.id, slug: p.slug, name: v.name, description: v.description, benefits: v.benefits, version: v.version,
id: p.id, slug: p.slug, name: v.name, description: v.description, benefits: v.benefits, version: v.version,
perks: (await Promise.all(v.perks.map(async (x) => ({ key: x.key, name: x.name, kind: await kindOf(x.id) })))),
perks: (await Promise.all(v.perks.map(async (x) => ({ key: x.key, name: x.name, kind: await kindOf(x.id) })))),
- purchasable: p.purchasable,
+ purchasable: p.purchasable, tier: p.tier,
+ price_cents: p.price_cents != null ? p.price_cents : null,
join_url: `${u.page}#plan-${encodeURIComponent(p.slug)}`,
join_url: `${u.page}#plan-${encodeURIComponent(p.slug)}`,
terms_url: `${u.page}/plans/${encodeURIComponent(p.slug)}`,
terms_url: `${u.page}/plans/${encodeURIComponent(p.slug)}`,
};
};
}))),
}))),
join_url: u.page,
join_url: u.page,
joining_open: joiningOpen,
joining_open: joiningOpen,
- price: sub ? { price_cents: sub.price_cents, period_days: sub.period_days, charged_by: 'OpenVibe.Billing' } : null,
+ // The lowest price a new member can join at (one plan per tier, each priced by Billing).
+ price: sub ? { price_cents: lowest(list, sub), period_days: sub.period_days, charged_by: 'OpenVibe.Billing' } : null,
members: { count: members.count, shown: members.shown, stale: members.stale },
members: { count: members.count, shown: members.shown, stale: members.stale },
badge: { ...await badge(c, list), svg_url: u.badge },
badge: { ...await badge(c, list), svg_url: u.badge },
widget_url: u.widget,
widget_url: u.widget,
@@ -114,6 +118,11 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
@@ -114,6 +118,11 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
};
};
}
}
+ function lowest(list, sub) {
+ const priced = list.filter((p) => p.purchasable && p.price_cents != null).map((p) => p.price_cents);
+ return priced.length ? Math.min(...priced) : sub.price_cents;
+ }
+
return { card, badge, memberCount, publicPlans, urls };
return { card, badge, memberCount, publicPlans, urls };
}
}
@@ -24,6 +24,8 @@ function billingProblem(err) {
@@ -24,6 +24,8 @@ function billingProblem(err) {
if (code === 'billing.insufficient_funds') fail(409, 'vip.checkout.insufficient_credit', 'your Vibes credit does not cover this membership');
if (code === 'billing.insufficient_funds') fail(409, 'vip.checkout.insufficient_credit', 'your Vibes credit does not cover this membership');
if (code === 'billing.provider_disabled' || code === 'billing.invalid_input') fail(409, 'vip.checkout.provider_unavailable', 'that payment method is not available right now');
if (code === 'billing.provider_disabled' || code === 'billing.invalid_input') fail(409, 'vip.checkout.provider_unavailable', 'that payment method is not available right now');
if (code === 'billing.self_dealing') fail(422, 'vip.checkout.self', 'you cannot join your own plan');
if (code === 'billing.self_dealing') fail(422, 'vip.checkout.self', 'you cannot join your own plan');
+ if (code === 'billing.tier_change_unsupported') fail(409, 'vip.checkout.other_tier', 'you already hold another tier of this creator; cancel it and join this one when it ends');
+ if (code === 'billing.tier_unknown' || code === 'billing.tier_archived') fail(409, 'vip.checkout.unavailable', 'this plan has no price on Billing yet, so it cannot be joined');
if (err.status === 403 || err.status === 401) fail(503, 'vip.billing_unavailable', 'VIP is not allowed to reach Billing for this yet');
if (err.status === 403 || err.status === 401) fail(503, 'vip.billing_unavailable', 'VIP is not allowed to reach Billing for this yet');
fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; nothing was charged');
fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; nothing was charged');
}
}
@@ -60,7 +62,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -60,7 +62,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
if (p === 'credit') {
if (p === 'credit') {
let out;
let out;
try {
try {
- out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, autoRenew, key: `vip:checkout:${id}`, traceparent });
+ out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew, key: `vip:checkout:${id}`, traceparent });
} catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
} catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
const sub = out.subscription || {};
const sub = out.subscription || {};
const ent = out.entitlement || {};
const ent = out.entitlement || {};
@@ -70,7 +72,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -70,7 +72,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
await entitlements.apply({
await entitlements.apply({
member, creator: creator.subject, active: !!ent.active, expiresAt: ent.expires_at || sub.current_period_end || null,
member, creator: creator.subject, active: !!ent.active, expiresAt: ent.expires_at || sub.current_period_end || null,
cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id || null, subscriptionStatus: sub.status || null,
cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id || null, subscriptionStatus: sub.status || null,
- reason: 'granted', source: 'billing_check', asOf: now(), checkoutId: id, traceparent,
+ reason: 'granted', source: 'billing_check', asOf: now(), checkoutId: id, tier: ent.tier, traceparent,
});
});
const txn = out.transaction && out.transaction.id;
const txn = out.transaction && out.transaction.id;
if (txn) {
if (txn) {
@@ -86,7 +88,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -86,7 +88,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
let out;
let out;
try {
try {
out = await billing.createIntent({
out = await billing.createIntent({
- provider: p, subject: member, creator: creator.subject, autoRenew,
+ provider: p, subject: member, creator: creator.subject, tier: Number(plan.billing_tier) || 1, autoRenew,
successUrl: successUrl || `${config.baseUrl}/me?joined=${encodeURIComponent(creator.username || '')}`,
successUrl: successUrl || `${config.baseUrl}/me?joined=${encodeURIComponent(creator.username || '')}`,
cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,
cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,
key: `vip:checkout:${id}`, traceparent,
key: `vip:checkout:${id}`, traceparent,
@@ -60,7 +60,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -60,7 +60,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
* Apply what Billing said about (member, creator). Runs in a transaction (joins the caller's).
* Apply what Billing said about (member, creator). Runs in a transaction (joins the caller's).
* input: { member, creator, kind?, active, expiresAt, cancelAtPeriodEnd?, subscriptionId?,
* input: { member, creator, kind?, active, expiresAt, cancelAtPeriodEnd?, subscriptionId?,
* subscriptionStatus?, graceUntil?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),
* subscriptionStatus?, graceUntil?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),
- * origin?, checkoutId?, traceparent? }
+ * origin?, checkoutId?, tier? (Billing's, for filing a membership made elsewhere under the plan of that tier), traceparent? }
*/
*/
async function apply(input) {
async function apply(input) {
return await db.tx(async () => {
return await db.tx(async () => {
@@ -97,7 +97,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -97,7 +97,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
subscription_status = excluded.subscription_status, grace_until = excluded.grace_until, source = excluded.source, last_reason = excluded.last_reason,
subscription_status = excluded.subscription_status, grace_until = excluded.grace_until, source = excluded.source, last_reason = excluded.last_reason,
billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,
billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,
valid_until = excluded.valid_until`).run(next);
valid_until = excluded.valid_until`).run(next);
- const { membership } = await memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });
+ const { membership } = await memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId, tier: input.tier });
// A first look that finds no membership is not a change anyone needs to hear about.
// A first look that finds no membership is not a change anyone needs to hear about.
const changed = prev
const changed = prev
? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end
? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end
@@ -10,6 +10,7 @@ const { createEntitlements } = require('./entitlements');
@@ -10,6 +10,7 @@ const { createEntitlements } = require('./entitlements');
const { createPolicies } = require('./policies');
const { createPolicies } = require('./policies');
const { createCheckout } = require('./checkout');
const { createCheckout } = require('./checkout');
const { createCards } = require('./cards');
const { createCards } = require('./cards');
+const { createPrices } = require('./prices');
function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console, indexnow = null }) {
function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console, indexnow = null }) {
const creators = createCreators({ db, now });
const creators = createCreators({ db, now });
@@ -20,9 +21,10 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), log
@@ -20,9 +21,10 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), log
const entitlements = createEntitlements({ db, now, config, billing, outbox, memberships, plans, log });
const entitlements = createEntitlements({ db, now, config, billing, outbox, memberships, plans, log });
const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });
const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });
const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements, periods });
const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements, periods });
- const cards = createCards({ db, config, creators, plans, perks, billing, now, log });
+ const prices = createPrices({ config, billing, now });
+ const cards = createCards({ db, config, creators, plans, perks, billing, prices, now, log });
const tx = async (fn) => await db.tx(fn);
const tx = async (fn) => await db.tx(fn);
- return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards };
+ return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards, prices };
}
}
module.exports = { createDomain };
module.exports = { createDomain };
@@ -27,7 +27,7 @@ function createMemberships({ db, now, creators, plans }) {
@@ -27,7 +27,7 @@ function createMemberships({ db, now, creators, plans }) {
}
}
/** Record the version a membership is under. Inside the projection's transaction. */
/** Record the version a membership is under. Inside the projection's transaction. */
- async function onProjection({ prev, next, reason, origin = null, checkoutId = null }) {
+ async function onProjection({ prev, next, reason, origin = null, checkoutId = null, tier = null }) {
const creator = await creators.bySubject(next.creator_subject) || await creators.ensure({ subject: next.creator_subject, origin: 'system' });
const creator = await creators.bySubject(next.creator_subject) || await creators.ensure({ subject: next.creator_subject, origin: 'system' });
const member = next.member_subject;
const member = next.member_subject;
let m = await get(member, creator.id);
let m = await get(member, creator.id);
@@ -54,7 +54,7 @@ function createMemberships({ db, now, creators, plans }) {
@@ -54,7 +54,7 @@ function createMemberships({ db, now, creators, plans }) {
planId = checkout.plan_id; versionId = checkout.plan_version_id; how = 'checkout';
planId = checkout.plan_id; versionId = checkout.plan_version_id; how = 'checkout';
await db.prepare("UPDATE vip_checkouts SET status = 'used', billing_subscription_id = COALESCE(billing_subscription_id, ?), updated_at = ? WHERE id = ?").run(next.subscription_id || null, at, checkout.id);
await db.prepare("UPDATE vip_checkouts SET status = 'used', billing_subscription_id = COALESCE(billing_subscription_id, ?), updated_at = ? WHERE id = ?").run(next.subscription_id || null, at, checkout.id);
} else {
} else {
- const plan = await plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind);
+ const plan = await plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind, tier || 1);
if (plan) { planId = plan.id; versionId = plan.current_version_id; }
if (plan) { planId = plan.id; versionId = plan.current_version_id; }
}
}
if (m) {
if (m) {
@@ -9,15 +9,17 @@
@@ -9,15 +9,17 @@
* members buy; members who joined earlier keep the version they bought (vip_memberships).
* members buy; members who joined earlier keep the version they bought (vip_memberships).
*
*
* Prices are not plan terms here: Billing prices and charges a subscription, and records what each
* Prices are not plan terms here: Billing prices and charges a subscription, and records what each
- * period cost. A plan says which Billing product it is sold as (billing_kind), and Billing sells one
- * channel subscription per (member, creator) today, so a creator has at most one published plan
- * bound to it. A plan with no Billing product (a network plan today) can be published as terms, but
- * has no checkout until Billing sells it.
+ * period cost. A plan says which Billing product it is sold as (billing_kind) and at which tier
+ * (billing_tier, 1 to MAX_TIER, fixed when the plan is created): a creator publishes at most one plan per tier,
+ * and sets each tier's monthly price on Billing (domain/prices.js, billing.tier.manage). A member holds one
+ * tier of a creator at a time. A plan with no Billing product (a network plan today) can be published as
+ * terms, but has no checkout until Billing sells it.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { fail, prefixedId, iso, text, slug, slugify, json } = require('../util');
const { fail, prefixedId, iso, text, slug, slugify, json } = require('../util');
const BILLING_KINDS = ['channel_subscription'];
const BILLING_KINDS = ['channel_subscription'];
+const MAX_TIER = 10;
function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow = null }) {
function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow = null }) {
const byId = async (id) => await db.prepare('SELECT * FROM vip_plans WHERE id = ?').get(id) || null;
const byId = async (id) => await db.prepare('SELECT * FROM vip_plans WHERE id = ?').get(id) || null;
@@ -55,7 +57,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
@@ -55,7 +57,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
const at = iso(now());
const at = iso(now());
const id = prefixedId('vpv', now());
const id = prefixedId('vpv', now());
const perkSnap = perkRows.map((p) => ({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator' }));
const perkSnap = perkRows.map((p) => ({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator' }));
- const terms = { name, description, benefits, perks: perkSnap, billing_kind: plan.billing_kind, price: plan.billing_kind ? 'set and charged by OpenVibe.Billing' : null };
+ const terms = { name, description, benefits, perks: perkSnap, billing_kind: plan.billing_kind, ...(plan.billing_kind ? { tier: Number(plan.billing_tier) || 1 } : {}), price: plan.billing_kind ? 'set and charged by OpenVibe.Billing' : null };
terms.digest = crypto.createHash('sha256').update(JSON.stringify([terms.name, terms.description, terms.benefits, terms.perks, terms.billing_kind])).digest('hex').slice(0, 16);
terms.digest = crypto.createHash('sha256').update(JSON.stringify([terms.name, terms.description, terms.benefits, terms.perks, terms.billing_kind])).digest('hex').slice(0, 16);
await db.prepare(`INSERT INTO vip_plan_versions (id, plan_id, version, name, description, benefits, terms, change_note, created_by, created_at, published_at)
await db.prepare(`INSERT INTO vip_plan_versions (id, plan_id, version, name, description, benefits, terms, change_note, created_by, created_at, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, plan.id, n, name, description, JSON.stringify(benefits), JSON.stringify(terms), changeNote || null, actor, at, publish ? at : null);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, plan.id, n, name, description, JSON.stringify(benefits), JSON.stringify(terms), changeNote || null, actor, at, publish ? at : null);
@@ -82,14 +84,26 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
@@ -82,14 +84,26 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
}
}
function publishConflict(e) {
function publishConflict(e) {
- // PostgreSQL unique_violation (23505) on the one-published-plan-per-billing-kind index of vip_plans.
- if (e && e.code === '23505' && e.table === 'vip_plans' && /\(creator_id, billing_kind\)/.test(e.detail || '')) {
- fail(409, 'vip.plan.billing_product_taken', 'this creator already has a published plan sold as a channel subscription; Billing sells one per member and creator today, so archive the other plan first');
+ // PostgreSQL unique_violation (23505) on the one-published-plan-per-tier index of vip_plans.
+ if (e && e.code === '23505' && e.table === 'vip_plans' && /\(creator_id, billing_kind, billing_tier\)/.test(e.detail || '')) {
+ fail(409, 'vip.plan.billing_product_taken', 'another published plan is already sold at this tier; archive it first, or create this plan at another tier');
}
}
throw e;
throw e;
}
}
- async function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, publish = false, changeNote, actor = null, traceparent }) {
+ /** A tier from a form or the API: 1..MAX_TIER; when absent, the creator's lowest tier no live plan uses. */
+ async function tierFor(creatorId, value) {
+ if (value != null && value !== '') {
+ const n = Number(value);
+ if (!Number.isSafeInteger(n) || n < 1 || n > MAX_TIER) fail(422, 'vip.invalid_input', `tier must be a whole number from 1 to ${MAX_TIER}`);
+ return n;
+ }
+ const used = new Set((await db.prepare("SELECT billing_tier FROM vip_plans WHERE creator_id = ? AND status <> 'archived' AND billing_kind IS NOT NULL").all(creatorId)).map((r) => Number(r.billing_tier)));
+ for (let t = 1; t <= MAX_TIER; t++) if (!used.has(t)) return t;
+ fail(409, 'vip.plan.tiers_full', `a creator sells at most ${MAX_TIER} tiers; archive a plan first`);
+ }
+
+ async function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, tier, publish = false, changeNote, actor = null, traceparent }) {
const creator = await creators.byId(creatorId);
const creator = await creators.byId(creatorId);
if (!creator) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
if (!creator) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
const kind = billingKind === undefined ? (creator.kind === 'network' ? null : 'channel_subscription') : (billingKind || null);
const kind = billingKind === undefined ? (creator.kind === 'network' ? null : 'channel_subscription') : (billingKind || null);
@@ -99,12 +113,13 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
@@ -99,12 +113,13 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
const planSlug = s ? slug(s) : slugify(cleanName);
const planSlug = s ? slug(s) : slugify(cleanName);
if (await db.prepare('SELECT 1 FROM vip_plans WHERE creator_id = ? AND slug = ?').get(creatorId, planSlug)) fail(409, 'vip.plan_exists', `a plan ${planSlug} already exists`);
if (await db.prepare('SELECT 1 FROM vip_plans WHERE creator_id = ? AND slug = ?').get(creatorId, planSlug)) fail(409, 'vip.plan_exists', `a plan ${planSlug} already exists`);
const perkRows = await perks.resolveForPlan(creatorId, perkRefs || []);
const perkRows = await perks.resolveForPlan(creatorId, perkRefs || []);
+ const billingTier = kind ? await tierFor(creatorId, tier) : 1;
const at = iso(now());
const at = iso(now());
const id = prefixedId('vpl', now());
const id = prefixedId('vpl', now());
try {
try {
const plan = await db.tx(async () => {
const plan = await db.tx(async () => {
- await db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, current_version_id, latest_version, created_by, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(id, creatorId, planSlug, publish ? 'published' : 'draft', kind, actor, at, at);
+ await db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, billing_tier, current_version_id, latest_version, sort, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, NULL, 0, ?, ?, ?, ?)`).run(id, creatorId, planSlug, publish ? 'published' : 'draft', kind, billingTier, billingTier, actor, at, at);
const v = await insertVersion(await byId(id), {
const v = await insertVersion(await byId(id), {
name: cleanName, description: text(description, 'description', 2000), benefits: cleanBenefits(benefits) || [],
name: cleanName, description: text(description, 'description', 2000), benefits: cleanBenefits(benefits) || [],
perkRows, changeNote: changeNote || 'created', actor, publish,
perkRows, changeNote: changeNote || 'created', actor, publish,
@@ -180,8 +195,8 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
@@ -180,8 +195,8 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
return await db.prepare(`SELECT * FROM vip_plans WHERE creator_id = ? AND status IN (${st.map(() => '?').join(',')}) ORDER BY sort, created_at`).all(creatorId, ...st);
return await db.prepare(`SELECT * FROM vip_plans WHERE creator_id = ? AND status IN (${st.map(() => '?').join(',')}) ORDER BY sort, created_at`).all(creatorId, ...st);
}
}
- /** What a new member of this creator buys through Billing's product `kind`. */
- const purchasable = async (creatorId, kind = 'channel_subscription') => await db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND status = 'published'").get(creatorId, kind) || null;
+ /** What a new member of this creator buys through Billing's product `kind` at `tier` (a subscription made elsewhere, such as Live's, is tier 1). */
+ const purchasable = async (creatorId, kind = 'channel_subscription', tier = 1) => await db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND billing_tier = ? AND status = 'published'").get(creatorId, kind, Number(tier) || 1) || null;
async function presentVersion(v) {
async function presentVersion(v) {
if (!v) return null;
if (!v) return null;
@@ -198,6 +213,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
@@ -198,6 +213,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
const latest = await latestVersion(plan.id);
const latest = await latestVersion(plan.id);
return {
return {
id: plan.id, creator_id: plan.creator_id, slug: plan.slug, status: plan.status, billing_kind: plan.billing_kind,
id: plan.id, creator_id: plan.creator_id, slug: plan.slug, status: plan.status, billing_kind: plan.billing_kind,
+ tier: plan.billing_kind ? Number(plan.billing_tier) || 1 : null,
purchasable: plan.status === 'published' && !!plan.billing_kind,
purchasable: plan.status === 'published' && !!plan.billing_kind,
current_version: await presentVersion(current),
current_version: await presentVersion(current),
draft_version: latest && (!current || latest.version > current.version) ? await presentVersion(latest) : null,
draft_version: latest && (!current || latest.version > current.version) ? await presentVersion(latest) : null,
@@ -206,7 +222,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
@@ -206,7 +222,7 @@ function createPlans({ db, now, outbox, creators, perks, baseUrl = '', indexnow
};
};
}
}
- return { byId, version, latestVersion, versions, versionPerks, create, update, publish, archive, list, purchasable, present, presentVersion, BILLING_KINDS };
+ return { byId, version, latestVersion, versions, versionPerks, create, update, publish, archive, list, purchasable, present, presentVersion, tierFor, BILLING_KINDS, MAX_TIER };
}
}
module.exports = { createPlans };
module.exports = { createPlans };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+
+/**
+ * A creator's tier prices, kept by OpenVibe.Billing (billing.tier.manage, openvibe-contracts 0.201.0; ADR-012: Billing
+ * prices, VIP never does). Each plan sold through Billing names its tier (plans.billing_tier); the creator sets that
+ * tier's monthly price here and Billing charges it. Tier 1 without a price is sold at Billing's default price; a
+ * higher tier without one is not for sale until the creator prices it.
+ *
+ * forCreator(subject) { tiers: Map(tier → { price_cents, status }), defaultCents, minCents, maxCents } or
+ * null when Billing does not answer (pages then say the price is shown at checkout).
+ * Cached per creator (config.billing.ratesTtlMs), dropped when the creator sets one.
+ * priceOf(table, tier) the price new members pay for a tier, or null when it is not for sale
+ * set(creator, plan, cents) set the plan's tier price on Billing
+ */
+const { fail, prefixedId } = require('../util');
+const { BillingCallError } = require('../billing-client');
+
+function createPrices({ config, billing, now }) {
+ const cache = new Map();
+
+ async function forCreator(subject) {
+ if (!subject) return null;
+ const hit = cache.get(subject);
+ if (hit && now() - hit.at < config.billing.ratesTtlMs) return hit.value;
+ let value = null;
+ try {
+ const out = await billing.listTiers(subject);
+ value = {
+ tiers: new Map((out.tiers || []).map((t) => [Number(t.tier), { price_cents: Number(t.price_cents), status: t.status }])),
+ defaultCents: Number(out.default_price_cents) || null, minCents: Number(out.min_price_cents) || null, maxCents: Number(out.max_price_cents) || null,
+ };
+ cache.set(subject, { at: now(), value });
+ } catch {
+ // Billing did not answer: keep the last answer a little longer rather than show nothing.
+ value = hit ? hit.value : null;
+ cache.set(subject, { at: now() - config.billing.ratesTtlMs + 30_000, value });
+ }
+ return value;
+ }
+
+ function priceOf(table, tier) {
+ if (!table) return null;
+ const t = Number(tier) || 1;
+ const row = table.tiers.get(t);
+ if (row) return row.status === 'active' ? row.price_cents : null;
+ return t === 1 ? table.defaultCents : null;
+ }
+
+ /** Presented plans with `price_cents` (null when unknown or unpriced); a higher tier without a price is not purchasable. */
+ async function withPrices(creatorSubject, plans) {
+ const table = await forCreator(creatorSubject);
+ return plans.map((p) => {
+ if (!p || !p.billing_kind) return p;
+ const price = priceOf(table, p.tier);
+ const unpriced = !!table && price == null;
+ return { ...p, price_cents: price, purchasable: p.purchasable && !unpriced };
+ });
+ }
+
+ /** The creator sets their plan's tier price (USD cents a month). */
+ async function set(creator, plan, priceCents, { traceparent } = {}) {
+ if (!plan || !plan.billing_kind) fail(409, 'vip.plan.not_sold', 'this plan is not sold through Billing, so it has no price');
+ if (!creator || !creator.subject) fail(409, 'vip.plan.not_sold', 'this creator cannot sell through Billing');
+ const cents = Number(priceCents);
+ if (!Number.isSafeInteger(cents) || cents <= 0) fail(422, 'vip.invalid_input', 'price must be an amount in dollars and cents, like 4.99');
+ try {
+ // A fresh key per change: the same price set twice is two calls, and a retry of one call is the same call.
+ const out = await billing.setTier({ creator: creator.subject, tier: Number(plan.billing_tier) || 1, priceCents: cents, key: `vip:tier:${prefixedId('vtp', now())}`, traceparent });
+ cache.delete(creator.subject);
+ return out.tier;
+ } catch (e) {
+ if (!(e instanceof BillingCallError)) throw e;
+ if (e.code === 'billing.invalid_amount' || e.code === 'billing.invalid_input') fail(422, 'vip.invalid_input', (e.body && e.body.detail) || 'Billing refused that price');
+ if (e.code === 'billing.frozen') fail(503, 'vip.billing_frozen', 'payments are paused right now; please try again later');
+ if (e.status === 403 || e.status === 401) fail(503, 'vip.billing_unavailable', 'VIP is not allowed to set prices on Billing yet');
+ fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; the price did not change');
+ }
+ }
+
+ /** "$4.99" from cents, and cents from "4.99" / "$4.99" / "5" (null when it is not an amount). */
+ const centsFromDollars = (v) => {
+ const m = /^\s*\$?\s*(\d{1,6})(?:\.(\d{1,2}))?\s*$/.exec(String(v == null ? '' : v));
+ return m ? Number(m[1]) * 100 + Number((m[2] || '0').padEnd(2, '0')) : null;
+ };
+
+ return { forCreator, priceOf, withPrices, set, centsFromDollars };
+}
+
+module.exports = { createPrices };
@@ -63,6 +63,7 @@ function consumerRouter({ domain, config, log = console, accountData = null, acc
@@ -63,6 +63,7 @@ function consumerRouter({ domain, config, log = console, accountData = null, acc
member, creator, active: !!p.active && !reversed, expiresAt: p.expires_at || null,
member, creator, active: !!p.active && !reversed, expiresAt: p.expires_at || null,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
subscriptionStatus: sub ? sub.status : undefined, graceUntil: p.grace_until || undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,
subscriptionStatus: sub ? sub.status : undefined, graceUntil: p.grace_until || undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,
+ tier: p.tier || undefined,
});
});
if (reversed) await entitlements.doubt(member, creator);
if (reversed) await entitlements.doubt(member, creator);
return out.outcome;
return out.outcome;
@@ -25,10 +25,11 @@ function notice(q) {
@@ -25,10 +25,11 @@ function notice(q) {
return '';
return '';
}
}
-function priceLine(rates) {
+function priceLine(rates, cents) {
const sub = rates && rates.subscription;
const sub = rates && rates.subscription;
- if (!sub || !sub.price_cents) return '<p class="price muted">OpenVibe.Billing sets the price; it is shown at checkout.</p>';
- return `<p class="price"><b>${money(sub.price_cents)}</b> every ${esc(sub.period_days)} days, charged and renewed by OpenVibe.Billing.</p>`;
+ const price = cents != null ? cents : sub && sub.price_cents;
+ if (!price) return '<p class="price muted">OpenVibe.Billing charges the price; it is shown at checkout.</p>';
+ return `<p class="price"><b>${money(price)}</b> every ${esc((sub && sub.period_days) || 31)} days, charged and renewed by OpenVibe.Billing.</p>`;
}
}
function perkList(perks) {
function perkList(perks) {
@@ -116,7 +117,7 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You jo
@@ -116,7 +117,7 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You jo
const planCards = plans.length ? plans.map((p) => {
const planCards = plans.length ? plans.map((p) => {
const v = p.current_version;
const v = p.current_version;
const join = !p.purchasable
const join = !p.purchasable
- ? '<p class="muted">This plan is not sold through Billing yet.</p>'
+ ? `<p class="muted">${p.billing_kind && p.status === 'published' ? 'This plan has no price yet, so it cannot be joined.' : 'This plan is not sold through Billing yet.'}</p>`
: isOwner ? '<p class="muted">This is your plan.</p>'
: isOwner ? '<p class="muted">This is your plan.</p>'
: member && member.status === 'active' ? ''
: member && member.status === 'active' ? ''
: !providers.length ? '<p class="muted">Joining through OpenVibe.Billing is not open yet.</p>'
: !providers.length ? '<p class="muted">Joining through OpenVibe.Billing is not open yet.</p>'
@@ -127,11 +128,11 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You jo
@@ -127,11 +128,11 @@ ${member.membership && member.membership.plan_version ? `<p class="muted">You jo
<button type="submit">Join ${esc(v.name)}</button></form>`
<button type="submit">Join ${esc(v.name)}</button></form>`
: `<p><a class="button" href="/auth/login?next=${encodeURIComponent(`/${creator.username}`)}">Sign in to join</a></p>`;
: `<p><a class="button" href="/auth/login?next=${encodeURIComponent(`/${creator.username}`)}">Sign in to join</a></p>`;
return `<article class="card plan" id="plan-${esc(p.slug)}">
return `<article class="card plan" id="plan-${esc(p.slug)}">
-<h2>${esc(v.name)}</h2>
+<h2>${esc(v.name)}${plans.filter((x) => x.billing_kind).length > 1 && p.tier ? ` <span class="pill">Tier ${esc(p.tier)}</span>` : ''}</h2>
${v.description ? `<p>${esc(v.description)}</p>` : ''}
${v.description ? `<p>${esc(v.description)}</p>` : ''}
${benefitList(v.benefits)}
${benefitList(v.benefits)}
${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}
${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}
-${p.purchasable ? priceLine(rates) : ''}
+${p.purchasable ? priceLine(rates, p.price_cents) : ''}
${join}
${join}
<p class="muted small">Version ${esc(v.version)} · published ${fmtDate(v.published_at)} · <a href="/${encodeURIComponent(creator.username)}/plans/${encodeURIComponent(p.slug)}">terms history</a></p>
<p class="muted small">Version ${esc(v.version)} · published ${fmtDate(v.published_at)} · <a href="/${encodeURIComponent(creator.username)}/plans/${encodeURIComponent(p.slug)}">terms history</a></p>
</article>`;
</article>`;
@@ -194,7 +195,7 @@ function perkChoices(allPerks, selectedKeys) {
@@ -194,7 +195,7 @@ function perkChoices(allPerks, selectedKeys) {
return `<fieldset class="perk-choices"><legend>Perks</legend>${allPerks.map((p) => `<label class="check"><input type="checkbox" name="perks" value="${esc(p.key)}"${selectedKeys.includes(p.key) ? ' checked' : ''}> ${esc(p.name)}${p.scope === 'network' ? ' <span class="muted small">(network)</span>' : ''}</label>`).join('')}</fieldset>`;
return `<fieldset class="perk-choices"><legend>Perks</legend>${allPerks.map((p) => `<label class="check"><input type="checkbox" name="perks" value="${esc(p.key)}"${selectedKeys.includes(p.key) ? ' checked' : ''}> ${esc(p.name)}${p.scope === 'network' ? ' <span class="muted small">(network)</span>' : ''}</label>`).join('')}</fieldset>`;
}
}
-function planEditor({ plan, allPerks, token, base }) {
+function planEditor({ plan, allPerks, token, base, priceTable }) {
const v = plan.draft_version || plan.current_version;
const v = plan.draft_version || plan.current_version;
const keys = v ? v.perks.map((p) => p.key) : [];
const keys = v ? v.perks.map((p) => p.key) : [];
const versions = (plan.versions || []).map((x) => `<li>v${esc(x.version)} · ${esc(x.name)} · ${x.published_at ? `published ${fmtDate(x.published_at)}` : 'draft'}${x.change_note ? ` · ${esc(x.change_note)}` : ''}${x.perks.length ? ` · perks: ${x.perks.map((p) => esc(p.key)).join(', ')}` : ''}</li>`).join('');
const versions = (plan.versions || []).map((x) => `<li>v${esc(x.version)} · ${esc(x.name)} · ${x.published_at ? `published ${fmtDate(x.published_at)}` : 'draft'}${x.change_note ? ` · ${esc(x.change_note)}` : ''}${x.perks.length ? ` · perks: ${x.perks.map((p) => esc(p.key)).join(', ')}` : ''}</li>`).join('');
@@ -203,7 +204,8 @@ function planEditor({ plan, allPerks, token, base }) {
@@ -203,7 +204,8 @@ function planEditor({ plan, allPerks, token, base }) {
plan.status !== 'archived' ? `<form method="post" action="${base}/plans/${esc(plan.id)}/archive" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Archive</button></form>` : '',
plan.status !== 'archived' ? `<form method="post" action="${base}/plans/${esc(plan.id)}/archive" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Archive</button></form>` : '',
].join(' ');
].join(' ');
return `<article class="card plan-editor"><h3>${esc(v ? v.name : plan.slug)} <span class="pill">${esc(plan.status)}</span></h3>
return `<article class="card plan-editor"><h3>${esc(v ? v.name : plan.slug)} <span class="pill">${esc(plan.status)}</span></h3>
-<p class="muted small">${plan.billing_kind ? 'Sold through OpenVibe.Billing as a channel subscription.' : 'Not sold through Billing (terms only).'} ${plan.current_version ? `New members buy v${esc(plan.current_version.version)}.` : ''}</p>
+<p class="muted small">${plan.billing_kind ? `Tier ${esc(plan.tier)}, sold through OpenVibe.Billing as a channel subscription${plan.price_cents != null ? ` at <b>${money(plan.price_cents)}</b> a month` : priceTable ? ', with no price yet: set one so members can join' : ''}.` : 'Not sold through Billing (terms only).'} ${plan.current_version ? `New members buy v${esc(plan.current_version.version)}.` : ''}</p>
+${plan.billing_kind && plan.status !== 'archived' ? `<form method="post" action="${base}/plans/${esc(plan.id)}/price" class="inline price-form">${csrf(token)}<label>Monthly price ($) <input name="price" inputmode="decimal" required pattern="\\$?[0-9]{1,6}(\\.[0-9]{1,2})?" size="8" value="${plan.price_cents != null ? esc((plan.price_cents / 100).toFixed(2)) : ''}"${priceTable && priceTable.minCents ? ` placeholder="${esc((priceTable.minCents / 100).toFixed(2))} to ${esc((priceTable.maxCents / 100).toFixed(2))}"` : ''}></label> <button type="submit" class="button-quiet">Set price</button></form>` : ''}
${plan.status !== 'archived' ? `<details><summary>Edit (creates version ${esc((plan.versions || []).length + 1)})</summary>
${plan.status !== 'archived' ? `<details><summary>Edit (creates version ${esc((plan.versions || []).length + 1)})</summary>
<form method="post" action="${base}/plans/${esc(plan.id)}">${csrf(token)}
<form method="post" action="${base}/plans/${esc(plan.id)}">${csrf(token)}
<label>Name <input name="name" maxlength="80" required value="${esc(v ? v.name : '')}"></label>
<label>Name <input name="name" maxlength="80" required value="${esc(v ? v.name : '')}"></label>
@@ -233,7 +235,7 @@ function shareCard({ creator, embeds, hasPlan }) {
@@ -233,7 +235,7 @@ function shareCard({ creator, embeds, hasPlan }) {
<label>Card for developers (JSON, open to any site) <input readonly value="${esc(embeds.card)}"></label></section>`;
<label>Card for developers (JSON, open to any site) <input readonly value="${esc(embeds.card)}"></label></section>`;
}
}
-function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base, embeds }) {
+function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base, embeds, priceTable = null }) {
if (!viewer) {
if (!viewer) {
return `<section class="card narrow"><h1>Creator dashboard</h1><p>Sign in with your OpenVibe account to offer a membership plan.</p>
return `<section class="card narrow"><h1>Creator dashboard</h1><p>Sign in with your OpenVibe account to offer a membership plan.</p>
<p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;
<p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;
@@ -248,14 +250,16 @@ ${isNetwork ? '' : `<section class="card"><h2>Profile</h2><form method="post" ac
@@ -248,14 +250,16 @@ ${isNetwork ? '' : `<section class="card"><h2>Profile</h2><form method="post" ac
<label class="check"><input type="checkbox" name="show_member_count" value="1"${creator.show_member_count ? ' checked' : ''}> Show how many members you have on your card and widget</label>
<label class="check"><input type="checkbox" name="show_member_count" value="1"${creator.show_member_count ? ' checked' : ''}> Show how many members you have on your card and widget</label>
<button type="submit">Save</button></form></section>
<button type="submit">Save</button></form></section>
${shareCard({ creator, embeds, hasPlan: plans.some((p) => p.status === 'published') })}`}
${shareCard({ creator, embeds, hasPlan: plans.some((p) => p.status === 'published') })}`}
-<section><h2>Plans</h2>${plans.length ? plans.map((p) => planEditor({ plan: p, allPerks, token, base })).join('') : '<p class="muted">No plans yet.</p>'}
+<section><h2>Plans</h2>${plans.length ? plans.map((p) => planEditor({ plan: p, allPerks, token, base, priceTable })).join('') : '<p class="muted">No plans yet.</p>'}
<details class="card"><summary>New plan</summary><form method="post" action="${base}/plans">${csrf(token)}
<details class="card"><summary>New plan</summary><form method="post" action="${base}/plans">${csrf(token)}
<label>Name <input name="name" maxlength="80" required></label>
<label>Name <input name="name" maxlength="80" required></label>
<label>Description <textarea name="description" maxlength="2000" rows="3"></textarea></label>
<label>Description <textarea name="description" maxlength="2000" rows="3"></textarea></label>
<label>Benefits, one per line <textarea name="benefits" rows="3"></textarea></label>
<label>Benefits, one per line <textarea name="benefits" rows="3"></textarea></label>
${perkChoices(allPerks, [])}
${perkChoices(allPerks, [])}
+${isNetwork ? '' : `<label>Tier <select name="tier"><option value="">the next free tier</option>${Array.from({ length: 10 }, (_, i) => `<option value="${i + 1}"${plans.some((p) => p.billing_kind && p.status !== 'archived' && p.tier === i + 1) ? ' disabled' : ''}>Tier ${i + 1}</option>`).join('')}</select></label>
+<label>Monthly price ($) <input name="price" inputmode="decimal" pattern="\\$?[0-9]{1,6}(\\.[0-9]{1,2})?" size="8"${priceTable && priceTable.minCents ? ` placeholder="${esc((priceTable.minCents / 100).toFixed(2))} to ${esc((priceTable.maxCents / 100).toFixed(2))}"` : ''}></label>`}
<label class="check"><input type="checkbox" name="publish" value="1"> Publish now</label>
<label class="check"><input type="checkbox" name="publish" value="1"> Publish now</label>
-<p class="muted small">${isNetwork ? 'Network plans are terms only until Billing sells a network membership.' : 'A published plan is sold through OpenVibe.Billing as your channel subscription; Billing sets the price. One published plan can be sold at a time.'}</p>
+<p class="muted small">${isNetwork ? 'Network plans are terms only until Billing sells a network membership.' : `Each plan is one tier of your membership, sold through OpenVibe.Billing as your channel subscription at the price you set${priceTable && priceTable.defaultCents ? ` (tier 1 without a price sells at ${money(priceTable.defaultCents)})` : ''}. A member holds one tier at a time; put everything a tier includes in its perks and benefits.`}</p>
<button type="submit">Create plan</button></form></details></section>
<button type="submit">Create plan</button></form></details></section>
<section><h2>Perks</h2>
<section><h2>Perks</h2>
${ownPerks.length ? `<ul class="perk-admin">${ownPerks.map((p) => `<li><details><summary><b>${esc(p.name)}</b> <span class="muted small">${esc(p.key)} · ${esc(p.kind)} · ${esc(p.status)}</span></summary>
${ownPerks.length ? `<ul class="perk-admin">${ownPerks.map((p) => `<li><details><summary><b>${esc(p.name)}</b> <span class="muted small">${esc(p.key)} · ${esc(p.kind)} · ${esc(p.status)}</span></summary>
@@ -38,7 +38,7 @@ const LLMS_FULL_TTL_MS = 3600 * 1000;
@@ -38,7 +38,7 @@ const LLMS_FULL_TTL_MS = 3600 * 1000;
function createWebRoutes({ domain, config, layout, userAuth }) {
function createWebRoutes({ domain, config, layout, userAuth }) {
const router = express.Router();
const router = express.Router();
- const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;
+ const { creators, plans, perks, memberships, entitlements, policies, checkout, billing, prices } = domain;
const forms = createForms({ secret: config.formSecret, now: domain.now });
const forms = createForms({ secret: config.formSecret, now: domain.now });
const isStaff = (v) => !!v && v.staff === true;
const isStaff = (v) => !!v && v.staff === true;
router.use(viewerMiddleware(userAuth));
router.use(viewerMiddleware(userAuth));
@@ -52,6 +52,17 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -52,6 +52,17 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
}
}
const send = (res, status, o) => res.status(status).type('html').send(layout.page(o));
const send = (res, status, o) => res.status(status).type('html').send(layout.page(o));
+ const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
+ /** A price typed in a form ("4.99", "$5"): cents, or null when left blank; anything else is refused. */
+ function priceInput(v, { required = false } = {}) {
+ if (v == null || String(v).trim() === '') {
+ if (required) throw new VipError(422, 'vip.invalid_input', 'Type a monthly price, like 4.99.');
+ return null;
+ }
+ const cents = prices.centsFromDollars(v);
+ if (cents == null || cents <= 0) throw new VipError(422, 'vip.invalid_input', 'Type the monthly price in dollars and cents, like 4.99.');
+ return cents;
+ }
const notFound = (req, res) => send(res, 404, { title: 'Not found', robots: 'noindex', viewer: req.viewer, body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) });
const notFound = (req, res) => send(res, 404, { title: 'Not found', robots: 'noindex', viewer: req.viewer, body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) });
// Notices ride in the redirect (?ok= / ?error=) and carry an HMAC of their text, so a crafted
// Notices ride in the redirect (?ok= / ?error=) and carry an HMAC of their text, so a crafted
// link cannot put words of its choosing on a VIP page (e.g. "send your Vibes to …").
// link cannot put words of its choosing on a VIP page (e.g. "send your Vibes to …").
@@ -215,7 +226,9 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -215,7 +226,9 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
let creator;
let creator;
try { creator = await dashCreator(req); } catch (e) { return send(res, 403, { title: 'Dashboard', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.errorPage({ status: 403, title: 'Not allowed', message: e.detail }) }); }
try { creator = await dashCreator(req); } catch (e) { return send(res, 403, { title: 'Dashboard', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.errorPage({ status: 403, title: 'Not allowed', message: e.detail }) }); }
const isNetwork = creator.kind === 'network';
const isNetwork = creator.kind === 'network';
- const planRows = (await Promise.all((await plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true })).map(async (p) => await plans.present(p, { withVersions: true }))));
+ const planList = (await Promise.all((await plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true })).map(async (p) => await plans.present(p, { withVersions: true }))));
+ const planRows = isNetwork ? planList : await prices.withPrices(creator.subject, planList);
+ const priceTable = isNetwork ? null : await prices.forCreator(creator.subject);
const allPerks = (await Promise.all((await perks.list({ creatorId: creator.id })).map(async (p) => await perks.present(p, { withBindings: false }))));
const allPerks = (await Promise.all((await perks.list({ creatorId: creator.id })).map(async (p) => await perks.present(p, { withBindings: false }))));
const ownPerks = (await Promise.all((await perks.list({ creatorId: creator.id, includeNetwork: false, includeRetired: true })).map(async (p) => await perks.present(p))));
const ownPerks = (await Promise.all((await perks.list({ creatorId: creator.id, includeNetwork: false, includeRetired: true })).map(async (p) => await perks.present(p))));
let members = [];
let members = [];
@@ -233,7 +246,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -233,7 +246,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
send(res, 200, {
send(res, 200, {
title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,
title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,
body: pages.dashboard({
body: pages.dashboard({
- viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource,
+ viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource, priceTable,
rules: isNetwork ? [] : await Promise.all((await policies.list(creator.id)).map(policies.present)), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',
rules: isNetwork ? [] : await Promise.all((await policies.list(creator.id)).map(policies.present)), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',
embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,
embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,
}),
}),
@@ -255,8 +268,18 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -255,8 +268,18 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
post(`${base}/plans`, async (req, res, to) => {
post(`${base}/plans`, async (req, res, to) => {
withAs(req);
withAs(req);
const c = await dashCreator(req);
const c = await dashCreator(req);
- const plan = await plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });
- back(res, to, 'ok', `Plan created${plan.status === 'published' ? ' and published' : ''}.`);
+ const cents = priceInput(req.body.price);
+ const plan = await plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), tier: req.body.tier || undefined, publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });
+ if (cents != null && plan.billing_kind) await prices.set(c, plan, cents, { traceparent: req.ov.traceparent });
+ back(res, to, 'ok', `Plan created${plan.status === 'published' ? ' and published' : ''}${cents != null && plan.billing_kind ? ` at ${dollars(cents)} a month` : ''}.`);
+ }, opts);
+ // The plan's tier price, kept and charged by Billing (domain/prices.js).
+ post(`${base}/plans/:id/price`, async (req, res, to) => {
+ const plan = await plans.byId(req.params.id);
+ const c = await own(req, plan);
+ const cents = priceInput(req.body.price, { required: true });
+ await prices.set(c, plan, cents, { traceparent: req.ov.traceparent });
+ back(res, to, 'ok', `Price set: ${dollars(cents)} a month for new members. Current members keep the price they joined at.`);
}, opts);
}, opts);
post(`${base}/plans/:id`, async (req, res, to) => {
post(`${base}/plans/:id`, async (req, res, to) => {
const plan = await plans.byId(req.params.id);
const plan = await plans.byId(req.params.id);
@@ -314,7 +337,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -314,7 +337,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
const c = await creators.byUsername(req.params.username);
const c = await creators.byUsername(req.params.username);
if (!c || c.status !== 'active') return notFound(req, res);
if (!c || c.status !== 'active') return notFound(req, res);
if (c.username !== req.params.username) return res.redirect(301, `/${encodeURIComponent(c.username)}`);
if (c.username !== req.params.username) return res.redirect(301, `/${encodeURIComponent(c.username)}`);
- const list = (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p))));
+ const list = await prices.withPrices(c.subject, (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p)))));
const isOwner = !!(req.viewer && req.viewer.subject === c.subject);
const isOwner = !!(req.viewer && req.viewer.subject === c.subject);
const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
const name = c.display_name || c.username;
const name = c.display_name || c.username;
@@ -73,7 +73,13 @@ async function startBilling(network, clock) {
@@ -73,7 +73,13 @@ async function startBilling(network, clock) {
const periods = []; // { sub_id, subject, scope, starts, ends, revoked, txn }
const periods = []; // { sub_id, subject, scope, starts, ends, revoked, txn }
const idem = new Map();
const idem = new Map();
const calls = [];
const calls = [];
- const state = { down: false, frozen: false, credit: new Map() };
+ const state = { down: false, frozen: false, credit: new Map(), tiers: new Map() }; // tiers: creator → Map(tier → { price_cents, status })
+ /** Billing's tier price (contracts 0.201.0): a row, else tier 1 at the default 499; [status, code] when not for sale. */
+ function tierPrice(creator, tier) {
+ const row = (state.tiers.get(creator) || new Map()).get(tier);
+ if (row) return row.status === 'active' ? row.price_cents : [409, 'billing.tier_archived'];
+ return tier === 1 ? 499 : [422, 'billing.tier_unknown'];
+ }
const now = () => clock.now();
const now = () => clock.now();
const iso = (ms) => new Date(ms).toISOString();
const iso = (ms) => new Date(ms).toISOString();
let seq = 0;
let seq = 0;
@@ -95,12 +101,13 @@ async function startBilling(network, clock) {
@@ -95,12 +101,13 @@ async function startBilling(network, clock) {
const s = byPair(subject, scope);
const s = byPair(subject, scope);
return {
return {
subject: { type: 'user', id: subject }, streamer: { type: 'user', id: scope }, kind: 'channel_subscription', active: !!exp, expires_at: exp ? iso(exp) : null,
subject: { type: 'user', id: subject }, streamer: { type: 'user', id: scope }, kind: 'channel_subscription', active: !!exp, expires_at: exp ? iso(exp) : null,
+ tier: s ? s.tier || 1 : null,
subscription: s ? { id: s.id, status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, provider: s.provider } : null,
subscription: s ? { id: s.id, status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, provider: s.provider } : null,
};
};
}
}
const present = (s) => ({
const present = (s) => ({
- id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, tier: 1, provider: s.provider,
- status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, price_cents: 499,
+ id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, tier: s.tier || 1, provider: s.provider,
+ status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, price_cents: s.price_cents || 499,
current_period_end: s.current_period_end, created_at: s.created_at, updated_at: s.updated_at, legacy_live_id: s.legacy_live_id ?? null,
current_period_end: s.current_period_end, created_at: s.created_at, updated_at: s.updated_at, legacy_live_id: s.legacy_live_id ?? null,
});
});
function envelope(type, subject, payload) {
function envelope(type, subject, payload) {
@@ -112,7 +119,7 @@ async function startBilling(network, clock) {
@@ -112,7 +119,7 @@ async function startBilling(network, clock) {
const entEvent = (member, creator, reason, extra = {}) => envelope('billing.entitlement.changed', { type: 'entitlement', id: `${member}:channel_subscription:${creator}` }, { ...entitlement(member, creator), reason, ...extra });
const entEvent = (member, creator, reason, extra = {}) => envelope('billing.entitlement.changed', { type: 'entitlement', id: `${member}:channel_subscription:${creator}` }, { ...entitlement(member, creator), reason, ...extra });
/** Pay one period (grant or renew). Returns { sub, events }. */
/** Pay one period (grant or renew). Returns { sub, events }. */
- function pay(member, creator, { provider = 'credit', autoRenew = true, legacyLiveId = null } = {}) {
+ function pay(member, creator, { provider = 'credit', autoRenew = true, legacyLiveId = null, tier = null, priceCents = null } = {}) {
const t = now();
const t = now();
let s = byPair(member, creator);
let s = byPair(member, creator);
const renewal = !!(s && s.status === 'active');
const renewal = !!(s && s.status === 'active');
@@ -124,6 +131,7 @@ async function startBilling(network, clock) {
@@ -124,6 +131,7 @@ async function startBilling(network, clock) {
subs.set(s.id, s);
subs.set(s.id, s);
}
}
Object.assign(s, { status: 'active', auto_renew: autoRenew, cancel_at_period_end: false, current_period_end: iso(end), updated_at: iso(t) });
Object.assign(s, { status: 'active', auto_renew: autoRenew, cancel_at_period_end: false, current_period_end: iso(end), updated_at: iso(t) });
+ if (tier != null) Object.assign(s, { tier, price_cents: priceCents || s.price_cents || 499 });
periods.push({ sub_id: s.id, subject: member, scope: creator, starts: start, ends: end, revoked: false, txn });
periods.push({ sub_id: s.id, subject: member, scope: creator, starts: start, ends: end, revoked: false, txn });
return { sub: s, txn, events: [envelope('billing.transaction.settled', { type: 'transaction', id: txn }, { transaction_id: txn, type: 'subscription', from_subject: member, to_subject: creator }), entEvent(member, creator, renewal ? 'renewed' : 'granted', { transaction_id: txn })] };
return { sub: s, txn, events: [envelope('billing.transaction.settled', { type: 'transaction', id: txn }, { transaction_id: txn, type: 'subscription', from_subject: member, to_subject: creator }), entEvent(member, creator, renewal ? 'renewed' : 'granted', { transaction_id: txn })] };
}
}
@@ -182,8 +190,13 @@ async function startBilling(network, clock) {
@@ -182,8 +190,13 @@ async function startBilling(network, clock) {
if (replay()) return undefined;
if (replay()) return undefined;
if (body.subject.id === body.streamer.id) return send(res, 422, { code: 'billing.self_dealing' });
if (body.subject.id === body.streamer.id) return send(res, 422, { code: 'billing.self_dealing' });
if (!['powerchat', 'stripe'].includes(body.provider)) return send(res, 409, { code: 'billing.provider_disabled' });
if (!['powerchat', 'stripe'].includes(body.provider)) return send(res, 409, { code: 'billing.provider_disabled' });
+ const tier = Number(body.tier || 1);
+ const price = tierPrice(body.streamer.id, tier);
+ if (Array.isArray(price)) return send(res, price[0], { code: price[1] });
+ const held = byPair(body.subject.id, body.streamer.id);
+ if (held && held.status === 'active' && (held.tier || 1) !== tier) return send(res, 409, { code: 'billing.tier_change_unsupported' });
const id = nextId('pi');
const id = nextId('pi');
- const intent = { id, provider: body.provider, kind: 'subscription', subject: body.subject, streamer: body.streamer, amount_cents: body.provider === 'powerchat' ? 549 : 499, status: 'created', checkout_ref: body.provider === 'powerchat' ? `pcsub:${id}` : undefined };
+ const intent = { id, provider: body.provider, kind: 'subscription', subject: body.subject, streamer: body.streamer, tier, amount_cents: body.provider === 'powerchat' ? Math.round(price * 1.1) : price, status: 'created', checkout_ref: body.provider === 'powerchat' ? `pcsub:${id}` : undefined };
return store(201, { intent, checkout_url: body.provider === 'stripe' ? `https://checkout.stripe.test/${id}` : null });
return store(201, { intent, checkout_url: body.provider === 'stripe' ? `https://checkout.stripe.test/${id}` : null });
}
}
if (p === '/api/v1/subscriptions' && req.method === 'POST') {
if (p === '/api/v1/subscriptions' && req.method === 'POST') {
@@ -192,10 +205,15 @@ async function startBilling(network, clock) {
@@ -192,10 +205,15 @@ async function startBilling(network, clock) {
if (state.frozen) return send(res, 503, { code: 'billing.frozen' });
if (state.frozen) return send(res, 503, { code: 'billing.frozen' });
const member = body.subscriber.id; const creator = body.streamer.id;
const member = body.subscriber.id; const creator = body.streamer.id;
if (member === creator) return send(res, 422, { code: 'billing.self_dealing' });
if (member === creator) return send(res, 422, { code: 'billing.self_dealing' });
+ const tier = Number(body.tier || 1);
+ const price = tierPrice(creator, tier);
+ if (Array.isArray(price)) return send(res, price[0], { code: price[1] });
+ const held = byPair(member, creator);
+ if (held && held.status === 'active' && (held.tier || 1) !== tier) return send(res, 409, { code: 'billing.tier_change_unsupported' });
const credit = state.credit.get(member) || 0;
const credit = state.credit.get(member) || 0;
- if (credit < 499) return send(res, 409, { code: 'billing.insufficient_funds', detail: 'not enough credit' });
- state.credit.set(member, credit - 499);
- const out = pay(member, creator, { autoRenew: body.auto_renew !== false });
+ if (credit < price) return send(res, 409, { code: 'billing.insufficient_funds', detail: 'not enough credit' });
+ state.credit.set(member, credit - price);
+ const out = pay(member, creator, { autoRenew: body.auto_renew !== false, tier, priceCents: price });
state.lastEvents = out.events;
state.lastEvents = out.events;
return store(201, { subscription: present(out.sub), entitlement: entitlement(member, creator), transaction: { id: out.txn } });
return store(201, { subscription: present(out.sub), entitlement: entitlement(member, creator), transaction: { id: out.txn } });
}
}
@@ -222,6 +240,26 @@ async function startBilling(network, clock) {
@@ -222,6 +240,26 @@ async function startBilling(network, clock) {
const list = [...subs.values()].filter((s) => (!q.streamer || s.streamer === q.streamer) && (!q.subscriber || s.subscriber === q.subscriber) && (!q.status || s.status === q.status));
const list = [...subs.values()].filter((s) => (!q.streamer || s.streamer === q.streamer) && (!q.subscriber || s.subscriber === q.subscriber) && (!q.status || s.status === q.status));
return send(res, 200, { subscriptions: list.map(present) });
return send(res, 200, { subscriptions: list.map(present) });
}
}
+ m = p.match(/^\/api\/v1\/tiers\/([^/]+)$/);
+ if (m && req.method === 'GET') {
+ const e = auth(req, null);
+ if (e) return send(res, e[0], e[1]);
+ const tiers = [...(state.tiers.get(m[1]) || new Map()).entries()].sort((a, b) => a[0] - b[0]).map(([tier, r]) => ({ tier, price_cents: r.price_cents, status: r.status }));
+ return send(res, 200, { streamer: { type: 'user', id: m[1] }, tiers, default_price_cents: 499, min_price_cents: 100, max_price_cents: 50000, max_tiers: 10 });
+ }
+ m = p.match(/^\/api\/v1\/tiers\/([^/]+)\/(\d+)$/);
+ if (m && req.method === 'PUT') {
+ if (!need('billing.tier.manage')) return undefined;
+ if (!req.headers['idempotency-key']) return send(res, 400, { code: 'idempotency.key_required' });
+ if (replay()) return undefined;
+ if (state.frozen) return send(res, 503, { code: 'billing.frozen' });
+ const cents = Number(body.price_cents);
+ if (!Number.isSafeInteger(cents) || cents < 100 || cents > 50000) return send(res, 422, { code: 'billing.invalid_amount', detail: 'price_cents must be from 100 to 50000' });
+ if (!state.tiers.has(m[1])) state.tiers.set(m[1], new Map());
+ const row = { price_cents: cents, status: body.status || 'active' };
+ state.tiers.get(m[1]).set(Number(m[2]), row);
+ return store(200, { tier: { tier: Number(m[2]), ...row } });
+ }
m = p.match(/^\/api\/v1\/entitlements\/([^/]+)$/);
m = p.match(/^\/api\/v1\/entitlements\/([^/]+)$/);
if (m && req.method === 'GET') {
if (m && req.method === 'GET') {
if (!need('billing.entitlement.check')) return undefined;
if (!need('billing.entitlement.check')) return undefined;
@@ -110,11 +110,15 @@ const { test, run } = harness('plans');
@@ -110,11 +110,15 @@ const { test, run } = harness('plans');
assert.strictEqual((await t.domain.plans.version(m.plan_version_id)).version, 3);
assert.strictEqual((await t.domain.plans.version(m.plan_version_id)).version, 3);
});
});
- test('Billing sells one channel subscription per creator: a second published plan is refused', async () => {
- const res = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second', publish: true } });
+ test('one published plan per tier: a second plan at tier 1 is refused; without a tier it takes the next free one', async () => {
+ const res = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second', tier: 1, publish: true } });
assert.strictEqual(res.status, 409);
assert.strictEqual(res.status, 409);
assert.strictEqual(res.json.code, 'vip.plan.billing_product_taken');
assert.strictEqual(res.json.code, 'vip.plan.billing_product_taken');
- const draft = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second' } });
+ const next = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Superfan' } });
+ assert.strictEqual(next.status, 201, next.text);
+ assert.strictEqual(next.json.plan.tier, 2);
+ assert.strictEqual((await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Eleven', tier: 11 } })).status, 422);
+ const draft = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second', tier: 1 } });
assert.strictEqual(draft.status, 201);
assert.strictEqual(draft.status, 201);
assert.strictEqual((await t.call('POST', `/api/v1/plans/${draft.json.plan.id}/publish`, { user: creator })).status, 409);
assert.strictEqual((await t.call('POST', `/api/v1/plans/${draft.json.plan.id}/publish`, { user: creator })).status, 409);
assert.strictEqual((await t.call('POST', `/api/v1/plans/${plan.id}/archive`, { user: creator })).status, 200);
assert.strictEqual((await t.call('POST', `/api/v1/plans/${plan.id}/archive`, { user: creator })).status, 200);
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+/**
+ * Tiers (owner's ask: creator memberships with tiers; openvibe-contracts 0.201.0). A creator publishes one plan per
+ * tier and sets each tier's monthly price on Billing (VIP never keeps a price). Pages, the card and the API show each
+ * plan's price; a tier above 1 without a price cannot be joined; a checkout buys the plan's tier; a subscription made
+ * elsewhere (Live's, tier 1) is filed under the tier-1 plan, and Billing's tier decides the plan when no checkout did.
+ */
+const assert = require('assert');
+const { boot, harness } = require('./helpers/app');
+const { test, run } = harness('tiers');
+
+(async () => {
+ const t = await boot();
+ const creator = t.network.newUser('tierra', { role: 'streamer' });
+ const fan = t.network.newUser('fanny');
+ const live = t.network.newUser('lively');
+ let basic;
+ let superfan;
+
+ test('a creator publishes two tiers and prices them on Billing (tier 1 at the default until set)', async () => {
+ const one = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Supporter', benefits: ['Badge'], publish: true } });
+ assert.strictEqual(one.status, 201, one.text);
+ basic = one.json.plan;
+ assert.deepStrictEqual([basic.tier, basic.price_cents, basic.purchasable], [1, 499, true], 'tier 1 without a price sells at Billing\'s default');
+ const two = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Superfan', benefits: ['Badge', 'Monthly call'], tier: 2, publish: true } });
+ assert.strictEqual(two.status, 201, two.text);
+ superfan = two.json.plan;
+ assert.deepStrictEqual([superfan.tier, superfan.price_cents, superfan.purchasable], [2, null, false], 'tier 2 is not for sale until priced');
+ const priced = await t.call('PATCH', `/api/v1/plans/${superfan.id}`, { user: creator, body: { price_cents: 1500 } });
+ assert.strictEqual(priced.status, 200, priced.text);
+ assert.deepStrictEqual([priced.json.plan.price_cents, priced.json.plan.purchasable, priced.json.unchanged], [1500, true, true], 'a price is not a plan term: no new version');
+ assert.deepStrictEqual(t.billing.state.tiers.get(creator.subject).get(2), { price_cents: 1500, status: 'active' });
+ const bad = await t.call('PATCH', `/api/v1/plans/${superfan.id}`, { user: creator, body: { price_cents: 50 } });
+ assert.strictEqual(bad.status, 422);
+ const other = await t.call('PATCH', `/api/v1/plans/${superfan.id}`, { user: fan, body: { price_cents: 100 } });
+ assert.strictEqual(other.status, 403, 'only the creator prices their tiers');
+ });
+
+ test('the dashboard sets a price from "$3" and refuses a non-amount', async () => {
+ const d = await t.page('/dashboard', { user: creator });
+ assert.match(d.text, /Tier 2, sold through OpenVibe\.Billing as a channel subscription at <b>\$15\.00<\/b> a month/);
+ const token = t.csrf(d.text);
+ const ok = await t.page(`/dashboard/plans/${basic.id}/price`, { user: creator, method: 'POST', form: { _csrf: token, price: '$3' } });
+ assert.match(decodeURIComponent(ok.location), /Price set: \$3\.00 a month/);
+ assert.strictEqual(t.billing.state.tiers.get(creator.subject).get(1).price_cents, 300);
+ const no = await t.page(`/dashboard/plans/${basic.id}/price`, { user: creator, method: 'POST', form: { _csrf: token, price: 'three' } });
+ assert.match(no.location, /error=/);
+ });
+
+ test('the creator page and the card show each tier and its price', async () => {
+ const page = await t.page(`/${creator.username}`);
+ assert.match(page.text, /Supporter <span class="pill">Tier 1<\/span>/);
+ assert.match(page.text, /<b>\$3\.00<\/b> every/);
+ assert.match(page.text, /<b>\$15\.00<\/b> every/);
+ const card = await t.call('GET', `/api/v1/creators/${creator.username}/card`);
+ assert.strictEqual(card.status, 200, card.text);
+ assert.deepStrictEqual(card.json.card.plans.map((p) => [p.tier, p.price_cents, p.purchasable]), [[1, 300, true], [2, 1500, true]]);
+ assert.strictEqual(card.json.card.price.price_cents, 300, 'the card\'s headline price is the lowest tier');
+ });
+
+ test('a checkout buys the plan\'s tier at its price and files the member under that plan', async () => {
+ t.billing.state.credit.set(fan.subject, 2000);
+ const co = await t.call('POST', '/api/v1/checkout', { user: fan, body: { plan_id: superfan.id, provider: 'credit' } });
+ assert.strictEqual(co.status, 201, co.text);
+ const sent = t.billing.calls.filter((c) => c.path === '/api/v1/subscriptions' && c.method === 'POST').pop();
+ assert.strictEqual(sent.body.tier, 2);
+ assert.strictEqual(t.billing.state.credit.get(fan.subject), 500, 'charged the tier-2 price');
+ const m = await t.domain.memberships.get(fan.subject, (await t.domain.creators.bySubject(creator.subject)).id);
+ assert.strictEqual(m.plan_id, superfan.id);
+ const pc = await t.call('POST', '/api/v1/checkout', { user: t.network.newUser('pc'), body: { plan_id: superfan.id, provider: 'powerchat' } });
+ assert.strictEqual(pc.status, 201, pc.text);
+ const intent = t.billing.calls.filter((c) => c.path === '/api/v1/intents').pop();
+ assert.strictEqual(intent.body.tier, 2);
+ });
+
+ test('a subscription made elsewhere is filed under the plan of Billing\'s tier', async () => {
+ await t.deliverAll(t.billing.pay(live.subject, creator.subject).events); // Live's subscribe button: tier 1
+ const c = await t.domain.creators.bySubject(creator.subject);
+ assert.strictEqual((await t.domain.memberships.get(live.subject, c.id)).plan_id, basic.id);
+ const vipper = t.network.newUser('vipper');
+ await t.deliverAll(t.billing.pay(vipper.subject, creator.subject, { tier: 2, priceCents: 1500 }).events);
+ assert.strictEqual((await t.domain.memberships.get(vipper.subject, c.id)).plan_id, superfan.id, 'Billing said tier 2');
+ });
+
+ test('a tier with no price cannot be joined, and says so', async () => {
+ const three = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Patron', tier: 3, publish: true } });
+ assert.strictEqual(three.status, 201, three.text);
+ const page = await t.page(`/${creator.username}`);
+ assert.match(page.text, /This plan has no price yet, so it cannot be joined\./);
+ const co = await t.call('POST', '/api/v1/checkout', { user: t.network.newUser('early'), body: { plan_id: three.json.plan.id, provider: 'credit' } });
+ assert.strictEqual(co.status, 409);
+ assert.strictEqual(co.json.code, 'vip.checkout.unavailable');
+ });
+
+ await run();
+})().catch((e) => { console.error(e); process.exit(1); });