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
@@ -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";
|
||||
Reference in new issue
Block a user