feat: add paid author and conference hosting with Stripe

This commit is contained in:
Thomas Durieux committed 2026-11-02 20:49:03 +00:00
1 parent b09ef0e5b0
commit 410863d253
54 files changed
+3263 -369

No files matched your search

+43
View File
@@ -2,6 +2,24 @@ import { resolve } from "path";
import { randomBytes } from "crypto";
interface Config {
BILLING_ENABLED: boolean;
BILLING_CHECKOUT_ENABLED: boolean;
BILLING_ORIGIN: string;
BILLING_AUTOMATIC_TAX: boolean;
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
STRIPE_AUTHOR_MONTHLY_PRICE: string;
STRIPE_AUTHOR_ANNUAL_PRICE: string;
STRIPE_AUTHOR_PASS_PRICE: string;
STRIPE_CONFERENCE_PRICE: string;
FREE_AUTHOR_REPOSITORIES: number;
FREE_AUTHOR_STORAGE: number;
PAID_AUTHOR_REPOSITORIES: number;
PAID_AUTHOR_STORAGE: number;
PAID_PASS_STORAGE: number;
PAID_CONFERENCE_REPOSITORIES: number;
PAID_CONFERENCE_STORAGE: number;
GITHUB_APP_ENABLED: boolean;
GITHUB_APP_NEW_CONNECTIONS: boolean;
GITHUB_OAUTH_ENABLED: boolean;
@@ -67,6 +85,24 @@ interface Config {
RATE_LIMIT: number;
}
const config: Config = {
BILLING_ENABLED: false,
BILLING_CHECKOUT_ENABLED: true,
BILLING_ORIGIN: "http://localhost:5000",
BILLING_AUTOMATIC_TAX: false,
STRIPE_SECRET_KEY: "",
STRIPE_WEBHOOK_SECRET: "",
STRIPE_AUTHOR_MONTHLY_PRICE: "",
STRIPE_AUTHOR_ANNUAL_PRICE: "",
STRIPE_AUTHOR_PASS_PRICE: "",
STRIPE_CONFERENCE_PRICE: "",
FREE_AUTHOR_REPOSITORIES: 5,
FREE_AUTHOR_STORAGE: 2 * 1024 ** 3,
PAID_AUTHOR_REPOSITORIES: 20,
PAID_AUTHOR_STORAGE: 10 * 1024 ** 3,
PAID_PASS_STORAGE: 2 * 1024 ** 3,
PAID_CONFERENCE_REPOSITORIES: 100,
PAID_CONFERENCE_STORAGE: 50 * 1024 ** 3,
GITHUB_APP_ENABLED: false,
GITHUB_APP_NEW_CONNECTIONS: true,
GITHUB_OAUTH_ENABLED: true,
@@ -199,3 +235,10 @@ if (config.GITHUB_APP_ENABLED) {
if (!config.GITHUB_APP_PRIVATE_KEY && !config.GITHUB_APP_PRIVATE_KEY_FILE) throw new Error("A GitHub App private key is required");
}
export default config;
if (config.BILLING_ENABLED) {
for (const key of ["FREE_AUTHOR_REPOSITORIES", "FREE_AUTHOR_STORAGE", "PAID_AUTHOR_REPOSITORIES", "PAID_AUTHOR_STORAGE", "PAID_PASS_STORAGE", "PAID_CONFERENCE_REPOSITORIES", "PAID_CONFERENCE_STORAGE"] as const) {
if (!Number.isSafeInteger(config[key]) || config[key] <= 0) throw new Error(`${key} must be a positive integer`);
}
if (!config.STRIPE_SECRET_KEY || !config.STRIPE_WEBHOOK_SECRET) throw new Error("Billing requires STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET");
}
+1
View File
@@ -265,6 +265,7 @@ export default class AnonymizedFile {
folder === "" || fileDir === folder || fileDir.startsWith(folder + "/")
);
if (!isAffected) return null;
if (this.repository.model.billingManaged) throw new AnonymousError("repository_tree_incomplete", { httpStatus: 409 });
const source = this.repository.source as {
fetchFileInfoFromPath?: (filePath: string) => Promise<IFile | null>;
};
+6
View File
@@ -1,3 +1,4 @@
import { allowance, reserveRepository } from "./billing/quotas";
import AnonymizedPathModel from "./model/anonymized-path";
import storage from "./storage";
import { contentGenerationPrefix, contentRetirementMarker } from "./content-generation";
@@ -224,6 +225,7 @@ export default class Repository {
const streamed = this.model.source.type !== "Zip";
const source = this.source;
const files = await source.getFiles(opt.progress);
if (this.model.billingManaged) await reserveRepository(String(this.model.owner), this.repoId, files.reduce((sum, file) => sum + (file.size || 0), 0), this.model.conference);
const sourceWithTruncation = source as unknown as { truncatedFolderList?: string[] };
const truncatedFolders = Array.isArray(sourceWithTruncation.truncatedFolderList)
? [...sourceWithTruncation.truncatedFolderList] : [];
@@ -638,6 +640,10 @@ export default class Repository {
});
}
}
// Reviewer visits must not trigger an unaffordable refresh after paid access ends.
// Free managed repositories can still be refreshed explicitly by their owner.
if (config.BILLING_ENABLED && this.model.billingManaged && !opt?.force &&
(await allowance(String(this.model.owner), this.repoId, this.model.conference)).plan === "free") return;
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (
+24
View File
@@ -0,0 +1,24 @@
import config from "../../config";
import AnonymousError from "../AnonymousError";
export type PlanId = "author_monthly" | "author_annual" | "author_pass" | "conference";
export const GiB = 1024 ** 3;
export function billingPlans() {
return [
{ id: "author_monthly" as PlanId, name: "Author monthly", priceId: config.STRIPE_AUTHOR_MONTHLY_PRICE, recurring: true, repositories: config.PAID_AUTHOR_REPOSITORIES, storage: config.PAID_AUTHOR_STORAGE, description: "Never-expiring repositories while subscribed, then a 90-day grace period. Renews monthly. Cancel renewal at any time." },
{ id: "author_annual" as PlanId, name: "Author annual", priceId: config.STRIPE_AUTHOR_ANNUAL_PRICE, recurring: true, repositories: config.PAID_AUTHOR_REPOSITORIES, storage: config.PAID_AUTHOR_STORAGE, description: "Never-expiring repositories while subscribed, then a 90-day grace period. Renews annually. Cancel renewal at any time." },
{ id: "author_pass" as PlanId, name: "Author review pass", priceId: config.STRIPE_AUTHOR_PASS_PRICE, recurring: false, repositories: 1, storage: config.PAID_PASS_STORAGE, description: "One repository for 180 days. No automatic renewal." },
{ id: "conference" as PlanId, name: "Conference review package", priceId: config.STRIPE_CONFERENCE_PRICE, recurring: false, repositories: config.PAID_CONFERENCE_REPOSITORIES, storage: config.PAID_CONFERENCE_STORAGE, description: "A review window of up to 180 days. No automatic renewal." },
];
}
export function billingError(code: string, status = 400) { return new AnonymousError(code, { httpStatus: status }); }
export function billingOrigin() {
const url = new URL(config.BILLING_ORIGIN);
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash ||
(url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)))) throw new Error("Invalid BILLING_ORIGIN");
return url.origin;
}
export function requireBilling() {
if (!config.BILLING_ENABLED || !config.STRIPE_SECRET_KEY || !config.STRIPE_WEBHOOK_SECRET) throw billingError("billing_unavailable", 503);
billingOrigin();
}
+23
View File
@@ -0,0 +1,23 @@
import { randomUUID } from "crypto";
import { BillingDraft } from "./models";
import { billingError } from "./config";
const fields = ["sourceUrl", "terms", "repoId", "source", "options", "conference", "githubConnection"];
export function validDraftPath(path: unknown): path is string {
return typeof path === "string" && /^\/anonymize(?:\/[A-Za-z0-9_-]{1,200})?$/.test(path);
}
export async function saveBillingDraft(owner: string, path: unknown, values: unknown) {
if (!validDraftPath(path) || !values || typeof values !== "object" || Array.isArray(values) ||
Buffer.byteLength(JSON.stringify(values)) > 64 * 1024) throw billingError("billing_invalid_draft");
const selected = Object.fromEntries(fields.filter(key => Object.prototype.hasOwnProperty.call(values, key))
.map(key => [key, (values as Record<string, unknown>)[key]]));
// A draft is private user input, never an authorization or an entitlement.
return BillingDraft.create({ _id: randomUUID(), owner, path, values: selected,
expiresAt: new Date(Date.now() + 7 * 86400_000) });
}
export async function getBillingDraft(owner: string, id: unknown) {
if (typeof id !== "string" || !/^[a-f0-9-]{36}$/.test(id)) throw billingError("billing_draft_not_found", 404);
const draft = await BillingDraft.findOne({ _id: id, owner, expiresAt: { $gt: new Date() } }).lean();
if (!draft) throw billingError("billing_draft_not_found", 404);
return draft;
}
+55
View File
@@ -0,0 +1,55 @@
import Stripe = require("stripe");
import config from "../../config";
import { randomUUID } from "crypto";
import { BillingEvent, BillingAccount } from "./models";
import { processBillingEvent } from "./stripe";
import { createLogger } from "../logger";
const logger = createLogger("billing");
export function billingFailure(error: unknown) {
const e = error as { type?: string; code?: string; requestId?: string };
// Do not persist provider messages, request bodies, email addresses or keys.
return [e?.type, e?.code, e?.requestId].filter(v => typeof v === "string" && /^[a-zA-Z0-9_]+$/.test(v)).join(":") || "billing_processing_failed";
}
export async function receiveBillingEvent(event: Stripe.Event) {
const value = event.data.object as unknown as Record<string, unknown>;
// Retain only routing references, never the full Stripe customer/payment payload.
const parent = value.parent as { subscription_details?: { subscription?: unknown } } | undefined;
const ref = (v: unknown) => typeof v === "string" ? v : (v as { id?: string } | null)?.id;
const object = { id: value.id, parent: { subscription_details: { subscription: ref(parent?.subscription_details?.subscription) } }, payment_intent: ref(value.payment_intent),
charge: ref(value.charge), amount: value.amount, amount_refunded: value.amount_refunded, refunded: value.refunded, disputed: value.disputed };
const id = `${event.livemode ? "live" : "test"}:${event.account || "platform"}:${event.id}`;
await BillingEvent.updateOne({ _id: id }, { $setOnInsert: { eventId: event.id, type: event.type, object } }, { upsert: true });
}
export async function processBillingInbox(limit = 25, processEvent = processBillingEvent, now = new Date()) {
for (let i = 0; i < limit; i++) {
const token = randomUUID();
const namespace = /^(sk|rk)_live_/.test(config.STRIPE_SECRET_KEY) ? /^live:platform:/ : /^test:platform:/;
const event = await BillingEvent.findOneAndUpdate({ _id: namespace, $or: [
{ state: "pending", nextAttemptAt: { $lte: now } },
{ state: "processing", leaseUntil: { $lte: now } },
] }, { $set: { state: "processing", token, leaseUntil: new Date(now.getTime() + 300_000) }, $inc: { attempts: 1 } },
{ new: true, sort: { receivedAt: 1 } });
if (!event) break;
try {
await processEvent({ id: event.eventId, type: event.type, data: { object: event.object } } as Stripe.Event);
await BillingEvent.updateOne({ _id: event._id, token }, { $set: { state: "complete", completedAt: new Date() }, $unset: { token: 1, leaseUntil: 1, lastError: 1 } });
} catch (error) {
const dead = event.attempts >= 12;
const lastError = billingFailure(error);
await BillingEvent.updateOne({ _id: event._id, token }, { $set: { state: dead ? "failed" : "pending", lastError,
nextAttemptAt: new Date(now.getTime() + Math.min(3600_000, 1000 * 2 ** event.attempts)) }, $unset: { token: 1, leaseUntil: 1 } });
logger.error(dead ? "billing event requires replay" : "billing event retry scheduled", { eventId: event.eventId, type: event.type, attempts: event.attempts, code: lastError });
}
}
}
export async function billingHealth() {
const [pending, failed, oldest, unresolved, reconciliationFailed] = await Promise.all([
BillingEvent.countDocuments({ state: { $in: ["pending", "processing"] } }),
BillingEvent.countDocuments({ state: "failed" }),
BillingEvent.findOne({ state: { $in: ["pending", "processing"] } }).sort({ receivedAt: 1 }).select("receivedAt").lean(),
BillingAccount.countDocuments({ "checkout.key": { $exists: true }, "checkout.id": { $exists: false } }),
BillingAccount.countDocuments({ reconcileError: { $exists: true } }),
]);
return { pending, failed, oldestPendingSeconds: oldest?.receivedAt ? Math.floor((Date.now() - oldest.receivedAt.getTime()) / 1000) : 0, unresolved, reconciliationFailed };
}
+78
View File
@@ -0,0 +1,78 @@
import { reconcileBillingAccount } from "./reconciliation";
import { createLogger } from "../logger";
import config from "../../config";
import { BillingGrant } from "./models";
import { billingError } from "./config";
import { usableGrant } from "./quotas";
import Repo from "../model/anonymizedRepositories/anonymizedRepositories.model";
export const NEVER_EXPIRATION_GRACE_MS = 90 * 86400000;
async function authorGrants(owner: string) {
return BillingGrant.find({ scope: `user:${owner}`, planId: { $in: ["author_monthly", "author_annual"] } }).lean();
}
// This is an author benefit, independent of which scope pays the storage quota.
export async function neverExpirationDeadline(owner: string, existing?: Date) {
if (!config.BILLING_ENABLED) return undefined;
const grants = await authorGrants(owner);
const active = grants.filter(g => usableGrant(g));
if (!active.length) {
if (existing && existing > new Date()) return existing;
throw billingError("subscription_required_for_never_expiration", 402);
}
return new Date(Math.max(existing?.getTime() || 0, ...active.map(g => g.until!.getTime() + NEVER_EXPIRATION_GRACE_MS)));
}
// Run before ordinary expiration maintenance. Legacy never-expiring links have
// no billing deadline and are deliberately excluded.
export async function reconcileNeverExpiration(now = new Date(), reconcile = reconcileBillingAccount) {
if (!config.BILLING_ENABLED) return;
const cursor = Repo.find({ "options.expirationMode": "never", billingNeverExpiresUntil: { $exists: true },
status: { $nin: ["removed", "expired", "expiring", "removing"] } }).select("owner billingNeverExpiresUntil").cursor();
const deadlines = new Map<string, number>();
const verified = new Set<string>();
const deferred = new Set<string>();
for await (const repo of cursor) {
const owner = String(repo.owner);
if (!deadlines.has(owner)) {
const grants = await authorGrants(owner);
deadlines.set(owner, Math.max(0, ...grants.filter(g => !g.revoked && g.until)
.map(g => g.until!.getTime() + NEVER_EXPIRATION_GRACE_MS)));
}
const previous = repo.billingNeverExpiresUntil!;
let deadline = new Date(Math.max(previous.getTime(), deadlines.get(owner)!));
if (deadline <= now) {
if (deferred.has(owner)) continue;
try {
if (!verified.has(owner)) {
await reconcile(owner);
verified.add(owner);
const fresh = await authorGrants(owner);
deadlines.set(owner, Math.max(0, ...fresh.filter(g => !g.revoked && g.until).map(g => g.until!.getTime() + NEVER_EXPIRATION_GRACE_MS)));
}
deadline = new Date(Math.max(previous.getTime(), deadlines.get(owner)!));
} catch {
deferred.add(owner);
createLogger("billing").error("repository expiration deferred: billing verification failed", { owner });
continue;
}
}
const filter = { _id: repo._id, "options.expirationMode": "never", billingNeverExpiresUntil: previous };
if (deadline <= now) {
await Repo.updateOne(filter, { $set: { "options.expirationMode": "remove", "options.expirationDate": deadline },
$unset: { billingNeverExpiresUntil: 1 } });
} else if (deadline > previous) {
await Repo.updateOne(filter, { $set: { billingNeverExpiresUntil: deadline } });
}
}
}
export function validateBillingExpiration(options: { expirationMode?: string; expirationDate?: string | Date }) {
if (!config.BILLING_ENABLED || options.expirationMode === "never") return;
const date = options.expirationDate && new Date(options.expirationDate);
const maximum = new Date();
maximum.setFullYear(maximum.getFullYear() + 1);
maximum.setHours(23, 59, 59, 999);
if (!["remove", "redirect"].includes(options.expirationMode || "") || !date ||
!Number.isFinite(date.getTime()) || date > maximum) throw billingError("invalid_billing_expiration");
}
+36
View File
@@ -0,0 +1,36 @@
import * as schedule from "node-schedule";
import config from "../../config";
import { BillingEvent, BillingAccount } from "./models";
import { processBillingInbox, billingHealth, billingFailure } from "./events";
import { reconcileBillingAccounts } from "./reconciliation";
import { createLogger } from "../logger";
const logger = createLogger("billing");
export async function startBillingMaintenance() {
if (!config.BILLING_ENABLED) return;
await Promise.all([BillingEvent.createIndexes(), BillingAccount.createIndexes()]);
let eventsRunning = false;
let reconcileRunning = false;
const events = async () => {
if (eventsRunning) return;
eventsRunning = true;
try { await processBillingInbox(); }
catch (error) { logger.error("billing worker failed", { code: billingFailure(error) }); }
finally { eventsRunning = false; }
};
const reconcile = async () => {
if (reconcileRunning) return;
reconcileRunning = true;
try {
await reconcileBillingAccounts();
const health = await billingHealth();
if (health.failed || health.oldestPendingSeconds > 300 || health.unresolved || health.reconciliationFailed) logger.error("billing requires attention", health);
// Remove routing envelopes after 90 days; retain failures for investigation.
await BillingEvent.deleteMany({ state: "complete", completedAt: { $lt: new Date(Date.now() - 90 * 86400_000) } });
} catch (error) { logger.error("billing maintenance failed", { code: billingFailure(error) }); }
finally { reconcileRunning = false; }
};
schedule.scheduleJob("*/10 * * * * *", events);
schedule.scheduleJob("*/5 * * * *", reconcile);
void events();
void reconcile();
}
+46
View File
@@ -0,0 +1,46 @@
import { Schema, model } from "mongoose";
// Billing records are separate from public user/repository serialization.
export const BillingAccount = model("billingAccount", new Schema({
_id: String, customerId: String,
customerAttemptAt: Date,
reconciledAt: Date, reconcileAfter: { type: Date, default: () => new Date(0), index: true }, reconcileError: String,
checkout: { id: String, key: String, planId: String, target: String, expiresAt: Date, createdAt: Date, params: Schema.Types.Mixed },
}));
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,
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, createdAt: Date }],
}));
// A short lease serializes Stripe checkout creation across API instances.
export const BillingLock = model("billingLock", new Schema({ _id: String, token: String, until: Date }));
const eventSchema = new Schema({
_id: String, eventId: String, type: String, object: Schema.Types.Mixed,
state: { type: String, default: "pending" }, attempts: { type: Number, default: 0 },
receivedAt: { type: Date, default: Date.now }, nextAttemptAt: { type: Date, default: Date.now },
leaseUntil: Date, token: String, completedAt: Date, lastError: String,
});
eventSchema.index({ state: 1, nextAttemptAt: 1, leaseUntil: 1 });
export const BillingEvent = model("billingEvent", eventSchema);
export const BillingAudit = model("billingAudit", new Schema({
action: String, target: String, actor: String, reason: String, at: { type: Date, default: Date.now },
}));
// A reversal can arrive before its grant exists. Keep the payment-level decision.
export const BillingRevocation = model("billingRevocation", new Schema({
_id: String, subscriptionId: String, reason: String, reviewed: { type: Boolean, default: false },
createdAt: { type: Date, default: Date.now },
}));
// Short-lived, private task context. Explicit expiry checks also cover TTL sweep delays.
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 },
}));
+58
View File
@@ -0,0 +1,58 @@
import ConferenceModel from "../model/conference/conferences.model";
import config from "../../config";
import { BillingGrant, BillingQuota } from "./models";
import { billingError, billingPlans } from "./config";
import RepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model";
export function usableGrant(grant: { status?: string | null; until?: Date | null; revoked?: boolean | null }, now = new Date()) {
return !grant.revoked && !!grant.until && grant.until > now && ["active", "paid", "past_due"].includes(grant.status || "");
}
export async function allowance(owner: string, repoId?: string, conference?: string) {
const scopes = [`user:${owner}`, ...(repoId ? [`repo:${repoId}`] : []), ...(conference ? [`conference:${conference}`] : [])];
const grants = await BillingGrant.find({ scope: { $in: scopes }, until: { $gt: new Date() }, revoked: false }).lean();
// A conference pays before the author. There is no double charging of allowance.
for (const scope of scopes.reverse()) {
const grant = grants.find(g => g.scope === scope && usableGrant(g));
const plan = grant && billingPlans().find(p => p.id === grant.planId);
if (plan) return { scope, repositories: plan.repositories, storage: plan.storage, plan: plan.id };
}
return { scope: `user:${owner}`, repositories: config.FREE_AUTHOR_REPOSITORIES, storage: config.FREE_AUTHOR_STORAGE, plan: "free" };
}
export function allocationExpression(repoId: string) {
return { $filter: { input: "$allocations", as: "allocation", cond: { $ne: ["$$allocation.repoId", { $literal: repoId }] } } };
}
export async function reserveRepository(owner: string, repoId: string, bytes: number, conference?: string) {
if (!config.BILLING_ENABLED) return;
if (!Number.isSafeInteger(bytes) || bytes < 0) throw billingError("billing_invalid_repository_size");
const limits = await allowance(owner, repoId, conference);
// Seed existing repositories once. Legacy review links are preserved, but they
// count toward the owner's capacity when creating new work.
if (!await BillingQuota.exists({ _id: limits.scope })) {
const existing = await RepositoryModel.find(limits.scope.startsWith("user:") ? { owner, status: { $nin: ["removed", "expired"] } }
: limits.scope.startsWith("conference:") ? { conference, status: { $nin: ["removed", "expired"] } } : { repoId, status: { $nin: ["removed", "expired"] } })
.select("repoId size").lean();
try { await BillingQuota.create({ _id: limits.scope, allocations: existing.map(r => ({ repoId: r.repoId, 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) } } } });
const others = allocationExpression(repoId);
// Never shrink a reservation during refresh. Concurrent generations and
// failed builds cannot free bytes still needed by the published generation.
const reservedBytes = { $max: [bytes, { $sum: { $map: { input: { $filter: { input: "$allocations", as: "a", cond: { $eq: ["$$a.repoId", { $literal: repoId }] } } }, as: "a", in: "$$a.bytes" } } }] };
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, createdAt: new Date() }]] } } }]);
if (!result.matchedCount) throw billingError("quota_exceeded", 402);
}
export async function assertConferenceAdmission(owner: string, repoId: string, conference?: string) {
if (!conference || !config.BILLING_ENABLED) return;
const conf = await ConferenceModel.findOne({ conferenceID: conference }).lean();
if (!conf || conf.status !== "ready" || conf.startDate > new Date() || conf.endDate <= new Date()) throw billingError("conf_not_activated");
if (conf.plan.planID === "conference" && (await allowance(owner, repoId, conference)).scope !== `conference:${conference}`) throw billingError("conference_payment_required", 402);
}
+59
View File
@@ -0,0 +1,59 @@
import { BillingAccount } from "./models";
import Stripe = require("stripe");
import { fulfillCheckout, syncSubscription, stripeClient, processBillingEvent, withBillingLock } from "./stripe";
import { billingFailure } from "./events";
import { createLogger } from "../logger";
const logger = createLogger("billing");
// Paginate provider history so a missing local grant is discoverable after restore
// or lost delivery. Only paid customer accounts are scanned, once per day.
export async function reconcileBillingAccount(owner: string, stripe = stripeClient()) {
return withBillingLock(`reconcile:${owner}`, async () => {
const account = await BillingAccount.findById(owner);
if (!account?.customerId) throw new Error("billing_customer_missing");
for await (const sub of stripe.subscriptions.list({ customer: account.customerId, status: "all", limit: 100 })) {
await syncSubscription(sub.id, stripe);
}
for await (const session of stripe.checkout.sessions.list({ customer: account.customerId, limit: 100 })) {
if (session.metadata?.application !== "anonymous-github" || session.metadata.owner !== owner) continue;
if (account.checkout?.key && !account.checkout.id && session.metadata.attempt === account.checkout.key) {
await BillingAccount.updateOne({ _id: owner, "checkout.key": account.checkout.key }, { $set: { "checkout.id": session.id } });
}
if (session.status === "complete") await fulfillCheckout(session.id, stripe);
}
// Refunds on older invoices must also survive lost webhook deliveries.
for await (const charge of stripe.charges.list({ customer: account.customerId, limit: 100 })) {
if (charge.refunded || charge.disputed) await processBillingEvent({ type: "charge.refunded", data: { object: { ...charge, refunded: true } } } as Stripe.Event, stripe);
}
await BillingAccount.updateOne({ _id: owner }, { $set: { reconciledAt: new Date(), reconcileAfter: new Date(Date.now() + 86400_000) }, $unset: { reconcileError: 1 } });
});
}
export async function reconcileBillingAccounts(limit = 10) {
const accounts = await BillingAccount.find({ customerId: { $exists: true }, $or: [
{ reconcileAfter: { $lte: new Date() } }, { reconcileAfter: { $exists: false } },
] }).sort({ reconcileAfter: 1 }).limit(limit).lean();
for (const account of accounts) {
try { await reconcileBillingAccount(account._id!); }
catch (error) {
const code = billingFailure(error);
await BillingAccount.updateOne({ _id: account._id }, { $set: { reconcileAfter: new Date(Date.now() + 900_000), reconcileError: code } });
logger.error("billing reconciliation deferred", { owner: account._id, code });
}
}
}
export async function discoverBillingCustomers(stripe = stripeClient()) {
const Users = (await import("../model/users/users.model")).default;
for await (const customer of stripe.customers.list({ limit: 100 })) {
const owner = customer.metadata.owner;
if (customer.metadata.application !== "anonymous-github" || !owner || !/^[a-f0-9]{24}$/i.test(owner)) continue;
if (!await Users.exists({ _id: owner })) continue;
const current = await BillingAccount.findById(owner);
if (current?.customerId && current.customerId !== customer.id) {
logger.error("duplicate Stripe customer requires review", { owner });
continue;
}
await BillingAccount.updateOne({ _id: owner, $or: [{ customerId: { $exists: false } }, { customerId: customer.id }] },
{ $set: { customerId: customer.id, reconcileAfter: new Date(0) } }, { upsert: true });
}
}
+251
View File
@@ -0,0 +1,251 @@
import Stripe = require("stripe");
import { randomUUID } from "crypto";
import config from "../../config";
import { BillingAccount, BillingGrant, BillingLock, BillingRevocation } from "./models";
import { billingError, billingOrigin, billingPlans, requireBilling } from "./config";
import ConferenceModel from "../model/conference/conferences.model";
import RepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model";
export function stripeClient() { requireBilling(); return new Stripe(config.STRIPE_SECRET_KEY, { maxNetworkRetries: 2, timeout: 10_000 }); }
const objectId = (value: string | { id: string } | null) => typeof value === "string" ? value : value?.id;
export async function withBillingLock<T>(id: string, work: () => Promise<T>): Promise<T> {
const token = randomUUID();
try { await BillingLock.updateOne({ _id: id, until: { $lt: new Date() } }, { $set: { token, until: new Date(Date.now() + 180_000) } }, { upsert: true }); }
catch (error) { if ((error as { code?: number }).code === 11000) throw billingError("billing_busy", 409); throw error; }
const heartbeat = setInterval(() => {
BillingLock.updateOne({ _id: id, token }, { $set: { until: new Date(Date.now() + 180_000) } }).catch(() => undefined);
}, 30_000);
heartbeat.unref();
try { return await work(); }
finally { clearInterval(heartbeat); await BillingLock.deleteOne({ _id: id, token }); }
}
export async function syncSubscription(id: string, stripe = stripeClient()) {
// Compare-and-swap prevents a slow, earlier fetch from overwriting a newer one.
for (let retry = 0; retry < 4; retry++) {
const previous = await BillingGrant.findById(id).lean();
const sub = await stripe.subscriptions.retrieve(id, { expand: ["latest_invoice"] });
const owner = sub.metadata.owner;
if (!owner || sub.metadata.application !== "anonymous-github") return;
const account = await BillingAccount.findById(owner).lean();
if (!account || account.customerId !== objectId(sub.customer)) throw billingError("billing_customer_mismatch");
const priceId = sub.items.data[0]?.price.id;
const plan = billingPlans().find(p => p.recurring && p.priceId === priceId);
const invoice = sub.latest_invoice as Stripe.Invoice | null;
const paid = !!plan && sub.items.data.length === 1 && sub.status === "active" && invoice?.status === "paid";
const periodEnd = sub.items.data[0]?.current_period_end;
let paidThrough = paid && periodEnd ? new Date(periodEnd * 1000) : previous?.until || new Date(0);
// A missed paid renewal followed by a failed invoice must be recoverable.
// Only matching subscription-item service periods count, not arbitrary invoices.
if (!paid && plan && sub.items.data.length === 1 && stripe.invoices?.list) {
for await (const historical of stripe.invoices.list({ subscription: id, status: "paid", limit: 100 })) {
for await (const line of stripe.invoices.listLineItems(historical.id, { limit: 100 })) {
if (!line.parent?.subscription_item_details?.proration && line.amount >= 0 &&
line.parent?.subscription_item_details?.subscription_item === sub.items.data[0].id &&
line.period.end * 1000 > paidThrough.getTime()) paidThrough = new Date(line.period.end * 1000);
}
}
}
const values = { owner, scope: `user:${owner}`, planId: plan?.id || "unknown", customerId: account.customerId,
subscriptionId: id, status: sub.status, cancelAtPeriodEnd: sub.cancel_at_period_end || !!sub.cancel_at,
until: paidThrough,
};
if (!previous) {
try { await BillingGrant.create({ _id: id, ...values }); return; }
catch (error) { if ((error as { code?: number }).code !== 11000) throw error; }
} else {
const result = await BillingGrant.updateOne({ _id: id, revision: previous.revision }, { $set: values, $inc: { revision: 1 } });
if (result.matchedCount) return;
}
}
throw billingError("billing_busy", 409);
}
export async function fulfillCheckout(id: string, stripe = stripeClient()) {
const session = await stripe.checkout.sessions.retrieve(id, { expand: ["line_items", "payment_intent.latest_charge"] });
if (session.metadata?.application !== "anonymous-github") return;
const { owner, planId, target } = session.metadata;
const account = await BillingAccount.findById(owner).lean();
if (!account || account.customerId !== objectId(session.customer)) throw billingError("billing_customer_mismatch");
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");
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;
const revoked = !!charge && (charge.refunded || charge.disputed);
const scope = plan.id === "conference" ? `conference:${target}` : `repo:${target}`;
let until = new Date(session.created * 1000 + 180 * 86400_000);
if (plan.id === "conference") {
const conference = await ConferenceModel.findOne({ conferenceID: target });
// Historical sessions can outlive a deliberately deleted conference.
if (!conference) return;
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 });
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 } });
if (plan.id === "conference" && (!revoked || reversal?.reviewed)) {
const grant = await BillingGrant.findById(id).lean();
if (!grant?.revoked) await ConferenceModel.updateOne({ conferenceID: target, status: "pending_payment", "plan.planID": "conference" }, { $set: { status: "ready" } });
}
}
export async function processBillingEvent(event: Stripe.Event, stripe = stripeClient()) {
if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") {
await fulfillCheckout(event.data.object.id, stripe);
} else if (event.type.startsWith("customer.subscription.")) {
await syncSubscription((event.data.object as Stripe.Subscription).id, stripe);
} else if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
const id = objectId(invoice.parent?.subscription_details?.subscription || null);
if (id) await syncSubscription(id, stripe);
} else if (event.type === "charge.refunded" || event.type === "charge.dispute.created") {
const charge = event.type === "charge.refunded" ? event.data.object as Stripe.Charge
: await stripe.charges.retrieve(objectId((event.data.object as Stripe.Dispute).charge)!);
// Partial goodwill refunds preserve access. Full refunds and disputes revoke.
if (event.type === "charge.refunded" && charge.refunded === false) return;
const intent = objectId(charge.payment_intent);
if (!intent) return;
const reason = event.type === "charge.dispute.created" || charge.disputed ? "dispute" : "full_refund";
await BillingRevocation.updateOne({ _id: intent }, { $setOnInsert: { reason } }, { upsert: true });
// A dispute escalates a reviewed refund. An older refund replay must not
// undo the operator's review of that dispute.
if (reason === "dispute") await BillingRevocation.updateOne({ _id: intent, reason: "full_refund" }, { $set: { reason, reviewed: false } });
const reversal = await BillingRevocation.findById(intent).lean();
if (reversal?.reviewed) return;
const revocation = { revoked: true, revocationReason: reason, revokedAt: new Date() };
await BillingGrant.updateMany({ paymentIntentId: intent }, { $set: revocation, $inc: { revision: 1 } });
// Resolve the affected subscription, not every subscription owned by this payer.
let after: string | undefined;
do {
const payments = await stripe.invoicePayments.list({ payment: { type: "payment_intent", payment_intent: intent }, limit: 100, ...(after ? { starting_after: after } : {}) });
for (const payment of payments.data) {
const invoice = await stripe.invoices.retrieve(objectId(payment.invoice)!);
const subscriptionId = objectId(invoice.parent?.subscription_details?.subscription || null);
if (subscriptionId) {
await BillingRevocation.updateOne({ _id: intent }, { $set: { subscriptionId } });
await BillingGrant.updateOne({ _id: subscriptionId }, { $set: revocation, $inc: { revision: 1 } }, { upsert: true });
}
}
after = payments.has_more ? payments.data.at(-1)?.id : undefined;
} while (after);
}
}
export async function createCheckout(owner: string, planId: string, target = "", stripe = stripeClient(), returnPath?: string) {
if (!config.BILLING_CHECKOUT_ENABLED) throw billingError("billing_checkout_disabled", 503);
const plan = billingPlans().find(p => p.id === planId && p.priceId);
if (!plan) throw billingError("billing_plan_unavailable");
if (plan.id === "conference") {
const conf = await ConferenceModel.findOne({ conferenceID: target, owners: owner });
if (!conf || conf.plan.planID !== "conference" || conf.status !== "pending_payment") throw billingError("billing_invalid_conference");
if (conf.endDate <= new Date() || conf.endDate.getTime() - conf.startDate.getTime() > 180 * 86400_000) throw billingError("billing_invalid_review_window");
} else if (plan.id === "author_pass") {
const repo = await RepositoryModel.exists({ repoId: target, owner, status: { $nin: ["removed", "expired"] } });
if (!repo) throw billingError("repo_not_found", 404);
} else target = "";
const destination = returnPath || (plan.id === "conference" ? `/conference/${encodeURIComponent(target)}` : "/profile");
if (returnPath && !/^\/anonymize(?:\/[A-Za-z0-9_-]{1,200})?\?billingDraft=[a-f0-9-]{36}$/.test(returnPath)) throw billingError("billing_invalid_draft");
const outcomeUrl = (outcome: string) => `${billingOrigin()}${destination}${destination.includes("?") ? "&" : "?"}billing=${outcome}`;
return withBillingLock(`checkout:${owner}`, async () => {
let account = await BillingAccount.findById(owner);
if (!account) account = await BillingAccount.create({ _id: owner });
if (!account.customerId) {
if (account.customerAttemptAt && Date.now() - account.customerAttemptAt.getTime() >= 23 * 3600_000) {
// A customer creation timeout must not cause a duplicate after key pruning.
throw billingError("billing_recovery_required", 409);
}
account.customerAttemptAt ||= new Date();
await account.save();
const customer = await stripe.customers.create({ metadata: { application: "anonymous-github", owner } }, { idempotencyKey: `customer:${owner}` });
account.customerId = customer.id;
await account.save();
}
if (account.checkout?.key && !account.checkout.id &&
(!account.checkout.createdAt || Date.now() - account.checkout.createdAt.getTime() >= 23 * 3600_000)) {
for await (const candidate of stripe.checkout.sessions.list({ customer: account.customerId, limit: 100 })) {
if (candidate.metadata?.attempt === account.checkout.key && candidate.metadata.owner === owner) {
account.checkout.id = candidate.id;
await account.save();
break;
}
}
if (!account.checkout.id) {
// After the provider key window, an expired immutable request can no
// longer create a session. Legacy attempts without expiry need review.
if (account.checkout.params?.expires_at && account.checkout.createdAt &&
Date.now() - account.checkout.createdAt.getTime() > 24 * 3600_000 &&
account.checkout.params.expires_at * 1000 < Date.now()) {
account.checkout = undefined;
await account.save();
} else throw billingError("billing_recovery_required", 409);
}
}
if (account.checkout?.params?.success_url && !account.checkout.id && account.checkout.params.success_url !== outcomeUrl("success")) throw billingError("billing_checkout_in_progress", 409);
if (account.checkout?.id) {
const existing = await stripe.checkout.sessions.retrieve(account.checkout.id);
if (existing.status === "open") {
if (account.checkout.planId !== planId || account.checkout.target !== target) throw billingError("billing_checkout_in_progress", 409);
if (account.checkout.params?.success_url && account.checkout.params.success_url !== outcomeUrl("success")) throw billingError("billing_checkout_in_progress", 409);
return existing.url;
}
if (existing.status === "complete") await fulfillCheckout(existing.id, stripe);
}
const price = await stripe.prices.retrieve(plan.priceId);
const interval = plan.id === "author_monthly" ? "month" : "year";
if (!price.active || price.currency !== "eur" || price.unit_amount == null || price.unit_amount <= 0 ||
(plan.recurring ? price.recurring?.interval !== interval || price.recurring?.interval_count !== 1 : !!price.recurring)) throw billingError("billing_price_misconfigured", 503);
if (plan.recurring) {
let after: string | undefined;
do {
const subscriptions = await stripe.subscriptions.list({ customer: account.customerId, status: "all", limit: 100, ...(after ? { starting_after: after } : {}) });
if (subscriptions.data.some(s => !["canceled", "incomplete_expired"].includes(s.status))) throw billingError("billing_subscription_exists", 409);
after = subscriptions.has_more ? subscriptions.data.at(-1)?.id : undefined;
} while (after);
} else if (await BillingGrant.exists({ scope: `${plan.id === "conference" ? "conference" : "repo"}:${target}`, revoked: false, until: { $gt: new Date() } })) throw billingError("billing_already_paid", 409);
// Persist the idempotency key before sending a request. A timeout can safely retry it.
if (!account.checkout?.key || account.checkout.id) {
account.checkout = { key: randomUUID(), planId, target, createdAt: new Date(), expiresAt: new Date(Date.now() + 1860_000) };
await account.save();
}
if (account.checkout.planId !== planId || account.checkout.target !== target) throw billingError("billing_checkout_in_progress", 409);
const metadata = { application: "anonymous-github", owner, planId, target, attempt: account.checkout.key! };
const params: Stripe.Checkout.SessionCreateParams = { customer: account.customerId, expires_at: Math.floor(Date.now() / 1000) + 1860,
mode: plan.recurring ? "subscription" : "payment", line_items: [{ price: plan.priceId, quantity: 1 }],
allowed_payment_method_types: ["card"], metadata,
...(plan.recurring ? { subscription_data: { metadata } } : { payment_intent_data: { metadata }, invoice_creation: { enabled: true } }),
billing_address_collection: "required", tax_id_collection: { enabled: true }, customer_update: { address: "auto", name: "auto" },
automatic_tax: { enabled: config.BILLING_AUTOMATIC_TAX },
success_url: outcomeUrl("success"), cancel_url: outcomeUrl("cancelled"),
};
// Persist the exact parameters: configuration changes cannot alter a retry.
if (!account.checkout.params) {
account.checkout.params = params;
await account.save();
}
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();
return session.url;
});
}
export async function cancelAccountBilling(owner: string) {
const account = await BillingAccount.findById(owner).lean();
if (!account?.customerId) return;
if (account.checkout?.key && !account.checkout.id) throw billingError("billing_checkout_in_progress", 409);
await withBillingLock(`checkout:${owner}`, async () => {
const account = await BillingAccount.findById(owner).lean();
if (!account?.customerId) return;
if (account.checkout?.key && !account.checkout.id) throw billingError("billing_checkout_in_progress", 409);
const stripe = stripeClient();
if (account.checkout?.id) {
const session = await stripe.checkout.sessions.retrieve(account.checkout.id);
if (session.status === "open") await stripe.checkout.sessions.expire(session.id);
}
for await (const subscription of stripe.subscriptions.list({ customer: account.customerId, status: "all", limit: 100 })) {
if (!["canceled", "incomplete_expired"].includes(subscription.status)) await stripe.subscriptions.cancel(subscription.id);
}
await BillingGrant.updateMany({ owner, subscriptionId: { $exists: true } }, { $set: { status: "canceled" }, $inc: { revision: 1 } });
});
}
@@ -2,6 +2,8 @@ import { repositoryAccessSchema } from "../repository-access.schema";
import { Schema } from "mongoose";
const AnonymizedRepositorySchema = new Schema({
billingManaged: Boolean,
billingNeverExpiresUntil: Date,
repoId: {
type: String,
index: { unique: true, collation: { locale: "en", strength: 2 } },
@@ -3,6 +3,8 @@ import { Document, Model } from "mongoose";
import { RepositoryStatus } from "../../types";
export interface IAnonymizedRepository {
billingManaged?: boolean;
billingNeverExpiresUntil?: Date;
repoId: string;
status?: RepositoryStatus;
statusMessage?: string;
+1 -1
View File
@@ -17,6 +17,6 @@ export enum RepositoryStatus {
ERROR = "error",
}
export type ConferenceStatus = "ready" | "expired" | "removed";
export type ConferenceStatus = "ready" | "expired" | "removed" | "pending_payment";
export type SourceStatus = "available" | "unavailable";
+43
View File
@@ -0,0 +1,43 @@
import "dotenv/config";
import mongoose from "mongoose";
import config from "../config";
import { BillingAccount, BillingEvent, BillingAudit, BillingRevocation, BillingGrant } from "../core/billing/models";
import { billingHealth, processBillingInbox } from "../core/billing/events";
import { reconcileBillingAccount, discoverBillingCustomers } from "../core/billing/reconciliation";
async function main() {
const [command, target, reason] = process.argv.slice(2);
if (!["health", "inspect", "replay", "reconcile", "discover", "drain", "reinstate"].includes(command)) throw new Error("invalid_command");
const mutations = ["replay", "reconcile", "discover", "drain", "reinstate"].includes(command);
if (mutations && (!process.env.BILLING_OPERATOR || !reason)) throw new Error("operator_and_reason_required");
const uri = config.MONGODB_URI || `mongodb://${config.DB_USERNAME}:${config.DB_PASSWORD}@${config.DB_HOSTNAME}:27017/production`;
await mongoose.connect(uri, { autoIndex: false, ...(config.MONGODB_URI ? {} : { authSource: "admin" }) });
if (mutations) await BillingAudit.create({ action: command, target, reason, actor: process.env.BILLING_OPERATOR });
if (command === "reinstate") {
const reversal = await BillingRevocation.findById(target);
if (!reversal) throw new Error("payment_reversal_missing");
reversal.reviewed = true;
await reversal.save();
if (reversal.subscriptionId && await BillingRevocation.exists({ subscriptionId: reversal.subscriptionId, reviewed: false })) {
console.log("This payment is reviewed; other unresolved reversals still suspend the subscription.");
return;
}
const affected = reversal.subscriptionId ? { _id: reversal.subscriptionId } : { paymentIntentId: target };
await BillingGrant.updateMany(affected, { $set: { revoked: false }, $unset: { revocationReason: 1, revokedAt: 1 }, $inc: { revision: 1 } });
console.log("Reinstatement recorded. Reconcile the owner to verify current paid time.");
}
if (command === "health") console.log(JSON.stringify(await billingHealth(), null, 2));
if (command === "inspect") console.log(JSON.stringify(await BillingAccount.findById(target).select("customerId reconciledAt reconcileError checkout.id checkout.createdAt"), null, 2));
if (command === "reconcile") await reconcileBillingAccount(target);
if (command === "discover") await discoverBillingCustomers();
if (command === "drain") await processBillingInbox(100);
if (command === "replay") {
const result = await BillingEvent.updateOne({ _id: target, state: { $in: ["failed", "complete", "pending"] } },
{ $set: { state: "pending", attempts: 0, nextAttemptAt: new Date() }, $unset: { completedAt: 1, lastError: 1 } });
if (!result.matchedCount) throw new Error("event_not_replayable");
}
}
main().catch(() => {
console.error("Billing operation failed. Check the command, target, BILLING_OPERATOR, reason, and server configuration. No credentials were logged.");
process.exitCode = 1;
}).finally(() => mongoose.disconnect());
+5
View File
@@ -1,3 +1,5 @@
import { startBillingMaintenance } from "../core/billing/maintenance";
import billing, { billingWebhook } from "./routes/billing";
import { githubAppRouter, githubAppWebhook } from "./routes/github-app";
import { config as dotenv } from "dotenv";
dotenv();
@@ -107,6 +109,7 @@ export default async function start() {
startPerformanceMonitoring("api", getAnonymizationPoolStats);
app.set("query parser", "extended");
app.use("/github/app/webhook", githubAppWebhook);
app.use("/api/billing/webhook", billingWebhook);
app.use(express.json());
// Preserve the empty body used by API validation when no JSON was parsed.
app.use((req, _res, next) => {
@@ -210,6 +213,7 @@ export default async function start() {
const apiRouter = express.Router();
app.use("/api", rate, apiRouter);
apiRouter.use("/billing", billing);
apiRouter.use("/admin", router.admin);
apiRouter.use("/options", router.option);
apiRouter.use("/conferences", router.conference);
@@ -300,6 +304,7 @@ export default async function start() {
dailyStatsSnapshot();
await connect();
await startBillingMaintenance();
await startTemporaryStorageMaintenance();
app.listen(config.PORT);
logger.info("server started", { port: config.PORT });
+114
View File
@@ -0,0 +1,114 @@
import * as express from "express";
import { randomBytes } from "crypto";
import { ensureAuthenticated } from "./connection";
import { getUser, handleError } from "./route-utils";
import config from "../../config";
import { BillingAccount, BillingGrant } from "../../core/billing/models";
import { billingOrigin, billingPlans, requireBilling } from "../../core/billing/config";
import { createCheckout, stripeClient, fulfillCheckout, syncSubscription } from "../../core/billing/stripe";
import { saveBillingDraft, getBillingDraft } from "../../core/billing/drafts";
import { allowance } from "../../core/billing/quotas";
import { receiveBillingEvent, billingFailure } from "../../core/billing/events";
import { createLogger } from "../../core/logger";
import { rateLimit } from "express-rate-limit";
declare module "express-session" { interface SessionData { billingCSRF?: string } }
export const billingWebhook = express.Router();
billingWebhook.post("/", express.raw({ type: "application/json", limit: "256kb" }), async (req, res) => {
if (!config.BILLING_ENABLED) { res.sendStatus(404); return; }
let event;
try { event = stripeClient().webhooks.constructEvent(req.body, req.headers["stripe-signature"] as string, config.STRIPE_WEBHOOK_SECRET); }
catch { res.status(400).json({ error: "invalid_billing_signature" }); return; }
if (event.account || (typeof event.livemode === "boolean" && event.livemode !== /^(sk|rk)_live_/.test(config.STRIPE_SECRET_KEY))) {
res.status(400).json({ error: "billing_environment_mismatch" }); return;
}
try { await receiveBillingEvent(event); res.json({ received: true }); }
catch (error) { createLogger("billing").error("billing event storage failed", { eventId: event.id, code: billingFailure(error) }); res.status(500).json({ error: "billing_webhook_failed" }); }
});
const priceCache = new Map<string, { expires: number; value: Awaited<ReturnType<ReturnType<typeof stripeClient>["prices"]["retrieve"]>> }>();
async function displayedPrice(id: string) {
const cached = priceCache.get(id);
if (cached && cached.expires > Date.now()) return cached.value;
const value = await stripeClient().prices.retrieve(id);
priceCache.set(id, { expires: Date.now() + 60_000, value });
return value;
}
const router = express.Router();
router.use(ensureAuthenticated);
router.get("/", async (req, res) => {
try {
const user = await getUser(req);
req.session.billingCSRF ||= randomBytes(32).toString("hex");
await new Promise<void>((resolve, reject) => req.session.save(error => error ? reject(error) : resolve()));
const account = await BillingAccount.findById(user.id).lean();
const grants = await BillingGrant.find({ owner: user.id }).select("planId scope status until cancelAtPeriodEnd revoked").lean();
const plans = config.BILLING_ENABLED ? await Promise.all(billingPlans().filter(p => p.priceId).map(async p => {
const price = await displayedPrice(p.priceId);
return { id: p.id, name: p.name, description: p.description, repositories: p.repositories, storage: p.storage,
amount: price.unit_amount, currency: price.currency, interval: price.recurring?.interval, intervalCount: price.recurring?.interval_count };
})) : [];
res.set("Cache-Control", "no-store").json({ enabled: config.BILLING_ENABLED, csrf: req.session.billingCSRF, plans, checkoutEnabled: config.BILLING_CHECKOUT_ENABLED,
grants: grants.map(g => ({ planId: g.planId, scope: g.scope, status: g.revoked ? "revoked" : g.status, until: g.until, cancelAtPeriodEnd: g.cancelAtPeriodEnd })),
portal: !!account?.customerId, allowance: config.BILLING_ENABLED ? await allowance(user.id) : null });
} catch (error) { handleError(error, res, req); }
});
router.get("/draft/:id", async (req, res) => {
try {
const user = await getUser(req);
const draft = await getBillingDraft(user.id, req.params.id);
res.set("Cache-Control", "no-store").json({ id: draft._id, path: draft.path, values: draft.values });
} catch (error) { handleError(error, res, req); }
});
router.use((req, res, next) => {
if (!req.session.billingCSRF || req.headers["x-csrf-token"] !== req.session.billingCSRF) { res.status(403).json({ error: "invalid_auth_state" }); return; }
next();
});
router.use(rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: "draft-8", legacyHeaders: false }));
router.post("/draft", async (req, res) => {
try {
requireBilling();
const user = await getUser(req);
const draft = await saveBillingDraft(user.id, req.body.path, req.body.values);
res.set("Cache-Control", "no-store").json({ id: draft._id });
} catch (error) { handleError(error, res, req); }
});
router.post("/checkout", async (req, res) => {
try {
requireBilling();
const user = await getUser(req);
if (typeof req.body.planId !== "string" || (req.body.target != null && typeof req.body.target !== "string")) { res.sendStatus(400); return; }
let returnPath: string | undefined;
if (req.body.draftId != null) {
const draft = await getBillingDraft(user.id, req.body.draftId);
returnPath = `${draft.path}?billingDraft=${draft._id}`;
}
res.json({ url: await createCheckout(user.id, req.body.planId, req.body.target, undefined, returnPath) });
} catch (error) { handleError(error, res, req); }
});
router.post("/portal", async (req, res) => {
try {
const user = await getUser(req);
const account = await BillingAccount.findById(user.id).lean();
if (!account?.customerId) { res.status(404).json({ error: "billing_customer_not_found" }); return; }
const portal = await stripeClient().billingPortal.sessions.create({ customer: account.customerId, return_url: `${billingOrigin()}/profile?billing=portal` });
res.json({ url: portal.url });
} catch (error) { handleError(error, res, req); }
});
router.post("/refresh", async (req, res) => {
try {
requireBilling();
const user = await getUser(req);
const account = await BillingAccount.findById(user.id).lean();
let pending = !!account?.checkout?.key && !account.checkout.id;
if (account?.checkout?.id) {
await fulfillCheckout(account.checkout.id);
const checkout = await stripeClient().checkout.sessions.retrieve(account.checkout.id);
pending = checkout.status !== "expired" && checkout.payment_status === "unpaid";
}
const grants = await BillingGrant.find({ owner: user.id, subscriptionId: { $exists: true } }).lean();
for (const grant of grants) await syncSubscription(grant.subscriptionId!);
res.json({ ok: true, pending });
} catch (error) { handleError(error, res, req); }
});
export default router;
+21 -34
View File
@@ -1,3 +1,5 @@
import config from "../../config";
import { billingError } from "../../core/billing/config";
import * as express from "express";
import AnonymousError from "../../core/AnonymousError";
import Conference from "../../core/Conference";
@@ -12,37 +14,14 @@ const router = express.Router();
router.use(ensureAuthenticated);
const plans = [
{
id: "free_conference",
name: "Free",
pricePerRepo: 0,
storagePerRepo: -1,
description: `<li><strong>Quota is deducted from user account</strong></li>
<li>No-download</li>
<li>Conference dashboard</li>`,
},
{
id: "premium_conference",
name: "Premium",
pricePerRepo: 0.5,
storagePerRepo: 500 * 8 * 1024,
description: `<li>500Mo / repository</li>
<li>Repository download</li>
<li>Conference dashboard</li>`,
},
{
id: "unlimited_conference",
name: "Unlimited",
pricePerRepo: 3,
storagePerRepo: 0,
description: `<li><strong>Unlimited</strong> repository size</li>
<li>Repository download</li>
<li>Conference dashboard</li>`,
},
{ id: "free_conference", name: "Free", pricePerRepo: 0, storagePerRepo: -1,
description: "<li>Uses each author's allowance</li><li>Conference dashboard</li>" },
{ id: "conference", name: "Review package", pricePerRepo: 0, storagePerRepo: config.PAID_CONFERENCE_STORAGE,
description: `<li>Up to ${config.PAID_CONFERENCE_REPOSITORIES} repositories</li><li>Up to 180 days</li><li>Prepaid at checkout</li>` },
];
router.get("/plans", async (req: express.Request, res: express.Response) => {
res.json(plans);
res.json(config.BILLING_ENABLED && config.STRIPE_CONFERENCE_PRICE ? plans : plans.filter(p => p.id === "free_conference"));
});
router.get("/", async (req: express.Request, res: express.Response) => {
@@ -99,11 +78,13 @@ function validateConferenceForm(conf: any) {
object: conf,
httpStatus: 400,
});
if (plans.filter((p) => p.id == conf.plan.planID).length != 1)
if (!conf.plan || plans.filter((p) => p.id == conf.plan.planID).length != 1)
throw new AnonymousError("invalid_plan", {
object: conf,
httpStatus: 400,
});
if (!Number.isFinite(new Date(conf.startDate).getTime()) || !Number.isFinite(new Date(conf.endDate).getTime())) throw billingError("conf_start_date_invalid");
if (conf.plan.planID === "conference" && (!config.BILLING_ENABLED || !config.STRIPE_CONFERENCE_PRICE || new Date(conf.endDate).getTime() - new Date(conf.startDate).getTime() > 180 * 86400_000)) throw billingError("billing_invalid_review_window");
const plan = plans.filter((p) => p.id == conf.plan.planID)[0];
if (plan.pricePerRepo > 0) {
const billing = conf.billing;
@@ -145,16 +126,16 @@ function validateConferenceForm(conf: any) {
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function applyConferenceForm(
model: IConferenceDocument,
body: any,
body: { name: string; startDate: string | Date; endDate: string | Date; url: string;
options: IConferenceDocument["options"]; plan?: { planID: string } },
isNew: boolean
) {
model.name = body.name;
model.startDate = new Date(body.startDate);
model.endDate = new Date(body.endDate);
model.status = "ready";
if (isNew) model.status = body.plan?.planID === "conference" ? "pending_payment" : "ready";
model.url = body.url;
model.options = body.options;
if (isNew) model.repositories = [];
@@ -178,7 +159,13 @@ router.post(
model = queryModel;
isOwnerOrAdmin(model.owners, user);
}
validateConferenceForm(req.body);
if (req.params.conferenceID) {
if (req.body.plan?.planID !== model.plan.planID) throw billingError("billing_plan_change_not_supported");
if (model.plan.planID === "conference" && (new Date(req.body.startDate).getTime() !== model.startDate.getTime() || new Date(req.body.endDate).getTime() !== model.endDate.getTime())) throw billingError("billing_review_window_locked");
}
// Historical conference plans remain editable without being offered for purchase.
const legacyPlan = req.params.conferenceID && !plans.some(p => p.id === model.plan.planID);
validateConferenceForm(legacyPlan ? { ...req.body, plan: { planID: "free_conference" } } : req.body);
applyConferenceForm(model, req.body, !req.params.conferenceID);
if (!req.params.conferenceID) {
@@ -194,7 +181,7 @@ router.post(
size: plans.filter((p) => p.id == req.body.plan.planID)[0]
.storagePerRepo,
file: 0,
repository: 0,
repository: req.body.plan.planID === "conference" ? config.PAID_CONFERENCE_REPOSITORIES : 0,
},
};
+21
View File
@@ -1,3 +1,6 @@
import { neverExpirationDeadline, validateBillingExpiration } from "../../core/billing/expiration";
import config from "../../config";
import { assertConferenceAdmission, reserveRepository } from "../../core/billing/quotas";
import { randomUUID } from "crypto";
import { githubQuotaKey } from "../../core/github-token-context";
import { selectRepositoryAccess, boundAppToken, appError } from "../../core/github-app";
@@ -506,6 +509,14 @@ router.post(
const sourceChanged = reconnecting || hasRepositorySourceChanged(repo.model, repoUpdate);
const previousAccessRevision = repo.model.githubAccess?.revision;
if (repo.model.billingManaged) validateBillingExpiration(repoUpdate.options);
if (config.BILLING_ENABLED && repoUpdate.options.expirationMode === "never" &&
(repo.model.options.expirationMode !== "never" || repo.model.billingNeverExpiresUntil)) {
repo.model.billingNeverExpiresUntil = await neverExpirationDeadline(String(repo.model.owner),
repo.model.options.expirationMode === "never" ? repo.model.billingNeverExpiresUntil : undefined);
} else if (repoUpdate.options.expirationMode !== "never") {
repo.model.billingNeverExpiresUntil = undefined;
}
updateRepoModel(repo.model, repoUpdate);
const reactivating = shouldReactivateInactiveRepository(repo.model);
@@ -513,6 +524,9 @@ router.post(
repo.model.anonymizeDate = new Date();
}
await assertConferenceAdmission(String(repo.model.owner), repo.repoId, repoUpdate.conference);
if (repo.model.billingManaged) await reserveRepository(String(repo.model.owner), repo.repoId, repo.model.size?.storage || 0, repoUpdate.conference);
if (sourceChanged) {
const parsedRepository = gh(repoUpdate.fullName);
if (!parsedRepository?.owner || !parsedRepository?.name) {
@@ -691,6 +705,10 @@ router.post("/", async (req, res) => {
repo.owner = user.id;
repo.githubAccess = selectedAccess.binding;
validateBillingExpiration(repoUpdate.options);
if (repoUpdate.options.expirationMode === "never") {
repo.billingNeverExpiresUntil = await neverExpirationDeadline(user.id);
}
updateRepoModel(repo, repoUpdate);
repo.source.type = "GitHubStream";
repo.source.repositoryId = repository.model.id;
@@ -698,6 +716,9 @@ router.post("/", async (req, res) => {
repo.conference = repoUpdate.conference;
await assertConferenceAdmission(user.id, repo.repoId, repo.conference);
repo.billingManaged = config.BILLING_ENABLED;
await reserveRepository(user.id, repo.repoId, 0, repo.conference);
await repo.save();
if (repoUpdate.conference) {
+1
View File
@@ -32,6 +32,7 @@ router.get(
}
const repo = await getRepo(req, res);
if (!repo) return;
if (repo.model.billingManaged && repo.model.truncatedFolders?.length) throw new AnonymousError("repository_tree_incomplete", { httpStatus: 409 });
let user: User | undefined = undefined;
try {
+12 -4
View File
@@ -1,3 +1,6 @@
import { BillingQuota, BillingDraft } from "../../core/billing/models";
import { cancelAccountBilling } from "../../core/billing/stripe";
import { allowance } from "../../core/billing/quotas";
import DashboardName from "../../core/model/dashboard-name";
import { dashboardSummary } from "./dashboard-summary";
import { revokeGrant } from "./github-app";
@@ -112,18 +115,21 @@ router.get("/quota", async (req, res) => {
}
}
const limits = config.BILLING_ENABLED ? await allowance(user.id) : null;
const reserved = limits ? await BillingQuota.findById(limits.scope).lean() : null;
res.json({
storage: {
used: totalStorage,
total: config.DEFAULT_QUOTA,
unit: limits ? "bytes" : "legacy",
used: reserved ? reserved.allocations.reduce((sum, entry) => sum + (entry.bytes || 0), 0) : totalStorage,
total: limits?.storage ?? config.DEFAULT_QUOTA,
},
file: {
used: totalFiles,
total: 0,
},
repository: {
used: ready.length,
total: 20,
used: reserved ? reserved.allocations.length : ready.length,
total: limits?.repositories ?? 20,
},
});
} catch (error) {
@@ -175,6 +181,8 @@ router.post("/default", async (req, res) => {
router.delete("/", async (req, res) => {
try {
const user = await getUser(req);
if (config.BILLING_ENABLED) await cancelAccountBilling(user.id);
if (isConnected) await BillingDraft.deleteMany({ owner: user.id });
const repositories = (await user.getRepositories()).filter(
(r) => r.owner.id === user.model.id && r.status !== "removed"
+2
View File
@@ -1,3 +1,4 @@
import { reconcileNeverExpiration } from "../core/billing/expiration";
import { Model, Document } from "mongoose";
import * as schedule from "node-schedule";
import AnonymizedGistModel from "../core/model/anonymizedGists/anonymizedGists.model";
@@ -62,6 +63,7 @@ export function repositoryMaintenanceQuery(now: Date) {
}
export async function runRepositoryStatusCheck(now = new Date()) {
await reconcileNeverExpiration(now);
logger.info("checking repository status and unused repositories");
const fourMonthAgo = new Date(now);
fourMonthAgo.setMonth(fourMonthAgo.getMonth() - 4);