openvibers/openvibe.vip Commits

Record each paid period's charge and expose renewal grace

OP OpenVibers committed 4 Oct 2026 · 36987e9407 · 12 files, +483 −30

Asked: Part of a cross-repository initiative (init-t5-step-11-dossier-ds-prep-t-1004T1836): T5 step 11 (dossier /home/workstation/openvibe/agents/ds/prep/T5.md row 11; checkout and cancel already landed in OpenVibe.VIP server/domain/checkout.js and server/billing-client.js): converge VIP renewals and refu…
Harness job init-t5-step-11-dossi-1836-s4-203912 (plan T5).

Changes

ddiff --git a/README.md b/README.md
iindex d8a02d6..6edf5a7 100644
---- a/README.md
++++ b/README.md
@@@ -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
`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
--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 hour
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)
- `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` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 346edb4..b4e47d9 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/docs/cutover-0002-renewal-periods.md b/docs/cutover-0002-renewal-periods.md
nnew file mode 100644
iindex 0000000..14788aa
---- /dev/null
++++ b/docs/cutover-0002-renewal-periods.md
@@@ -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).
ddiff --git a/migrations/0002_renewal_periods.sql b/migrations/0002_renewal_periods.sql
nnew file mode 100644
iindex 0000000..c87818d
---- /dev/null
++++ b/migrations/0002_renewal_periods.sql
@@@ -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";
ddiff --git a/server/app.js b/server/app.js
iindex 0d7751d..9955d19 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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');
ddiff --git a/server/domain/checkout.js b/server/domain/checkout.js
iindex ea3bd46..7b1eb70 100644
---- a/server/domain/checkout.js
++++ b/server/domain/checkout.js
@@@ -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, entitlement
const 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 };
}
ddiff --git a/server/domain/entitlements.js b/server/domain/entitlements.js
iindex a9063ca..57a938b 100644
---- a/server/domain/entitlements.js
++++ b/server/domain/entitlements.js
@@@ -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, pla
const 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, pla
async 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, pla
const 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);
}
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex f1c2b4c..db1e560 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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(), log
const 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 };
ddiff --git a/server/domain/periods.js b/server/domain/periods.js
nnew file mode 100644
iindex 0000000..95d6ff9
---- /dev/null
++++ b/server/domain/periods.js
@@@ -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 };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex b198dc2..2814deb 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -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';
ddiff --git a/server/index.js b/server/index.js
iindex 7d05761..8e9c469 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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();
}
ddiff --git a/test/renewal.test.js b/test/renewal.test.js
nnew file mode 100644
iindex 0000000..7c2c3be
---- /dev/null
++++ b/test/renewal.test.js
@@@ -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());
++})();