openvibers/openvibe.vip Commits
Record each paid period's charge and expose renewal grace
Changes
@@@ -42,7 +42,9 @@ membership was bought under — never whether it is active), `vip_entitlement_prBilling entitlements with `valid_until`), `vip_checkouts` (checkout hand-offs), and the openvibe-sdk`event_outbox` / `idempotency_receipts`. `vip_memberships` is a tenth table the charter did notlist; it is needed so that "a membership always points at the version it was bought under" has a--home that is not a cache.++home that is not a cache. An eleventh, `vip_membership_periods` (migration 0002), is the per-period++charge ledger described under "Renewals, grace and refunds" below; it is a record of Billing's++transactions, not an authority for anything.## Does not own@@@ -139,6 +141,33 @@ The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) is no- A background job re-confirms, with Billing, projections that products used in the last hourbefore they go stale.++### Renewals, grace and refunds (Billing does them; VIP records and converges)++++- A membership renews by **Billing's own sweep**: one charge per period from the subscriber's credit,++ idempotent on `renew:<subscription id>:<period end>`, so a retried or replayed renewal never charges++ twice. VIP has no renewal call; it projects `billing.entitlement.changed` (`reason: renewed`), and a++ renewal keeps the plan version the membership already has.++- **Charge ledger.** `vip_membership_periods` holds one row per paid period, unique on the Billing++ transaction (`billing_transaction_id`), written from a `granted`/`renewed` entitlement event that names++ its `transaction_id` and from the credit checkout answer. A replayed event, or the same transaction++ again, never records a second row. `billing.transaction.reversed` marks the period it reverses as++ `reversed` (`reversed_by` = the reversing transaction); if the reversal arrives **before** the grant++ event, a tombstone is written and the grant that follows leaves it reversed and grants nothing.++ Provider refunds and chargebacks (`entitlements_revoked`) and credit refunds of a subscription period++ (`metadata.original_type = subscription`) both count; a reversal of anything else is ignored.++- **Renewal grace.** When a renewal charge fails Billing moves the subscription to `past_due` until++ `grace_until`, retrying on the same key, then ends it (`grace_ended`). VIP stores both++ (`vip_entitlement_projection.grace_until`), answers `status: inactive` with++ `grace: { until, reason: "renewal_failed" }` while the grace runs (`null` otherwise), and **never++ authorizes a `past_due` row**, whatever an event says. After the grace the membership row is kept and++ inactive; a later paid renewal restores it on the same plan version. `vip.membership.changed` carries++ `renewal_failed` / `grace_ended` as its `reason`; its payload is unchanged.++- **Audit.** `periods.audit()` runs with the projection refresh job: an active, fresh row that claims a++ payment (last word `granted`/`renewed`, or the pair already has ledger rows) without a `paid` period++ reaching its `expires_at`, or whose period was reversed, is logged, counted (`vip_period_audit_offenders`)++ and put in doubt (`valid_until = 0`) so the next check asks Billing. Pairs VIP only knows from a Billing++ check (imports, shadow entitlements) are not audited until a payment event names them.++### Checkout and cancellation (Billing does both)- `POST /api/v1/checkout` asks Billing first (a member cannot buy twice), records the plan version@@@ -345,6 +374,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).| plan edits create versions and never rewrite historical terms | `test/plans.test.js` (edit → v2, v1 row byte-identical, triggers refuse UPDATE/DELETE, perk rename keeps terms) || old members keep old terms; renewals keep them; a new purchase after a lapse takes the current version | `test/plans.test.js`, `test/api.test.js` (checkout version survives a later edit) || cancel / renew / refund converge (events, and with events lost) | `test/entitlements.test.js` (injected clock) |++| a renewal replayed or re-sent records one period; renew then refund reverses it; a reversal before the grant stays reversed; a failed renewal is inactive with a grace and never authorized; the audit doubts a paid period without a record | `test/renewal.test.js` || a stale projection cannot authorize past `valid_until` (+ grace), even with Billing down for 100 days | `test/entitlements.test.js` || authoritative fallback and sensitive checks | `test/entitlements.test.js`, `test/policies.test.js` || gated-resource evaluation fails closed (no rule, unknown, perk missing, guest, VIP down in the client) | `test/policies.test.js` |@@@ -21,7 +21,8 @@"the Live subscription import has run: 43 plans and plan versions, 44 creators, 87 migration maps (production, 2026-09-23)","Chat (subscriber badge, vip.entitlement.check), Community (members-only spaces and threads) and Blog (members-only posts) call VIP in production (vip.resource.policy.evaluate grants for Community and Blog)","14 vip.* capabilities, the service manifest and its events registered in openvibe-contracts v0.19.0 (this repository pins v0.79.0)",-- "per-actor limits on /api/v1 (openvibe-sdk/limits)"++ "per-actor limits on /api/v1 (openvibe-sdk/limits)",++ "renewal/refund convergence via Billing events: a per-period charge ledger (vip_membership_periods, unique per Billing transaction; reversals and reversal-before-grant tombstones), a past_due renewal grace that never authorizes (answer.grace), and a period audit that doubts a paid period without a record (migration 0002; docs/cutover-0002-renewal-periods.md)"],"not_yet": ["not launched: openvibe.vip is still an OpenVibe.Sites placeholder and the service answers on loopback only",nnew file mode 100644@@@ -0,0 +1,30 @@++# Cutover note: migration 0002 (renewal periods and grace)++++`migrations/0002_renewal_periods.sql` is **additive** (`-- phase: expand`):++++- a new table `vip_membership_periods` (one row per paid period, `UNIQUE (billing_transaction_id)`),++- a nullable column `vip_entitlement_projection.grace_until`.++++Nothing is renamed, dropped or rewritten.++++## Order++++1. Apply the migration **before** the code that uses it deploys. VIP applies `migrations/` at boot as the owner++ role (`DATABASE_DIRECT_URL`), so deploying the release applies it first; the previous release ignores both++ additions, so the order only matters if the migration is run by hand.++2. Deploy the release. Period rows fill from the next `billing.entitlement.changed` (granted/renewed with a++ `transaction_id`), `billing.transaction.reversed` and credit checkout. Existing active rows are not++ back-filled; the audit does not look at a pair until a payment event names it, so nothing is doubted at deploy.++3. The grace answer (`grace: { until, reason: "renewal_failed" }`) appears only once Billing sends `past_due`++ events (Billing's grace is off by default until its own step turns it on). Until then `grace` is `null`.++++## Rollback++++Roll the code back; leave the table and the column in place, unused. The previous release neither reads nor writes++them. Do not drop `vip_membership_periods`: it is the record that each paid period had a charge.++++## Watch++++- `vip_period_audit_offenders` (gauge, `/metrics`) and the log line `[VIP] period audit:` after the first renewals.++ A persistent non-zero value means Billing says a member is active for a period VIP has no paid charge for++ (a lost grant event, or a reversal that did not shorten the membership).nnew file mode 100644@@@ -0,0 +1,24 @@++-- phase: expand++-- Renewal and refund convergence on OpenVibe.Billing (T5 step 11): one row per paid period, keyed by the Billing++-- transaction that paid it, so VIP can show that every paid period has a charge record and that a reversed one is++-- not counted. Additive only: a new table and a nullable column. Rolling back the code leaves both unused.++++CREATE TABLE vip_membership_periods (++ id text COLLATE "C" PRIMARY KEY,++ member_subject text COLLATE "C" NOT NULL,++ creator_subject text COLLATE "C" NOT NULL,++ billing_subscription_id text COLLATE "C",++ billing_transaction_id text COLLATE "C" NOT NULL,++ period_start text COLLATE "C",++ period_end text COLLATE "C",++ status text COLLATE "C" NOT NULL CHECK (status IN ('paid', 'reversed')),++ reversed_by text COLLATE "C",++ reason text COLLATE "C",++ recorded_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ UNIQUE (billing_transaction_id)++);++CREATE INDEX idx_vip_periods_pair ON vip_membership_periods (member_subject, creator_subject, status, period_end);++++-- past_due: when Billing stops retrying a failed renewal (null outside a renewal grace)++ALTER TABLE vip_entitlement_projection ADD COLUMN grace_until text COLLATE "C";@@@ -73,6 +73,7 @@ async function createApp(opts = {}) {return [{ labels: { state: 'fresh' }, value: r.fresh || 0 }, { labels: { state: 'stale' }, value: r.stale || 0 }];},});++ metrics.registry.gauge({ name: 'vip_period_audit_offenders', help: 'Active memberships the last period audit found without a paid period record', collect: () => domain.periods.stats.offenders });app.use(http.middleware());app.use((req, res, next) => {res.setHeader('X-Content-Type-Options', 'nosniff');@@@ -28,7 +28,7 @@ function billingProblem(err) {fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; nothing was charged');}--function createCheckout({ db, now, config, billing, creators, plans, entitlements }) {++function createCheckout({ db, now, config, billing, creators, plans, entitlements, periods }) {const byId = async (id) => await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {@@@ -65,10 +65,20 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlementconst sub = out.subscription || {};const ent = out.entitlement || {};await mark({ status: 'paid', billing_subscription_id: sub.id || null });-- 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,++ // The projection and the charge that paid the first period land together (the event may come before or after).++ await db.tx(async () => {++ 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,++ });++ const txn = out.transaction && out.transaction.id;++ if (txn) {++ await periods.recordPaid({++ member, creator: creator.subject, subscriptionId: sub.id || null, transactionId: txn,++ periodStart: iso(now()), periodEnd: ent.expires_at || sub.current_period_end || null, reason: 'granted',++ });++ }});return { checkout: present(await byId(id)), membership_started: !!ent.active, checkout_url: null, checkout_ref: null };}@@@ -17,6 +17,10 @@* Ordering: every row remembers the Billing time it reflects (billing_as_of). An older event that* would grant is ignored; an older event that would revoke puts the row in doubt (valid_until = 0),* so the next check goes to Billing — out-of-order delivery can deny briefly, never grant wrongly.++ *++ * Renewal grace: a failed renewal charge puts Billing's subscription in `past_due` until `grace_until`, then++ * ends it. The entitlement is not active meanwhile; VIP stores both, answers inactive with++ * `grace: { until, reason: 'renewal_failed' }`, and never authorizes a past_due row, whatever the event says.*/const { iso, userRef } = require('../util');@@@ -55,7 +59,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?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),++ * subscriptionStatus?, graceUntil?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),* origin?, checkoutId?, traceparent? }*/async function apply(input) {@@@ -70,29 +74,34 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla}return { outcome: 'stale_ignored', changed: false };}++ const status = input.subscriptionStatus !== undefined ? input.subscriptionStatus : (prev ? prev.subscription_status : null);++ // past_due never authorizes (Billing's entitlement is false then); the grace ends with the status.++ const active = input.active && status !== 'past_due';const next = {member_subject: input.member, creator_subject: input.creator, kind,-- active: input.active ? 1 : 0, expires_at: input.expiresAt || null,++ active: active ? 1 : 0, expires_at: input.expiresAt || null,cancel_at_period_end: input.cancelAtPeriodEnd !== undefined ? (input.cancelAtPeriodEnd ? 1 : 0) : (prev ? prev.cancel_at_period_end : 0),subscription_id: input.subscriptionId !== undefined ? input.subscriptionId : (prev ? prev.subscription_id : null),-- subscription_status: input.subscriptionStatus !== undefined ? input.subscriptionStatus : (prev ? prev.subscription_status : null),++ subscription_status: status,++ grace_until: status === 'past_due' ? (input.graceUntil || (prev ? prev.grace_until : null) || null) : null,source: input.source, last_reason: input.reason || null, billing_event_id: input.eventId || null,-- billing_as_of: input.asOf, synced_at: at, valid_until: validUntil(at, input.active, input.expiresAt),++ billing_as_of: input.asOf, synced_at: at, valid_until: validUntil(at, active, input.expiresAt),last_used_at: prev ? prev.last_used_at : null,};await db.prepare(`INSERT INTO vip_entitlement_projection (member_subject, creator_subject, kind, active, expires_at, cancel_at_period_end, subscription_id,-- subscription_status, source, last_reason, billing_event_id, billing_as_of, synced_at, valid_until, last_used_at)-- VALUES (@member_subject, @creator_subject, @kind, @active, @expires_at, @cancel_at_period_end, @subscription_id, @subscription_status,++ subscription_status, grace_until, source, last_reason, billing_event_id, billing_as_of, synced_at, valid_until, last_used_at)++ VALUES (@member_subject, @creator_subject, @kind, @active, @expires_at, @cancel_at_period_end, @subscription_id, @subscription_status, @grace_until,@source, @last_reason, @billing_event_id, @billing_as_of, @synced_at, @valid_until, @last_used_at)ON CONFLICT (member_subject, creator_subject, kind) DO UPDATE SET active = excluded.active, expires_at = excluded.expires_at,cancel_at_period_end = excluded.cancel_at_period_end, subscription_id = excluded.subscription_id,-- subscription_status = excluded.subscription_status, source = excluded.source, last_reason = excluded.last_reason,++ subscription_status = excluded.subscription_status, grace_until = excluded.grace_until, source = excluded.source, last_reason = excluded.last_reason,billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,valid_until = excluded.valid_until`).run(next);const { membership } = await memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });// 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)++ ? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end++ || (prev.grace_until || null) !== next.grace_until): !!next.active;if (changed) await emitChanged(next, prev, { reason: input.reason, membership }, input.traceparent);return { outcome: changed ? 'changed' : 'unchanged', changed, row: await getRow(input.member, input.creator, kind), membership };@@@ -108,7 +117,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, placonst active = subscriptionStatus === 'active' ? !!prev.active : false;return await apply({member, creator, active, expiresAt: active ? (prev.expires_at || currentPeriodEnd) : prev.expires_at, cancelAtPeriodEnd: true,-- subscriptionId, subscriptionStatus, reason: 'cancel_scheduled', source, eventId, asOf, traceparent,++ subscriptionId, subscriptionStatus, graceUntil: prev.grace_until, reason: 'cancel_scheduled', source, eventId, asOf, traceparent,});});}@@@ -120,18 +129,20 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, plaasync function answer(row, source, { stale = false, membership } = {}) {const t = now();-- const active = !!row.active && !!row.expires_at && Date.parse(row.expires_at) > t;++ const active = !!row.active && row.subscription_status !== 'past_due' && !!row.expires_at && Date.parse(row.expires_at) > t;++ const graceEnd = row.subscription_status === 'past_due' && row.grace_until ? Date.parse(row.grace_until) : NaN;return {member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,status: active ? 'active' : 'inactive', active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,source, stale, valid_until: iso(Math.max(0, row.valid_until)), checked_at: iso(t),membership: membership ? await memberships.present(membership) : null,++ grace: !active && graceEnd > t ? { until: row.grace_until, reason: 'renewal_failed' } : null,};}function unknown(member, creator, reason) {return {member: userRef(member), creator: userRef(creator), kind: KIND, status: 'unknown', active: false, expires_at: null,-- cancel_at_period_end: false, source: 'none', stale: false, valid_until: null, checked_at: iso(now()), reason, membership: null,++ cancel_at_period_end: false, source: 'none', stale: false, valid_until: null, checked_at: iso(now()), reason, membership: null, grace: null,};}@@@ -147,7 +158,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, placonst out = await apply({member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,-- subscriptionStatus: sub ? sub.status : undefined, reason: 'checked', source: 'billing_check', asOf: now(), traceparent,++ subscriptionStatus: sub ? sub.status : undefined, graceUntil: (e && e.grace_until) || (sub && sub.grace_until) || undefined, reason: 'checked', source: 'billing_check', asOf: now(), traceparent,});return out.row || await getRow(member, creator);}@@@ -5,6 +5,7 @@ const { createCreators } = require('./creators');const { createPerks } = require('./perks');const { createPlans } = require('./plans');const { createMemberships } = require('./memberships');++const { createPeriods } = require('./periods');const { createEntitlements } = require('./entitlements');const { createPolicies } = require('./policies');const { createCheckout } = require('./checkout');@@@ -15,12 +16,13 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), logconst perks = createPerks({ db, now });const plans = createPlans({ db, now, outbox, creators, perks });const memberships = createMemberships({ db, now, creators, plans });++ const periods = createPeriods({ db, 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 });++ const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements, periods });const cards = createCards({ db, config, creators, plans, perks, billing, now, log });const tx = async (fn) => await db.tx(fn);-- return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, entitlements, policies, checkout, cards };++ return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards };}module.exports = { createDomain };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++++/**++ * The per-period charge ledger: one row per paid membership period, keyed by the OpenVibe.Billing++ * transaction that paid it (UNIQUE billing_transaction_id), so a replayed or retried event never records++ * a period twice. Billing charges once per (subscription, period); VIP only records what Billing says.++ *++ * recordPaid billing.entitlement.changed granted/renewed with a transaction_id, or the credit checkout answer++ * recordReversed billing.transaction.reversed: the period whose transaction was reversed. A reversal that++ * arrives BEFORE the grant event writes a tombstone (status reversed); the grant that follows++ * fills in the details and leaves it reversed.++ * audit active, fresh projection rows whose paid period has no 'paid' record, or whose period was++ * reversed, are offenders: they are logged, counted and put in doubt (valid_until = 0) so the++ * next check asks Billing++ */++const { iso, prefixedId } = require('../util');++++const KIND = 'channel_subscription';++const PAID_REASONS = new Set(['granted', 'renewed']);++++function createPeriods({ db, now, log = console }) {++ const stats = { runs: 0, offenders: 0, offenders_total: 0 };++++ const byTransaction = async (txn) => await db.prepare('SELECT * FROM vip_membership_periods WHERE billing_transaction_id = ?').get(txn) || null;++++ /** Record the period a transaction paid. Idempotent on the transaction; never un-reverses a period. */++ async function recordPaid({ member, creator, subscriptionId = null, transactionId, periodStart = null, periodEnd = null, reason = null }) {++ if (!transactionId) return { outcome: 'no_transaction' };++ return await db.tx(async () => {++ const prev = await byTransaction(transactionId);++ const at = iso(now());++ await db.prepare(`INSERT INTO vip_membership_periods (id, member_subject, creator_subject, billing_subscription_id, billing_transaction_id,++ period_start, period_end, status, reason, recorded_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, 'paid', ?, ?, ?)++ ON CONFLICT (billing_transaction_id) DO UPDATE SET member_subject = excluded.member_subject, creator_subject = excluded.creator_subject,++ billing_subscription_id = COALESCE(excluded.billing_subscription_id, vip_membership_periods.billing_subscription_id),++ period_start = COALESCE(vip_membership_periods.period_start, excluded.period_start),++ period_end = COALESCE(excluded.period_end, vip_membership_periods.period_end),++ reason = COALESCE(excluded.reason, vip_membership_periods.reason), updated_at = excluded.updated_at`)++ .run(prefixedId('vmp', now()), member, creator, subscriptionId, transactionId, periodStart, periodEnd, reason, at, at);++ const row = await byTransaction(transactionId);++ return { outcome: !prev ? 'recorded' : (row.status === 'reversed' ? 'reversed' : 'replayed'), row };++ });++ }++++ /** Mark the period paid by `reversesTxn` reversed by `reversedBy`; a tombstone when it is not recorded yet. */++ async function recordReversed({ member, creator, reversesTxn, reversedBy = null, reason = null }) {++ if (!reversesTxn) return { outcome: 'no_transaction' };++ return await db.tx(async () => {++ const prev = await byTransaction(reversesTxn);++ const at = iso(now());++ await db.prepare(`INSERT INTO vip_membership_periods (id, member_subject, creator_subject, billing_transaction_id, status, reversed_by, reason, recorded_at, updated_at)++ VALUES (?, ?, ?, ?, 'reversed', ?, ?, ?, ?)++ ON CONFLICT (billing_transaction_id) DO UPDATE SET status = 'reversed',++ reversed_by = COALESCE(vip_membership_periods.reversed_by, excluded.reversed_by), updated_at = excluded.updated_at`)++ .run(prefixedId('vmp', now()), member, creator, reversesTxn, reversedBy, reason, at, at);++ return { outcome: !prev ? 'tombstone' : (prev.status === 'reversed' ? 'replayed' : 'reversed'), row: await byTransaction(reversesTxn) };++ });++ }++++ const forPair = async (member, creator) => await db.prepare(`SELECT * FROM vip_membership_periods WHERE member_subject = ? AND creator_subject = ?++ ORDER BY period_end DESC`).all(member, creator);++++ /**++ * Rows that claim a paid period (the last word was a payment, or the pair already has ledger rows) without a++ * 'paid' record that reaches their expires_at, or whose covering period was reversed. A pair VIP only ever++ * learned from a Billing check (an import, a shadow entitlement) is not audited until a payment event names it.++ */++ async function audit({ limit = 500 } = {}) {++ const t = now();++ const rows = await db.prepare(`SELECT p.* FROM vip_entitlement_projection p++ WHERE p.kind = ? AND p.active = 1 AND p.valid_until >= ? AND p.expires_at > ?++ AND (p.last_reason IN ('granted', 'renewed') OR EXISTS (SELECT 1 FROM vip_membership_periods q++ WHERE q.member_subject = p.member_subject AND q.creator_subject = p.creator_subject))++ ORDER BY p.valid_until LIMIT ?`).all(KIND, t, iso(t), limit);++ const offenders = [];++ for (const r of rows) {++ const paid = await db.prepare(`SELECT 1 AS ok FROM vip_membership_periods WHERE member_subject = ? AND creator_subject = ?++ AND status = 'paid' AND period_end >= ? LIMIT 1`).get(r.member_subject, r.creator_subject, r.expires_at);++ if (paid) continue;++ const reversed = await db.prepare(`SELECT billing_transaction_id FROM vip_membership_periods WHERE member_subject = ? AND creator_subject = ?++ AND status = 'reversed' AND (period_end IS NULL OR period_end >= ?) ORDER BY recorded_at DESC LIMIT 1`).get(r.member_subject, r.creator_subject, r.expires_at);++ offenders.push({++ member: r.member_subject, creator: r.creator_subject, expires_at: r.expires_at,++ problem: reversed ? 'period_reversed' : 'no_paid_period', billing_transaction_id: reversed ? reversed.billing_transaction_id : null,++ });++ await db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(r.member_subject, r.creator_subject, KIND);++ }++ stats.runs++;++ stats.offenders = offenders.length;++ stats.offenders_total += offenders.length;++ if (offenders.length) log.warn(`[VIP] period audit: ${offenders.length} active membership(s) without a paid period record; doubted: ${offenders.slice(0, 5).map((o) => `${o.member}→${o.creator} ${o.problem}`).join(', ')}`);++ return { checked: rows.length, offenders };++ }++++ return { recordPaid, recordReversed, byTransaction, forPair, audit, stats, PAID_REASONS };++}++++module.exports = { createPeriods };@@@ -5,11 +5,14 @@* (scripts/subscribe.js): billing.entitlement.*, billing.subscription.*, billing.transaction.reversed.** billing.entitlement.changed the projection takes Billing's { active, expires_at, subscription }-- * (granted, renewed, canceled, expired, refund, chargeback, …)++ * (granted, renewed, canceled, expired, refund, chargeback, …); a granted/renewed++ * event with a transaction_id also records the paid period (domain/periods.js);++ * renewal_failed (past_due, grace_until) and grace_ended are stored, never authorize* billing.subscription.canceled cancel at period end: the flag changes, the period still runs* billing.transaction.reversed a reversal that revoked subscription periods puts the pair's* projection in doubt at once (the next check asks Billing), even-- * before — or without — the matching entitlement event++ * before — or without — the matching entitlement event; the period that++ * transaction paid is marked reversed (a tombstone when it is not recorded yet)** Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same PostgreSQL transaction as* the change. Order: rows remember the Billing time they reflect; see domain/entitlements.js.@@@ -26,7 +29,7 @@ const CONSUMER = 'vip-billing';function consumerRouter({ domain, config, log = console }) {const router = express.Router();const inbox = createPgInbox(domain.db, { now: domain.now }); // idempotency_receipts: migrations/0001_initial.sql-- const { entitlements } = domain;++ const { entitlements, periods } = domain;async function handle(event) {const p = event.payload && typeof event.payload === 'object' ? event.payload : {};@@@ -38,11 +41,24 @@ function consumerRouter({ domain, config, log = console }) {if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';if (p.kind && p.kind !== entitlements.KIND) return 'ignored:kind';const sub = p.subscription || null;-- return (await entitlements.apply({-- member, creator, active: !!p.active, expiresAt: p.expires_at || null,++ let reversed = false;++ if (p.transaction_id && periods.PAID_REASONS.has(p.reason)) {++ // A renewal starts where the paid period ends, or now when it already ended.++ const prev = await entitlements.getRow(member, creator);++ const prevEnd = prev && prev.expires_at && Date.parse(prev.expires_at) > asOf ? prev.expires_at : event.timestamp;++ // A period already reversed (its reversal came first) grants nothing, whatever this event says.++ reversed = (await periods.recordPaid({++ member, creator, subscriptionId: sub ? sub.id : null, transactionId: p.transaction_id,++ periodStart: prevEnd, periodEnd: p.expires_at || null, reason: p.reason,++ })).outcome === 'reversed';++ }++ const out = await entitlements.apply({++ 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, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,-- })).outcome;++ subscriptionStatus: sub ? sub.status : undefined, graceUntil: p.grace_until || undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,++ });++ if (reversed) await entitlements.doubt(member, creator);++ return out.outcome;}if (event.event_type === 'billing.subscription.canceled') {const s = p.subscription || {};@@@ -55,11 +71,15 @@ function consumerRouter({ domain, config, log = console }) {})).outcome;}if (event.event_type === 'billing.transaction.reversed') {-- if (!(Number(p.entitlements_revoked) > 0)) return 'ignored:no_entitlement';++ // A provider reversal says how many periods it revoked; a credit refund of a subscription period says what it reversed.++ const meta = p.metadata && typeof p.metadata === 'object' ? p.metadata : {};++ const ofSubscription = meta.original_type === 'subscription' || typeof meta.subscription_id === 'string';++ if (!(Number(p.entitlements_revoked) > 0) && !ofSubscription) return 'ignored:no_entitlement';// The reversal runs streamer → subscriber (from/to of the original swapped).const member = p.to_subject;const creator = p.from_subject;if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';++ await periods.recordReversed({ member, creator, reversesTxn: p.reverses_txn, reversedBy: p.transaction_id || null, reason: p.type || null });return await entitlements.doubt(member, creator) ? 'doubt' : 'no_projection';}return 'ignored:type';@@@ -46,7 +46,10 @@ async function start() {const timers = [];if (config.jobs.enabled) {const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };-- every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());++ every(config.jobs.refreshIntervalMs, async () => {++ await domain.periods.audit(); // doubts what has no paid period record, so refreshDue asks Billing about it now++ await domain.entitlements.refreshDue();++ });every(6 * 3600 * 1000, async () => await outbox.outbox.prune());outbox.start();}nnew file mode 100644@@@ -0,0 +1,222 @@++'use strict';++/**++ * Renewals, failed renewals, refunds and reversals converge on Billing (T5 step 11), money path:++ *++ * one period row per Billing transaction, however often an event is replayed or re-sent++ * renew then refund the period is reversed, the membership ends++ * reversal before the grant event a tombstone; the grant that follows leaves it reversed and grants nothing++ * past_due (renewal failed) inactive with grace { until, reason }, never authorized++ * grace_ended inactive, no grace; a later paid renewal restores it++ * audit an active row without a paid period record, or on a reversed one, is doubted++ *++ * Real VIP over stub Network and Billing, one injected clock.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, harness } = require('./helpers/app');++++const { test, run } = harness('renewal');++const DAY = 86_400_000;++++(async () => {++ const t = await boot();++ const { periods, entitlements, db } = t.domain;++ const creator = t.network.newUser('rena', { role: 'streamer' });++ const plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } })).json.plan;++++ const rows = async (m) => await db.prepare('SELECT * FROM vip_membership_periods WHERE member_subject = ? ORDER BY recorded_at, id').all(m.subject);++ const ask = async (m) => await entitlements.check(m.subject, creator.subject, { mode: 'projection' });++ const txnId = () => `txn_${ids.ulid(t.clock.now())}${Math.random().toString(36).slice(2, 5).toUpperCase()}`.slice(0, 30);++ const entEvent = (m, payload) => t.billing.envelope('billing.entitlement.changed',++ { type: 'entitlement', id: `${m.subject}:channel_subscription:${creator.subject}` },++ { subject: { type: 'user', id: m.subject }, streamer: { type: 'user', id: creator.subject }, kind: 'channel_subscription', ...payload });++ const reversal = (m, reverses, extra = {}) => {++ const id = txnId();++ return { id, event: t.billing.envelope('billing.transaction.reversed', { type: 'transaction', id }, { transaction_id: id, type: 'refund', test: false, from_subject: creator.subject, to_subject: m.subject, provider: null, metadata: {}, reverses_txn: reverses, entitlements_revoked: 1, ...extra }) };++ };++ const sub = (id, status, extra = {}) => ({ id, status, auto_renew: true, cancel_at_period_end: false, provider: 'credit', ...extra });++++ test('a replayed or re-sent renewal records one period per transaction', async () => {++ const m = t.network.newUser('r1');++ const first = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(first.events);++ t.clock.advance(31 * DAY);++ const renew = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(renew.events);++ assert.deepStrictEqual((await rows(m)).map((r) => [r.billing_transaction_id, r.status]), [[first.txn, 'paid'], [renew.txn, 'paid']]);++ const again = await t.deliverAll(renew.events); // same event ids: the inbox says duplicate++ assert.ok(again.every((r) => r.json.duplicate), 'replay is a no-op');++ // Billing re-sends the same charge under a new event id: still one row for the transaction++ const resent = t.billing.envelope('billing.entitlement.changed', renew.events[1].subject, renew.events[1].payload);++ await t.deliver(resent);++ const all = await rows(m);++ assert.strictEqual(all.length, 2);++ assert.strictEqual(all[1].billing_subscription_id, renew.sub.id);++ assert.strictEqual(all[1].period_end, renew.events[1].payload.expires_at);++ assert.strictEqual((await ask(m)).status, 'active');++ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((e) => e.payload.member.id === m.subject && e.payload.reason === 'renewed').length, 1);++ });++++ test('renew then refund: the period is reversed and the membership ends', async () => {++ const m = t.network.newUser('r2');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ t.clock.advance(31 * DAY);++ const renew = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(renew.events);++ assert.strictEqual((await ask(m)).active, true);++ const out = t.billing.refund(m.subject, creator.subject);++ await t.deliverAll(out.events);++ const [, second] = await rows(m);++ assert.strictEqual(second.billing_transaction_id, renew.txn);++ assert.strictEqual(second.status, 'reversed');++ assert.strictEqual(second.reversed_by, out.events[0].payload.transaction_id);++ const a = await ask(m);++ assert.strictEqual(a.active, false);++ assert.strictEqual(a.status, 'inactive');++ const dup = await t.deliverAll(out.events); // replayed refund++ assert.ok(dup.every((r) => r.json.duplicate));++ assert.strictEqual((await rows(m)).length, 2);++ });++++ test('a reversal before the grant event ends reversed and grants nothing', async () => {++ const m = t.network.newUser('r3');++ const paid = t.billing.pay(m.subject, creator.subject);++ const rev = reversal(m, paid.txn);++ const first = await t.deliver(rev.event); // no projection yet++ assert.strictEqual(first.json.outcome, 'no_projection');++ let [tomb] = await rows(m);++ assert.strictEqual(tomb.status, 'reversed');++ assert.strictEqual(tomb.billing_transaction_id, paid.txn);++ assert.strictEqual(tomb.period_end, null);++ await t.deliverAll(paid.events); // the (now stale) grant arrives: active as of its time++ const all = await rows(m);++ assert.strictEqual(all.length, 1);++ [tomb] = all;++ assert.strictEqual(tomb.status, 'reversed');++ assert.strictEqual(tomb.billing_subscription_id, paid.sub.id, 'the grant fills in the details');++ assert.strictEqual(tomb.period_end, paid.events[1].payload.expires_at);++ const a = await ask(m);++ assert.strictEqual(a.active, false, 'a reversed period never grants');++ assert.notStrictEqual(a.status, 'active');++ // And a replay of the reversal under a new event id changes nothing++ await t.deliver(t.billing.envelope('billing.transaction.reversed', rev.event.subject, rev.event.payload));++ assert.strictEqual((await rows(m)).length, 1);++ });++++ test('a credit refund of a subscription period (no entitlements_revoked count) reverses the period too', async () => {++ const m = t.network.newUser('r3b');++ const paid = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(paid.events);++ const rev = reversal(m, paid.txn, { entitlements_revoked: undefined, metadata: { original_type: 'subscription', subscription_id: paid.sub.id } });++ assert.strictEqual((await t.deliver(rev.event)).json.outcome, 'doubt');++ assert.strictEqual((await rows(m))[0].status, 'reversed');++ // A reversal of something that is no subscription period is still ignored++ const donation = reversal(m, txnId(), { entitlements_revoked: undefined, type: 'refund', metadata: { original_type: 'donation' } });++ assert.strictEqual((await t.deliver(donation.event)).json.outcome, 'ignored:no_entitlement');++ assert.strictEqual((await rows(m)).length, 1);++ });++++ test('failed renewal: past_due is inactive with a grace, never authorized', async () => {++ const m = t.network.newUser('r4');++ const paid = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(paid.events);++ const end = paid.events[1].payload.expires_at;++ t.clock.advance(31 * DAY);++ const until = new Date(t.clock.now() + 3 * DAY).toISOString();++ // Billing says inactive; even an event that claims active while past_due must not authorize++ for (const active of [false, true]) {++ t.clock.advance(1000);++ const r = await t.deliver(entEvent(m, { active, expires_at: end, subscription: sub(paid.sub.id, 'past_due'), reason: 'renewal_failed', grace_until: until, renewal_period_end: until }));++ assert.strictEqual(r.status, 200);++ const a = await ask(m);++ assert.strictEqual(a.active, false, `active:${active} past_due`);++ assert.strictEqual(a.status, 'inactive');++ assert.deepStrictEqual(a.grace, { until, reason: 'renewal_failed' });++ for (const mode of ['auto', 'projection']) assert.strictEqual((await entitlements.check(m.subject, creator.subject, { mode })).active, false);++ const route = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}&mode=projection`, { user: m });++ assert.strictEqual(route.json.active, false);++ assert.deepStrictEqual(route.json.grace, { until, reason: 'renewal_failed' });++ }++ const row = await entitlements.getRow(m.subject, creator.subject);++ assert.strictEqual(row.subscription_status, 'past_due');++ assert.strictEqual(row.grace_until, until);++ const changed = (await t.outboxEvents('vip.membership.changed')).filter((e) => e.payload.member.id === m.subject).pop();++ assert.strictEqual(changed.payload.reason, 'renewal_failed');++ assert.strictEqual(changed.payload.active, false);++ assert.strictEqual((await rows(m)).length, 1, 'a failed renewal records no period');++ // An older event cannot undo it (ordering still holds)++ const stale = entEvent(m, { active: true, expires_at: end, subscription: sub(paid.sub.id, 'active'), reason: 'renewed', transaction_id: txnId() });++ stale.timestamp = new Date(t.clock.now() - 40 * DAY).toISOString();++ await t.deliver(stale);++ assert.strictEqual((await ask(m)).active, false);++ // Past the grace the answer carries no grace++ t.clock.advance(4 * DAY);++ assert.strictEqual((await ask(m)).grace, null);++ });++++ test('grace ended: inactive, no grace; a later paid renewal restores the membership on the same version', async () => {++ const m = t.network.newUser('r5');++ const paid = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(paid.events);++ const end = paid.events[1].payload.expires_at;++ const versionBefore = (await ask(m)).membership.plan_version_id;++ t.clock.advance(31 * DAY);++ await t.deliver(entEvent(m, { active: false, expires_at: end, subscription: sub(paid.sub.id, 'past_due'), reason: 'renewal_failed', grace_until: new Date(t.clock.now() + 3 * DAY).toISOString() }));++ t.clock.advance(3 * DAY + 1000);++ await t.deliver(entEvent(m, { active: false, expires_at: end, subscription: sub(paid.sub.id, 'expired'), reason: 'grace_ended' }));++ let a = await ask(m);++ assert.strictEqual(a.active, false);++ assert.strictEqual(a.grace, null);++ assert.strictEqual((await entitlements.getRow(m.subject, creator.subject)).grace_until, null);++ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((e) => e.payload.member.id === m.subject).pop().payload.reason, 'grace_ended');++ assert.ok(await t.domain.memberships.present(await t.domain.db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ?').get(m.subject)), 'the membership row is kept');++ // Billing renews (after a top-up, say): the membership is back, on the version it already had++ const renew = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(renew.events);++ a = await ask(m);++ assert.strictEqual(a.active, true);++ assert.strictEqual(a.grace, null);++ assert.strictEqual(a.membership.plan_version_id, versionBefore);++ assert.strictEqual((await rows(m)).length, 2);++ });++++ test('the credit checkout records its period, and Billing\'s event for it adds no second row', async () => {++ const m = t.network.newUser('r6');++ t.billing.state.credit.set(m.subject, 1000);++ const ok = await t.call('POST', '/api/v1/checkout', { user: m, body: { plan_id: plan.id, provider: 'credit' } });++ assert.strictEqual(ok.status, 201, ok.text);++ let all = await rows(m);++ assert.strictEqual(all.length, 1);++ assert.strictEqual(all[0].status, 'paid');++ assert.ok(all[0].billing_subscription_id && all[0].period_end);++ await t.deliverAll(t.billing.state.lastEvents);++ all = await rows(m);++ assert.strictEqual(all.length, 1, 'the same transaction');++ assert.strictEqual((await periods.audit()).offenders.filter((o) => o.member === m.subject).length, 0);++ });++++ test('audit: an active row without a paid period record, or on a reversed one, is doubted', async () => {++ const good = t.network.newUser('r7');++ await t.deliverAll(t.billing.pay(good.subject, creator.subject).events);++ // granted with no transaction: nothing records a charge++ const bare = t.network.newUser('r8');++ const p = t.billing.pay(bare.subject, creator.subject);++ await t.deliver(t.billing.envelope('billing.entitlement.changed', p.events[1].subject, { ...p.events[1].payload, transaction_id: undefined }));++ // a recorded period that a reversal marked reversed while the row still runs to its end++ const rev = t.network.newUser('r9');++ const q = t.billing.pay(rev.subject, creator.subject);++ await t.deliverAll(q.events);++ await db.prepare("UPDATE vip_membership_periods SET status = 'reversed' WHERE billing_transaction_id = ?").run(q.txn);++ const out = await periods.audit();++ const mine = out.offenders.filter((o) => [good, bare, rev].some((u) => u.subject === o.member));++ assert.deepStrictEqual(mine.map((o) => [o.member, o.problem]).sort(), [[bare.subject, 'no_paid_period'], [rev.subject, 'period_reversed']].sort());++ assert.strictEqual(periods.stats.offenders, out.offenders.length);++ for (const u of [bare, rev]) assert.strictEqual((await entitlements.getRow(u.subject, creator.subject)).valid_until, 0);++ assert.ok((await entitlements.getRow(good.subject, creator.subject)).valid_until > t.clock.now(), 'a recorded paid period is left alone');++ // doubted: the next check asks Billing, which still says active for the unrecorded one++ const a = await entitlements.check(bare.subject, creator.subject, { mode: 'auto' });++ assert.strictEqual(a.source, 'billing');++ });++++ await run().finally(async () => await t.close());++})();
@@ -42,7 +42,9 @@ membership was bought under — never whether it is active), `vip_entitlement_pr
@@ -42,7 +42,9 @@ membership was bought under — never whether it is active), `vip_entitlement_pr
Billing entitlements with `valid_until`), `vip_checkouts` (checkout hand-offs), and the openvibe-sdk
Billing entitlements with `valid_until`), `vip_checkouts` (checkout hand-offs), and the openvibe-sdk
`event_outbox` / `idempotency_receipts`. `vip_memberships` is a tenth table the charter did not
`event_outbox` / `idempotency_receipts`. `vip_memberships` is a tenth table the charter did not
list; it is needed so that "a membership always points at the version it was bought under" has a
list; it is needed so that "a membership always points at the version it was bought under" has a
-home that is not a cache.
+home that is not a cache. An eleventh, `vip_membership_periods` (migration 0002), is the per-period
+charge ledger described under "Renewals, grace and refunds" below; it is a record of Billing's
+transactions, not an authority for anything.
## Does not own
## Does not own
@@ -139,6 +141,33 @@ The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) is no
@@ -139,6 +141,33 @@ The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) is no
- A background job re-confirms, with Billing, projections that products used in the last hour
- A background job re-confirms, with Billing, projections that products used in the last hour
before they go stale.
before they go stale.
+### Renewals, grace and refunds (Billing does them; VIP records and converges)
+
+- A membership renews by **Billing's own sweep**: one charge per period from the subscriber's credit,
+ idempotent on `renew:<subscription id>:<period end>`, so a retried or replayed renewal never charges
+ twice. VIP has no renewal call; it projects `billing.entitlement.changed` (`reason: renewed`), and a
+ renewal keeps the plan version the membership already has.
+- **Charge ledger.** `vip_membership_periods` holds one row per paid period, unique on the Billing
+ transaction (`billing_transaction_id`), written from a `granted`/`renewed` entitlement event that names
+ its `transaction_id` and from the credit checkout answer. A replayed event, or the same transaction
+ again, never records a second row. `billing.transaction.reversed` marks the period it reverses as
+ `reversed` (`reversed_by` = the reversing transaction); if the reversal arrives **before** the grant
+ event, a tombstone is written and the grant that follows leaves it reversed and grants nothing.
+ Provider refunds and chargebacks (`entitlements_revoked`) and credit refunds of a subscription period
+ (`metadata.original_type = subscription`) both count; a reversal of anything else is ignored.
+- **Renewal grace.** When a renewal charge fails Billing moves the subscription to `past_due` until
+ `grace_until`, retrying on the same key, then ends it (`grace_ended`). VIP stores both
+ (`vip_entitlement_projection.grace_until`), answers `status: inactive` with
+ `grace: { until, reason: "renewal_failed" }` while the grace runs (`null` otherwise), and **never
+ authorizes a `past_due` row**, whatever an event says. After the grace the membership row is kept and
+ inactive; a later paid renewal restores it on the same plan version. `vip.membership.changed` carries
+ `renewal_failed` / `grace_ended` as its `reason`; its payload is unchanged.
+- **Audit.** `periods.audit()` runs with the projection refresh job: an active, fresh row that claims a
+ payment (last word `granted`/`renewed`, or the pair already has ledger rows) without a `paid` period
+ reaching its `expires_at`, or whose period was reversed, is logged, counted (`vip_period_audit_offenders`)
+ and put in doubt (`valid_until = 0`) so the next check asks Billing. Pairs VIP only knows from a Billing
+ check (imports, shadow entitlements) are not audited until a payment event names them.
+
### Checkout and cancellation (Billing does both)
### Checkout and cancellation (Billing does both)
- `POST /api/v1/checkout` asks Billing first (a member cannot buy twice), records the plan version
- `POST /api/v1/checkout` asks Billing first (a member cannot buy twice), records the plan version
@@ -345,6 +374,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -345,6 +374,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
| plan edits create versions and never rewrite historical terms | `test/plans.test.js` (edit → v2, v1 row byte-identical, triggers refuse UPDATE/DELETE, perk rename keeps terms) |
| plan edits create versions and never rewrite historical terms | `test/plans.test.js` (edit → v2, v1 row byte-identical, triggers refuse UPDATE/DELETE, perk rename keeps terms) |
| old members keep old terms; renewals keep them; a new purchase after a lapse takes the current version | `test/plans.test.js`, `test/api.test.js` (checkout version survives a later edit) |
| old members keep old terms; renewals keep them; a new purchase after a lapse takes the current version | `test/plans.test.js`, `test/api.test.js` (checkout version survives a later edit) |
| cancel / renew / refund converge (events, and with events lost) | `test/entitlements.test.js` (injected clock) |
| cancel / renew / refund converge (events, and with events lost) | `test/entitlements.test.js` (injected clock) |
+| a renewal replayed or re-sent records one period; renew then refund reverses it; a reversal before the grant stays reversed; a failed renewal is inactive with a grace and never authorized; the audit doubts a paid period without a record | `test/renewal.test.js` |
| a stale projection cannot authorize past `valid_until` (+ grace), even with Billing down for 100 days | `test/entitlements.test.js` |
| a stale projection cannot authorize past `valid_until` (+ grace), even with Billing down for 100 days | `test/entitlements.test.js` |
| authoritative fallback and sensitive checks | `test/entitlements.test.js`, `test/policies.test.js` |
| authoritative fallback and sensitive checks | `test/entitlements.test.js`, `test/policies.test.js` |
| gated-resource evaluation fails closed (no rule, unknown, perk missing, guest, VIP down in the client) | `test/policies.test.js` |
| gated-resource evaluation fails closed (no rule, unknown, perk missing, guest, VIP down in the client) | `test/policies.test.js` |
@@ -21,7 +21,8 @@
@@ -21,7 +21,8 @@
"the Live subscription import has run: 43 plans and plan versions, 44 creators, 87 migration maps (production, 2026-09-23)",
"the Live subscription import has run: 43 plans and plan versions, 44 creators, 87 migration maps (production, 2026-09-23)",
"Chat (subscriber badge, vip.entitlement.check), Community (members-only spaces and threads) and Blog (members-only posts) call VIP in production (vip.resource.policy.evaluate grants for Community and Blog)",
"Chat (subscriber badge, vip.entitlement.check), Community (members-only spaces and threads) and Blog (members-only posts) call VIP in production (vip.resource.policy.evaluate grants for Community and Blog)",
"14 vip.* capabilities, the service manifest and its events registered in openvibe-contracts v0.19.0 (this repository pins v0.79.0)",
"14 vip.* capabilities, the service manifest and its events registered in openvibe-contracts v0.19.0 (this repository pins v0.79.0)",
- "per-actor limits on /api/v1 (openvibe-sdk/limits)"
+ "per-actor limits on /api/v1 (openvibe-sdk/limits)",
+ "renewal/refund convergence via Billing events: a per-period charge ledger (vip_membership_periods, unique per Billing transaction; reversals and reversal-before-grant tombstones), a past_due renewal grace that never authorizes (answer.grace), and a period audit that doubts a paid period without a record (migration 0002; docs/cutover-0002-renewal-periods.md)"
],
],
"not_yet": [
"not_yet": [
"not launched: openvibe.vip is still an OpenVibe.Sites placeholder and the service answers on loopback only",
"not launched: openvibe.vip is still an OpenVibe.Sites placeholder and the service answers on loopback only",
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+# Cutover note: migration 0002 (renewal periods and grace)
+
+`migrations/0002_renewal_periods.sql` is **additive** (`-- phase: expand`):
+
+- a new table `vip_membership_periods` (one row per paid period, `UNIQUE (billing_transaction_id)`),
+- a nullable column `vip_entitlement_projection.grace_until`.
+
+Nothing is renamed, dropped or rewritten.
+
+## Order
+
+1. Apply the migration **before** the code that uses it deploys. VIP applies `migrations/` at boot as the owner
+ role (`DATABASE_DIRECT_URL`), so deploying the release applies it first; the previous release ignores both
+ additions, so the order only matters if the migration is run by hand.
+2. Deploy the release. Period rows fill from the next `billing.entitlement.changed` (granted/renewed with a
+ `transaction_id`), `billing.transaction.reversed` and credit checkout. Existing active rows are not
+ back-filled; the audit does not look at a pair until a payment event names it, so nothing is doubted at deploy.
+3. The grace answer (`grace: { until, reason: "renewal_failed" }`) appears only once Billing sends `past_due`
+ events (Billing's grace is off by default until its own step turns it on). Until then `grace` is `null`.
+
+## Rollback
+
+Roll the code back; leave the table and the column in place, unused. The previous release neither reads nor writes
+them. Do not drop `vip_membership_periods`: it is the record that each paid period had a charge.
+
+## Watch
+
+- `vip_period_audit_offenders` (gauge, `/metrics`) and the log line `[VIP] period audit:` after the first renewals.
+ A persistent non-zero value means Billing says a member is active for a period VIP has no paid charge for
+ (a lost grant event, or a reversal that did not shorten the membership).
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+-- phase: expand
+-- Renewal and refund convergence on OpenVibe.Billing (T5 step 11): one row per paid period, keyed by the Billing
+-- transaction that paid it, so VIP can show that every paid period has a charge record and that a reversed one is
+-- not counted. Additive only: a new table and a nullable column. Rolling back the code leaves both unused.
+
+CREATE TABLE vip_membership_periods (
+ id text COLLATE "C" PRIMARY KEY,
+ member_subject text COLLATE "C" NOT NULL,
+ creator_subject text COLLATE "C" NOT NULL,
+ billing_subscription_id text COLLATE "C",
+ billing_transaction_id text COLLATE "C" NOT NULL,
+ period_start text COLLATE "C",
+ period_end text COLLATE "C",
+ status text COLLATE "C" NOT NULL CHECK (status IN ('paid', 'reversed')),
+ reversed_by text COLLATE "C",
+ reason text COLLATE "C",
+ recorded_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ UNIQUE (billing_transaction_id)
+);
+CREATE INDEX idx_vip_periods_pair ON vip_membership_periods (member_subject, creator_subject, status, period_end);
+
+-- past_due: when Billing stops retrying a failed renewal (null outside a renewal grace)
+ALTER TABLE vip_entitlement_projection ADD COLUMN grace_until text COLLATE "C";
@@ -73,6 +73,7 @@ async function createApp(opts = {}) {
@@ -73,6 +73,7 @@ async function createApp(opts = {}) {
return [{ labels: { state: 'fresh' }, value: r.fresh || 0 }, { labels: { state: 'stale' }, value: r.stale || 0 }];
return [{ labels: { state: 'fresh' }, value: r.fresh || 0 }, { labels: { state: 'stale' }, value: r.stale || 0 }];
},
},
});
});
+ metrics.registry.gauge({ name: 'vip_period_audit_offenders', help: 'Active memberships the last period audit found without a paid period record', collect: () => domain.periods.stats.offenders });
app.use(http.middleware());
app.use(http.middleware());
app.use((req, res, next) => {
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Content-Type-Options', 'nosniff');
@@ -28,7 +28,7 @@ function billingProblem(err) {
@@ -28,7 +28,7 @@ function billingProblem(err) {
fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; nothing was charged');
fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; nothing was charged');
}
}
-function createCheckout({ db, now, config, billing, creators, plans, entitlements }) {
+function createCheckout({ db, now, config, billing, creators, plans, entitlements, periods }) {
const byId = async (id) => await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;
const byId = async (id) => await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;
async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {
async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {
@@ -65,10 +65,20 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -65,10 +65,20 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
const sub = out.subscription || {};
const sub = out.subscription || {};
const ent = out.entitlement || {};
const ent = out.entitlement || {};
await mark({ status: 'paid', billing_subscription_id: sub.id || null });
await mark({ status: 'paid', billing_subscription_id: sub.id || null });
- 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,
+ // The projection and the charge that paid the first period land together (the event may come before or after).
+ await db.tx(async () => {
+ 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,
+ });
+ const txn = out.transaction && out.transaction.id;
+ if (txn) {
+ await periods.recordPaid({
+ member, creator: creator.subject, subscriptionId: sub.id || null, transactionId: txn,
+ periodStart: iso(now()), periodEnd: ent.expires_at || sub.current_period_end || null, reason: 'granted',
+ });
+ }
});
});
return { checkout: present(await byId(id)), membership_started: !!ent.active, checkout_url: null, checkout_ref: null };
return { checkout: present(await byId(id)), membership_started: !!ent.active, checkout_url: null, checkout_ref: null };
}
}
@@ -17,6 +17,10 @@
@@ -17,6 +17,10 @@
* Ordering: every row remembers the Billing time it reflects (billing_as_of). An older event that
* Ordering: every row remembers the Billing time it reflects (billing_as_of). An older event that
* would grant is ignored; an older event that would revoke puts the row in doubt (valid_until = 0),
* would grant is ignored; an older event that would revoke puts the row in doubt (valid_until = 0),
* so the next check goes to Billing — out-of-order delivery can deny briefly, never grant wrongly.
* so the next check goes to Billing — out-of-order delivery can deny briefly, never grant wrongly.
+ *
+ * Renewal grace: a failed renewal charge puts Billing's subscription in `past_due` until `grace_until`, then
+ * ends it. The entitlement is not active meanwhile; VIP stores both, answers inactive with
+ * `grace: { until, reason: 'renewal_failed' }`, and never authorizes a past_due row, whatever the event says.
*/
*/
const { iso, userRef } = require('../util');
const { iso, userRef } = require('../util');
@@ -55,7 +59,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -55,7 +59,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
/**
/**
* Apply what Billing said about (member, creator). Runs in a transaction (joins the caller's).
* Apply what Billing said about (member, creator). Runs in a transaction (joins the caller's).
* input: { member, creator, kind?, active, expiresAt, cancelAtPeriodEnd?, subscriptionId?,
* input: { member, creator, kind?, active, expiresAt, cancelAtPeriodEnd?, subscriptionId?,
- * subscriptionStatus?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),
+ * subscriptionStatus?, graceUntil?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),
* origin?, checkoutId?, traceparent? }
* origin?, checkoutId?, traceparent? }
*/
*/
async function apply(input) {
async function apply(input) {
@@ -70,29 +74,34 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -70,29 +74,34 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
}
}
return { outcome: 'stale_ignored', changed: false };
return { outcome: 'stale_ignored', changed: false };
}
}
+ const status = input.subscriptionStatus !== undefined ? input.subscriptionStatus : (prev ? prev.subscription_status : null);
+ // past_due never authorizes (Billing's entitlement is false then); the grace ends with the status.
+ const active = input.active && status !== 'past_due';
const next = {
const next = {
member_subject: input.member, creator_subject: input.creator, kind,
member_subject: input.member, creator_subject: input.creator, kind,
- active: input.active ? 1 : 0, expires_at: input.expiresAt || null,
+ active: active ? 1 : 0, expires_at: input.expiresAt || null,
cancel_at_period_end: input.cancelAtPeriodEnd !== undefined ? (input.cancelAtPeriodEnd ? 1 : 0) : (prev ? prev.cancel_at_period_end : 0),
cancel_at_period_end: input.cancelAtPeriodEnd !== undefined ? (input.cancelAtPeriodEnd ? 1 : 0) : (prev ? prev.cancel_at_period_end : 0),
subscription_id: input.subscriptionId !== undefined ? input.subscriptionId : (prev ? prev.subscription_id : null),
subscription_id: input.subscriptionId !== undefined ? input.subscriptionId : (prev ? prev.subscription_id : null),
- subscription_status: input.subscriptionStatus !== undefined ? input.subscriptionStatus : (prev ? prev.subscription_status : null),
+ subscription_status: status,
+ grace_until: status === 'past_due' ? (input.graceUntil || (prev ? prev.grace_until : null) || null) : null,
source: input.source, last_reason: input.reason || null, billing_event_id: input.eventId || null,
source: input.source, last_reason: input.reason || null, billing_event_id: input.eventId || null,
- billing_as_of: input.asOf, synced_at: at, valid_until: validUntil(at, input.active, input.expiresAt),
+ billing_as_of: input.asOf, synced_at: at, valid_until: validUntil(at, active, input.expiresAt),
last_used_at: prev ? prev.last_used_at : null,
last_used_at: prev ? prev.last_used_at : null,
};
};
await db.prepare(`INSERT INTO vip_entitlement_projection (member_subject, creator_subject, kind, active, expires_at, cancel_at_period_end, subscription_id,
await db.prepare(`INSERT INTO vip_entitlement_projection (member_subject, creator_subject, kind, active, expires_at, cancel_at_period_end, subscription_id,
- subscription_status, source, last_reason, billing_event_id, billing_as_of, synced_at, valid_until, last_used_at)
- VALUES (@member_subject, @creator_subject, @kind, @active, @expires_at, @cancel_at_period_end, @subscription_id, @subscription_status,
+ subscription_status, grace_until, source, last_reason, billing_event_id, billing_as_of, synced_at, valid_until, last_used_at)
+ VALUES (@member_subject, @creator_subject, @kind, @active, @expires_at, @cancel_at_period_end, @subscription_id, @subscription_status, @grace_until,
@source, @last_reason, @billing_event_id, @billing_as_of, @synced_at, @valid_until, @last_used_at)
@source, @last_reason, @billing_event_id, @billing_as_of, @synced_at, @valid_until, @last_used_at)
ON CONFLICT (member_subject, creator_subject, kind) DO UPDATE SET active = excluded.active, expires_at = excluded.expires_at,
ON CONFLICT (member_subject, creator_subject, kind) DO UPDATE SET active = excluded.active, expires_at = excluded.expires_at,
cancel_at_period_end = excluded.cancel_at_period_end, subscription_id = excluded.subscription_id,
cancel_at_period_end = excluded.cancel_at_period_end, subscription_id = excluded.subscription_id,
- subscription_status = excluded.subscription_status, source = excluded.source, last_reason = excluded.last_reason,
+ subscription_status = excluded.subscription_status, grace_until = excluded.grace_until, source = excluded.source, last_reason = excluded.last_reason,
billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,
billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,
valid_until = excluded.valid_until`).run(next);
valid_until = excluded.valid_until`).run(next);
const { membership } = await memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });
const { membership } = await memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });
// A first look that finds no membership is not a change anyone needs to hear about.
// A first look that finds no membership is not a change anyone needs to hear about.
const changed = prev
const changed = prev
- ? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end)
+ ? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end
+ || (prev.grace_until || null) !== next.grace_until)
: !!next.active;
: !!next.active;
if (changed) await emitChanged(next, prev, { reason: input.reason, membership }, input.traceparent);
if (changed) await emitChanged(next, prev, { reason: input.reason, membership }, input.traceparent);
return { outcome: changed ? 'changed' : 'unchanged', changed, row: await getRow(input.member, input.creator, kind), membership };
return { outcome: changed ? 'changed' : 'unchanged', changed, row: await getRow(input.member, input.creator, kind), membership };
@@ -108,7 +117,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -108,7 +117,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
const active = subscriptionStatus === 'active' ? !!prev.active : false;
const active = subscriptionStatus === 'active' ? !!prev.active : false;
return await apply({
return await apply({
member, creator, active, expiresAt: active ? (prev.expires_at || currentPeriodEnd) : prev.expires_at, cancelAtPeriodEnd: true,
member, creator, active, expiresAt: active ? (prev.expires_at || currentPeriodEnd) : prev.expires_at, cancelAtPeriodEnd: true,
- subscriptionId, subscriptionStatus, reason: 'cancel_scheduled', source, eventId, asOf, traceparent,
+ subscriptionId, subscriptionStatus, graceUntil: prev.grace_until, reason: 'cancel_scheduled', source, eventId, asOf, traceparent,
});
});
});
});
}
}
@@ -120,18 +129,20 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -120,18 +129,20 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
async function answer(row, source, { stale = false, membership } = {}) {
async function answer(row, source, { stale = false, membership } = {}) {
const t = now();
const t = now();
- const active = !!row.active && !!row.expires_at && Date.parse(row.expires_at) > t;
+ const active = !!row.active && row.subscription_status !== 'past_due' && !!row.expires_at && Date.parse(row.expires_at) > t;
+ const graceEnd = row.subscription_status === 'past_due' && row.grace_until ? Date.parse(row.grace_until) : NaN;
return {
return {
member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
status: active ? 'active' : 'inactive', active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
status: active ? 'active' : 'inactive', active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
source, stale, valid_until: iso(Math.max(0, row.valid_until)), checked_at: iso(t),
source, stale, valid_until: iso(Math.max(0, row.valid_until)), checked_at: iso(t),
membership: membership ? await memberships.present(membership) : null,
membership: membership ? await memberships.present(membership) : null,
+ grace: !active && graceEnd > t ? { until: row.grace_until, reason: 'renewal_failed' } : null,
};
};
}
}
function unknown(member, creator, reason) {
function unknown(member, creator, reason) {
return {
return {
member: userRef(member), creator: userRef(creator), kind: KIND, status: 'unknown', active: false, expires_at: null,
member: userRef(member), creator: userRef(creator), kind: KIND, status: 'unknown', active: false, expires_at: null,
- cancel_at_period_end: false, source: 'none', stale: false, valid_until: null, checked_at: iso(now()), reason, membership: null,
+ cancel_at_period_end: false, source: 'none', stale: false, valid_until: null, checked_at: iso(now()), reason, membership: null, grace: null,
};
};
}
}
@@ -147,7 +158,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -147,7 +158,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
const out = await apply({
const out = await apply({
member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,
member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
- subscriptionStatus: sub ? sub.status : undefined, reason: 'checked', source: 'billing_check', asOf: now(), traceparent,
+ subscriptionStatus: sub ? sub.status : undefined, graceUntil: (e && e.grace_until) || (sub && sub.grace_until) || undefined, reason: 'checked', source: 'billing_check', asOf: now(), traceparent,
});
});
return out.row || await getRow(member, creator);
return out.row || await getRow(member, creator);
}
}
@@ -5,6 +5,7 @@ const { createCreators } = require('./creators');
@@ -5,6 +5,7 @@ const { createCreators } = require('./creators');
const { createPerks } = require('./perks');
const { createPerks } = require('./perks');
const { createPlans } = require('./plans');
const { createPlans } = require('./plans');
const { createMemberships } = require('./memberships');
const { createMemberships } = require('./memberships');
+const { createPeriods } = require('./periods');
const { createEntitlements } = require('./entitlements');
const { createEntitlements } = require('./entitlements');
const { createPolicies } = require('./policies');
const { createPolicies } = require('./policies');
const { createCheckout } = require('./checkout');
const { createCheckout } = require('./checkout');
@@ -15,12 +16,13 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), log
@@ -15,12 +16,13 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), log
const perks = createPerks({ db, now });
const perks = createPerks({ db, now });
const plans = createPlans({ db, now, outbox, creators, perks });
const plans = createPlans({ db, now, outbox, creators, perks });
const memberships = createMemberships({ db, now, creators, plans });
const memberships = createMemberships({ db, now, creators, plans });
+ const periods = createPeriods({ db, now, log });
const entitlements = createEntitlements({ db, now, config, billing, outbox, memberships, plans, log });
const entitlements = createEntitlements({ db, now, config, billing, outbox, memberships, plans, log });
const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });
const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });
- const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements });
+ const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements, periods });
const cards = createCards({ db, config, creators, plans, perks, billing, now, log });
const cards = createCards({ db, config, creators, plans, perks, billing, now, log });
const tx = async (fn) => await db.tx(fn);
const tx = async (fn) => await db.tx(fn);
- return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, entitlements, policies, checkout, cards };
+ return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards };
}
}
module.exports = { createDomain };
module.exports = { createDomain };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+
+/**
+ * The per-period charge ledger: one row per paid membership period, keyed by the OpenVibe.Billing
+ * transaction that paid it (UNIQUE billing_transaction_id), so a replayed or retried event never records
+ * a period twice. Billing charges once per (subscription, period); VIP only records what Billing says.
+ *
+ * recordPaid billing.entitlement.changed granted/renewed with a transaction_id, or the credit checkout answer
+ * recordReversed billing.transaction.reversed: the period whose transaction was reversed. A reversal that
+ * arrives BEFORE the grant event writes a tombstone (status reversed); the grant that follows
+ * fills in the details and leaves it reversed.
+ * audit active, fresh projection rows whose paid period has no 'paid' record, or whose period was
+ * reversed, are offenders: they are logged, counted and put in doubt (valid_until = 0) so the
+ * next check asks Billing
+ */
+const { iso, prefixedId } = require('../util');
+
+const KIND = 'channel_subscription';
+const PAID_REASONS = new Set(['granted', 'renewed']);
+
+function createPeriods({ db, now, log = console }) {
+ const stats = { runs: 0, offenders: 0, offenders_total: 0 };
+
+ const byTransaction = async (txn) => await db.prepare('SELECT * FROM vip_membership_periods WHERE billing_transaction_id = ?').get(txn) || null;
+
+ /** Record the period a transaction paid. Idempotent on the transaction; never un-reverses a period. */
+ async function recordPaid({ member, creator, subscriptionId = null, transactionId, periodStart = null, periodEnd = null, reason = null }) {
+ if (!transactionId) return { outcome: 'no_transaction' };
+ return await db.tx(async () => {
+ const prev = await byTransaction(transactionId);
+ const at = iso(now());
+ await db.prepare(`INSERT INTO vip_membership_periods (id, member_subject, creator_subject, billing_subscription_id, billing_transaction_id,
+ period_start, period_end, status, reason, recorded_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'paid', ?, ?, ?)
+ ON CONFLICT (billing_transaction_id) DO UPDATE SET member_subject = excluded.member_subject, creator_subject = excluded.creator_subject,
+ billing_subscription_id = COALESCE(excluded.billing_subscription_id, vip_membership_periods.billing_subscription_id),
+ period_start = COALESCE(vip_membership_periods.period_start, excluded.period_start),
+ period_end = COALESCE(excluded.period_end, vip_membership_periods.period_end),
+ reason = COALESCE(excluded.reason, vip_membership_periods.reason), updated_at = excluded.updated_at`)
+ .run(prefixedId('vmp', now()), member, creator, subscriptionId, transactionId, periodStart, periodEnd, reason, at, at);
+ const row = await byTransaction(transactionId);
+ return { outcome: !prev ? 'recorded' : (row.status === 'reversed' ? 'reversed' : 'replayed'), row };
+ });
+ }
+
+ /** Mark the period paid by `reversesTxn` reversed by `reversedBy`; a tombstone when it is not recorded yet. */
+ async function recordReversed({ member, creator, reversesTxn, reversedBy = null, reason = null }) {
+ if (!reversesTxn) return { outcome: 'no_transaction' };
+ return await db.tx(async () => {
+ const prev = await byTransaction(reversesTxn);
+ const at = iso(now());
+ await db.prepare(`INSERT INTO vip_membership_periods (id, member_subject, creator_subject, billing_transaction_id, status, reversed_by, reason, recorded_at, updated_at)
+ VALUES (?, ?, ?, ?, 'reversed', ?, ?, ?, ?)
+ ON CONFLICT (billing_transaction_id) DO UPDATE SET status = 'reversed',
+ reversed_by = COALESCE(vip_membership_periods.reversed_by, excluded.reversed_by), updated_at = excluded.updated_at`)
+ .run(prefixedId('vmp', now()), member, creator, reversesTxn, reversedBy, reason, at, at);
+ return { outcome: !prev ? 'tombstone' : (prev.status === 'reversed' ? 'replayed' : 'reversed'), row: await byTransaction(reversesTxn) };
+ });
+ }
+
+ const forPair = async (member, creator) => await db.prepare(`SELECT * FROM vip_membership_periods WHERE member_subject = ? AND creator_subject = ?
+ ORDER BY period_end DESC`).all(member, creator);
+
+ /**
+ * Rows that claim a paid period (the last word was a payment, or the pair already has ledger rows) without a
+ * 'paid' record that reaches their expires_at, or whose covering period was reversed. A pair VIP only ever
+ * learned from a Billing check (an import, a shadow entitlement) is not audited until a payment event names it.
+ */
+ async function audit({ limit = 500 } = {}) {
+ const t = now();
+ const rows = await db.prepare(`SELECT p.* FROM vip_entitlement_projection p
+ WHERE p.kind = ? AND p.active = 1 AND p.valid_until >= ? AND p.expires_at > ?
+ AND (p.last_reason IN ('granted', 'renewed') OR EXISTS (SELECT 1 FROM vip_membership_periods q
+ WHERE q.member_subject = p.member_subject AND q.creator_subject = p.creator_subject))
+ ORDER BY p.valid_until LIMIT ?`).all(KIND, t, iso(t), limit);
+ const offenders = [];
+ for (const r of rows) {
+ const paid = await db.prepare(`SELECT 1 AS ok FROM vip_membership_periods WHERE member_subject = ? AND creator_subject = ?
+ AND status = 'paid' AND period_end >= ? LIMIT 1`).get(r.member_subject, r.creator_subject, r.expires_at);
+ if (paid) continue;
+ const reversed = await db.prepare(`SELECT billing_transaction_id FROM vip_membership_periods WHERE member_subject = ? AND creator_subject = ?
+ AND status = 'reversed' AND (period_end IS NULL OR period_end >= ?) ORDER BY recorded_at DESC LIMIT 1`).get(r.member_subject, r.creator_subject, r.expires_at);
+ offenders.push({
+ member: r.member_subject, creator: r.creator_subject, expires_at: r.expires_at,
+ problem: reversed ? 'period_reversed' : 'no_paid_period', billing_transaction_id: reversed ? reversed.billing_transaction_id : null,
+ });
+ await db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(r.member_subject, r.creator_subject, KIND);
+ }
+ stats.runs++;
+ stats.offenders = offenders.length;
+ stats.offenders_total += offenders.length;
+ if (offenders.length) log.warn(`[VIP] period audit: ${offenders.length} active membership(s) without a paid period record; doubted: ${offenders.slice(0, 5).map((o) => `${o.member}→${o.creator} ${o.problem}`).join(', ')}`);
+ return { checked: rows.length, offenders };
+ }
+
+ return { recordPaid, recordReversed, byTransaction, forPair, audit, stats, PAID_REASONS };
+}
+
+module.exports = { createPeriods };
@@ -5,11 +5,14 @@
@@ -5,11 +5,14 @@
* (scripts/subscribe.js): billing.entitlement.*, billing.subscription.*, billing.transaction.reversed.
* (scripts/subscribe.js): billing.entitlement.*, billing.subscription.*, billing.transaction.reversed.
*
*
* billing.entitlement.changed the projection takes Billing's { active, expires_at, subscription }
* billing.entitlement.changed the projection takes Billing's { active, expires_at, subscription }
- * (granted, renewed, canceled, expired, refund, chargeback, …)
+ * (granted, renewed, canceled, expired, refund, chargeback, …); a granted/renewed
+ * event with a transaction_id also records the paid period (domain/periods.js);
+ * renewal_failed (past_due, grace_until) and grace_ended are stored, never authorize
* billing.subscription.canceled cancel at period end: the flag changes, the period still runs
* billing.subscription.canceled cancel at period end: the flag changes, the period still runs
* billing.transaction.reversed a reversal that revoked subscription periods puts the pair's
* billing.transaction.reversed a reversal that revoked subscription periods puts the pair's
* projection in doubt at once (the next check asks Billing), even
* projection in doubt at once (the next check asks Billing), even
- * before — or without — the matching entitlement event
+ * before — or without — the matching entitlement event; the period that
+ * transaction paid is marked reversed (a tombstone when it is not recorded yet)
*
*
* Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same PostgreSQL transaction as
* Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same PostgreSQL transaction as
* the change. Order: rows remember the Billing time they reflect; see domain/entitlements.js.
* the change. Order: rows remember the Billing time they reflect; see domain/entitlements.js.
@@ -26,7 +29,7 @@ const CONSUMER = 'vip-billing';
@@ -26,7 +29,7 @@ const CONSUMER = 'vip-billing';
function consumerRouter({ domain, config, log = console }) {
function consumerRouter({ domain, config, log = console }) {
const router = express.Router();
const router = express.Router();
const inbox = createPgInbox(domain.db, { now: domain.now }); // idempotency_receipts: migrations/0001_initial.sql
const inbox = createPgInbox(domain.db, { now: domain.now }); // idempotency_receipts: migrations/0001_initial.sql
- const { entitlements } = domain;
+ const { entitlements, periods } = domain;
async function handle(event) {
async function handle(event) {
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
@@ -38,11 +41,24 @@ function consumerRouter({ domain, config, log = console }) {
@@ -38,11 +41,24 @@ function consumerRouter({ domain, config, log = console }) {
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
if (p.kind && p.kind !== entitlements.KIND) return 'ignored:kind';
if (p.kind && p.kind !== entitlements.KIND) return 'ignored:kind';
const sub = p.subscription || null;
const sub = p.subscription || null;
- return (await entitlements.apply({
- member, creator, active: !!p.active, expiresAt: p.expires_at || null,
+ let reversed = false;
+ if (p.transaction_id && periods.PAID_REASONS.has(p.reason)) {
+ // A renewal starts where the paid period ends, or now when it already ended.
+ const prev = await entitlements.getRow(member, creator);
+ const prevEnd = prev && prev.expires_at && Date.parse(prev.expires_at) > asOf ? prev.expires_at : event.timestamp;
+ // A period already reversed (its reversal came first) grants nothing, whatever this event says.
+ reversed = (await periods.recordPaid({
+ member, creator, subscriptionId: sub ? sub.id : null, transactionId: p.transaction_id,
+ periodStart: prevEnd, periodEnd: p.expires_at || null, reason: p.reason,
+ })).outcome === 'reversed';
+ }
+ const out = await entitlements.apply({
+ member, creator, active: !!p.active && !reversed, expiresAt: p.expires_at || null,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
- subscriptionStatus: sub ? sub.status : undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,
- })).outcome;
+ subscriptionStatus: sub ? sub.status : undefined, graceUntil: p.grace_until || undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,
+ });
+ if (reversed) await entitlements.doubt(member, creator);
+ return out.outcome;
}
}
if (event.event_type === 'billing.subscription.canceled') {
if (event.event_type === 'billing.subscription.canceled') {
const s = p.subscription || {};
const s = p.subscription || {};
@@ -55,11 +71,15 @@ function consumerRouter({ domain, config, log = console }) {
@@ -55,11 +71,15 @@ function consumerRouter({ domain, config, log = console }) {
})).outcome;
})).outcome;
}
}
if (event.event_type === 'billing.transaction.reversed') {
if (event.event_type === 'billing.transaction.reversed') {
- if (!(Number(p.entitlements_revoked) > 0)) return 'ignored:no_entitlement';
+ // A provider reversal says how many periods it revoked; a credit refund of a subscription period says what it reversed.
+ const meta = p.metadata && typeof p.metadata === 'object' ? p.metadata : {};
+ const ofSubscription = meta.original_type === 'subscription' || typeof meta.subscription_id === 'string';
+ if (!(Number(p.entitlements_revoked) > 0) && !ofSubscription) return 'ignored:no_entitlement';
// The reversal runs streamer → subscriber (from/to of the original swapped).
// The reversal runs streamer → subscriber (from/to of the original swapped).
const member = p.to_subject;
const member = p.to_subject;
const creator = p.from_subject;
const creator = p.from_subject;
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
+ await periods.recordReversed({ member, creator, reversesTxn: p.reverses_txn, reversedBy: p.transaction_id || null, reason: p.type || null });
return await entitlements.doubt(member, creator) ? 'doubt' : 'no_projection';
return await entitlements.doubt(member, creator) ? 'doubt' : 'no_projection';
}
}
return 'ignored:type';
return 'ignored:type';
@@ -46,7 +46,10 @@ async function start() {
@@ -46,7 +46,10 @@ async function start() {
const timers = [];
const timers = [];
if (config.jobs.enabled) {
if (config.jobs.enabled) {
const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };
const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };
- every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());
+ every(config.jobs.refreshIntervalMs, async () => {
+ await domain.periods.audit(); // doubts what has no paid period record, so refreshDue asks Billing about it now
+ await domain.entitlements.refreshDue();
+ });
every(6 * 3600 * 1000, async () => await outbox.outbox.prune());
every(6 * 3600 * 1000, async () => await outbox.outbox.prune());
outbox.start();
outbox.start();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
+'use strict';
+/**
+ * Renewals, failed renewals, refunds and reversals converge on Billing (T5 step 11), money path:
+ *
+ * one period row per Billing transaction, however often an event is replayed or re-sent
+ * renew then refund the period is reversed, the membership ends
+ * reversal before the grant event a tombstone; the grant that follows leaves it reversed and grants nothing
+ * past_due (renewal failed) inactive with grace { until, reason }, never authorized
+ * grace_ended inactive, no grace; a later paid renewal restores it
+ * audit an active row without a paid period record, or on a reversed one, is doubted
+ *
+ * Real VIP over stub Network and Billing, one injected clock.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, harness } = require('./helpers/app');
+
+const { test, run } = harness('renewal');
+const DAY = 86_400_000;
+
+(async () => {
+ const t = await boot();
+ const { periods, entitlements, db } = t.domain;
+ const creator = t.network.newUser('rena', { role: 'streamer' });
+ const plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } })).json.plan;
+
+ const rows = async (m) => await db.prepare('SELECT * FROM vip_membership_periods WHERE member_subject = ? ORDER BY recorded_at, id').all(m.subject);
+ const ask = async (m) => await entitlements.check(m.subject, creator.subject, { mode: 'projection' });
+ const txnId = () => `txn_${ids.ulid(t.clock.now())}${Math.random().toString(36).slice(2, 5).toUpperCase()}`.slice(0, 30);
+ const entEvent = (m, payload) => t.billing.envelope('billing.entitlement.changed',
+ { type: 'entitlement', id: `${m.subject}:channel_subscription:${creator.subject}` },
+ { subject: { type: 'user', id: m.subject }, streamer: { type: 'user', id: creator.subject }, kind: 'channel_subscription', ...payload });
+ const reversal = (m, reverses, extra = {}) => {
+ const id = txnId();
+ return { id, event: t.billing.envelope('billing.transaction.reversed', { type: 'transaction', id }, { transaction_id: id, type: 'refund', test: false, from_subject: creator.subject, to_subject: m.subject, provider: null, metadata: {}, reverses_txn: reverses, entitlements_revoked: 1, ...extra }) };
+ };
+ const sub = (id, status, extra = {}) => ({ id, status, auto_renew: true, cancel_at_period_end: false, provider: 'credit', ...extra });
+
+ test('a replayed or re-sent renewal records one period per transaction', async () => {
+ const m = t.network.newUser('r1');
+ const first = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(first.events);
+ t.clock.advance(31 * DAY);
+ const renew = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(renew.events);
+ assert.deepStrictEqual((await rows(m)).map((r) => [r.billing_transaction_id, r.status]), [[first.txn, 'paid'], [renew.txn, 'paid']]);
+ const again = await t.deliverAll(renew.events); // same event ids: the inbox says duplicate
+ assert.ok(again.every((r) => r.json.duplicate), 'replay is a no-op');
+ // Billing re-sends the same charge under a new event id: still one row for the transaction
+ const resent = t.billing.envelope('billing.entitlement.changed', renew.events[1].subject, renew.events[1].payload);
+ await t.deliver(resent);
+ const all = await rows(m);
+ assert.strictEqual(all.length, 2);
+ assert.strictEqual(all[1].billing_subscription_id, renew.sub.id);
+ assert.strictEqual(all[1].period_end, renew.events[1].payload.expires_at);
+ assert.strictEqual((await ask(m)).status, 'active');
+ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((e) => e.payload.member.id === m.subject && e.payload.reason === 'renewed').length, 1);
+ });
+
+ test('renew then refund: the period is reversed and the membership ends', async () => {
+ const m = t.network.newUser('r2');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ t.clock.advance(31 * DAY);
+ const renew = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(renew.events);
+ assert.strictEqual((await ask(m)).active, true);
+ const out = t.billing.refund(m.subject, creator.subject);
+ await t.deliverAll(out.events);
+ const [, second] = await rows(m);
+ assert.strictEqual(second.billing_transaction_id, renew.txn);
+ assert.strictEqual(second.status, 'reversed');
+ assert.strictEqual(second.reversed_by, out.events[0].payload.transaction_id);
+ const a = await ask(m);
+ assert.strictEqual(a.active, false);
+ assert.strictEqual(a.status, 'inactive');
+ const dup = await t.deliverAll(out.events); // replayed refund
+ assert.ok(dup.every((r) => r.json.duplicate));
+ assert.strictEqual((await rows(m)).length, 2);
+ });
+
+ test('a reversal before the grant event ends reversed and grants nothing', async () => {
+ const m = t.network.newUser('r3');
+ const paid = t.billing.pay(m.subject, creator.subject);
+ const rev = reversal(m, paid.txn);
+ const first = await t.deliver(rev.event); // no projection yet
+ assert.strictEqual(first.json.outcome, 'no_projection');
+ let [tomb] = await rows(m);
+ assert.strictEqual(tomb.status, 'reversed');
+ assert.strictEqual(tomb.billing_transaction_id, paid.txn);
+ assert.strictEqual(tomb.period_end, null);
+ await t.deliverAll(paid.events); // the (now stale) grant arrives: active as of its time
+ const all = await rows(m);
+ assert.strictEqual(all.length, 1);
+ [tomb] = all;
+ assert.strictEqual(tomb.status, 'reversed');
+ assert.strictEqual(tomb.billing_subscription_id, paid.sub.id, 'the grant fills in the details');
+ assert.strictEqual(tomb.period_end, paid.events[1].payload.expires_at);
+ const a = await ask(m);
+ assert.strictEqual(a.active, false, 'a reversed period never grants');
+ assert.notStrictEqual(a.status, 'active');
+ // And a replay of the reversal under a new event id changes nothing
+ await t.deliver(t.billing.envelope('billing.transaction.reversed', rev.event.subject, rev.event.payload));
+ assert.strictEqual((await rows(m)).length, 1);
+ });
+
+ test('a credit refund of a subscription period (no entitlements_revoked count) reverses the period too', async () => {
+ const m = t.network.newUser('r3b');
+ const paid = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(paid.events);
+ const rev = reversal(m, paid.txn, { entitlements_revoked: undefined, metadata: { original_type: 'subscription', subscription_id: paid.sub.id } });
+ assert.strictEqual((await t.deliver(rev.event)).json.outcome, 'doubt');
+ assert.strictEqual((await rows(m))[0].status, 'reversed');
+ // A reversal of something that is no subscription period is still ignored
+ const donation = reversal(m, txnId(), { entitlements_revoked: undefined, type: 'refund', metadata: { original_type: 'donation' } });
+ assert.strictEqual((await t.deliver(donation.event)).json.outcome, 'ignored:no_entitlement');
+ assert.strictEqual((await rows(m)).length, 1);
+ });
+
+ test('failed renewal: past_due is inactive with a grace, never authorized', async () => {
+ const m = t.network.newUser('r4');
+ const paid = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(paid.events);
+ const end = paid.events[1].payload.expires_at;
+ t.clock.advance(31 * DAY);
+ const until = new Date(t.clock.now() + 3 * DAY).toISOString();
+ // Billing says inactive; even an event that claims active while past_due must not authorize
+ for (const active of [false, true]) {
+ t.clock.advance(1000);
+ const r = await t.deliver(entEvent(m, { active, expires_at: end, subscription: sub(paid.sub.id, 'past_due'), reason: 'renewal_failed', grace_until: until, renewal_period_end: until }));
+ assert.strictEqual(r.status, 200);
+ const a = await ask(m);
+ assert.strictEqual(a.active, false, `active:${active} past_due`);
+ assert.strictEqual(a.status, 'inactive');
+ assert.deepStrictEqual(a.grace, { until, reason: 'renewal_failed' });
+ for (const mode of ['auto', 'projection']) assert.strictEqual((await entitlements.check(m.subject, creator.subject, { mode })).active, false);
+ const route = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}&mode=projection`, { user: m });
+ assert.strictEqual(route.json.active, false);
+ assert.deepStrictEqual(route.json.grace, { until, reason: 'renewal_failed' });
+ }
+ const row = await entitlements.getRow(m.subject, creator.subject);
+ assert.strictEqual(row.subscription_status, 'past_due');
+ assert.strictEqual(row.grace_until, until);
+ const changed = (await t.outboxEvents('vip.membership.changed')).filter((e) => e.payload.member.id === m.subject).pop();
+ assert.strictEqual(changed.payload.reason, 'renewal_failed');
+ assert.strictEqual(changed.payload.active, false);
+ assert.strictEqual((await rows(m)).length, 1, 'a failed renewal records no period');
+ // An older event cannot undo it (ordering still holds)
+ const stale = entEvent(m, { active: true, expires_at: end, subscription: sub(paid.sub.id, 'active'), reason: 'renewed', transaction_id: txnId() });
+ stale.timestamp = new Date(t.clock.now() - 40 * DAY).toISOString();
+ await t.deliver(stale);
+ assert.strictEqual((await ask(m)).active, false);
+ // Past the grace the answer carries no grace
+ t.clock.advance(4 * DAY);
+ assert.strictEqual((await ask(m)).grace, null);
+ });
+
+ test('grace ended: inactive, no grace; a later paid renewal restores the membership on the same version', async () => {
+ const m = t.network.newUser('r5');
+ const paid = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(paid.events);
+ const end = paid.events[1].payload.expires_at;
+ const versionBefore = (await ask(m)).membership.plan_version_id;
+ t.clock.advance(31 * DAY);
+ await t.deliver(entEvent(m, { active: false, expires_at: end, subscription: sub(paid.sub.id, 'past_due'), reason: 'renewal_failed', grace_until: new Date(t.clock.now() + 3 * DAY).toISOString() }));
+ t.clock.advance(3 * DAY + 1000);
+ await t.deliver(entEvent(m, { active: false, expires_at: end, subscription: sub(paid.sub.id, 'expired'), reason: 'grace_ended' }));
+ let a = await ask(m);
+ assert.strictEqual(a.active, false);
+ assert.strictEqual(a.grace, null);
+ assert.strictEqual((await entitlements.getRow(m.subject, creator.subject)).grace_until, null);
+ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((e) => e.payload.member.id === m.subject).pop().payload.reason, 'grace_ended');
+ assert.ok(await t.domain.memberships.present(await t.domain.db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ?').get(m.subject)), 'the membership row is kept');
+ // Billing renews (after a top-up, say): the membership is back, on the version it already had
+ const renew = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(renew.events);
+ a = await ask(m);
+ assert.strictEqual(a.active, true);
+ assert.strictEqual(a.grace, null);
+ assert.strictEqual(a.membership.plan_version_id, versionBefore);
+ assert.strictEqual((await rows(m)).length, 2);
+ });
+
+ test('the credit checkout records its period, and Billing\'s event for it adds no second row', async () => {
+ const m = t.network.newUser('r6');
+ t.billing.state.credit.set(m.subject, 1000);
+ const ok = await t.call('POST', '/api/v1/checkout', { user: m, body: { plan_id: plan.id, provider: 'credit' } });
+ assert.strictEqual(ok.status, 201, ok.text);
+ let all = await rows(m);
+ assert.strictEqual(all.length, 1);
+ assert.strictEqual(all[0].status, 'paid');
+ assert.ok(all[0].billing_subscription_id && all[0].period_end);
+ await t.deliverAll(t.billing.state.lastEvents);
+ all = await rows(m);
+ assert.strictEqual(all.length, 1, 'the same transaction');
+ assert.strictEqual((await periods.audit()).offenders.filter((o) => o.member === m.subject).length, 0);
+ });
+
+ test('audit: an active row without a paid period record, or on a reversed one, is doubted', async () => {
+ const good = t.network.newUser('r7');
+ await t.deliverAll(t.billing.pay(good.subject, creator.subject).events);
+ // granted with no transaction: nothing records a charge
+ const bare = t.network.newUser('r8');
+ const p = t.billing.pay(bare.subject, creator.subject);
+ await t.deliver(t.billing.envelope('billing.entitlement.changed', p.events[1].subject, { ...p.events[1].payload, transaction_id: undefined }));
+ // a recorded period that a reversal marked reversed while the row still runs to its end
+ const rev = t.network.newUser('r9');
+ const q = t.billing.pay(rev.subject, creator.subject);
+ await t.deliverAll(q.events);
+ await db.prepare("UPDATE vip_membership_periods SET status = 'reversed' WHERE billing_transaction_id = ?").run(q.txn);
+ const out = await periods.audit();
+ const mine = out.offenders.filter((o) => [good, bare, rev].some((u) => u.subject === o.member));
+ assert.deepStrictEqual(mine.map((o) => [o.member, o.problem]).sort(), [[bare.subject, 'no_paid_period'], [rev.subject, 'period_reversed']].sort());
+ assert.strictEqual(periods.stats.offenders, out.offenders.length);
+ for (const u of [bare, rev]) assert.strictEqual((await entitlements.getRow(u.subject, creator.subject)).valid_until, 0);
+ assert.ok((await entitlements.getRow(good.subject, creator.subject)).valid_until > t.clock.now(), 'a recorded paid period is left alone');
+ // doubted: the next check asks Billing, which still says active for the unrecorded one
+ const a = await entitlements.check(bare.subject, creator.subject, { mode: 'auto' });
+ assert.strictEqual(a.source, 'billing');
+ });
+
+ await run().finally(async () => await t.close());
+})();