mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
feat: let authors pay once per paper instead of subscribing
Most authors use the service for one paper a few times a year, so a subscription does not fit. Add cheap one-time options (Paper pack, Extra repository, Live updates, Extended expiration, Never expire), cap free dated expiration at 5 months, keep cheaper subscriptions for heavy users, and make every new conference a paid review package in three sizes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
643f4b87e9
commit
d25e5336ce
28 files changed
+1237
-261
No files matched your search
+23
-1
@@ -12,6 +12,13 @@ interface Config {
|
||||
STRIPE_AUTHOR_ANNUAL_PRICE: string;
|
||||
STRIPE_AUTHOR_PASS_PRICE: string;
|
||||
STRIPE_CONFERENCE_PRICE: string;
|
||||
STRIPE_CONFERENCE_SMALL_PRICE: string;
|
||||
STRIPE_CONFERENCE_LARGE_PRICE: string;
|
||||
STRIPE_REPO_EXTEND_PRICE: string;
|
||||
STRIPE_REPO_NEVER_PRICE: string;
|
||||
STRIPE_REPO_SLOT_PRICE: string;
|
||||
STRIPE_REPO_PACK_PRICE: string;
|
||||
FREE_EXPIRATION_MONTHS: number;
|
||||
FREE_AUTHOR_REPOSITORIES: number;
|
||||
FREE_AUTHOR_STORAGE: number;
|
||||
PAID_AUTHOR_REPOSITORIES: number;
|
||||
@@ -19,6 +26,10 @@ interface Config {
|
||||
PAID_PASS_STORAGE: number;
|
||||
PAID_CONFERENCE_REPOSITORIES: number;
|
||||
PAID_CONFERENCE_STORAGE: number;
|
||||
PAID_CONFERENCE_SMALL_REPOSITORIES: number;
|
||||
PAID_CONFERENCE_SMALL_STORAGE: number;
|
||||
PAID_CONFERENCE_LARGE_REPOSITORIES: number;
|
||||
PAID_CONFERENCE_LARGE_STORAGE: number;
|
||||
|
||||
RESEND_API_KEY: string;
|
||||
EMAIL_FROM: string;
|
||||
@@ -112,6 +123,13 @@ const config: Config = {
|
||||
STRIPE_AUTHOR_ANNUAL_PRICE: "",
|
||||
STRIPE_AUTHOR_PASS_PRICE: "",
|
||||
STRIPE_CONFERENCE_PRICE: "",
|
||||
STRIPE_CONFERENCE_SMALL_PRICE: "",
|
||||
STRIPE_CONFERENCE_LARGE_PRICE: "",
|
||||
STRIPE_REPO_EXTEND_PRICE: "",
|
||||
STRIPE_REPO_NEVER_PRICE: "",
|
||||
STRIPE_REPO_SLOT_PRICE: "",
|
||||
STRIPE_REPO_PACK_PRICE: "",
|
||||
FREE_EXPIRATION_MONTHS: 5,
|
||||
FREE_AUTHOR_REPOSITORIES: 5,
|
||||
FREE_AUTHOR_STORAGE: 2 * 1024 ** 3,
|
||||
PAID_AUTHOR_REPOSITORIES: 20,
|
||||
@@ -119,6 +137,10 @@ const config: Config = {
|
||||
PAID_PASS_STORAGE: 2 * 1024 ** 3,
|
||||
PAID_CONFERENCE_REPOSITORIES: 100,
|
||||
PAID_CONFERENCE_STORAGE: 50 * 1024 ** 3,
|
||||
PAID_CONFERENCE_SMALL_REPOSITORIES: 25,
|
||||
PAID_CONFERENCE_SMALL_STORAGE: 10 * 1024 ** 3,
|
||||
PAID_CONFERENCE_LARGE_REPOSITORIES: 400,
|
||||
PAID_CONFERENCE_LARGE_STORAGE: 200 * 1024 ** 3,
|
||||
|
||||
RESEND_API_KEY: "",
|
||||
EMAIL_FROM: "",
|
||||
@@ -270,7 +292,7 @@ if (config.GITHUB_APP_ENABLED) {
|
||||
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) {
|
||||
for (const key of ["FREE_EXPIRATION_MONTHS", "FREE_AUTHOR_REPOSITORIES", "FREE_AUTHOR_STORAGE", "PAID_AUTHOR_REPOSITORIES", "PAID_AUTHOR_STORAGE", "PAID_PASS_STORAGE", "PAID_CONFERENCE_REPOSITORIES", "PAID_CONFERENCE_STORAGE", "PAID_CONFERENCE_SMALL_REPOSITORIES", "PAID_CONFERENCE_SMALL_STORAGE", "PAID_CONFERENCE_LARGE_REPOSITORIES", "PAID_CONFERENCE_LARGE_STORAGE"] as const) {
|
||||
if (!Number.isSafeInteger(config[key]) || config[key] <= 0) throw new Error(`${key} must be a positive integer`);
|
||||
}
|
||||
if (!config.STRIPE_SECRET_KEY || !config.STRIPE_WEBHOOK_SECRET) throw new Error("Billing requires STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { REPOSITORY_ID_COLLATION } from "./repository-id";
|
||||
import { allowance, reserveRepository } from "./billing/quotas";
|
||||
import { MANUAL_REFRESH_PLANS } from "./billing/config";
|
||||
import { FILE_TREE_BATCH_SIZE, FILE_LISTING_LIMIT, deleteRowsInBatches } from "./file-tree";
|
||||
import { SourceBase, sourceStorageSize } from "./source/Source";
|
||||
import { notifyOwnerAccessProblem, withOwnerAccessRecovery } from "./owner-notifications";
|
||||
@@ -724,7 +725,7 @@ 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;
|
||||
MANUAL_REFRESH_PLANS.includes((await allowance(String(this.model.owner), this.repoId, this.model.conference)).plan)) return;
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import config from "../../config";
|
||||
import AnonymousError from "../AnonymousError";
|
||||
|
||||
export type PlanId = "author_monthly" | "author_annual" | "author_pass" | "conference";
|
||||
export type PlanId = "author_monthly" | "author_annual" | "author_pass" | "repo_extend" | "repo_never" | "repo_slot" | "repo_pack" | "conference_workshop" | "conference" | "conference_large";
|
||||
// Repository add-ons bought once for a single repository.
|
||||
export const REPOSITORY_ADDONS: PlanId[] = ["author_pass", "repo_extend", "repo_never", "repo_slot", "repo_pack"];
|
||||
// A paper pack bundles what one paper needs during review into one checkout.
|
||||
export const PACK_PLANS: PlanId[] = ["repo_slot", "author_pass", "repo_extend"];
|
||||
// Capacity plans that do not include automatic updates.
|
||||
export const MANUAL_REFRESH_PLANS = ["free", "repo_slot"];
|
||||
// Conference review packages, smallest first. "conference" keeps its historical
|
||||
// id for the middle tier so existing grants and conferences stay valid.
|
||||
export const CONFERENCE_PLANS: PlanId[] = ["conference_workshop", "conference", "conference_large"];
|
||||
export function isConferencePlan(id?: string | null) { return !!id && (CONFERENCE_PLANS as string[]).includes(id); }
|
||||
export const REPO_EXTEND_MS = 365 * 86400_000;
|
||||
export const REPO_NEVER_UNTIL = new Date("9999-12-31T00:00:00Z");
|
||||
export const GiB = 1024 ** 3;
|
||||
// Plans with `capacity` provide a storage allowance. The others only change expiration.
|
||||
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." },
|
||||
{ id: "author_monthly" as PlanId, name: "Author monthly", priceId: config.STRIPE_AUTHOR_MONTHLY_PRICE, recurring: true, capacity: 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, capacity: 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: "Live updates", priceId: config.STRIPE_AUTHOR_PASS_PRICE, recurring: false, capacity: true, repositories: 1, storage: config.PAID_PASS_STORAGE, description: "Automatic updates for one repository for 180 days, with its own storage allowance. No automatic renewal." },
|
||||
{ id: "repo_extend" as PlanId, name: "Extended expiration", priceId: config.STRIPE_REPO_EXTEND_PRICE, recurring: false, capacity: false, repositories: 0, storage: 0, description: "Keep one repository online for up to one more year. Buy again to add another year. No automatic renewal." },
|
||||
{ id: "repo_never" as PlanId, name: "Never expire", priceId: config.STRIPE_REPO_NEVER_PRICE, recurring: false, capacity: false, repositories: 0, storage: 0, description: "One repository never expires. One-time payment, no subscription." },
|
||||
{ id: "repo_slot" as PlanId, name: "Extra repository", priceId: config.STRIPE_REPO_SLOT_PRICE, recurring: false, capacity: true, repositories: 1, storage: config.PAID_PASS_STORAGE, description: "Host one more repository beyond the free allowance, with its own storage. It does not count toward your free repositories. One-time payment, valid while the repository exists." },
|
||||
{ id: "repo_pack" as PlanId, name: "Paper pack", priceId: config.STRIPE_REPO_PACK_PRICE, recurring: false, capacity: false, repositories: 0, storage: 0, description: "Everything one paper needs during review: an extra repository, automatic updates for 180 days, and one more year before it expires. One-time payment." },
|
||||
{ id: "conference_workshop" as PlanId, name: "Workshop", priceId: config.STRIPE_CONFERENCE_SMALL_PRICE, recurring: false, capacity: true, repositories: config.PAID_CONFERENCE_SMALL_REPOSITORIES, storage: config.PAID_CONFERENCE_SMALL_STORAGE, description: "For a workshop or small venue. A review window of up to 180 days. No automatic renewal." },
|
||||
{ id: "conference" as PlanId, name: "Conference", priceId: config.STRIPE_CONFERENCE_PRICE, recurring: false, capacity: true, repositories: config.PAID_CONFERENCE_REPOSITORIES, storage: config.PAID_CONFERENCE_STORAGE, description: "For a typical conference track. A review window of up to 180 days. No automatic renewal." },
|
||||
{ id: "conference_large" as PlanId, name: "Large conference", priceId: config.STRIPE_CONFERENCE_LARGE_PRICE, recurring: false, capacity: true, repositories: config.PAID_CONFERENCE_LARGE_REPOSITORIES, storage: config.PAID_CONFERENCE_LARGE_STORAGE, description: "For a large venue with several tracks. A review window of up to 180 days. No automatic renewal." },
|
||||
];
|
||||
}
|
||||
export function billingError(code: string, status = 400) { return new AnonymousError(code, { httpStatus: status }); }
|
||||
|
||||
@@ -5,30 +5,70 @@ import { BillingGrant } from "./models";
|
||||
import { billingError } from "./config";
|
||||
import { usableGrant } from "./quotas";
|
||||
import Repo from "../model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
import ConferenceModel from "../model/conference/conferences.model";
|
||||
|
||||
export const NEVER_EXPIRATION_GRACE_MS = 90 * 86400000;
|
||||
// A one-time Never expire add-on keeps a rolling deadline this far ahead, so a
|
||||
// refund or dispute still ends it after the usual grace period.
|
||||
const ROLLING_REFRESH_MS = 7 * 86400000;
|
||||
async function authorGrants(owner: string) {
|
||||
return BillingGrant.find({ scope: `user:${owner}`, planId: { $in: ["author_monthly", "author_annual"] } }).lean();
|
||||
}
|
||||
async function hasRepositoryGrant(owner: string, repoId: string, planId: string, now = new Date()) {
|
||||
const grants = await BillingGrant.find({ scope: `repo:${repoId}`, owner, planId, revoked: false, until: { $gt: now } }).lean();
|
||||
return grants.filter(g => usableGrant(g, now));
|
||||
}
|
||||
|
||||
// This is an author benefit, independent of which scope pays the storage quota.
|
||||
export async function neverExpirationDeadline(owner: string, existing?: Date) {
|
||||
// A repository's own Never expire add-on also qualifies.
|
||||
export async function neverExpirationDeadline(owner: string, existing?: Date, repoId?: string) {
|
||||
if (!config.BILLING_ENABLED) return undefined;
|
||||
const now = new Date();
|
||||
const grants = await authorGrants(owner);
|
||||
const active = grants.filter(g => usableGrant(g));
|
||||
if (!active.length) {
|
||||
if (existing && existing > new Date()) return existing;
|
||||
const active = grants.filter(g => usableGrant(g, now));
|
||||
const addon = repoId ? (await hasRepositoryGrant(owner, repoId, "repo_never", now)).length > 0 : false;
|
||||
if (!active.length && !addon) {
|
||||
if (existing && existing > now) 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)));
|
||||
return new Date(Math.max(existing?.getTime() || 0, ...active.map(g => g.until!.getTime() + NEVER_EXPIRATION_GRACE_MS),
|
||||
addon ? now.getTime() + NEVER_EXPIRATION_GRACE_MS : 0));
|
||||
}
|
||||
|
||||
function addMonths(date: Date, months: number) {
|
||||
const result = new Date(date);
|
||||
result.setMonth(result.getMonth() + months);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
}
|
||||
// Free repositories can be scheduled FREE_EXPIRATION_MONTHS ahead. Author
|
||||
// subscribers keep one year; an Extended expiration add-on sets its own limit.
|
||||
export function freeExpirationLimit(now = new Date()) { return addMonths(now, config.FREE_EXPIRATION_MONTHS); }
|
||||
export async function maximumExpiration(owner: string, repoId?: string, conference?: string, now = new Date()) {
|
||||
if (!config.BILLING_ENABLED) return addMonths(now, 12);
|
||||
let maximum = freeExpirationLimit(now).getTime();
|
||||
// Conference submissions can stay online until the end of the review window.
|
||||
const conf = conference && await ConferenceModel.findOne({ conferenceID: conference }).select("endDate").lean();
|
||||
if (conf && conf.endDate) maximum = Math.max(maximum, new Date(conf.endDate).getTime());
|
||||
if ((await authorGrants(owner)).some(g => usableGrant(g, now))) maximum = Math.max(maximum, addMonths(now, 12).getTime());
|
||||
if (repoId) {
|
||||
for (const grant of await hasRepositoryGrant(owner, repoId, "repo_extend", now)) {
|
||||
const limit = new Date(grant.until!);
|
||||
limit.setHours(23, 59, 59, 999);
|
||||
maximum = Math.max(maximum, limit.getTime());
|
||||
}
|
||||
}
|
||||
return new Date(maximum);
|
||||
}
|
||||
|
||||
// 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 addons = new Set((await BillingGrant.find({ planId: "repo_never", revoked: false, until: { $gt: now } }).select("owner scope status until revoked").lean())
|
||||
.filter(g => usableGrant(g, now)).map(g => `${g.owner} ${g.scope}`));
|
||||
const cursor = Repo.find({ "options.expirationMode": "never", billingNeverExpiresUntil: { $exists: true },
|
||||
status: { $nin: ["removed", "expired", "expiring", "removing"] } }).select("owner billingNeverExpiresUntil").cursor();
|
||||
status: { $nin: ["removed", "expired", "expiring", "removing"] } }).select("owner repoId billingNeverExpiresUntil").cursor();
|
||||
const deadlines = new Map<string, number>();
|
||||
const verified = new Set<string>();
|
||||
const deferred = new Set<string>();
|
||||
@@ -40,7 +80,10 @@ export async function reconcileNeverExpiration(now = new Date(), reconcile = rec
|
||||
.map(g => g.until!.getTime() + NEVER_EXPIRATION_GRACE_MS)));
|
||||
}
|
||||
const previous = repo.billingNeverExpiresUntil!;
|
||||
let deadline = new Date(Math.max(previous.getTime(), deadlines.get(owner)!));
|
||||
// Roll the add-on deadline forward weekly rather than on every run.
|
||||
const rolling = addons.has(`${owner} repo:${repo.repoId}`) && previous.getTime() < now.getTime() + NEVER_EXPIRATION_GRACE_MS - ROLLING_REFRESH_MS
|
||||
? now.getTime() + NEVER_EXPIRATION_GRACE_MS : 0;
|
||||
let deadline = new Date(Math.max(previous.getTime(), deadlines.get(owner)!, rolling));
|
||||
if (deadline <= now) {
|
||||
if (deferred.has(owner)) continue;
|
||||
try {
|
||||
@@ -50,7 +93,7 @@ export async function reconcileNeverExpiration(now = new Date(), reconcile = rec
|
||||
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)!));
|
||||
deadline = new Date(Math.max(previous.getTime(), deadlines.get(owner)!, rolling));
|
||||
} catch {
|
||||
deferred.add(owner);
|
||||
createLogger("billing").error("repository expiration deferred: billing verification failed", { owner });
|
||||
@@ -67,12 +110,13 @@ export async function reconcileNeverExpiration(now = new Date(), reconcile = rec
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBillingExpiration(options: { expirationMode?: string; expirationDate?: string | Date }) {
|
||||
// `current` keeps an already saved date valid, so lowering the limit does not
|
||||
// block unrelated edits to existing repositories.
|
||||
export function validateBillingExpiration(options: { expirationMode?: string; expirationDate?: string | Date },
|
||||
maximum = freeExpirationLimit(), current?: string | Date | null) {
|
||||
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);
|
||||
const unchanged = !!date && !!current && date.getTime() === new Date(current).getTime();
|
||||
if (!["remove", "redirect"].includes(options.expirationMode || "") || !date ||
|
||||
!Number.isFinite(date.getTime()) || date > maximum) throw billingError("invalid_billing_expiration");
|
||||
!Number.isFinite(date.getTime()) || (date > maximum && !unchanged)) throw billingError("invalid_billing_expiration");
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const BillingAccount = model("billingAccount", new Schema({
|
||||
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,
|
||||
status: String, until: Date, cancelAtPeriodEnd: Boolean, purchasedAt: Date,
|
||||
priceId: String,
|
||||
revision: { type: Number, default: 0 }, revoked: { type: Boolean, default: false },
|
||||
revocationReason: String, revokedAt: Date,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
|
||||
import ConferenceModel from "../model/conference/conferences.model";
|
||||
import config from "../../config";
|
||||
import { BillingGrant, BillingQuota } from "./models";
|
||||
import { billingError, billingPlans } from "./config";
|
||||
import { billingError, billingPlans, MANUAL_REFRESH_PLANS, isConferencePlan } 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()) {
|
||||
@@ -14,8 +14,13 @@ export async function allowance(owner: string, repoId?: string, conference?: str
|
||||
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);
|
||||
// Expiration add-ons share the repository scope but carry no storage allowance.
|
||||
// Repository add-ons only fund the owner who bought them; the ID could
|
||||
// otherwise be bought before another account creates that repository.
|
||||
const plan = grants.filter(g => g.scope === scope && usableGrant(g) && (!scope.startsWith("repo:") || g.owner === owner))
|
||||
.map(g => billingPlans().find(p => p.id === g.planId && p.capacity)).filter(Boolean)
|
||||
// Prefer a plan with automatic updates when several fund the same scope.
|
||||
.sort((a, b) => Number(MANUAL_REFRESH_PLANS.includes(a!.id)) - Number(MANUAL_REFRESH_PLANS.includes(b!.id)))[0];
|
||||
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" };
|
||||
@@ -74,7 +79,7 @@ export async function assertConferenceAdmission(owner: string, repoId: string, c
|
||||
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);
|
||||
if (isConferencePlan(conf.plan.planID) && (await allowance(owner, repoId, conference)).scope !== `conference:${conference}`) throw billingError("conference_payment_required", 402);
|
||||
}
|
||||
|
||||
// Source trees report LFS pointer sizes. Charge resolved bytes before allowing
|
||||
|
||||
+54
-21
@@ -1,8 +1,10 @@
|
||||
import Stripe = require("stripe");
|
||||
import { randomUUID } from "crypto";
|
||||
import config from "../../config";
|
||||
import { BillingAccount, BillingGrant, BillingLock, BillingRevocation, BillingPrice } from "./models";
|
||||
import { billingError, billingOrigin, billingPlans, requireBilling } from "./config";
|
||||
import { BillingAccount, BillingGrant, BillingLock, BillingRevocation, BillingPrice, BillingQuota } from "./models";
|
||||
import { REPOSITORY_ID_COLLATION } from "../repository-id";
|
||||
import { billingError, billingOrigin, billingPlans, requireBilling, REPOSITORY_ADDONS, REPO_EXTEND_MS, REPO_NEVER_UNTIL, PACK_PLANS, isConferencePlan } from "./config";
|
||||
import { usableGrant } from "./quotas";
|
||||
import ConferenceModel from "../model/conference/conferences.model";
|
||||
import RepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
|
||||
@@ -47,7 +49,7 @@ export async function syncSubscription(id: string, stripe = stripeClient()) {
|
||||
}
|
||||
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,
|
||||
until: paidThrough, ...(sub.start_date ? { purchasedAt: new Date(sub.start_date * 1000) } : {}),
|
||||
};
|
||||
if (!previous) {
|
||||
try { await BillingGrant.create({ _id: id, ...values }); return; }
|
||||
@@ -71,7 +73,7 @@ export async function fulfillCheckout(id: string, stripe = stripeClient()) {
|
||||
const priceId = session.line_items?.data[0]?.price?.id;
|
||||
const previousGrant = await BillingGrant.findById(id).lean();
|
||||
const historicalPrice = priceId ? await BillingPrice.findById(priceId).lean() : null;
|
||||
const previousScope = planId === "conference" ? `conference:${target}` : `repo:${target}`;
|
||||
const previousScope = isConferencePlan(planId) ? `conference:${target}` : `repo:${target}`;
|
||||
const acceptedGrant = previousGrant?.owner === owner && previousGrant?.customerId === account.customerId
|
||||
&& previousGrant?.planId === planId && previousGrant?.scope === previousScope
|
||||
&& (!previousGrant.priceId || previousGrant.priceId === priceId);
|
||||
@@ -82,22 +84,46 @@ export async function fulfillCheckout(id: string, stripe = stripeClient()) {
|
||||
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 scope = isConferencePlan(plan.id) ? `conference:${target}` : `repo:${target}`;
|
||||
const purchased = new Date(session.created * 1000);
|
||||
// A paper pack is fulfilled as its three add-ons. They share the payment
|
||||
// intent, so a refund or dispute revokes all of them together.
|
||||
const parts: { _id: string; planId: string }[] = plan.id === "repo_pack"
|
||||
? PACK_PLANS.map(part => ({ _id: `${id}:${part}`, planId: part })) : [{ _id: id, planId: plan.id }];
|
||||
const untilFor = async (part: { _id: string; planId: string }) => {
|
||||
if (part.planId === "repo_never" || part.planId === "repo_slot") return REPO_NEVER_UNTIL;
|
||||
if (part.planId === "repo_extend") {
|
||||
// Extensions stack: each purchase adds a year after any earlier paid limit.
|
||||
// $setOnInsert below keeps this value stable across duplicate deliveries.
|
||||
const earlier = await BillingGrant.find({ _id: { $ne: part._id }, scope, owner, planId: "repo_extend" }).lean();
|
||||
const base = Math.max(purchased.getTime(), ...earlier.filter(g => usableGrant(g, purchased)).map(g => g.until!.getTime()));
|
||||
return new Date(base + REPO_EXTEND_MS);
|
||||
}
|
||||
return new Date(purchased.getTime() + 180 * 86400_000);
|
||||
};
|
||||
let conferenceEnd: Date | undefined;
|
||||
if (isConferencePlan(plan.id)) {
|
||||
const conference = await ConferenceModel.findOne({ conferenceID: target });
|
||||
// Historical sessions can outlive a deliberately deleted conference.
|
||||
if (!conference) return;
|
||||
until = conference.endDate;
|
||||
conferenceEnd = conference.endDate;
|
||||
}
|
||||
await BillingGrant.updateOne({ _id: id }, { $setOnInsert: { owner, scope, planId, customerId: account.customerId,
|
||||
paymentIntentId: objectId(session.payment_intent), priceId, status: "paid", until }, ...(revoked ? { $set: { revoked: true } } : {}) }, { upsert: true });
|
||||
const reversal = intent && await BillingRevocation.findById(intent.id).lean();
|
||||
if (reversal?.reviewed) await BillingGrant.updateOne({ _id: id }, { $set: { revoked: false } });
|
||||
else if (reversal) await BillingGrant.updateOne({ _id: id }, { $set: { revoked: true, revocationReason: reversal.reason } });
|
||||
if (plan.id === "conference" && (!revoked || reversal?.reviewed)) {
|
||||
for (const part of parts) {
|
||||
const until = conferenceEnd || await untilFor(part);
|
||||
await BillingGrant.updateOne({ _id: part._id }, { $setOnInsert: { owner, scope, planId: part.planId, customerId: account.customerId,
|
||||
paymentIntentId: objectId(session.payment_intent), priceId, status: "paid", until, purchasedAt: purchased }, ...(revoked ? { $set: { revoked: true } } : {}) }, { upsert: true });
|
||||
if (reversal?.reviewed) await BillingGrant.updateOne({ _id: part._id }, { $set: { revoked: false } });
|
||||
else if (reversal) await BillingGrant.updateOne({ _id: part._id }, { $set: { revoked: true, revocationReason: reversal.reason } });
|
||||
if (part.planId === "repo_slot" && !(await BillingGrant.findById(part._id).lean())?.revoked) {
|
||||
// A bought repository no longer counts toward its owner's free allowance.
|
||||
// Its next admission reserves it in its own repository ledger instead.
|
||||
await BillingQuota.updateOne({ _id: `user:${owner}` }, { $pull: { allocations: { repoId: target } } });
|
||||
}
|
||||
}
|
||||
if (isConferencePlan(plan.id) && (!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" } });
|
||||
if (!grant?.revoked) await ConferenceModel.updateOne({ conferenceID: target, status: "pending_payment", "plan.planID": plan.id }, { $set: { status: "ready" } });
|
||||
}
|
||||
}
|
||||
export async function processBillingEvent(event: Stripe.Event, stripe = stripeClient()) {
|
||||
@@ -145,15 +171,20 @@ export async function createCheckout(owner: string, planId: string, target = "",
|
||||
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") {
|
||||
if (isConferencePlan(plan.id)) {
|
||||
// A conference pays for the tier it was created with.
|
||||
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 || conf.plan.planID !== plan.id || 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 if (REPOSITORY_ADDONS.includes(plan.id)) {
|
||||
// An add-on can be bought for a repository the owner is about to create:
|
||||
// a full free allowance blocks creation until it is paid for.
|
||||
if (typeof target !== "string" || !/^[0-9a-zA-Z_-]{3,200}$/.test(target)) throw billingError("repo_not_found", 404);
|
||||
const repo = await RepositoryModel.findOne({ repoId: target }, null, { collation: REPOSITORY_ID_COLLATION }).select("repoId owner status").lean();
|
||||
if (repo && (String(repo.owner) !== owner || repo.status === "removed")) throw billingError("repo_not_found", 404);
|
||||
if (repo?.repoId) target = repo.repoId;
|
||||
} else target = "";
|
||||
const destination = returnPath || (plan.id === "conference" ? `/conference/${encodeURIComponent(target)}` : "/profile");
|
||||
const destination = returnPath || (isConferencePlan(plan.id) ? `/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 () => {
|
||||
@@ -211,7 +242,9 @@ export async function createCheckout(owner: string, planId: string, target = "",
|
||||
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);
|
||||
} else if (isConferencePlan(plan.id) ? await BillingGrant.exists({ scope: `conference:${target}`, revoked: false, until: { $gt: new Date() } })
|
||||
// Extensions stack, so only live updates and never expire can already be paid.
|
||||
: !["repo_extend", "repo_pack"].includes(plan.id) && await BillingGrant.exists({ scope: `repo:${target}`, owner, planId: plan.id, 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) };
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import * as express from "express";
|
||||
import { randomBytes } from "crypto";
|
||||
import { createHash, 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 { billingOrigin, billingPlans, requireBilling, PACK_PLANS, REPO_NEVER_UNTIL } 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 { allowance, usableGrant } from "../../core/billing/quotas";
|
||||
import RepositoryModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
|
||||
import { receiveBillingEvent, billingFailure } from "../../core/billing/events";
|
||||
import { createLogger } from "../../core/logger";
|
||||
@@ -34,6 +35,44 @@ async function displayedPrice(id: string) {
|
||||
priceCache.set(id, { expires: Date.now() + 60_000, value });
|
||||
return value;
|
||||
}
|
||||
type GrantRow = { _id?: unknown; planId?: string | null; scope?: string | null; status?: string | null; until?: Date | null;
|
||||
cancelAtPeriodEnd?: boolean | null; revoked?: boolean | null; revocationReason?: string | null; purchasedAt?: Date | null; priceId?: string | null };
|
||||
// A readable purchase list: pack parts grouped, internal IDs and the
|
||||
// year-9999 "no end" sentinel hidden, statuses reduced to a few states.
|
||||
export async function purchaseHistory(grants: GrantRow[], now = new Date()) {
|
||||
const state = (g: GrantRow) => g.revoked ? (g.revocationReason === "dispute" ? "disputed" : "refunded")
|
||||
: g.status === "past_due" ? "overdue" : usableGrant(g, now) ? "active" : "ended";
|
||||
const until = (g: GrantRow) => g.until && g.until.getTime() < REPO_NEVER_UNTIL.getTime() ? g.until : null;
|
||||
const groups = new Map<string, GrantRow[]>();
|
||||
for (const grant of grants) {
|
||||
const id = String(grant._id);
|
||||
const key = /^cs_.+:(repo_slot|author_pass|repo_extend)$/.test(id) && grants.filter(g => String(g._id).startsWith(id.split(":")[0] + ":")).length === PACK_PLANS.length
|
||||
? id.split(":")[0] : id;
|
||||
groups.set(key, [...(groups.get(key) || []), grant]);
|
||||
}
|
||||
const purchases = [];
|
||||
for (const [key, rows] of groups) {
|
||||
const first = rows[0];
|
||||
const pack = rows.length > 1;
|
||||
let amount: number | null = null, currency: string | null = null, interval: string | null = null;
|
||||
if (first.priceId) {
|
||||
try { const price = await displayedPrice(first.priceId); amount = price.unit_amount; currency = price.currency; interval = price.recurring?.interval || null; } catch { /* Price display is optional. */ }
|
||||
}
|
||||
const [kind, target] = String(first.scope || "").split(/:(.*)/s);
|
||||
const states = rows.map(state);
|
||||
purchases.push({
|
||||
id: createHash("sha256").update(key).digest("hex").slice(0, 12),
|
||||
planId: pack ? "repo_pack" : first.planId, recurring: ["author_monthly", "author_annual"].includes(first.planId || ""),
|
||||
repoId: kind === "repo" ? target : null, conference: kind === "conference" ? target : null,
|
||||
status: states.includes("disputed") ? "disputed" : states.includes("refunded") ? "refunded" : states.includes("overdue") ? "overdue"
|
||||
: states.includes("active") ? "active" : "ended",
|
||||
until: pack ? null : until(first), purchasedAt: first.purchasedAt || null, cancelAtPeriodEnd: !!first.cancelAtPeriodEnd, amount, currency, interval,
|
||||
parts: pack ? rows.map(g => ({ planId: g.planId, until: until(g), status: state(g) })) : [],
|
||||
});
|
||||
}
|
||||
return purchases.sort((a, b) => Number(b.status === "active" || b.status === "overdue") - Number(a.status === "active" || a.status === "overdue")
|
||||
|| (b.purchasedAt?.getTime() || 0) - (a.purchasedAt?.getTime() || 0));
|
||||
}
|
||||
const router = express.Router();
|
||||
router.use(ensureAuthenticated);
|
||||
router.get("/", async (req, res) => {
|
||||
@@ -42,14 +81,18 @@ router.get("/", async (req, res) => {
|
||||
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 grants = await BillingGrant.find({ owner: user.id }).select("planId scope status until cancelAtPeriodEnd revoked revocationReason purchasedAt priceId").lean();
|
||||
const repositories = config.BILLING_ENABLED ? await RepositoryModel.find({ owner: user.id, status: { $ne: "removed" } })
|
||||
.select("repoId status options.expirationMode options.expirationDate").sort({ anonymizeDate: -1 }).limit(500).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,
|
||||
res.set("Cache-Control", "no-store").json({ enabled: config.BILLING_ENABLED, csrf: req.session.billingCSRF, plans, checkoutEnabled: config.BILLING_CHECKOUT_ENABLED, freeExpirationMonths: config.FREE_EXPIRATION_MONTHS,
|
||||
grants: grants.map(g => ({ planId: g.planId, scope: g.scope, status: g.revoked ? "revoked" : g.status, until: g.until, cancelAtPeriodEnd: g.cancelAtPeriodEnd })),
|
||||
purchases: config.BILLING_ENABLED ? await purchaseHistory(grants) : [],
|
||||
repositories: repositories.map(r => ({ repoId: r.repoId, status: r.status, expirationMode: r.options?.expirationMode, expirationDate: r.options?.expirationDate })),
|
||||
portal: !!account?.customerId, allowance: config.BILLING_ENABLED ? await allowance(user.id) : null });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import config from "../../config";
|
||||
import { billingError } from "../../core/billing/config";
|
||||
import { billingError, billingPlans, isConferencePlan } from "../../core/billing/config";
|
||||
import * as express from "express";
|
||||
import AnonymousError from "../../core/AnonymousError";
|
||||
import Conference from "../../core/Conference";
|
||||
@@ -13,15 +13,26 @@ const router = express.Router();
|
||||
// user needs to be connected for all user API
|
||||
router.use(ensureAuthenticated);
|
||||
|
||||
const plans = [
|
||||
{ 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>` },
|
||||
];
|
||||
// The free plan plus one review package per venue size, smallest first.
|
||||
function conferencePlans() {
|
||||
return [
|
||||
{ id: "free_conference", name: "Free", pricePerRepo: 0, storagePerRepo: -1, repositories: 0, priceId: "",
|
||||
description: "<li>Uses each author's allowance</li><li>Conference dashboard</li>" },
|
||||
...billingPlans().filter(p => isConferencePlan(p.id)).map(p => ({ id: p.id as string, name: p.name, pricePerRepo: 0,
|
||||
storagePerRepo: p.storage, repositories: p.repositories, priceId: p.priceId,
|
||||
description: `<li>Up to ${p.repositories} repositories</li><li>Up to 180 days</li><li>Prepaid at checkout</li>` })),
|
||||
];
|
||||
}
|
||||
|
||||
// With billing enabled every new conference is a paid review package; the free
|
||||
// plan is only offered on deployments without billing.
|
||||
export function offeredConferencePlans() {
|
||||
if (!config.BILLING_ENABLED) return conferencePlans().filter(p => p.id === "free_conference").map(({ priceId: _, ...p }) => p);
|
||||
return conferencePlans().filter(p => isConferencePlan(p.id) && p.priceId).map(({ priceId: _, ...p }) => p);
|
||||
}
|
||||
|
||||
router.get("/plans", async (req: express.Request, res: express.Response) => {
|
||||
res.json(config.BILLING_ENABLED && config.STRIPE_CONFERENCE_PRICE ? plans : plans.filter(p => p.id === "free_conference"));
|
||||
res.json(offeredConferencePlans());
|
||||
});
|
||||
|
||||
router.get("/", async (req: express.Request, res: express.Response) => {
|
||||
@@ -78,14 +89,16 @@ function validateConferenceForm(conf: any, isNew: boolean) {
|
||||
object: conf,
|
||||
httpStatus: 400,
|
||||
});
|
||||
if (!conf.plan || plans.filter((p) => p.id == conf.plan.planID).length != 1)
|
||||
if (!conf.plan || conferencePlans().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" && ((isNew && (!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 (isConferencePlan(conf.plan.planID) && ((isNew && (!config.BILLING_ENABLED || !offeredConferencePlans().some(p => p.id === conf.plan.planID))) || new Date(conf.endDate).getTime() - new Date(conf.startDate).getTime() > 180 * 86400_000)) throw billingError("billing_invalid_review_window");
|
||||
// A new conference can only use a plan that is offered: paid when billing is on.
|
||||
if (isNew && !offeredConferencePlans().some(p => p.id === conf.plan.planID)) throw new AnonymousError("invalid_plan", { object: conf, httpStatus: 400 });
|
||||
const plan = conferencePlans().filter((p) => p.id == conf.plan.planID)[0];
|
||||
if (plan.pricePerRepo > 0) {
|
||||
const billing = conf.billing;
|
||||
if (!billing)
|
||||
@@ -135,7 +148,7 @@ export function applyConferenceForm(
|
||||
model.name = body.name;
|
||||
model.startDate = new Date(body.startDate);
|
||||
model.endDate = new Date(body.endDate);
|
||||
if (isNew) model.status = body.plan?.planID === "conference" ? "pending_payment" : "ready";
|
||||
if (isNew) model.status = isConferencePlan(body.plan?.planID) ? "pending_payment" : "ready";
|
||||
model.url = body.url;
|
||||
model.options = body.options;
|
||||
if (isNew) model.repositories = [];
|
||||
@@ -161,10 +174,10 @@ router.post(
|
||||
}
|
||||
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");
|
||||
if (isConferencePlan(model.plan.planID) && (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);
|
||||
const legacyPlan = req.params.conferenceID && !offeredConferencePlans().some(p => p.id === model.plan.planID);
|
||||
validateConferenceForm(legacyPlan ? { ...req.body, plan: { planID: "free_conference" } } : req.body, !req.params.conferenceID);
|
||||
applyConferenceForm(model, req.body, !req.params.conferenceID);
|
||||
|
||||
@@ -172,17 +185,11 @@ router.post(
|
||||
model.owners.push(user.model.id);
|
||||
model.conferenceID = req.body.conferenceID;
|
||||
|
||||
const chosen = conferencePlans().find((p) => p.id == req.body.plan.planID)!;
|
||||
model.plan = {
|
||||
planID: req.body.plan.planID,
|
||||
pricePerRepository: plans.filter(
|
||||
(p) => p.id == req.body.plan.planID
|
||||
)[0].pricePerRepo,
|
||||
quota: {
|
||||
size: plans.filter((p) => p.id == req.body.plan.planID)[0]
|
||||
.storagePerRepo,
|
||||
file: 0,
|
||||
repository: req.body.plan.planID === "conference" ? config.PAID_CONFERENCE_REPOSITORIES : 0,
|
||||
},
|
||||
planID: chosen.id,
|
||||
pricePerRepository: chosen.pricePerRepo,
|
||||
quota: { size: chosen.storagePerRepo, file: 0, repository: chosen.repositories },
|
||||
};
|
||||
|
||||
if (req.body.billing)
|
||||
@@ -232,6 +239,9 @@ router.get(
|
||||
});
|
||||
const user = await getUser(req);
|
||||
const conference = new Conference(data);
|
||||
// Authors see whether the conference pays for their repository's hosting.
|
||||
const now = new Date();
|
||||
const hosting = isConferencePlan(data.plan?.planID) && data.status === "ready" && data.startDate <= now && data.endDate > now;
|
||||
try {
|
||||
isOwnerOrAdmin(conference.ownerIDs, user);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -239,6 +249,7 @@ router.get(
|
||||
o.repositories = (await conference.repositories()).map((r) =>
|
||||
r.toJSON()
|
||||
);
|
||||
o.hosting = hosting;
|
||||
res.json(o);
|
||||
} catch {
|
||||
return res.json({
|
||||
@@ -248,6 +259,7 @@ router.get(
|
||||
startDate: conference.startDate,
|
||||
endDate: conference.endDate,
|
||||
options: conference.options,
|
||||
hosting,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Repository from "../../core/Repository";
|
||||
import { sourceStorageSize } from "../../core/source/Source";
|
||||
import { neverExpirationDeadline, validateBillingExpiration } from "../../core/billing/expiration";
|
||||
import { maximumExpiration, neverExpirationDeadline, validateBillingExpiration } from "../../core/billing/expiration";
|
||||
import config from "../../config";
|
||||
import { assertConferenceAdmission, reserveRepository } from "../../core/billing/quotas";
|
||||
import { resetOwnerAccessAlerts } from "../../core/owner-notifications";
|
||||
@@ -206,10 +206,17 @@ router.post(
|
||||
const user = await getUser(req);
|
||||
isOwnerCoauthorOrAdmin(repo, user);
|
||||
|
||||
const newExpiration = extendExpirationDate(
|
||||
const maximum = await maximumExpiration(String(repo.model.owner), repo.repoId, repo.model.conference);
|
||||
let newExpiration = extendExpirationDate(
|
||||
repo.model.options.expirationDate
|
||||
);
|
||||
validateBillingExpiration({ ...repo.model.options, expirationDate: newExpiration });
|
||||
// Extend as far as the repository's limit allows, never shortening it.
|
||||
if (config.BILLING_ENABLED && newExpiration > maximum) {
|
||||
const current = repo.model.options.expirationDate && new Date(repo.model.options.expirationDate);
|
||||
if (current && current >= maximum) throw new AnonymousError("expiration_limit_reached", { httpStatus: 402 });
|
||||
newExpiration = maximum;
|
||||
}
|
||||
validateBillingExpiration({ ...repo.model.options, expirationDate: newExpiration }, maximum);
|
||||
const reactivating = repo.status === RepositoryStatus.EXPIRED;
|
||||
if (reactivating) await assertConferenceAdmission(String(repo.model.owner), repo.repoId, repo.model.conference);
|
||||
const updates: Record<string, Date | string | null> = {
|
||||
@@ -571,11 +578,12 @@ router.post(
|
||||
|
||||
// Keep proposed settings separate until cleanup passes the persisted fences.
|
||||
const proposed = new AnonymizedRepositoryModel(repo.model.toObject());
|
||||
if (repo.model.billingManaged) validateBillingExpiration(repoUpdate.options);
|
||||
if (repo.model.billingManaged) validateBillingExpiration(repoUpdate.options,
|
||||
await maximumExpiration(String(repo.model.owner), repo.repoId, repoUpdate.conference), repo.model.options.expirationDate);
|
||||
if (config.BILLING_ENABLED && repoUpdate.options.expirationMode === "never" &&
|
||||
(repo.model.options.expirationMode !== "never" || repo.model.billingNeverExpiresUntil)) {
|
||||
proposed.billingNeverExpiresUntil = await neverExpirationDeadline(String(repo.model.owner),
|
||||
repo.model.options.expirationMode === "never" ? repo.model.billingNeverExpiresUntil : undefined);
|
||||
repo.model.options.expirationMode === "never" ? repo.model.billingNeverExpiresUntil : undefined, repo.repoId);
|
||||
} else if (repoUpdate.options.expirationMode !== "never") {
|
||||
proposed.billingNeverExpiresUntil = undefined;
|
||||
}
|
||||
@@ -801,9 +809,9 @@ router.post("/", async (req, res) => {
|
||||
repo.owner = user.id;
|
||||
repo.githubAccess = selectedAccess.binding;
|
||||
|
||||
validateBillingExpiration(repoUpdate.options);
|
||||
validateBillingExpiration(repoUpdate.options, await maximumExpiration(user.id, repoUpdate.repoId, repoUpdate.conference));
|
||||
if (repoUpdate.options.expirationMode === "never") {
|
||||
repo.billingNeverExpiresUntil = await neverExpirationDeadline(user.id);
|
||||
repo.billingNeverExpiresUntil = await neverExpirationDeadline(user.id, undefined, repoUpdate.repoId);
|
||||
}
|
||||
updateRepoModel(repo, repoUpdate);
|
||||
repo.source.type = "GitHubStream";
|
||||
|
||||
Reference in new issue
Block a user