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:
Thomas DurieuxandClaude Opus 5.5 committed 2026-11-12 16:04:31 +00:00
1 parent 643f4b87e9
commit d25e5336ce
28 files changed
+1237 -261

No files matched your search

+23 -1
View File
@@ -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");
+2 -1
View File
@@ -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 (
+24 -5
View File
@@ -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 }); }
+57 -13
View File
@@ -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");
}
+1 -1
View File
@@ -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,
+9 -4
View File
@@ -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
View File
@@ -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) };
+48 -5
View File
@@ -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); }
});
+36 -24
View File
@@ -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) {
+15 -7
View File
@@ -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";