openvibers/openvibe.billing Commits

Refund credit-paid subscription periods and reconcile every paid period

OP OpenVibers committed 4 Oct 2026 · ffea53c89c · 6 files, +285 −5

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-s3-213201 (plan T5).

Changes

ddiff --git a/README.md b/README.md
iindex ab9d7a1..fa90f64 100644
---- a/README.md
++++ b/README.md
@@@ -199,6 +199,7 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
| `POST /api/v1/cashouts/:id/deny` | `billing.cashout.manage` | reversal back to payable `{reason?}` |
| `POST /api/v1/subscriptions` | `billing.subscription.manage` | start/renew a period `{subscriber, streamer, source: credit, auto_renew?}` (share credited every period); `source: receipt` also needs `billing.ledger.admin` |
| `POST /api/v1/subscriptions/:id/cancel` | `billing.subscription.manage` | cancel at period end; Stripe is cancelled at Stripe first |
++| `POST /api/v1/subscriptions/:id/refund` | `billing.ledger.admin` | staff refund of one whole period paid from credit `{transaction_id \| period_end, reason}` → 201 `{transaction, subscription, entitlement}`; the period is revoked (the subscription is shortened, or ends with its last period); a period already refunded or charged back → 200 `{transaction: null, noop: "already_reversed", reversed_by}`; a receipt-paid period → 422 `billing.not_refundable` (its provider reverses it) |
| `GET /api/v1/subscriptions[?subscriber&streamer&status]`, `GET …/:id` | `billing.entitlement.check` | read |
| `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, grace_until, subscription}` — needs nothing but Billing; `grace_until` is set only while the subscription is `past_due` (never active) |
| `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, promo_bits, payable_value_cents}` (`promo_bits`: unused free allowance, never transferable) |
@@@ -318,7 +319,11 @@ transaction as their effect. Adjustments are not in the console; they stay on th
Only PowerChat is expected to be enabled in production. A reversal of a purchase claws back the
buyer's remaining credit; credit already given to someone stays with them — their payable is never
touched — and the unrecovered value goes to `chargeback_loss`, flagged `review: required`.
--Reversing a subscription payment revokes the periods it granted.
++Reversing a subscription payment revokes the periods it granted. A period paid from credit has no receipt:
++staff refund it whole (`POST /api/v1/subscriptions/:id/refund`, one `refund` transaction reversing the period's
++payment): the subscriber gets the credit back (never the promo allowance, which pays no period), platform
++revenue gives back its part, and the creator share comes back out of `creator_payable` as far as it is still
++there — a share already recycled or cashed out goes to `chargeback_loss`, flagged `review: required`.
## Operations
@@@ -331,7 +336,10 @@ Reversing a subscription payment revokes the periods it granted.
receipt names its provider and moved exactly the receipt's cents through that provider's clearing
account (`receipts.ledger`), EXTERNAL receipts are never in the journal and each announced one has one
outbox event (`receipts.external`), and no receipt waits unprocessed longer than
-- `BILLING_RECONCILE_STALE_RECEIPT_MIN` (60) while the economy is open (`receipts.stale`). It also lists
++ `BILLING_RECONCILE_STALE_RECEIPT_MIN` (60) while the economy is open (`receipts.stale`) — and every paid
++ period against its charge: a granted period rests on an unreversed subscription transaction (periods imported
++ from Live are counted, not failed) and a period revoked for a refund or chargeback has its payment's reversal
++ (`subscriptions.period_charged`). It also lists
negative balances, unprocessed/rejected events, items for review and import holds; totals exclude test
transactions. Every run is stored in `reconciliation_runs` with its trigger (`scheduled`, `api`,
`console`, `script`, `import`); passing scheduled runs older than `BILLING_RECONCILE_KEEP_DAYS` (30) are
@@@ -366,7 +374,9 @@ random ports. What they prove: the journal balances per currency and refuses edi
(`ledger.test.js`); a receipt settles once whatever retries carry it, and reversals claw back only what
can be (`webhooks.test.js`, `providers.test.js`, `stripe.test.js`); subscriptions and entitlements
(`subscriptions.test.js`); a renewal is one charge per period, a failed one is retried on its key in the
--grace and then ended, a promo allowance never pays it (`renewal-grace.test.js`); EXTERNAL receipts are announced once and only under `billing`
++grace and then ended, a promo allowance never pays it (`renewal-grace.test.js`); a credit-paid period is refunded
++whole and once, the creator share clawed back or booked as loss, and every paid period has its charge record
++(`subscription-refund.test.js`); EXTERNAL receipts are announced once and only under `billing`
(`external.test.js`); the import reconciles (`import.test.js`); freeze, reconciliation and review
(`operations.test.js`); the cutover sequence (`cutover.test.js`); the staff console and its staff map
(`console.test.js`, `staff-map.test.js`); the public policy (`policy.test.js`); the vhost keeps `/api/v1`
ddiff --git a/STATUS.json b/STATUS.json
iindex 43ac53f..6946ea9 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -17,6 +17,7 @@
"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)",
++ "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",
"hourly reconciliation, ledger against receipts",
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 6cbffce..cec0c6c 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -20,6 +20,7 @@ const purchases = require('../ops/purchases');
const transfers = require('../ops/transfers');
const cashouts = require('../ops/cashouts');
const subscriptions = require('../ops/subscriptions');
++const { refundCreditPeriod } = require('../ops/reversals');
const intents = require('../ops/intents');
const admin = require('../ops/admin');
const providers = require('../providers');
@@@ -208,6 +209,17 @@ function v1Router({ ctx, auth, adapters, limits }) {
const out = await subscriptions.cancel(ctx, { id: req.params.id, actor: actorOf(req) }, adapters);
res.json({ subscription: subscriptions.present(out.subscription), provider_sync: out.provider_sync });
}));
++ // Staff refund of one period paid from credit (ops/reversals.js); a provider receipt is reversed by its provider event.
++ r.post('/subscriptions/:id/refund', ...write(CAP.admin, async (req, res) => {
++ const b = req.body || {};
++ const out = await refundCreditPeriod(ctx, {
++ subscriptionId: req.params.id, transactionId: b.transaction_id, periodEnd: b.period_end, reason: b.reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
++ });
++ const s = await subscriptions.find(db, req.params.id);
++ const state = { subscription: subscriptions.present(s), entitlement: await subscriptions.entitlement(db, s.subscriber, s.streamer, ctx.now()) };
++ if (out.noop) return res.json({ transaction: null, noop: out.noop, reversed_by: out.reversedBy, ...state });
++ res.status(201).json(txnOut(out.txn, state));
++ }));
r.get('/entitlements/:subject', ...read(CAP.entitlement, async (req, res) => {
const subject = pathSubject(req.params.subject);
if (req.query.streamer) return res.json(await subscriptions.entitlement(db, subject, pathSubject(req.query.streamer), ctx.now()));
ddiff --git a/server/ops/reversals.js b/server/ops/reversals.js
iindex e8e03d1..387b834 100644
---- a/server/ops/reversals.js
++++ b/server/ops/reversals.js
@@@ -13,10 +13,12 @@
* donation (site-routed tip) the creator's payable stays; loss booked, flagged for review.
*
* Partial reversals are supported; the cumulative reversed amount never exceeds what was paid.
++ *
++ * A subscription period paid from credit has no receipt: staff refund it whole (refundCreditPeriod).
*/
const { post, getTxn, balance, money } = require('../ledger');
const { enqueue } = require('../outbox');
--const { A, entry, fail } = require('./common');
++const { A, entry, fail, text } = require('./common');
const { summary } = require('./purchases');
const intents = require('./intents');
@@@ -103,4 +105,77 @@ async function reverseReceipt(ctx, input) {
});
}
--module.exports = { reverseReceipt, reversedCents };
++/**
++ * Refund one subscription period paid from credit (staff, POST /subscriptions/:id/refund), whole period only.
++ * input: { subscriptionId, transactionId? | periodEnd?, reason, idempotencyKey, actor }
++ *
++ * The entries undo the payment: user_credit:<subscriber> +cost, platform_revenue −(cost − share), and the creator's
++ * share comes back out of creator_payable:<streamer> as far as the payable still holds it; a share already cashed
++ * out (or moved on) is booked to chargeback_loss through fx and the refund is flagged for review, as for purchases.
++ * A credit period is paid from user_credit only, so a promo allowance is never refunded into credit or earnings.
++ * The period is revoked (revokeForTxn shortens or ends the subscription). Idempotent on the key; a period already
++ * refunded or charged back is a no-op whatever the key, so a second call never refunds twice.
++ */
++async function refundCreditPeriod(ctx, input) {
++ const { db, rates } = ctx;
++ const reason = text(input.reason, 'reason', 300);
++ if (!reason) fail(422, 'billing.invalid_input', 'a refund needs a reason');
++ return await money(db, async () => {
++ const existing = await db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { txn: await getTxn(db, existing.id), replay: true };
++ const sub = await db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(input.subscriptionId);
++ if (!sub) fail(404, 'billing.subscription_not_found', `no subscription ${input.subscriptionId}`);
++ let txnId = input.transactionId || null;
++ if (!txnId) {
++ const end = Date.parse(input.periodEnd);
++ if (!Number.isFinite(end)) fail(422, 'billing.invalid_input', 'name the period: transaction_id (the payment) or period_end');
++ const ent = (await db.prepare('SELECT source_txn, ends_at FROM entitlements WHERE subscription_id = ? ORDER BY ends_at DESC').all(sub.id))
++ .find((e) => Date.parse(e.ends_at) === end);
++ if (!ent || !ent.source_txn) fail(404, 'billing.period_not_found', `${sub.id} has no paid period ending ${input.periodEnd}`);
++ txnId = ent.source_txn;
++ }
++ const orig = await getTxn(db, txnId);
++ const granted = orig && await db.prepare('SELECT 1 AS x FROM entitlements WHERE source_txn = ? AND subscription_id = ? LIMIT 1').get(orig.id, sub.id);
++ if (!orig || orig.type !== 'subscription' || !granted) fail(404, 'billing.period_not_found', `${txnId} did not pay a period of ${sub.id}`);
++ if (orig.metadata.source !== 'credit') fail(422, 'billing.not_refundable', 'only periods paid from credit are refunded here; a provider receipt is reversed by its provider event');
++ const prior = await db.prepare("SELECT id FROM transactions WHERE reverses_txn = ? AND type IN ('refund', 'chargeback') ORDER BY created_at LIMIT 1").get(orig.id);
++ if (prior) return { txn: null, replay: false, noop: 'already_reversed', reversedBy: prior.id };
++
++ const subscriber = orig.from_subject;
++ const streamer = orig.to_subject;
++ const cost = Number(orig.metadata.cost_bits) || 0;
++ const share = Number(orig.metadata.share_bits) || 0;
++ const clawed = Math.max(0, Math.min(await balance(db, A.payable(streamer)), share));
++ const unrecovered = share - clawed;
++ const lossCents = rates.valueCents(unrecovered);
++ const entries = [
++ entry(A.credit(subscriber), cost), entry(A.revenue('vibes-bits'), -(cost - share)), entry(A.payable(streamer), -clawed),
++ entry(A.fxBits(), -unrecovered), entry(A.fxCents(), lossCents), entry(A.loss(), -lossCents),
++ ];
++ const review = unrecovered > 0;
++ const { txn } = await post(ctx, {
++ type: 'refund',
++ idempotencyKey: input.idempotencyKey,
++ reversesTxn: orig.id,
++ entries,
++ test: orig.test,
++ actor: input.actor,
++ fromSubject: streamer,
++ toSubject: subscriber,
++ metadata: {
++ source: 'credit', reason, subscription_id: sub.id, period_start: orig.metadata.period_start || null, period_end: orig.metadata.period_end || null,
++ bits_refunded: cost, share_bits: share, share_clawed_back_bits: clawed, unrecovered_share_bits: unrecovered, loss_cents: lossCents,
++ ...(review ? { review: 'required' } : {}), rates: rates.snapshot(),
++ },
++ });
++ const revoked = await require('./subscriptions').revokeForTxn(ctx, orig.id, 'refund');
++ if (orig.metadata.intent_id) await intents.setStatus(ctx, orig.metadata.intent_id, 'refunded');
++ await enqueue(ctx, {
++ event_type: 'billing.transaction.reversed', subject: { type: 'transaction', id: txn.id },
++ payload: { ...summary(txn), reverses_txn: orig.id, review: review ? 'required' : null, entitlements_revoked: revoked },
++ });
++ return { txn, replay: false, review };
++ });
++}
++
++module.exports = { reverseReceipt, reversedCents, refundCreditPeriod };
ddiff --git a/server/reconcile.js b/server/reconcile.js
iindex 9e0ce7a..a03b17b 100644
---- a/server/reconcile.js
++++ b/server/reconcile.js
@@@ -22,6 +22,10 @@
* receipts.stale no provider receipt waits unprocessed longer than
* BILLING_RECONCILE_STALE_RECEIPT_MIN while the economy is open (a provider,
* Network or code failure is holding money the ledger does not show)
++ * subscriptions.period_charged
++ * every period still granted rests on an unreversed subscription transaction (a
++ * period imported from Live, with no Billing payment, is counted, not failed), and
++ * every period revoked for a refund or chargeback has the reversal of its payment
* Warnings (reported, do not fail): negative user balances, unprocessed/rejected events, events and
* transactions flagged for review (chargebacks on donated credit, unattributed site tips, EXTERNAL
* tips on unmapped accounts), import holds. Totals exclude test transactions (ADR-012 rule 9).
@@@ -108,6 +112,19 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
HAVING bool_or(a.kind IN ('promo_credit', 'promo_reserve')) AND bool_or(a.kind NOT IN ('promo_credit', 'promo_reserve')) LIMIT 100`).all();
check('promo.isolated', !promoMixed.length, promoMixed.length ? { offenders: promoMixed } : null);
++ const periods = await db.prepare(`SELECT e.id, e.subscription_id, e.ends_at, e.source_txn, e.revoked_reason, t.type AS txn_type, s.legacy_live_id,
++ (SELECT COUNT(*) FROM transactions r WHERE r.reverses_txn = e.source_txn AND r.type IN ('refund', 'chargeback')) AS reversals
++ FROM entitlements e LEFT JOIN transactions t ON t.id = e.source_txn LEFT JOIN subscriptions s ON s.id = e.subscription_id
++ WHERE e.revoked_at IS NULL OR e.revoked_reason IN ('refund', 'chargeback')`).all();
++ const imported = periods.filter((p) => !p.source_txn && !p.revoked_reason && p.legacy_live_id);
++ const periodBad = periods.filter((p) => !imported.includes(p)).map((p) => {
++ const problem = p.revoked_reason ? (Number(p.reversals) ? null : `revoked for a ${p.revoked_reason} its payment has no reversal for`)
++ : !p.source_txn ? 'granted with no payment' : p.txn_type !== 'subscription' ? `paid by a ${p.txn_type || 'missing'} transaction`
++ : Number(p.reversals) ? 'still granted though its payment was reversed' : null;
++ return problem && { entitlement: p.id, subscription_id: p.subscription_id, ends_at: p.ends_at, source_txn: p.source_txn, problem };
++ }).filter(Boolean);
++ check('subscriptions.period_charged', !periodBad.length, periodBad.length ? { offenders: periodBad.slice(0, 100) } : { periods: periods.length, imported_from_live: imported.length });
++
const frozen = await isFrozen(db);
const staleMin = (ctx.config && ctx.config.reconcile && ctx.config.reconcile.staleReceiptMin) || 60;
const stale = frozen ? [] : await db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL AND received_at < ? ORDER BY id LIMIT 100')
ddiff --git a/test/subscription-refund.test.js b/test/subscription-refund.test.js
nnew file mode 100644
iindex 0000000..5ebaed4
---- /dev/null
++++ b/test/subscription-refund.test.js
@@@ -0,0 +1,165 @@
++'use strict';
++/**
++ * Refund of a subscription period paid from credit (plan T5 step 11, s3; money path): staff refund one whole
++ * period, the credit comes back, the creator share is clawed back from the payable (what was already moved on is
++ * booked to chargeback_loss, flagged for review), the period is revoked and the subscription shortened or ended;
++ * a replay or a second refund never refunds twice; a promo allowance is never touched; and reconciliation's
++ * subscriptions.period_charged holds every paid period to a charge record, processor reversals included.
++ */
++const assert = require('assert');
++const { boot, fund, check, done } = require('./helpers/app');
++
++const DAY = 86_400_000;
++
++(async () => {
++ const t = await boot();
++ const { reverseReceipt } = require('../server/ops/reversals');
++ const { reconcile } = require('../server/reconcile');
++ console.log('subscription refund');
++
++ const streamer = t.user(70);
++ const [fan, saver, other] = [t.user(71), t.user(72), t.user(73)];
++ const ADMIN = ['billing.ledger.admin'];
++ const refund = (subId, body, opts = {}) => t.call('POST', `/api/v1/subscriptions/${subId}/refund`, { body, cap: ADMIN, ...opts });
++ const subscribe = async (u) => {
++ const r = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: u, streamer, source: 'credit' } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json;
++ };
++ const ent = async (u) => (await t.call('GET', `/api/v1/entitlements/${u.id}?streamer=${streamer.id}`)).json;
++ const revenueBits = async () => (await t.db.prepare(`SELECT COALESCE(SUM(e.amount), 0)::bigint AS n FROM ledger_entries e JOIN accounts a ON a.id = e.account_id
++ WHERE a.kind = 'platform_revenue' AND a.currency = 'vibes-bits'`).get()).n;
++ const reversedEvents = async () => (await t.db.prepare('SELECT event FROM outbox ORDER BY seq').all()).map((r) => JSON.parse(r.event))
++ .filter((e) => e.event_type === 'billing.transaction.reversed');
++ const periodCheck = async () => (await reconcile(t.ctx, { store: false })).checks.find((c) => c.id === 'subscriptions.period_charged');
++
++ // fan: two periods (first + an early renewal), and a promo allowance that must never move.
++ await fund(t, fan, 998);
++ const first = await subscribe(fan);
++ const renewal = await subscribe(fan);
++ const subId = first.subscription.id;
++ const PE1 = first.subscription.current_period_end;
++ const PE2 = renewal.subscription.current_period_end;
++ assert.strictEqual(Date.parse(PE2) - Date.parse(PE1), 31 * DAY);
++ const promo = await t.call('POST', '/api/v1/admin/promo/grant', { body: { subject: fan, bits: 300 }, cap: ADMIN });
++ assert.strictEqual(promo.status, 201, promo.text);
++
++ let refundTxn;
++ await check('a refund needs billing.ledger.admin, a reason and a named period', async () => {
++ const denied = await refund(subId, { period_end: PE2, reason: 'x' }, { cap: ['billing.subscription.manage'] });
++ assert.strictEqual(denied.status, 403, denied.text);
++ const noReason = await refund(subId, { period_end: PE2 });
++ assert.strictEqual(noReason.status, 422, noReason.text);
++ const noPeriod = await refund(subId, { reason: 'x' });
++ assert.strictEqual(noPeriod.status, 422, noPeriod.text);
++ const wrong = await refund(subId, { period_end: new Date(Date.parse(PE2) + DAY).toISOString(), reason: 'x' });
++ assert.strictEqual(wrong.status, 404, wrong.text);
++ assert.strictEqual(wrong.json.code, 'billing.period_not_found');
++ assert.strictEqual((await t.balances(fan.id)).credit, 0, 'nothing moved');
++ });
++
++ await check('refunding the renewal returns the credit, claws back the share and shortens the membership', async () => {
++ const before = { fan: await t.balances(fan.id), payable: (await t.balances(streamer.id)).payable, revenue: await revenueBits() };
++ const r = await refund(subId, { period_end: PE2, reason: 'charged twice by mistake' }, { key: 'refund-renewal-1' });
++ assert.strictEqual(r.status, 201, r.text);
++ refundTxn = r.json.transaction;
++ assert.deepStrictEqual([refundTxn.type, refundTxn.reverses_txn, refundTxn.metadata.review], ['refund', renewal.transaction.id, undefined]);
++ assert.deepStrictEqual([refundTxn.metadata.bits_refunded, refundTxn.metadata.share_clawed_back_bits, refundTxn.metadata.unrecovered_share_bits], [499, 349, 0]);
++ const after = await t.balances(fan.id);
++ assert.strictEqual(after.credit, before.fan.credit + 499);
++ assert.strictEqual(after.promo_bits, before.fan.promo_bits, 'the promo allowance is untouched');
++ assert.strictEqual((await t.balances(streamer.id)).payable, before.payable - 349);
++ assert.strictEqual(await revenueBits(), before.revenue - 150);
++ assert.ok(!refundTxn.entries.some((e) => e.account.kind.startsWith('promo')), 'no promo account in the refund');
++ assert.strictEqual(r.json.subscription.current_period_end, PE1);
++ assert.strictEqual(r.json.subscription.status, 'active');
++ assert.strictEqual(r.json.entitlement.active, true);
++ assert.strictEqual(r.json.entitlement.expires_at, PE1);
++ const ev = (await reversedEvents()).find((e) => e.subject.id === refundTxn.id);
++ assert.ok(ev, 'billing.transaction.reversed enqueued');
++ assert.deepStrictEqual([ev.payload.reverses_txn, ev.payload.entitlements_revoked], [renewal.transaction.id, 1]);
++ await t.assertReconciled('after a credit refund');
++ });
++
++ await check('a replay, or a second refund of the same period under another key, never refunds twice', async () => {
++ const credit = (await t.balances(fan.id)).credit;
++ const replay = await refund(subId, { period_end: PE2, reason: 'charged twice by mistake' }, { key: 'refund-renewal-1' });
++ assert.strictEqual(replay.status, 201, replay.text);
++ assert.strictEqual(replay.json.transaction.id, refundTxn.id);
++ const again = await refund(subId, { transaction_id: renewal.transaction.id, reason: 'again' });
++ assert.strictEqual(again.status, 200, again.text);
++ assert.deepStrictEqual([again.json.transaction, again.json.noop, again.json.reversed_by], [null, 'already_reversed', refundTxn.id]);
++ assert.strictEqual((await t.balances(fan.id)).credit, credit);
++ const n = (await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE reverses_txn = ? AND type = 'refund'").get(renewal.transaction.id)).n;
++ assert.strictEqual(n, 1);
++ assert.strictEqual((await reversedEvents()).filter((e) => e.payload.reverses_txn === renewal.transaction.id).length, 1);
++ await t.assertReconciled('after replays');
++ });
++
++ await check('a share the creator already moved on is booked to chargeback_loss, flagged, and the last period ends it', async () => {
++ await fund(t, saver, 499);
++ const s = await subscribe(saver);
++ const payable = (await t.balances(streamer.id)).payable;
++ const rc = await t.call('POST', '/api/v1/recycle', { body: { subject: streamer, amount: payable - 100 } });
++ assert.strictEqual(rc.status, 201, rc.text);
++ const r = await refund(s.subscription.id, { transaction_id: s.transaction.id, reason: 'fraud review' });
++ assert.strictEqual(r.status, 201, r.text);
++ const m = r.json.transaction.metadata;
++ assert.deepStrictEqual([m.share_clawed_back_bits, m.unrecovered_share_bits, m.loss_cents, m.review], [100, 249, 249, 'required']);
++ assert.strictEqual((await t.balances(streamer.id)).payable, 0);
++ assert.strictEqual((await t.balances(saver.id)).credit, 499);
++ const loss = r.json.transaction.entries.find((e) => e.account.kind === 'chargeback_loss');
++ assert.strictEqual(loss.amount, -249);
++ assert.strictEqual(r.json.subscription.status, 'expired');
++ assert.strictEqual(r.json.subscription.auto_renew, false);
++ assert.strictEqual((await ent(saver)).active, false);
++ const report = await t.assertReconciled('after a refund with an unrecovered share');
++ assert.ok(report.warnings.transactions_for_review.some((x) => x.id === r.json.transaction.id), 'listed for review');
++ });
++
++ await check('a period paid by a provider receipt is not refunded here; its processor reversal passes period_charged', async () => {
++ const p = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: other, streamer, source: 'receipt', receipt: { provider: 'powerchat', provider_ref: 'rf1', amount_cents: 549, fee_cents: 50, route: 'site' } } });
++ assert.strictEqual(p.status, 201, p.text);
++ const r = await refund(p.json.subscription.id, { transaction_id: p.json.transaction.id, reason: 'x' });
++ assert.strictEqual(r.status, 422, r.text);
++ assert.strictEqual(r.json.code, 'billing.not_refundable');
++ const out = await reverseReceipt(t.ctx, { kind: 'chargeback', provider: 'powerchat', originalReceiptRef: 'powerchat:rf1', reversalRef: 'powerchat:rf1:cb', idempotencyKey: 'cb-rf1', actor: { principal: 'test' }, reason: 'disputed' });
++ assert.strictEqual(out.txn.type, 'chargeback');
++ assert.strictEqual((await ent(other)).active, false);
++ const c = await periodCheck();
++ assert.strictEqual(c.ok, true, JSON.stringify(c));
++ await t.assertReconciled('after a processor reversal');
++ });
++
++ await check('a renewal after a refund charges again: the refunded period end renews with a new charge', async () => {
++ await fund(t, fan, 499);
++ t.clock.offset += 32 * DAY;
++ const sw = await t.call('POST', '/api/v1/admin/sweep', { key: null });
++ assert.strictEqual(sw.status, 200, sw.text);
++ assert.ok(sw.json.renewed.includes(subId), JSON.stringify(sw.json));
++ const charge = await t.db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(`renew:${subId}:${PE1}`);
++ assert.ok(charge, 'renewed from the refunded period end');
++ const sub = (await t.call('GET', `/api/v1/subscriptions/${subId}`)).json.subscription;
++ assert.ok(Date.parse(sub.current_period_end) > t.ctx.now() + 30 * DAY);
++ await t.assertReconciled('after the renewal');
++ t.clock.offset = 0;
++ });
++
++ await check('period_charged fails on a period revoked for a refund with no reversal, or granted on a reversed payment', async () => {
++ const e = await t.db.prepare('SELECT id, source_txn FROM entitlements WHERE source_txn = ?').get(first.transaction.id);
++ await t.db.prepare("UPDATE entitlements SET revoked_at = ?, revoked_reason = 'refund' WHERE id = ?").run(new Date().toISOString(), e.id);
++ let c = await periodCheck();
++ assert.strictEqual(c.ok, false);
++ assert.deepStrictEqual(c.detail.offenders.map((o) => [o.entitlement, o.problem]), [[e.id, 'revoked for a refund its payment has no reversal for']]);
++ await t.db.prepare('UPDATE entitlements SET revoked_at = NULL, revoked_reason = NULL WHERE id = ?').run(e.id);
++ const refunded = await t.db.prepare('SELECT id FROM entitlements WHERE source_txn = ?').get(renewal.transaction.id);
++ await t.db.prepare('UPDATE entitlements SET revoked_at = NULL, revoked_reason = NULL WHERE id = ?').run(refunded.id);
++ c = await periodCheck();
++ assert.deepStrictEqual(c.detail.offenders.map((o) => [o.entitlement, o.problem]), [[refunded.id, 'still granted though its payment was reversed']]);
++ await t.db.prepare("UPDATE entitlements SET revoked_at = ?, revoked_reason = 'refund' WHERE id = ?").run(new Date().toISOString(), refunded.id);
++ assert.strictEqual((await periodCheck()).ok, true);
++ });
++
++ await t.close();
++ done();
++})();