openvibers/openvibe.billing Commits

Turn the 3-day renewal grace on by default

OP OpenVibers committed 4 Oct 2026 · e4c4f8aee5 · 7 files, +36 −27

A failed credit renewal now stays past_due for BILLING_RENEWAL_GRACE_DAYS (default 3)
before it expires: retried on the same key, grace_ended at grace_until. Set the env to 0
to restore immediate expiry. Pins subscriptions.test.js to grace 0 (it asserts the old
behavior) and updates README, .env.example, STATUS.json and docs/renewal-grace-cutover.md.

initiative: init-t5-step-11-dossier-ds-prep-t-1004T1836 step s5

Changes

ddiff --git a/.env.example b/.env.example
iindex ae9c638..277c446 100644
---- a/.env.example
++++ b/.env.example
@@@ -78,8 +78,8 @@ BILLING_SESSION_SECRET=
#BILLING_ESCROW_DAYS=14
#BILLING_STRIPE_GRACE_DAYS=3
# Days a failed credit renewal stays past_due (retried by the hourly sweep on the same key) before it ends.
--# 0 (default) ends it at once; turn on only after VIP reads past_due (docs/renewal-grace-cutover.md).
--#BILLING_RENEWAL_GRACE_DAYS=0
++# 3 is the default (VIP reads past_due, plan s4); set 0 to restore immediate expiry (docs/renewal-grace-cutover.md).
++#BILLING_RENEWAL_GRACE_DAYS=3
# ── Providers: each adapter is ENABLED only when its secrets are set ──
# PowerChat (the only rail carrying money in production)
ddiff --git a/README.md b/README.md
iindex fa90f64..56e3a1e 100644
---- a/README.md
++++ b/README.md
@@@ -350,11 +350,11 @@ there — a share already recycled or cashed out goes to `chargeback_loss`, flag
`POST /api/v1/admin/sweep`): a credit subscription whose period ended is charged once from the subscriber's
credit (never the promo allowance) under `renew:<sub>:<period end>` — `…:<k>` after `k` charges under that
period's earlier keys were refunded or charged back — so a retried, replayed or concurrent sweep charges and
-- credits the creator share once. A renewal the credit cannot pay ends the subscription at once
-- (`renewal_insufficient_credit`) unless `BILLING_RENEWAL_GRACE_DAYS` > 0 (default 0): then it is `past_due`
-- until the period end + the grace (`grace_until`; no access meanwhile, event reason `renewal_failed`), every
-- later sweep retries the same key (a top-up is picked up; a cancel ends it), and past `grace_until` it expires
-- (`grace_ended`). Turning the grace on: [docs/renewal-grace-cutover.md](docs/renewal-grace-cutover.md).
++ credits the creator share once. A renewal the credit cannot pay leaves the subscription `past_due`
++ for `BILLING_RENEWAL_GRACE_DAYS` days past the period end (default **3**; set `0` to end it at once
++ with `renewal_insufficient_credit`): no access meanwhile (event reason `renewal_failed`), every
++ later sweep retries the same key (a top-up is picked up; a cancel ends it), and past `grace_until` it
++ expires (`grace_ended`). Turning the grace on (or off): [docs/renewal-grace-cutover.md](docs/renewal-grace-cutover.md).
- **Freeze** before any risky change: the console's Freeze page, `POST /api/v1/admin/freeze {"on": true, "reason": "…"}`, or on the
host `node scripts/freeze.js on "<reason>"` (same switch; after `off` the running service processes the
held webhooks on its next retry tick).
ddiff --git a/STATUS.json b/STATUS.json
iindex 6946ea9..6a52c0f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -16,7 +16,7 @@
"provider receipts stored before processing, settled once (PowerChat, Stripe, PayPal, CCBill, NOWPayments adapters)",
"intents, transfers, refunds, recycle, cashouts with escrow",
"subscriptions and entitlements",
-- "renewal grace: a failed credit renewal is past_due and retried on its key until grace_until (BILLING_RENEWAL_GRACE_DAYS, off by default)",
++ "renewal grace: a failed credit renewal is past_due for BILLING_RENEWAL_GRACE_DAYS (default 3) and retried on its key until grace_until, then expires with grace_ended; set 0 for immediate expiry",
"staff refund of a credit-paid subscription period (POST /api/v1/subscriptions/:id/refund) and reconciliation of every paid period against its charge (subscriptions.period_charged)",
"EXTERNAL PowerChat receipts (billing.receipt.external under BILLING_AUTHORITY=billing)",
"economy freeze",
ddiff --git a/docs/renewal-grace-cutover.md b/docs/renewal-grace-cutover.md
iindex 4f4453b..31fc843 100644
---- a/docs/renewal-grace-cutover.md
++++ b/docs/renewal-grace-cutover.md
@@@ -1,11 +1,13 @@
--# Cutover runbook — renewal grace (OpenVibe.Billing, plan T5 step 11, s2)
++# Cutover runbook — renewal grace (OpenVibe.Billing, plan T5 step 11, s2 + s5)
A credit renewal the subscriber cannot pay used to end the subscription at once (`expired`, reason
`renewal_insufficient_credit`). With this change it can instead move to **`past_due`** for
`BILLING_RENEWAL_GRACE_DAYS` past the period end: the hourly sweep retries the same charge under the **same
idempotency key** until it is paid (`active` again, reason `renewed`, one charge, one creator share) or
--`grace_until` passes (`expired`, reason `grace_ended`). **The grace ships off** (`BILLING_RENEWAL_GRACE_DAYS`
--defaults to `0`), so the deploy changes no behavior: no row becomes `past_due` until the grace is turned on.
++`grace_until` passes (`expired`, reason `grace_ended`). **The grace shipped off in s2** (`BILLING_RENEWAL_GRACE_DAYS`
++defaulted to `0`, so that deploy changed no behavior); **s5 turns it on** — the default is now `3`, so a failed
++renewal is `past_due` for three days, retried on the same key, and only then expires. Setting the env back to `0`
++restores immediate expiry (see Rollback).
Also in this change, whatever the grace: the renewal key is `renew:<sub>:<period end>` as before, and becomes
`renew:<sub>:<period end>:<k>` only after `k` charges under that period's earlier keys were refunded or charged back
@@@ -69,7 +71,7 @@ runbook:
SELECT pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = 'subscriptions'::regclass AND conname = 'subscriptions_status_check';
SELECT id, name, phase FROM ov_migrations WHERE id = '0005'; -- 0005 | renewal_grace | expand
--SELECT COUNT(*) FROM subscriptions WHERE status = 'past_due'; -- 0 (the grace is off)
++SELECT COUNT(*) FROM subscriptions WHERE status = 'past_due'; -- 0 before the flip; failed renewals list here after s5
```
and compare `node scripts/reconcile.js` before and after: the same account, transaction and entry counts, every
@@@ -83,28 +85,34 @@ After the deploy, on production (read-only):
`migrations: applied 0005_renewal_grace (expand, NNms)`; `ov_migrations` has the `0005` row.
2. `\d subscriptions` lists `grace_until`, `renewal_failed_at` (nullable text) and `renewal_attempts`
(`bigint NOT NULL DEFAULT 0`), and `subscriptions_due_idx`.
--3. `SELECT count(*) FROM subscriptions WHERE status = 'past_due'` is **0**: the grace is still off
-- (`BILLING_RENEWAL_GRACE_DAYS` unset or `0` in `/etc/openvibe/billing.env`), so the deploy changed no
-- behavior. An unpaid renewal still ends at once (`expired`, reason `renewal_insufficient_credit`).
++3. `SELECT count(*) FROM subscriptions WHERE status = 'past_due'` — before s5 it is **0** (the grace is off,
++ `BILLING_RENEWAL_GRACE_DAYS` unset or `0`, so an unpaid renewal still ends at once with
++ `renewal_insufficient_credit`); after s5's flip it holds exactly the renewals inside their 3-day grace.
++ The flip itself changes no row: a subscription only enters `past_due` when the sweep next tries and
++ fails to renew its ended period. `SELECT id, status, grace_until, renewal_attempts FROM subscriptions
++ WHERE status = 'past_due'` lists them.
4. `node scripts/reconcile.js` shows the same totals as before the deploy, every check ok.
## Order
1. **Contracts** — v0.94.2 (`past_due`, `renewal_failed`, `grace_ended`, `grace_until`) is released (s1).
2. **Backup** — the snapshot above, and confirm it.
--3. **Billing, grace 0** — merge and deploy this change through its pipeline. Leave `BILLING_RENEWAL_GRACE_DAYS`
-- unset (= 0). Verify with the checks above. Renewals behave as before.
++3. **Billing, grace 0 (s2)** — merge and deploy this change through its pipeline. Before the s5 flip the env was
++ left unset (then = 0). Verify with the checks above. Renewals behave as before.
4. **VIP consumes `past_due`** — VIP (plan s4) treats `subscription.status: past_due` as not entitled, reads
`grace_until` for its "renew your membership" notice and records each period's charge. Do not go further until
VIP with that change is deployed.
--5. **Turn the grace on** — plan s5 sets the default to 3; on a host before that, set
-- `BILLING_RENEWAL_GRACE_DAYS=3` in `/etc/openvibe/billing.env` and restart through the deploy pipeline. Check
-- after the next sweep: the log line `subscription sweep: … past due …` and, for a failed renewal,
-- `SELECT id, status, grace_until, renewal_attempts FROM subscriptions WHERE status = 'past_due'`.
++5. **Turn the grace on — done (plan s5)** — the default is now `3`. No env change is needed: with
++ `BILLING_RENEWAL_GRACE_DAYS` unset in `/etc/openvibe/billing.env`, deploy this change through the pipeline
++ (VIP s4, which reads `past_due` and `grace_until`, must already be deployed). Check after the next sweep: the
++ log line `subscription sweep: … past due …` and, for a failed renewal,
++ `SELECT id, status, grace_until, renewal_attempts FROM subscriptions WHERE status = 'past_due'`. A host that
++ sets the env explicitly (e.g. `BILLING_RENEWAL_GRACE_DAYS=0`) keeps that value — remove it to take the default.
## Rollback
--- **Turn the grace off:** set `BILLING_RENEWAL_GRACE_DAYS=0` (or unset it) and restart. The next sweep retries each
++- **Turn the grace off:** set `BILLING_RENEWAL_GRACE_DAYS=0` in `/etc/openvibe/billing.env` and restart (do
++ **not** merely unset it — unset now means the default 3). The next sweep retries each
`past_due` subscription once more on its key — a subscriber who topped up renews, with one charge — and ends
the rest (`expired`, reason `grace_ended`). Nothing else changes. This is the way back for behavior; no
schema restore is needed.
ddiff --git a/server/config.js b/server/config.js
iindex 2957bec..9fc9876 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -113,7 +113,7 @@ function loadConfig(env = process.env) {
stripeGraceDays: num(env.BILLING_STRIPE_GRACE_DAYS, 3),
// A failed credit renewal stays past_due (retried by the sweep) this many days past the period end;
// 0 = end it at once (renewal_insufficient_credit).
-- renewalGraceDays: Math.max(0, num(env.BILLING_RENEWAL_GRACE_DAYS, 0)),
++ renewalGraceDays: Math.max(0, num(env.BILLING_RENEWAL_GRACE_DAYS, 3)),
},
// Provider adapters: each is enabled only when its secrets are set.
ddiff --git a/server/ops/subscriptions.js b/server/ops/subscriptions.js
iindex 57129a2..c9c41f7 100644
---- a/server/ops/subscriptions.js
++++ b/server/ops/subscriptions.js
@@@ -17,9 +17,9 @@
* entitled to a streamer while a non-revoked row covers now. It needs nothing from Live.
*
* A credit renewal the subscriber cannot pay leaves the subscription past_due for
-- * BILLING_RENEWAL_GRACE_DAYS (0 = end it at once): no period covers the grace, so the entitlement
-- * is not active, and the sweep retries the same charge under the same key until it is paid or
-- * grace_until passes.
++ * BILLING_RENEWAL_GRACE_DAYS (3 by default; 0 = end it at once): no period covers the grace, so the
++ * entitlement is not active, and the sweep retries the same charge under the same key until it is
++ * paid or grace_until passes.
*/
const { post, getTxn, requireFunds, iso, prefixedId, money } = require('../ledger');
const { enqueue } = require('../outbox');
ddiff --git a/test/subscriptions.test.js b/test/subscriptions.test.js
iindex d94ba3d..ff08d3d 100644
---- a/test/subscriptions.test.js
++++ b/test/subscriptions.test.js
@@@ -10,7 +10,8 @@ const { boot, fund, check, done } = require('./helpers/app');
const DAY = 86_400_000;
(async () => {
-- const t = await boot();
++ // The grace is on by default now; this file asserts the pre-grace behavior, so pin it to 0.
++ const t = await boot({ env: { BILLING_RENEWAL_GRACE_DAYS: '0' } });
const fan = t.user(31);
const streamer = t.user(32);
console.log('subscriptions');