openvibers/openvibe.contracts Commits

Add pastdue renewal grace and renewal reasons to billing events (#31)

OP OpenVibers committed 4 Oct 2026 · 744b4f0a7d · 41 files, +235 −71

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Contracts/pull/31

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1d36cec3..7a17eba9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,15 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
the notes were in the tag and commit messages (`git tag -n1`).
++## 0.94.2 — 2026-10-04
++
++**`past_due` renewal grace on the billing events** (plan T5 step 11, s1). Additive: no field is required or removed.
++`billing.entitlement.changed@1` `subscription.status` gains `past_due` (a failed renewal, retried until `grace_until`);
++new optional `grace_until` (date-time or null) and `renewal_period_end`; `reason` adds `renewal_failed`, `grace_ended`
++and `credit_refund`. `billing.subscription@1` status lists `past_due`. `billing.transaction.reversed@1` already carried a
++refund of a credit-paid subscription period (type `refund`, from/to swapped); its description now says so, and a fixture
++pins it. New test `test/billing-grace.test.js`. Billing and VIP pin this tag.
++
## 0.94.1 — 2026-10-04
`community.moderation.action@1` catalog version is `1.1.0` (the additive space-moderator actions of 0.94.0).
ddiff --git a/contracts/billing/subscription.v1.json b/contracts/billing/subscription.v1.json
iindex 5e7fa35c..6ef52743 100644
---- a/contracts/billing/subscription.v1.json
++++ b/contracts/billing/subscription.v1.json
@@@ -13,7 +13,7 @@
"provider": { "type": "string", "description": "credit, or the provider that takes the payments." },
"provider_ref": { "type": ["string", "null"] },
"route": { "type": ["string", "null"], "description": "site or direct (paid to the streamer's own provider account)." },
-- "status": { "type": "string", "description": "active, canceled, expired, …" },
++ "status": { "type": "string", "description": "active, past_due (a failed renewal, retried until grace_until), canceled, expired, …" },
"auto_renew": { "type": "boolean" },
"cancel_at_period_end": { "type": "boolean" },
"price_cents": { "type": ["integer", "null"] },
ddiff --git a/contracts/events/payloads/billing.entitlement.changed.v1.json b/contracts/events/payloads/billing.entitlement.changed.v1.json
iindex 2a45454f..2995eec9 100644
---- a/contracts/events/payloads/billing.entitlement.changed.v1.json
++++ b/contracts/events/payloads/billing.entitlement.changed.v1.json
@@@ -18,7 +18,7 @@
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^sub_[0-9A-HJKMNP-TV-Z]{26}$" },
-- "status": { "enum": ["active", "canceled", "expired"] },
++ "status": { "enum": ["active", "past_due", "canceled", "expired"], "description": "past_due: a renewal charge failed and Billing retries it until grace_until; the entitlement is not active while past_due." },
"auto_renew": { "type": "boolean" },
"cancel_at_period_end": { "type": "boolean" },
"provider": { "type": "string" }
@@@ -26,15 +26,17 @@
},
"reason": {
"type": "string",
-- "description": "granted, renewed, canceled, expired, stripe_not_renewed, stripe_deleted, renewal_insufficient_credit, refund, chargeback."
++ "description": "granted, renewed, canceled, expired, stripe_not_renewed, stripe_deleted, renewal_insufficient_credit, refund, chargeback, renewal_failed (a renewal charge failed: status past_due, grace_until set), grace_ended (the grace window passed without a paid renewal), credit_refund (a credit-paid period was refunded)."
},
"transaction_id": { "type": "string", "pattern": "^txn_[0-9A-HJKMNP-TV-Z]{26}$", "description": "granted/renewed: the transaction that paid the period." },
++ "grace_until": { "type": ["string", "null"], "format": "date-time", "description": "renewal_failed: when Billing stops retrying the renewal and ends the subscription; null or absent otherwise." },
++ "renewal_period_end": { "type": "string", "format": "date-time", "description": "renewal_failed, grace_ended: the end of the period the failed renewal charge was for." },
"revoked_period": {
"type": "object",
"required": ["starts_at", "ends_at"],
"additionalProperties": false,
"properties": { "starts_at": { "type": "string", "format": "date-time" }, "ends_at": { "type": "string", "format": "date-time" } },
-- "description": "refund/chargeback: the period the reversed payment had granted."
++ "description": "refund/chargeback/credit_refund: the period the reversed payment had granted."
}
}
}
ddiff --git a/contracts/events/payloads/billing.transaction.reversed.v1.json b/contracts/events/payloads/billing.transaction.reversed.v1.json
iindex f7a851cd..81725325 100644
---- a/contracts/events/payloads/billing.transaction.reversed.v1.json
++++ b/contracts/events/payloads/billing.transaction.reversed.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/billing.transaction.reversed.v1.json",
"title": "BillingTransactionReversedPayload",
-- "description": "billing.transaction.reversed v1 (OpenVibe.Billing server/ops/reversals.js reverseReceipt, transfers.js refund, cashouts.js deny). A transaction reversing an earlier one was posted: a provider refund or chargeback, a donation refund, a denied cash-out. Envelope: subject { type: transaction, id: txn_… (the reversing transaction) }, visibility internal, priority important, actor service:billing.",
++ "description": "billing.transaction.reversed v1 (OpenVibe.Billing server/ops/reversals.js reverseReceipt, transfers.js refund, cashouts.js deny). A transaction reversing an earlier one was posted: a provider refund or chargeback, a donation refund, a denied cash-out, a refund of a credit-paid subscription period (type refund, reverses_txn names the subscription transaction, from_subject and to_subject swapped, provider null; the entitlement then changes with reason credit_refund). Envelope: subject { type: transaction, id: txn_… (the reversing transaction) }, visibility internal, priority important, actor service:billing.",
"type": "object",
"required": ["transaction_id", "type", "test", "from_subject", "to_subject", "provider", "metadata", "reverses_txn"],
"additionalProperties": false,
ddiff --git a/fixtures/billing.entitlement.changed/invalid/unknown-status.json b/fixtures/billing.entitlement.changed/invalid/unknown-status.json
nnew file mode 100644
iindex 00000000..f680c3ac
---- /dev/null
++++ b/fixtures/billing.entitlement.changed/invalid/unknown-status.json
@@@ -0,0 +1,21 @@
++{
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ },
++ "streamer": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "kind": "channel_subscription",
++ "active": false,
++ "expires_at": null,
++ "subscription": {
++ "id": "sub_01JAB3C4D5E6F7G8H9J0K1MNQ0",
++ "status": "grace",
++ "auto_renew": true,
++ "cancel_at_period_end": false,
++ "provider": "credit"
++ },
++ "reason": "renewal_failed"
++}
ddiff --git a/fixtures/billing.entitlement.changed/valid/grace-ended.json b/fixtures/billing.entitlement.changed/valid/grace-ended.json
nnew file mode 100644
iindex 00000000..6761e1c2
---- /dev/null
++++ b/fixtures/billing.entitlement.changed/valid/grace-ended.json
@@@ -0,0 +1,23 @@
++{
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ },
++ "streamer": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "kind": "channel_subscription",
++ "active": false,
++ "expires_at": "2026-10-23T10:00:00.000Z",
++ "subscription": {
++ "id": "sub_01JAB3C4D5E6F7G8H9J0K1MNQ0",
++ "status": "expired",
++ "auto_renew": false,
++ "cancel_at_period_end": false,
++ "provider": "credit"
++ },
++ "reason": "grace_ended",
++ "grace_until": null,
++ "renewal_period_end": "2026-11-23T10:00:00.000Z"
++}
ddiff --git a/fixtures/billing.entitlement.changed/valid/renewal-failed-past-due.json b/fixtures/billing.entitlement.changed/valid/renewal-failed-past-due.json
nnew file mode 100644
iindex 00000000..3d2bcd35
---- /dev/null
++++ b/fixtures/billing.entitlement.changed/valid/renewal-failed-past-due.json
@@@ -0,0 +1,23 @@
++{
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ },
++ "streamer": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "kind": "channel_subscription",
++ "active": false,
++ "expires_at": "2026-10-23T10:00:00.000Z",
++ "subscription": {
++ "id": "sub_01JAB3C4D5E6F7G8H9J0K1MNQ0",
++ "status": "past_due",
++ "auto_renew": true,
++ "cancel_at_period_end": false,
++ "provider": "credit"
++ },
++ "reason": "renewal_failed",
++ "grace_until": "2026-10-26T10:00:00.000Z",
++ "renewal_period_end": "2026-11-23T10:00:00.000Z"
++}
ddiff --git a/fixtures/billing.subscription/valid/past-due.json b/fixtures/billing.subscription/valid/past-due.json
nnew file mode 100644
iindex 00000000..055299a8
---- /dev/null
++++ b/fixtures/billing.subscription/valid/past-due.json
@@@ -0,0 +1,10 @@
++{
++ "id": "sub_01JAB3C4D5E6F7G8H9J0K1MNQ0",
++ "subscriber": { "type": "user", "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR" },
++ "streamer": { "type": "user", "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ" },
++ "provider": "credit",
++ "status": "past_due",
++ "auto_renew": true,
++ "cancel_at_period_end": false,
++ "current_period_end": "2026-10-23T10:00:00.000Z"
++}
ddiff --git a/fixtures/billing.transaction.reversed/valid/credit-subscription-refund.json b/fixtures/billing.transaction.reversed/valid/credit-subscription-refund.json
nnew file mode 100644
iindex 00000000..92697b7c
---- /dev/null
++++ b/fixtures/billing.transaction.reversed/valid/credit-subscription-refund.json
@@@ -0,0 +1,14 @@
++{
++ "transaction_id": "txn_01JAB3C4D5E6F7G8H9J0K1MNQ4",
++ "type": "refund",
++ "test": false,
++ "from_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "to_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "provider": null,
++ "metadata": {
++ "original_type": "subscription",
++ "subscription_id": "sub_01JAB3C4D5E6F7G8H9J0K1MNQ0",
++ "amount_bits": 500
++ },
++ "reverses_txn": "txn_01JAB3C4D5E6F7G8H9J0K1MNQ5"
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex c7a89dff..56c8c47b 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -21095,7 +21095,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/billing.transaction.reversed.v1.json",
"title": "BillingTransactionReversedPayload",
-- "description": "billing.transaction.reversed v1 (OpenVibe.Billing server/ops/reversals.js reverseReceipt, transfers.js refund, cashouts.js deny). A transaction reversing an earlier one was posted: a provider refund or chargeback, a donation refund, a denied cash-out. Envelope: subject { type: transaction, id: txn_… (the reversing transaction) }, visibility internal, priority important, actor service:billing.",
++ "description": "billing.transaction.reversed v1 (OpenVibe.Billing server/ops/reversals.js reverseReceipt, transfers.js refund, cashouts.js deny). A transaction reversing an earlier one was posted: a provider refund or chargeback, a donation refund, a denied cash-out, a refund of a credit-paid subscription period (type refund, reverses_txn names the subscription transaction, from_subject and to_subject swapped, provider null; the entitlement then changes with reason credit_refund). Envelope: subject { type: transaction, id: txn_… (the reversing transaction) }, visibility internal, priority important, actor service:billing.",
"type": "object",
"required": [
"transaction_id",
@@@ -21239,9 +21239,11 @@
"status": {
"enum": [
"active",
++ "past_due",
"canceled",
"expired"
-- ]
++ ],
++ "description": "past_due: a renewal charge failed and Billing retries it until grace_until; the entitlement is not active while past_due."
},
"auto_renew": {
"type": "boolean"
@@@ -21256,13 +21258,26 @@
},
"reason": {
"type": "string",
-- "description": "granted, renewed, canceled, expired, stripe_not_renewed, stripe_deleted, renewal_insufficient_credit, refund, chargeback."
++ "description": "granted, renewed, canceled, expired, stripe_not_renewed, stripe_deleted, renewal_insufficient_credit, refund, chargeback, renewal_failed (a renewal charge failed: status past_due, grace_until set), grace_ended (the grace window passed without a paid renewal), credit_refund (a credit-paid period was refunded)."
},
"transaction_id": {
"type": "string",
"pattern": "^txn_[0-9A-HJKMNP-TV-Z]{26}$",
"description": "granted/renewed: the transaction that paid the period."
},
++ "grace_until": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "renewal_failed: when Billing stops retrying the renewal and ends the subscription; null or absent otherwise."
++ },
++ "renewal_period_end": {
++ "type": "string",
++ "format": "date-time",
++ "description": "renewal_failed, grace_ended: the end of the period the failed renewal charge was for."
++ },
"revoked_period": {
"type": "object",
"required": [
@@@ -21280,7 +21295,7 @@
"format": "date-time"
}
},
-- "description": "refund/chargeback: the period the reversed payment had granted."
++ "description": "refund/chargeback/credit_refund: the period the reversed payment had granted."
}
}
}
@@@ -23421,7 +23436,7 @@
},
"status": {
"type": "string",
-- "description": "active, canceled, expired, …"
++ "description": "active, past_due (a failed renewal, retried until grace_until), canceled, expired, …"
},
"auto_renew": {
"type": "boolean"
ddiff --git a/generated/openapi/ai.json b/generated/openapi/ai.json
iindex 49f809de..edeed018 100644
---- a/generated/openapi/ai.json
++++ b/generated/openapi/ai.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.AI API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/billing.json b/generated/openapi/billing.json
iindex 6d052454..61baf646 100644
---- a/generated/openapi/billing.json
++++ b/generated/openapi/billing.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Billing API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
@@@ -3049,7 +3049,7 @@
},
"status": {
"type": "string",
-- "description": "active, canceled, expired, …"
++ "description": "active, past_due (a failed renewal, retried until grace_until), canceled, expired, …"
},
"auto_renew": {
"type": "boolean"
ddiff --git a/generated/openapi/blog.json b/generated/openapi/blog.json
iindex edfe0c15..3c3d969d 100644
---- a/generated/openapi/blog.json
++++ b/generated/openapi/blog.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Blog API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/bot.json b/generated/openapi/bot.json
iindex 507fcb77..4efef3a5 100644
---- a/generated/openapi/bot.json
++++ b/generated/openapi/bot.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Bot API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Bot as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Bot as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/chat.json b/generated/openapi/chat.json
iindex 09a2d239..2fc52c6d 100644
---- a/generated/openapi/chat.json
++++ b/generated/openapi/chat.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Chat API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/codes.json b/generated/openapi/codes.json
iindex b25366f7..8a7fe5da 100644
---- a/generated/openapi/codes.json
++++ b/generated/openapi/codes.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Codes API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/community.json b/generated/openapi/community.json
iindex d866972c..98ffd89d 100644
---- a/generated/openapi/community.json
++++ b/generated/openapi/community.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Community API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/coupons.json b/generated/openapi/coupons.json
iindex d890cdfb..033111cb 100644
---- a/generated/openapi/coupons.json
++++ b/generated/openapi/coupons.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Coupons API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/deals.json b/generated/openapi/deals.json
iindex ef1f01ac..610cbb80 100644
---- a/generated/openapi/deals.json
++++ b/generated/openapi/deals.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Deals API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/events.json b/generated/openapi/events.json
iindex 1fcf312c..d7d3dc37 100644
---- a/generated/openapi/events.json
++++ b/generated/openapi/events.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Events API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/games.json b/generated/openapi/games.json
iindex 201810aa..d908c04e 100644
---- a/generated/openapi/games.json
++++ b/generated/openapi/games.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Games API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/host.json b/generated/openapi/host.json
iindex e61d9672..19bce4b5 100644
---- a/generated/openapi/host.json
++++ b/generated/openapi/host.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Host API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/live.json b/generated/openapi/live.json
iindex c0f504ed..e3593050 100644
---- a/generated/openapi/live.json
++++ b/generated/openapi/live.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Live API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/media.json b/generated/openapi/media.json
iindex 6ef1a664..3c2e5804 100644
---- a/generated/openapi/media.json
++++ b/generated/openapi/media.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Media API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/network.json b/generated/openapi/network.json
iindex 10e4e010..d9df3740 100644
---- a/generated/openapi/network.json
++++ b/generated/openapi/network.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Network API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/news.json b/generated/openapi/news.json
iindex c07aa002..80ea3964 100644
---- a/generated/openapi/news.json
++++ b/generated/openapi/news.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.News API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/openre.json b/generated/openapi/openre.json
iindex c6f83012..1388d489 100644
---- a/generated/openapi/openre.json
++++ b/generated/openapi/openre.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenRe.Stream API",
-- "version": "0.94.1",
-- "description": "Routes of OpenRe.Stream as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenRe.Stream as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/reviews.json b/generated/openapi/reviews.json
iindex d5a82fa2..725efbd6 100644
---- a/generated/openapi/reviews.json
++++ b/generated/openapi/reviews.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Reviews API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/search.json b/generated/openapi/search.json
iindex 23b3de2c..fa7021f4 100644
---- a/generated/openapi/search.json
++++ b/generated/openapi/search.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Search API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/sources.json b/generated/openapi/sources.json
iindex 5902f1fa..7ae4661f 100644
---- a/generated/openapi/sources.json
++++ b/generated/openapi/sources.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Sources API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/space.json b/generated/openapi/space.json
iindex 6ff53608..a3259ef3 100644
---- a/generated/openapi/space.json
++++ b/generated/openapi/space.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Space API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Space as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Space as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/tips.json b/generated/openapi/tips.json
iindex 71c1f93e..2a127c29 100644
---- a/generated/openapi/tips.json
++++ b/generated/openapi/tips.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Tips API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/tools.json b/generated/openapi/tools.json
iindex fe9ec7b0..1552d933 100644
---- a/generated/openapi/tools.json
++++ b/generated/openapi/tools.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Tools API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/trade.json b/generated/openapi/trade.json
iindex ca9a443d..30380833 100644
---- a/generated/openapi/trade.json
++++ b/generated/openapi/trade.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Trade API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/vip.json b/generated/openapi/vip.json
iindex 6a85ddbd..f7c6d0c2 100644
---- a/generated/openapi/vip.json
++++ b/generated/openapi/vip.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.VIP API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/openapi/wiki.json b/generated/openapi/wiki.json
iindex 57fac683..42e0b044 100644
---- a/generated/openapi/wiki.json
++++ b/generated/openapi/wiki.json
@@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "OpenVibe.Wiki API",
-- "version": "0.94.1",
-- "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.94.1): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
++ "version": "0.94.2",
++ "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.94.2): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"name": "MIT",
"identifier": "MIT"
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex b297f84a..e4f8c9d1 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -28461,7 +28461,7 @@ export interface BillingTransactionSettledPayload {
/** [email protected] (owner: billing) */
/**
-- * billing.transaction.reversed v1 (OpenVibe.Billing server/ops/reversals.js reverseReceipt, transfers.js refund, cashouts.js deny). A transaction reversing an earlier one was posted: a provider refund or chargeback, a donation refund, a denied cash-out. Envelope: subject { type: transaction, id: txn_… (the reversing transaction) }, visibility internal, priority important, actor service:billing.
++ * billing.transaction.reversed v1 (OpenVibe.Billing server/ops/reversals.js reverseReceipt, transfers.js refund, cashouts.js deny). A transaction reversing an earlier one was posted: a provider refund or chargeback, a donation refund, a denied cash-out, a refund of a credit-paid subscription period (type refund, reverses_txn names the subscription transaction, from_subject and to_subject swapped, provider null; the entitlement then changes with reason credit_refund). Envelope: subject { type: transaction, id: txn_… (the reversing transaction) }, visibility internal, priority important, actor service:billing.
*/
export interface BillingTransactionReversedPayload {
transaction_id: string;
@@@ -28524,13 +28524,16 @@ export interface BillingEntitlementChangedPayload {
expires_at: string | null;
subscription: {
id: string;
-- status: "active" | "canceled" | "expired";
++ /**
++ * past_due: a renewal charge failed and Billing retries it until grace_until; the entitlement is not active while past_due.
++ */
++ status: "active" | "past_due" | "canceled" | "expired";
auto_renew: boolean;
cancel_at_period_end: boolean;
provider: string;
} | null;
/**
-- * granted, renewed, canceled, expired, stripe_not_renewed, stripe_deleted, renewal_insufficient_credit, refund, chargeback.
++ * granted, renewed, canceled, expired, stripe_not_renewed, stripe_deleted, renewal_insufficient_credit, refund, chargeback, renewal_failed (a renewal charge failed: status past_due, grace_until set), grace_ended (the grace window passed without a paid renewal), credit_refund (a credit-paid period was refunded).
*/
reason: string;
/**
@@@ -28538,7 +28541,15 @@ export interface BillingEntitlementChangedPayload {
*/
transaction_id?: string;
/**
-- * refund/chargeback: the period the reversed payment had granted.
++ * renewal_failed: when Billing stops retrying the renewal and ends the subscription; null or absent otherwise.
++ */
++ grace_until?: string | null;
++ /**
++ * renewal_failed, grace_ended: the end of the period the failed renewal charge was for.
++ */
++ renewal_period_end?: string;
++ /**
++ * refund/chargeback/credit_refund: the period the reversed payment had granted.
*/
revoked_period?: {
starts_at: string;
@@@ -29211,7 +29222,7 @@ export interface BillingSubscription {
*/
route?: string | null;
/**
-- * active, canceled, expired, …
++ * active, past_due (a failed renewal, retried until grace_until), canceled, expired, …
*/
status: string;
auto_renew: boolean;
ddiff --git a/package-lock.json b/package-lock.json
iindex b1c3e69b..c3ce14e8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-contracts",
-- "version": "0.94.1",
++ "version": "0.94.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-contracts",
-- "version": "0.94.1",
++ "version": "0.94.2",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex ab1fc1e6..6213ecc2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.94.1",
++ "version": "0.94.2",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
ddiff --git a/test/billing-grace.test.js b/test/billing-grace.test.js
nnew file mode 100644
iindex 00000000..6d0188bb
---- /dev/null
++++ b/test/billing-grace.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++// T5 step 11: a failed renewal moves a subscription to past_due grace (additive on the billing events).
++const assert = require('assert');
++const contracts = require('..');
++
++const base = {
++ subject: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR' },
++ streamer: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' },
++ kind: 'channel_subscription',
++ active: false,
++ expires_at: '2026-10-23T10:00:00.000Z',
++ subscription: { id: 'sub_01JAB3C4D5E6F7G8H9J0K1MNQ0', status: 'active', auto_renew: true, cancel_at_period_end: false, provider: 'credit' },
++ reason: 'renewed',
++};
++const V = (p) => contracts.validate('billing.entitlement.changed@1', p);
++const withSub = (status) => ({ ...base, subscription: { ...base.subscription, status } });
++
++assert.ok(V(base).valid, 'a payload without the grace fields still validates');
++const pastDue = { ...withSub('past_due'), reason: 'renewal_failed', grace_until: '2026-10-26T10:00:00.000Z', renewal_period_end: '2026-11-23T10:00:00.000Z' };
++assert.ok(V(pastDue).valid, 'past_due with grace_until and renewal_failed validates');
++assert.ok(V({ ...pastDue, grace_until: null }).valid, 'grace_until may be null');
++assert.ok(V({ ...withSub('expired'), reason: 'grace_ended' }).valid, 'grace_ended validates without the optional fields');
++assert.ok(V({ ...withSub('expired'), reason: 'credit_refund', revoked_period: { starts_at: '2026-09-23T10:00:00.000Z', ends_at: '2026-10-23T10:00:00.000Z' } }).valid, 'credit_refund validates');
++assert.ok(['active', 'canceled', 'expired'].every((s) => V(withSub(s)).valid), 'the earlier statuses still validate');
++assert.ok(!V(withSub('grace')).valid, 'an unknown status is refused');
++assert.ok(!V({ ...pastDue, grace_until: 'soon' }).valid, 'grace_until is a date-time');
++assert.ok(!V({ ...pastDue, renewal_period_end: null }).valid, 'renewal_period_end is a date-time when present');
++
++const rev = {
++ transaction_id: 'txn_01JAB3C4D5E6F7G8H9J0K1MNQ4', type: 'refund', test: false,
++ from_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', to_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', provider: null,
++ metadata: { original_type: 'subscription' }, reverses_txn: 'txn_01JAB3C4D5E6F7G8H9J0K1MNQ5',
++};
++assert.ok(contracts.validate('billing.transaction.reversed@1', rev).valid, 'a refund of a credit-paid subscription period validates');
++console.log('billing-grace ok');
ddiff --git a/test/run.js b/test/run.js
iindex d40d38e8..e86f61dd 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -907,6 +907,7 @@ sub(path.join(__dirname, 'product-catalog.test.js'));
sub(path.join(__dirname, 'zones.test.js'));
// *.usage.recorded rollups are counts: only network, codes and zone may consume one, never billing (T5 step 8).
sub(path.join(__dirname, 'usage-topics.test.js'));
++sub(path.join(__dirname, 'billing-grace.test.js'));
// ── Generated output and compatibility gate ──────────────────────────────
sub(path.join(__dirname, 'platform-fabric.test.js'));