openvibers/openvibe.billing Commits

Subscription tiers priced by Billing (contracts 0.201.0) (#76)

OP OpenVibers committed 27 days ago · 3349a5bcce · 15 files, +320 −28

* Subscription tiers priced by Billing: GET/PUT /api/v1/tiers (billing.tier.manage), intents and credit periods at the tier's price, the tier on subscriptions and entitlements, renewals at the price bought at

* openvibe-contracts v0.201.0 (was v0.200.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.201.0 release.

* Tiers: README API rows, .env.example bounds, the policy page says a creator may price a tier (wording 2026-10-17)

Changes

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