fix: protect reactivation claims and retain historical purchase prices

This commit is contained in:
Thomas Durieux committed 2026-11-02 21:39:17 +00:00
1 parent afd7a8ff3b
commit 4dc4454459
8 files changed
+165 -13

No files matched your search

+6 -1
View File
@@ -189,7 +189,7 @@ HTTP endpoints.
### Restore and launch checks
Back up `billingaccounts`, `billinggrants`, `billingquotas`, `billinglocks`, `billingevents`,
`billingrevocations`, and `billingaudits` with repository data. Test a restore into an
`billingrevocations`, `billingprices`, and `billingaudits` with repository data. Test a restore into an
isolated environment with sandbox credentials. Keep destructive repository maintenance
stopped during restore, discover customer mappings, reconcile restored owners, drain
queued events, and inspect health before re-enabling normal scheduling. Never connect a
@@ -263,3 +263,8 @@ Reservations remain conservative across generations and failed fills and require
operator reconciliation to release unused capacity. This also covers public raw
downloads and expanded submodule content. Existing cached content is not retroactively
measured by this write-time guard.
Accepted one-time prices are stored in `billingprices` when checkout starts and when
existing purchases are fulfilled. Hiding an offer by removing its configured price
does not remove this mapping or invalidate paid grants. Include the mapping in backups
so reconciliation can reconstruct grants for retired offers and process their refunds.
+5 -1
View File
@@ -11,12 +11,13 @@ export const BillingGrant = model("billingGrant", new Schema({
_id: String, owner: { type: String, index: true }, scope: { type: String, index: true },
planId: String, customerId: String, subscriptionId: String, paymentIntentId: { type: String, index: true },
status: String, until: Date, cancelAtPeriodEnd: Boolean,
priceId: String,
revision: { type: Number, default: 0 }, revoked: { type: Boolean, default: false },
revocationReason: String, revokedAt: Date,
}));
export const BillingQuota = model("billingQuota", new Schema({
_id: String,
allocations: [{ _id: false, repoId: String, bytes: Number, baseBytes: Number,
allocations: [{ _id: false, repoId: String, reservationId: String, bytes: Number, baseBytes: Number,
expansions: [{ _id: false, key: String, bytes: Number }], createdAt: Date }],
}));
// A short lease serializes Stripe checkout creation across API instances.
@@ -45,3 +46,6 @@ export const BillingDraft = model("billingDraft", new Schema({
_id: String, owner: { type: String, required: true, index: true }, path: String, values: Schema.Types.Mixed,
expiresAt: { type: Date, required: true, expires: 0 },
}));
// Accepted prices survive removal from the advertised catalog.
export const BillingPrice = model("billingPrice", new Schema({ _id: String, planId: String }));
+6 -3
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import ConferenceModel from "../model/conference/conferences.model";
import config from "../../config";
import { BillingGrant, BillingQuota } from "./models";
@@ -35,14 +36,16 @@ export async function reserveRepository(owner: string, repoId: string, bytes: nu
for (const repository of existing) {
if ((await allowance(String(repository.owner), repository.repoId, repository.conference)).scope === limits.scope) funded.push(repository);
}
try { await BillingQuota.create({ _id: limits.scope, allocations: funded.map(r => ({ repoId: r.repoId, bytes: r.size?.storage || 0, createdAt: new Date() })) }); }
try { await BillingQuota.create({ _id: limits.scope, allocations: funded.map(r => ({ repoId: r.repoId, reservationId: randomUUID(), bytes: r.size?.storage || 0, createdAt: new Date() })) }); }
catch (error) { if ((error as { code?: number }).code !== 11000) throw error; }
}
// Only confirmed terminal repositories release capacity. Unknown reservations
// remain until reconciled by an operator, so a crash cannot oversell capacity.
const ledger = await BillingQuota.findById(limits.scope).lean();
const terminal = await RepositoryModel.find({ repoId: { $in: ledger?.allocations.map(a => a.repoId) || [] }, status: "removed" }).select("repoId").lean();
if (terminal.length) await BillingQuota.updateOne({ _id: limits.scope }, { $pull: { allocations: { repoId: { $in: terminal.map(r => r.repoId) } } } });
if (terminal.length) await BillingQuota.updateOne({ _id: limits.scope }, { $pull: { allocations: { $or: terminal.map(r => ({
repoId: r.repoId, reservationId: ledger?.allocations.find(a => a.repoId === r.repoId)?.reservationId || { $exists: false },
})) } } });
const others = allocationExpression(repoId);
// Never shrink a reservation during refresh. Concurrent generations and
// failed builds cannot free bytes still needed by the published generation.
@@ -61,7 +64,7 @@ export async function reserveRepository(owner: string, repoId: string, bytes: nu
const result = await BillingQuota.updateOne({ _id: limits.scope, $expr: { $and: [
{ $lt: [{ $size: others }, limits.repositories] },
{ $lte: [{ $add: [{ $sum: { $map: { input: others, as: "a", in: "$$a.bytes" } } }, reservedBytes] }, limits.storage] },
] } }, [{ $set: { allocations: { $concatArrays: [others, [{ repoId: { $literal: repoId }, bytes: reservedBytes, baseBytes, expansions, createdAt: new Date() }]] } } }]);
] } }, [{ $set: { allocations: { $concatArrays: [others, [{ repoId: { $literal: repoId }, reservationId: randomUUID(), bytes: reservedBytes, baseBytes, expansions, createdAt: new Date() }]] } } }]);
if (!result.matchedCount) throw billingError("quota_exceeded", 402);
}
+16 -3
View File
@@ -1,7 +1,7 @@
import Stripe = require("stripe");
import { randomUUID } from "crypto";
import config from "../../config";
import { BillingAccount, BillingGrant, BillingLock, BillingRevocation } from "./models";
import { BillingAccount, BillingGrant, BillingLock, BillingRevocation, BillingPrice } from "./models";
import { billingError, billingOrigin, billingPlans, requireBilling } from "./config";
import ConferenceModel from "../model/conference/conferences.model";
import RepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model";
@@ -68,7 +68,16 @@ export async function fulfillCheckout(id: string, stripe = stripeClient()) {
if (session.status !== "complete") return;
if (session.subscription) { await syncSubscription(objectId(session.subscription)!, stripe); return; }
const plan = billingPlans().find(p => p.id === planId && !p.recurring);
if (!plan || session.line_items?.data.length !== 1 || session.line_items.data[0].price?.id !== plan.priceId || session.line_items.data[0].quantity !== 1) throw billingError("billing_invalid_purchase");
const priceId = session.line_items?.data[0]?.price?.id;
const previousGrant = await BillingGrant.findById(id).lean();
const historicalPrice = priceId ? await BillingPrice.findById(priceId).lean() : null;
const previousScope = planId === "conference" ? `conference:${target}` : `repo:${target}`;
const acceptedGrant = previousGrant?.owner === owner && previousGrant?.customerId === account.customerId
&& previousGrant?.planId === planId && previousGrant?.scope === previousScope
&& (!previousGrant.priceId || previousGrant.priceId === priceId);
if (!plan || !priceId || session.line_items?.data.length !== 1 || session.line_items.data[0].quantity !== 1 ||
!(priceId === plan.priceId || historicalPrice?.planId === planId || acceptedGrant)) throw billingError("billing_invalid_purchase");
await BillingPrice.updateOne({ _id: priceId }, { $setOnInsert: { planId } }, { upsert: true });
if (session.payment_status !== "paid" || session.status !== "complete") return;
const intent = session.payment_intent as Stripe.PaymentIntent | null;
const charge = intent?.latest_charge as Stripe.Charge | null;
@@ -82,7 +91,7 @@ export async function fulfillCheckout(id: string, stripe = stripeClient()) {
until = conference.endDate;
}
await BillingGrant.updateOne({ _id: id }, { $setOnInsert: { owner, scope, planId, customerId: account.customerId,
paymentIntentId: objectId(session.payment_intent), status: "paid", until }, ...(revoked ? { $set: { revoked: true } } : {}) }, { upsert: true });
paymentIntentId: objectId(session.payment_intent), priceId, status: "paid", until }, ...(revoked ? { $set: { revoked: true } } : {}) }, { upsert: true });
const reversal = intent && await BillingRevocation.findById(intent.id).lean();
if (reversal?.reviewed) await BillingGrant.updateOne({ _id: id }, { $set: { revoked: false } });
else if (reversal) await BillingGrant.updateOne({ _id: id }, { $set: { revoked: true, revocationReason: reversal.reason } });
@@ -223,6 +232,10 @@ export async function createCheckout(owner: string, planId: string, target = "",
account.checkout.params = params;
await account.save();
}
if (!plan.recurring) {
const acceptedPrice = (account.checkout.params as Stripe.Checkout.SessionCreateParams).line_items?.[0]?.price;
if (acceptedPrice) await BillingPrice.updateOne({ _id: acceptedPrice }, { $setOnInsert: { planId } }, { upsert: true });
}
const session = await stripe.checkout.sessions.create(account.checkout.params as Stripe.Checkout.SessionCreateParams, { idempotencyKey: account.checkout.key });
account.checkout.id = session.id;
await account.save();
+20
View File
@@ -207,6 +207,7 @@ router.post(
);
validateBillingExpiration({ ...repo.model.options, expirationDate: newExpiration });
const reactivating = repo.status === RepositoryStatus.EXPIRED;
if (reactivating) await assertConferenceAdmission(String(repo.model.owner), repo.repoId, repo.model.conference);
const updates: Record<string, Date | string | null> = {
"options.expirationDate": newExpiration,
settingsSavedAt: new Date(),
@@ -498,6 +499,7 @@ export function shouldReactivateInactiveRepository(
router.post(
"/:repoId/",
async (req, res) => {
let recoverReactivation: (() => Promise<void>) | undefined;
try {
const repo = await getRepo(req, res, {
nocheck: true,
@@ -551,6 +553,22 @@ router.post(
const needsAdmission = sourceChanged || reactivating || repoUpdate.conference !== repo.model.conference;
if (needsAdmission) {
await assertConferenceAdmission(String(repo.model.owner), repo.repoId, repoUpdate.conference);
if (reactivating && config.BILLING_ENABLED && repo.model.billingManaged) {
const previousStatus = repo.status;
const previousStatusDate = repo.model.statusDate;
const claimedAt = new Date();
const claim = await AnonymizedRepositoryModel.updateOne({ _id: repo.model._id,
status: previousStatus, statusDate: previousStatusDate,
}, { $set: { status: RepositoryStatus.PREPARING, statusDate: claimedAt } }).exec();
if (!claim.matchedCount) throw appError("invalid_status", 409);
repo.model.status = RepositoryStatus.PREPARING;
repo.model.statusDate = claimedAt;
recoverReactivation = async () => {
await AnonymizedRepositoryModel.updateOne({ _id: repo.model._id,
status: RepositoryStatus.PREPARING, statusDate: claimedAt,
}, { $set: { status: previousStatus, statusDate: previousStatusDate || new Date() } }).exec();
};
}
if (repo.model.billingManaged && !sourceChanged) await reserveRepository(String(repo.model.owner), repo.repoId, repo.model.size?.storage || 0, repoUpdate.conference);
}
@@ -673,6 +691,7 @@ router.post(
}
).exec();
if (!saved.matchedCount) throw appError("connection_changed", 409);
recoverReactivation = undefined;
if (user.id === repo.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "repository", id: String(repo.model._id) });
if (!sourceChanged && !reactivating) {
return res.json({ status: repo.status });
@@ -684,6 +703,7 @@ router.post(
await enqueueSavedSnapshot(repo.model);
res.json({ status: repo.status });
} catch (error) {
if (recoverReactivation) await recoverReactivation();
return handleError(error, res, req);
}
}
+81 -3
View File
@@ -4,7 +4,7 @@ const { expect } = require("chai");
require("ts-node/register/transpile-only");
const mongoose = require("mongoose");
const config = require("../src/config").default;
const { BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft } = require("../src/core/billing/models");
const { BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft } = require("../src/core/billing/models");
const Repo = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
const Conference = require("../src/core/model/conference/conferences.model").default;
const { reserveRepository, allowance, assertConferenceAdmission } = require("../src/core/billing/quotas");
@@ -21,12 +21,12 @@ async function rejects(promise, message) {
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([BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.init()));
await Promise.all([BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.init()));
});
beforeEach(async () => {
Object.assign(config, { BILLING_ENABLED: true, FREE_AUTHOR_REPOSITORIES: 3, FREE_AUTHOR_STORAGE: 100,
PAID_AUTHOR_STORAGE: 1000, PAID_AUTHOR_REPOSITORIES: 20, STRIPE_AUTHOR_MONTHLY_PRICE: "price_monthly", STRIPE_AUTHOR_PASS_PRICE: "price_pass", STRIPE_CONFERENCE_PRICE: "price_conf" });
await Promise.all([BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.deleteMany({})));
await Promise.all([BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.deleteMany({})));
await BillingAccount.create({ _id: owner, customerId: "cus_owner" });
});
after(async () => { Object.assign(config, original); await mongoose.disconnect(); });
@@ -108,6 +108,61 @@ async function rejects(promise, message) {
} else await reserveRepository(owner, "competitor", 1);
});
}
for (const fails of [false, true]) {
it(`claims removed restoration before admission and ${fails ? "recovers a failed admission" : "protects it from a competitor"}`, async () => {
const Repository = require("../src/core/Repository").default;
const utils = require("../src/server/routes/route-utils");
const quotas = require("../src/core/billing/quotas");
const queue = require("../src/queue");
const saved = { getRepo: utils.getRepo, getUser: utils.getUser, handleError: utils.handleError, reserve: quotas.reserveRepository, queue: queue.downloadQueue };
config.FREE_AUTHOR_REPOSITORIES = 1;
const model = await Repo.create({ repoId: "restore", owner, billingManaged: true, status: "removed", statusDate: new Date(0),
source: { type: "GitHubStream", repositoryName: "owner/repo", commit: "abc123", branch: "main" }, size: { storage: 10 } });
utils.getRepo = async () => new Repository(model);
utils.getUser = async () => ({ isAdmin: true });
utils.handleError = error => { throw error; };
let queued = false;
queue.downloadQueue = { add: async () => { queued = true; } };
quotas.reserveRepository = async (...args) => {
expect((await Repo.findById(model._id)).status).to.equal("preparing");
if (fails) throw Error("quota_exceeded");
await saved.reserve(...args);
await rejects(saved.reserve(owner, "competitor", 1), "quota_exceeded");
};
try {
const router = require("../src/server/routes/repository-private").default;
const handler = router.stack.find(layer => layer.route?.path === "/:repoId/" && layer.route.methods.post).route.stack[0].handle;
const work = handler({ body: { repoId: "restore", fullName: "owner/repo", source: { commit: "abc123", branch: "main" }, terms: [],
options: { expirationMode: "remove", expirationDate: future() } } }, { json() {} });
if (fails) await rejects(work, "quota_exceeded"); else await work;
expect(queued).to.equal(!fails);
expect((await Repo.findById(model._id)).status).to.equal(fails ? "removed" : "preparing");
} finally {
utils.getRepo = saved.getRepo; utils.getUser = saved.getUser; utils.handleError = saved.handleError;
quotas.reserveRepository = saved.reserve; queue.downloadQueue = saved.queue;
}
});
}
it("does not let stale removal cleanup delete a fresh reservation", async () => {
config.FREE_AUTHOR_REPOSITORIES = 1;
await Repo.create({ repoId: "restoring", owner, status: "ready", size: { storage: 10 } });
await reserveRepository(owner, "restoring", 10);
await Repo.updateOne({ repoId: "restoring" }, { $set: { status: "removed" } });
const update = BillingQuota.updateOne;
let interleaved = false;
BillingQuota.updateOne = async function (...args) {
if (args[1].$pull && !interleaved) {
interleaved = true;
await Repo.updateOne({ repoId: "restoring" }, { $set: { status: "preparing" } });
await reserveRepository(owner, "restoring", 20);
}
return update.apply(this, args);
};
try { await rejects(reserveRepository(owner, "competitor", 1), "quota_exceeded"); }
finally { BillingQuota.updateOne = update; }
expect(interleaved).to.equal(true);
expect((await BillingQuota.findById(`user:${owner}`)).allocations[0].bytes).to.equal(20);
});
it("counts legacy repositories and releases removed repositories", async () => {
await Repo.create(["one", "two", "three"].map(repoId => ({ repoId, owner, status: "ready", size: { storage: 10 } })));
await rejects(reserveRepository(owner, "four", 1), "quota_exceeded");
@@ -199,6 +254,29 @@ async function rejects(promise, message) {
await fulfillCheckout("cs_pass", stripe);
expect((await BillingGrant.findById("cs_pass")).revoked).to.equal(true);
});
it("reconciles retired one-time prices and continues refund recovery", async () => {
const { reconcileBillingAccount } = require("../src/core/billing/reconciliation");
const iterable = values => ({ async *[Symbol.asyncIterator]() { yield* values; } });
const value = session();
const stripe = { checkout: { sessions: { retrieve: async () => value, list: () => iterable([value]) } },
subscriptions: { list: () => iterable([]) }, charges: { list: () => iterable([{ payment_intent: "pi_pass", refunded: true }]) },
invoicePayments: { list: async () => ({ data: [] }) } };
await fulfillCheckout(value.id, stripe);
config.STRIPE_AUTHOR_PASS_PRICE = "";
await BillingGrant.deleteMany({});
await reconcileBillingAccount(owner, stripe);
expect((await BillingGrant.findById(value.id)).revoked).to.equal(true);
expect((await BillingAccount.findById(owner)).reconciledAt).to.be.instanceOf(Date);
value.line_items.data[0].price.id = "price_unrecognized";
await rejects(fulfillCheckout(value.id, stripe), "billing_invalid_purchase");
});
it("preserves previously accepted grants without historical price fields", async () => {
const value = session();
await BillingGrant.create({ _id: value.id, owner, customerId: "cus_owner", scope: "repo:repo", planId: "author_pass", status: "paid" });
config.STRIPE_AUTHOR_PASS_PRICE = "";
await fulfillCheckout(value.id, { checkout: { sessions: { retrieve: async () => value } } });
expect((await BillingPrice.findById("price_pass")).planId).to.equal("author_pass");
});
it("rejects a checkout for a different Stripe customer", async () => {
const value = session(); value.customer = "cus_other";
await rejects(fulfillCheckout("cs_pass", { checkout: { sessions: { retrieve: async () => value } } }), "billing_customer_mismatch");
+18 -2
View File
@@ -3,7 +3,7 @@ const { expect } = require("chai");
require("ts-node/register/transpile-only");
const mongoose = require("mongoose");
const config = require("../src/config").default;
const { BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, BillingDraft } = require("../src/core/billing/models");
const { BillingPrice, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, BillingDraft } = require("../src/core/billing/models");
const { receiveBillingEvent, processBillingInbox, billingHealth } = require("../src/core/billing/events");
const { reconcileBillingAccount } = require("../src/core/billing/reconciliation");
const { createCheckout, processBillingEvent } = require("../src/core/billing/stripe");
@@ -12,7 +12,7 @@ const Repo = require("../src/core/model/anonymizedRepositories/anonymizedReposit
const uri = process.env.BILLING_TEST_MONGODB_URI;
const owner = "507f1f77bcf86cd799439011";
const { saveBillingDraft, getBillingDraft } = require("../src/core/billing/drafts");
const models = [BillingDraft, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, Repo];
const models = [BillingDraft, BillingPrice, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, Repo];
const iterable = values => ({ async *[Symbol.asyncIterator]() { yield* values; } });
const event = id => ({ id, livemode: false, type: "customer.created", data: { object: { id: "cus_test", email: "private@example.org" } } });
(uri ? describe : describe.skip)("billing failure recovery", function () {
@@ -158,6 +158,22 @@ const event = id => ({ id, livemode: false, type: "customer.created", data: { ob
retrieve: async () => ({ status: "open", url: "https://checkout.stripe.com/recovered" }),
} } };
}
it("records an accepted one-time price before an uncertain checkout response", async () => {
config.STRIPE_AUTHOR_PASS_PRICE = "price_retiring";
await Repo.create({ repoId: "pass-target", owner, status: "ready" });
const stripe = checkoutStripe();
stripe.prices.retrieve = async () => ({ active: true, currency: "eur", unit_amount: 1500 });
try { await createCheckout(owner, "author_pass", "pass-target", stripe); throw Error("expected network failure"); }
catch (error) { expect(error.message).to.equal("connection lost"); }
expect((await BillingPrice.findById("price_retiring")).planId).to.equal("author_pass");
config.STRIPE_AUTHOR_PASS_PRICE = "";
const { fulfillCheckout } = require("../src/core/billing/stripe");
stripe.checkout.sessions.retrieve = async () => ({ id: "cs_retired", status: "complete", payment_status: "paid", customer: "cus_owner",
created: Math.floor(Date.now() / 1000), metadata: { application: "anonymous-github", owner, planId: "author_pass", target: "pass-target" },
line_items: { data: [{ price: { id: "price_retiring" }, quantity: 1 }] }, payment_intent: null });
await fulfillCheckout("cs_retired", stripe);
expect((await BillingGrant.findById("cs_retired")).status).to.equal("paid");
});
it("keeps checkout drafts private, bounded and expired even before the TTL sweep", async () => {
const draft = await saveBillingDraft(owner, "/anonymize", { terms: "Private Author", options: { expirationMode: "never" }, customerId: "untrusted" });
expect((await getBillingDraft(owner, draft.id)).values).not.to.have.property("customerId");
+13
View File
@@ -141,6 +141,19 @@ describe("repository activity dates", () => {
expect(writes).to.equal(2);
expect(repo.model.options.expirationDate).to.deep.equal(savedDate);
});
it("does not extend an expired submission in a conference that no longer admits it", async () => {
const repo = repository("expired"); auth(repo);
repo.model.conference = "ended";
const quotas = require("../src/core/billing/quotas");
stub(quotas, "assertConferenceAdmission", async (_owner, _id, conference) => {
expect(conference).to.equal("ended"); throw Error("conf_not_activated");
});
stub(Model, "updateOne", () => { throw Error("must not persist extension"); });
let error;
try { await handler("repository-private", "/:repoId/extend", "post")({}, {}); } catch (caught) { error = caught; }
expect(error?.message).to.equal("conf_not_activated");
expect(repo.status).to.equal("expired");
});
it("dates a dashboard expiration extension", async () => {
const repo = repository(); auth(repo); let saved;
stub(Model, "updateOne", (_filter, update) => ({ exec: async () => { saved = update.$set; } }));