mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
feat: add paid author and conference hosting with Stripe
This commit is contained in:
1 parent
b09ef0e5b0
commit
410863d253
54 files changed
+3263
-369
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 },
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
@@ -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());
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user