openvibers/openvibe.billing Commits
Refund credit-paid subscription periods and reconcile every paid period
Changes
@@@ -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 thOnly PowerChat is expected to be enabled in production. A reversal of a purchase claws back thebuyer's remaining credit; credit already given to someone stays with them — their payable is nevertouched — 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 clearingaccount (`receipts.ledger`), EXTERNAL receipts are never in the journal and each announced one has oneoutbox 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 listsnegative balances, unprocessed/rejected events, items for review and import holds; totals exclude testtransactions. 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 whatcan 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`@@@ -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",@@@ -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()));@@@ -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 };@@@ -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')nnew file mode 100644@@@ -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();++})();
@@ -199,6 +199,7 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
@@ -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/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` | `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/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/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/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) |
| `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
@@ -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
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
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`.
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
## Operations
@@ -331,7 +336,10 @@ Reversing a subscription payment revokes the periods it granted.
@@ -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
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
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
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
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`,
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
`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
@@ -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
(`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
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
(`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
(`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
(`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`
(`console.test.js`, `staff-map.test.js`); the public policy (`policy.test.js`); the vhost keeps `/api/v1`
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
"intents, transfers, refunds, recycle, cashouts with escrow",
"intents, transfers, refunds, recycle, cashouts with escrow",
"subscriptions and entitlements",
"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 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)",
"EXTERNAL PowerChat receipts (billing.receipt.external under BILLING_AUTHORITY=billing)",
"economy freeze",
"economy freeze",
"hourly reconciliation, ledger against receipts",
"hourly reconciliation, ledger against receipts",
@@ -20,6 +20,7 @@ const purchases = require('../ops/purchases');
@@ -20,6 +20,7 @@ const purchases = require('../ops/purchases');
const transfers = require('../ops/transfers');
const transfers = require('../ops/transfers');
const cashouts = require('../ops/cashouts');
const cashouts = require('../ops/cashouts');
const subscriptions = require('../ops/subscriptions');
const subscriptions = require('../ops/subscriptions');
+const { refundCreditPeriod } = require('../ops/reversals');
const intents = require('../ops/intents');
const intents = require('../ops/intents');
const admin = require('../ops/admin');
const admin = require('../ops/admin');
const providers = require('../providers');
const providers = require('../providers');
@@ -208,6 +209,17 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -208,6 +209,17 @@ function v1Router({ ctx, auth, adapters, limits }) {
const out = await subscriptions.cancel(ctx, { id: req.params.id, actor: actorOf(req) }, adapters);
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 });
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) => {
r.get('/entitlements/:subject', ...read(CAP.entitlement, async (req, res) => {
const subject = pathSubject(req.params.subject);
const subject = pathSubject(req.params.subject);
if (req.query.streamer) return res.json(await subscriptions.entitlement(db, subject, pathSubject(req.query.streamer), ctx.now()));
if (req.query.streamer) return res.json(await subscriptions.entitlement(db, subject, pathSubject(req.query.streamer), ctx.now()));
@@ -13,10 +13,12 @@
@@ -13,10 +13,12 @@
* donation (site-routed tip) the creator's payable stays; loss booked, flagged for review.
* 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.
* 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 { post, getTxn, balance, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { enqueue } = require('../outbox');
-const { A, entry, fail } = require('./common');
+const { A, entry, fail, text } = require('./common');
const { summary } = require('./purchases');
const { summary } = require('./purchases');
const intents = require('./intents');
const intents = require('./intents');
@@ -103,4 +105,77 @@ async function reverseReceipt(ctx, input) {
@@ -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 };
@@ -22,6 +22,10 @@
@@ -22,6 +22,10 @@
* receipts.stale no provider receipt waits unprocessed longer than
* receipts.stale no provider receipt waits unprocessed longer than
* BILLING_RECONCILE_STALE_RECEIPT_MIN while the economy is open (a provider,
* BILLING_RECONCILE_STALE_RECEIPT_MIN while the economy is open (a provider,
* Network or code failure is holding money the ledger does not show)
* 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
* 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
* 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).
* 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' } = {}) {
@@ -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();
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);
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 frozen = await isFrozen(db);
const staleMin = (ctx.config && ctx.config.reconcile && ctx.config.reconcile.staleReceiptMin) || 60;
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')
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')
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -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();
+})();