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)

OP OpenVibers committed 27 days ago · 8aa2dc9dc6 · 19 files, +391 −75

Changes

ddiff --git a/README.md b/README.md
iindex 44922de..bb6a741 100644
---- a/README.md
++++ b/README.md
@@@ -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 |
ddiff --git a/STATUS.json b/STATUS.json
iindex 2d3533e..7d6375e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/migrations/0005_plan_tiers.sql b/migrations/0005_plan_tiers.sql
nnew file mode 100644
iindex 0000000..325b0a2
---- /dev/null
++++ b/migrations/0005_plan_tiers.sql
@@@ -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;
ddiff --git a/public/css/vip.css b/public/css/vip.css
iindex e0d45b0..dd9ffac 100644
---- a/public/css/vip.css
++++ b/public/css/vip.css
@@@ -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; }
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 6fde2fe..abb9508 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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 });
}));
ddiff --git a/server/billing-client.js b/server/billing-client.js
iindex 606e9ba..0090660 100644
---- a/server/billing-client.js
++++ b/server/billing-client.js
@@@ -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 }),
ddiff --git a/server/domain/cards.js b/server/domain/cards.js
iindex 5dfa785..3338cd4 100644
---- a/server/domain/cards.js
++++ b/server/domain/cards.js
@@@ -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 = c
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) {
-- 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 = c
return {
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 };
}
ddiff --git a/server/domain/checkout.js b/server/domain/checkout.js
iindex 7b1eb70..663ad98 100644
---- a/server/domain/checkout.js
++++ b/server/domain/checkout.js
@@@ -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, entitlement
if (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, entitlement
await 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, entitlement
let 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,
ddiff --git a/server/domain/entitlements.js b/server/domain/entitlements.js
iindex 57a938b..31381bc 100644
---- a/server/domain/entitlements.js
++++ b/server/domain/entitlements.js
@@@ -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, pla
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,
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
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 8fbe770..57de205 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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(), log
const 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 };
ddiff --git a/server/domain/memberships.js b/server/domain/memberships.js
iindex 954b2a9..3e5ba2b 100644
---- a/server/domain/memberships.js
++++ b/server/domain/memberships.js
@@@ -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) {
ddiff --git a/server/domain/plans.js b/server/domain/plans.js
iindex c3329a1..0994023 100644
---- a/server/domain/plans.js
++++ b/server/domain/plans.js
@@@ -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 = '', indexnow
const 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 = '', indexnow
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`);
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 = '', 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);
}
-- /** 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 = '', indexnow
const 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 };
ddiff --git a/server/domain/prices.js b/server/domain/prices.js
nnew file mode 100644
iindex 0000000..d3104c7
---- /dev/null
++++ b/server/domain/prices.js
@@@ -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 };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex 504c04a..2e172cd 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -63,6 +63,7 @@ function consumerRouter({ domain, config, log = console, accountData = null, acc
member, 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;
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 6a4fc8c..6e44446 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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 jo
const 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>
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex ad49e81..1212a59 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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;
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex 53b2871..c5ec367 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -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;
ddiff --git a/test/plans.test.js b/test/plans.test.js
iindex 091464d..f6ac66d 100644
---- a/test/plans.test.js
++++ b/test/plans.test.js
@@@ -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);
ddiff --git a/test/tiers.test.js b/test/tiers.test.js
nnew file mode 100644
iindex 0000000..14e20eb
---- /dev/null
++++ b/test/tiers.test.js
@@@ -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); });