Files
anonymous_github/test/admin-billing.test.js
Thomas DurieuxandClaude Opus 5.5 511dcd7067 feat(admin): billing tab to monitor subscriptions and sales (#952)
* feat(admin): add a Billing tab for subscriptions and sales

The admin can now follow subscriptions and sales without opening Stripe:
- Summary tiles: active subscriptions (monthly/annual), MRR with annual
  plans counted as 1/12, sales and revenue for 7d/30d/90d/12m/all time,
  all-time sales, subscriptions cancelling at period end, and refunds
  and disputes.
- A stacked chart of sales per day, week or month by plan family.
- A by-plan table: active grants, period and all-time sales and revenue.
- A paginated list of grants with plan, Stripe status, active/expired/
  revoked and user filters, linked to the admin user, repository or
  conference.
- One row of webhook health: pending, failed, oldest pending event, last
  error and unreviewed payment reversals.

A sale is one checkout or subscription, so a paper pack's three grants
count once. Revenue is each sale's Stripe list price, resolved once per
price and cached in memory. Currencies are never added together. Without
Stripe the page shows counts and marks revenue as unavailable.

GET /api/admin/billing/summary and /grants sit behind the admin check.
Grants get a { purchasedAt, _id } index for the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve payment history in admin billing totals

* fix: scope billing history and preserve original sale prices

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 14:37:29 +01:00

251 lines
16 KiB
JavaScript

const { URL } = require("node:url");
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const mongoose = require("mongoose");
const config = require("../src/config").default;
const { BillingGrant, BillingEvent, BillingRevocation } = require("../src/core/billing/models");
const User = require("../src/core/model/users/users.model").default;
const report = require("../src/core/billing/report");
const DAY = 86400000;
describe("admin billing report helpers", () => {
it("groups plans into families and buckets by period", () => {
expect(report.planFamily("author_annual")).to.equal("subscription");
expect(report.planFamily("conference_large")).to.equal("conference");
expect(report.planFamily("repo_pack")).to.equal("repository");
expect(["7d", "30d", "90d", "12m", "all"].map(report.bucketFor)).to.deep.equal(["day", "day", "day", "week", "month"]);
// Weeks start on Monday in UTC, like Mongo's $dateTrunc with startOfWeek: "monday".
expect(report.truncate(new Date("2026-11-15T23:00:00Z"), "week").toISOString()).to.equal("2026-11-09T00:00:00.000Z");
expect(report.truncate(new Date("2026-11-15T23:00:00Z"), "month").toISOString()).to.equal("2026-11-01T00:00:00.000Z");
});
it("normalizes recurring prices to a monthly amount", () => {
expect(report.monthlyAmount({ amount: 1200, interval: "year", intervalCount: 1 })).to.equal(100);
expect(report.monthlyAmount({ amount: 500, interval: "month", intervalCount: 1 })).to.equal(500);
expect(report.monthlyAmount({ amount: 1200, interval: null, intervalCount: 1 }, "author_annual")).to.equal(100);
});
it("passes only plain words from the query string into the grant filter", () => {
const { filter, state } = report.grantFilter({ plan: { $ne: "x" }, status: "active", state: "revoked" });
expect(filter).to.deep.equal({ status: "active" });
expect(state).to.deep.equal({ revoked: true });
expect(report.grantFilter({ plan: "repo_pack", status: "$where" }).filter).to.deep.equal({ _id: { $regex: "^cs_[^:]+:(repo_slot|author_pass|repo_extend)$" } });
});
it("mounts the billing routes behind the administrator check", () => {
const admin = require("../src/server/routes/admin").default;
const billingRouter = require("../src/server/routes/admin-billing").default;
const billing = admin.stack.findIndex(l => l.handle === billingRouter);
// ensureAuthenticated and the isOwnerOrAdmin middleware come first.
expect(billing).to.be.greaterThan(1);
expect(admin.stack.slice(0, billing).filter(l => !l.route && l.handle.length >= 3).length).to.be.at.least(2);
});
});
const uri = process.env.BILLING_TEST_MONGODB_URI;
(uri ? describe : describe.skip)("admin billing report with MongoDB", function () {
this.timeout(20000);
const original = { ...config };
const now = new Date();
const ago = days => new Date(now.getTime() - days * DAY);
const later = days => new Date(now.getTime() + days * DAY);
const prices = {
price_monthly: { unit_amount: 500, currency: "eur", recurring: { interval: "month", interval_count: 1 } },
price_annual: { unit_amount: 4800, currency: "eur", recurring: { interval: "year", interval_count: 1 } },
price_pack: { unit_amount: 1500, currency: "eur" },
price_never: { unit_amount: 2000, currency: "usd" },
price_conf: { unit_amount: 30000, currency: "eur" },
};
let lookups;
const lookup = async id => { lookups.push(id); if (!prices[id]) throw new Error("no such price"); return prices[id]; };
let alice, bob;
before(async () => {
if (!/^billing_test_/.test(new URL(uri).pathname.slice(1))) throw new Error("Use a disposable billing_test_ database");
await mongoose.connect(uri);
await Promise.all([BillingGrant, BillingEvent, BillingRevocation, User].map(m => m.init()));
});
beforeEach(async () => {
lookups = [];
report.clearPriceCache();
Object.assign(config, { BILLING_ENABLED: true, STRIPE_SECRET_KEY: "sk_test_x", STRIPE_WEBHOOK_SECRET: "whsec_x" });
await Promise.all([BillingGrant, BillingEvent, BillingRevocation, User].map(m => m.deleteMany({})));
[alice, bob] = await User.create([{ username: "alice-lab", accessTokens: {} }, { username: "bob" }]);
const a = String(alice._id), b = String(bob._id);
await BillingGrant.create([
{ _id: "sub_m", owner: a, scope: `user:${a}`, planId: "author_monthly", subscriptionId: "sub_m", priceId: "price_monthly", salePriceId: "price_monthly", salePlanId: "author_monthly", status: "active", until: later(20), purchasedAt: ago(3) },
{ _id: "sub_y", owner: b, scope: `user:${b}`, planId: "author_annual", subscriptionId: "sub_y", priceId: "price_annual", salePriceId: "price_annual", salePlanId: "author_annual", status: "active", until: later(200), purchasedAt: ago(100), cancelAtPeriodEnd: true },
{ _id: "sub_old", owner: b, scope: `user:${b}`, planId: "author_monthly", subscriptionId: "sub_old", priceId: "price_monthly", salePriceId: "price_monthly", salePlanId: "author_monthly", status: "canceled", until: ago(40), purchasedAt: ago(400) },
...["repo_slot", "author_pass", "repo_extend"].map(part => ({ _id: `cs_pack:${part}`, owner: a, scope: "repo:paper-1", planId: part, paymentIntentId: "pi_pack", priceId: "price_pack", status: "paid", until: later(100), purchasedAt: ago(2) })),
{ _id: "cs_never", owner: b, scope: "repo:other", planId: "repo_never", paymentIntentId: "pi_never", priceId: "price_never", status: "paid", until: new Date("9999-12-31T00:00:00Z"), purchasedAt: ago(5), revoked: true, revocationReason: "full_refund", revokedAt: ago(1) },
{ _id: "cs_conf", owner: a, scope: "conference:FSE26", planId: "conference", paymentIntentId: "pi_conf", priceId: "price_conf", status: "paid", until: later(60), purchasedAt: ago(10) },
{ _id: "cs_gone", owner: a, scope: "repo:paper-2", planId: "repo_extend", paymentIntentId: "pi_gone", priceId: "price_retired", status: "paid", until: later(10), purchasedAt: ago(1) },
]);
await BillingRevocation.create({ _id: "pi_never", reason: "full_refund", createdAt: ago(1) });
});
after(async () => { Object.assign(config, original); await mongoose.disconnect(); });
it("counts sales once per checkout, normalizes MRR and keeps currencies apart", async () => {
const s = await report.billingSummary("30d", now, lookup);
expect(s.subscriptions).to.deep.include({ active: 2, monthly: 1, annual: 1, cancelling: 1 });
expect(s.subscriptions.mrr).to.deep.equal({ eur: 500 + 400 });
// Monthly sub, pack (once), never, conference and the retired price: 5 sales in 30 days.
expect(s.sales.count).to.equal(5);
expect(s.sales.revenue).to.deep.equal({ eur: 500 + 1500 + 30000, usd: 2000 });
expect(s.allTime.count).to.equal(7);
expect(s.reversals).to.deep.equal({ refunds: 1, disputes: 0 });
expect(s.revenue).to.include({ available: true, missingSales: 1 });
const pack = s.plans.find(p => p.planId === "repo_pack");
expect(pack).to.include({ active: 1, sales: 1, allTimeSales: 1 });
expect(pack.revenue).to.deep.equal({ eur: 1500 });
expect(s.plans.find(p => p.planId === "repo_slot").sales).to.equal(0);
expect(s.series).to.have.length(31);
const total = s.series.reduce((n, d) => n + d.subscription + d.repository + d.conference, 0);
expect(total).to.equal(5);
expect(s.series.reduce((n, d) => n + d.conference, 0)).to.equal(1);
// One Stripe call per distinct price, then the cache.
expect(lookups.sort()).to.deep.equal(["price_annual", "price_conf", "price_monthly", "price_never", "price_pack", "price_retired"]);
lookups = [];
await report.billingSummary("all", now, lookup);
expect(lookups).to.deep.equal([]);
});
it("excludes never-paid subscriptions while retaining canceled paid sales", async () => {
await BillingGrant.create(["incomplete", "incomplete_expired", "canceled"].map(status => ({
_id: "sub_unpaid_" + status, planId: "author_monthly", subscriptionId: "sub_unpaid_" + status,
priceId: "price_monthly", status, until: new Date(0), purchasedAt: ago(1),
})));
const s = await report.billingSummary("all", now, lookup);
expect(s.sales.count).to.equal(7);
expect(s.plans.find(p => p.planId === "author_monthly").allTimeSales).to.equal(2);
expect(s.series.reduce((n, d) => n + d.subscription, 0)).to.equal(3);
});
it("retains reinstated refunds and counts separate renewal reversals", async () => {
await BillingGrant.updateOne({ _id: "cs_never" }, { $set: { revoked: false }, $unset: { revocationReason: 1, revokedAt: 1 } });
await BillingRevocation.updateOne({ _id: "pi_never" }, { $set: { reviewed: true } });
await BillingRevocation.create([
{ _id: "pi_renewal1", subscriptionId: "sub_m", reason: "full_refund", createdAt: ago(60), reviewed: true },
{ _id: "pi_renewal2", subscriptionId: "sub_m", reason: "full_refund", createdAt: ago(2) },
{ _id: "pi_dispute", subscriptionId: "sub_m", reason: "dispute", createdAt: ago(1) },
]);
expect((await report.billingSummary("30d", now, lookup)).reversals).to.deep.equal({ refunds: 2, disputes: 1 });
expect((await report.billingSummary("all", now, lookup)).reversals).to.deep.equal({ refunds: 3, disputes: 1 });
});
it("marks missing historical and active prices independently of period sales", async () => {
await BillingGrant.deleteOne({ _id: "cs_gone" });
await BillingGrant.updateOne({ _id: "sub_y" }, { $set: { priceId: "price_missing", salePriceId: "price_missing" } });
const s = await report.billingSummary("30d", now, lookup);
expect(s.revenue).to.include({ missingSales: 0, missingAllTimeSales: 1, missingSubscriptions: 1 });
expect(s.sales.complete).to.equal(true);
expect(s.allTime.complete).to.equal(false);
expect(s.subscriptions.complete).to.equal(false);
expect(s.plans.find(p => p.planId === "author_annual")).to.include({ revenueComplete: true, allTimeRevenueComplete: false });
});
it("excludes unrelated Stripe reversals and unresolved subscription stubs", async () => {
await BillingRevocation.create([
{ _id: "pi_external", reason: "full_refund", createdAt: ago(1) },
{ _id: "pi_stub", subscriptionId: "sub_stub", reason: "dispute", createdAt: ago(1) },
]);
await BillingGrant.create({ _id: "sub_stub", revoked: true });
expect((await report.billingSummary("all", now, lookup)).reversals).to.deep.equal({ refunds: 1, disputes: 0 });
});
it("keeps historical sales prices and plans separate from current MRR", async () => {
await BillingGrant.updateOne({ _id: "sub_m" }, { $set: { priceId: "price_annual", planId: "author_annual" } });
const s = await report.billingSummary("30d", now, lookup);
expect(s.subscriptions.mrr).to.deep.equal({ eur: 800 });
expect(s.sales.revenue.eur).to.equal(32000);
expect(s.plans.find(p => p.planId === "author_monthly")).to.include({ active: 0, sales: 1 });
expect(s.plans.find(p => p.planId === "author_annual")).to.include({ active: 2, sales: 0 });
await BillingGrant.updateOne({ _id: "sub_m" }, { $unset: { salePriceId: 1, salePlanId: 1 } });
const legacy = await report.billingSummary("30d", now, lookup);
expect(legacy.sales.complete).to.equal(false);
expect(legacy.subscriptions.complete).to.equal(true);
});
it("searches all matching users before grant pagination, including after 1000 nonbuyers", async () => {
await User.insertMany(Array.from({ length: 1001 }, (_, i) => ({ username: "matching-" + i })));
const buyer = await User.create({ username: "matching-buyer" });
await BillingGrant.create({ _id: "cs_search", owner: String(buyer._id), planId: "repo_slot", status: "paid", until: later(1), purchasedAt: ago(1) });
const names = async ids => new Map(ids.map(id => [id, "matching-buyer"]));
const s = await report.billingGrants({ search: "MATCHING", limit: 1 }, names, now);
expect(s.total).to.equal(1);
expect(s.results).to.have.length(1);
expect(s.results[0].user).to.equal("matching-buyer");
expect(s.stateCounts).to.deep.equal({ active: 1, expired: 0, revoked: 0 });
expect((await report.billingGrants({ search: "matching.*" }, names, now)).total).to.equal(0);
});
it("reports counts without revenue when Stripe is not configured", async () => {
config.STRIPE_SECRET_KEY = "";
const s = await report.billingSummary("7d", now, lookup);
expect(lookups).to.deep.equal([]);
expect(s.revenue).to.include({ available: false, reason: "stripe_not_configured" });
expect(s.sales).to.deep.equal({ count: 4, revenue: {}, complete: false });
expect(s.subscriptions.mrr).to.deep.equal({});
});
it("returns an empty report for a period without sales", async () => {
await BillingGrant.deleteMany({});
const s = await report.billingSummary("all", now, lookup);
expect(s.sales.count).to.equal(0);
expect(s.series).to.deep.equal([]);
expect(s.plans.every(p => p.sales === 0 && p.active === 0)).to.equal(true);
});
it("filters, searches and paginates grants without exposing Stripe identifiers", async () => {
const usernames = async ids => new Map((await User.find({ _id: { $in: ids } }).lean()).map(u => [String(u._id), u.username]));
const all = await report.billingGrants({ limit: 4 }, usernames, now);
expect(all.total).to.equal(9);
expect(all.results).to.have.length(4);
expect(all.stateCounts).to.deep.equal({ active: 7, expired: 1, revoked: 1 });
expect(JSON.stringify(all)).not.to.match(/sub_m|pi_|cus_|price_/);
expect(all.results[0]).to.include({ user: "alice-lab", planId: "repo_extend" });
const page3 = await report.billingGrants({ limit: 4, page: 3 }, usernames, now);
expect(page3.results).to.have.length(1);
const revoked = await report.billingGrants({ state: "revoked" }, usernames, now);
expect(revoked.results).to.have.length(1);
expect(revoked.results[0]).to.include({ planId: "repo_never", never: true, until: null, revocationReason: "full_refund", user: "bob" });
expect(revoked.results[0].scope).to.deep.equal({ kind: "repo", id: "other" });
const pack = await report.billingGrants({ plan: "repo_pack" }, usernames, now);
expect(pack.results.map(r => r.pack)).to.deep.equal([true, true, true]);
const expired = await report.billingGrants({ state: "expired", status: "canceled" }, usernames, now);
expect(expired.results.map(r => r.planId)).to.deep.equal(["author_monthly"]);
const bobs = await report.billingGrants({ owners: [String(bob._id)], sort: "until", direction: "asc" }, usernames, now);
expect(bobs.results.map(r => r.user)).to.deep.equal(["bob", "bob", "bob"]);
expect(bobs.results[0].scope.kind).to.equal("account");
});
it("serves the summary and the grant list through the admin router", async () => {
await BillingEvent.create([{ _id: "test:platform:evt_1", eventId: "evt_1", type: "invoice.paid", state: "failed", attempts: 12, lastError: "api_error:rate_limit", receivedAt: ago(1) },
{ _id: "test:platform:evt_2", eventId: "evt_2", type: "checkout.session.completed", state: "pending", receivedAt: new Date(now.getTime() - 120000) }]);
const router = require("../src/server/routes/admin-billing").default;
const call = async (path, query) => {
const handler = router.stack.find(l => l.route?.path === path).route.stack.at(-1).handle;
let body, status = 200;
const res = { set() { return res; }, status(code) { status = code; return res; }, json(data) { body = data; return res; } };
await handler({ query }, res);
return { body, status };
};
const summary = await call("/summary", { period: { $gt: "" } });
expect(summary.body.period).to.equal("30d");
expect(summary.body.webhooks).to.include({ pending: 1, failed: 1, unreviewedRevocations: 1 });
expect(summary.body.webhooks.oldestPendingSeconds).to.be.at.least(119);
expect(summary.body.webhooks.lastError).to.include({ type: "invoice.paid", error: "api_error:rate_limit", state: "failed" });
const grants = await call("/grants", { search: "ALICE", limit: "100" });
expect(grants.body.total).to.equal(6);
expect(grants.body.results.every(r => r.user === "alice-lab")).to.equal(true);
const none = await call("/grants", { search: "nobody" });
expect(none.body.total).to.equal(0);
});
it("uses the purchase date index to list grants", async () => {
await BillingGrant.createIndexes();
const plan = await BillingGrant.find({}).sort({ purchasedAt: -1, _id: -1 }).limit(25).explain("queryPlanner");
expect(JSON.stringify(plan.queryPlanner.winningPlan)).to.include("purchasedAt_-1");
});
});