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

+116 -2
View File
@@ -25,7 +25,7 @@ async function rejects(promise, message) {
});
beforeEach(async () => {
Object.assign(config, { BILLING_ENABLED: true, FREE_AUTHOR_REPOSITORIES: 3, FREE_AUTHOR_STORAGE: 100,
PAID_AUTHOR_STORAGE: 1000, PAID_AUTHOR_REPOSITORIES: 20, STRIPE_AUTHOR_MONTHLY_PRICE: "price_monthly", STRIPE_AUTHOR_PASS_PRICE: "price_pass", STRIPE_CONFERENCE_PRICE: "price_conf" });
PAID_AUTHOR_STORAGE: 1000, PAID_AUTHOR_REPOSITORIES: 20, STRIPE_AUTHOR_MONTHLY_PRICE: "price_monthly", STRIPE_AUTHOR_PASS_PRICE: "price_pass", STRIPE_CONFERENCE_PRICE: "price_conf", STRIPE_CONFERENCE_SMALL_PRICE: "price_conf_small", STRIPE_CONFERENCE_LARGE_PRICE: "price_conf_large", PAID_CONFERENCE_SMALL_REPOSITORIES: 25, STRIPE_REPO_EXTEND_PRICE: "", STRIPE_REPO_NEVER_PRICE: "", STRIPE_REPO_SLOT_PRICE: "", STRIPE_REPO_PACK_PRICE: "" });
await Promise.all([BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.deleteMany({})));
await BillingAccount.create({ _id: owner, customerId: "cus_owner" });
});
@@ -243,6 +243,108 @@ async function rejects(promise, message) {
customer: "cus_owner", status: "complete", payment_status: status, created: Math.floor(Date.now() / 1000),
line_items: { data: [{ price: { id: "price_pass" }, quantity: 1 }] }, payment_intent: { id: "pi_pass", latest_charge: { amount_refunded: 0, disputed: false } } };
}
function addonSession(id, planId, created = Math.floor(Date.now() / 1000)) {
return { id, metadata: { application: "anonymous-github", owner, planId, target: "repo" },
customer: "cus_owner", status: "complete", payment_status: "paid", created,
line_items: { data: [{ price: { id: `price_${planId}` }, quantity: 1 }] }, payment_intent: { id: `pi_${id}`, latest_charge: { amount_refunded: 0, disputed: false } } };
}
function sessionsStripe(sessions) {
return { checkout: { sessions: { retrieve: async id => sessions.find(s => s.id === id) } } };
}
it("stacks extended expiration purchases and records never expire once", async () => {
Object.assign(config, { STRIPE_REPO_EXTEND_PRICE: "price_repo_extend", STRIPE_REPO_NEVER_PRICE: "price_repo_never" });
const year = 365 * 86400000;
const first = addonSession("cs_extend_1", "repo_extend");
const second = addonSession("cs_extend_2", "repo_extend", first.created + 60);
const never = addonSession("cs_never", "repo_never");
const stripe = sessionsStripe([first, second, never]);
await fulfillCheckout(first.id, stripe);
await fulfillCheckout(second.id, stripe);
// A duplicate delivery must not add another year.
await fulfillCheckout(second.id, stripe);
expect((await BillingGrant.findById(first.id)).until.getTime()).to.equal(first.created * 1000 + year);
expect((await BillingGrant.findById(second.id)).until.getTime()).to.equal(first.created * 1000 + 2 * year);
await fulfillCheckout(never.id, stripe);
expect((await BillingGrant.findById(never.id)).until.toISOString()).to.equal("9999-12-31T00:00:00.000Z");
});
it("hosts an extra repository outside the free allowance for its buyer only", async () => {
config.STRIPE_REPO_SLOT_PRICE = "price_repo_slot";
await Promise.all(["a", "b", "repo"].map(id => reserveRepository(owner, id, 10)));
await rejects(reserveRepository(owner, "four", 10), "quota_exceeded");
// Buying a slot for a counted repository frees its place in the free ledger.
const slot = addonSession("cs_slot", "repo_slot");
await fulfillCheckout(slot.id, sessionsStripe([slot]));
expect((await BillingGrant.findById(slot.id)).until.toISOString()).to.equal("9999-12-31T00:00:00.000Z");
expect((await BillingQuota.findById(`user:${owner}`)).allocations.map(a => a.repoId)).to.not.include("repo");
expect((await allowance(owner, "repo")).plan).to.equal("repo_slot");
await reserveRepository(owner, "repo", 10);
await reserveRepository(owner, "four", 10);
// Another account cannot use a slot bought for the same repository ID.
expect((await allowance("507f1f77bcf86cd799439099", "repo")).plan).to.equal("free");
config.STRIPE_REPO_SLOT_PRICE = "";
});
it("fulfills a paper pack as three add-ons that a refund revokes together", async () => {
config.STRIPE_REPO_PACK_PRICE = "price_repo_pack";
const pack = addonSession("cs_pack", "repo_pack");
const stripe = sessionsStripe([pack]);
await fulfillCheckout(pack.id, stripe);
await fulfillCheckout(pack.id, stripe);
const grants = await BillingGrant.find({ paymentIntentId: "pi_cs_pack" }).lean();
expect(grants.map(g => g.planId).sort()).to.deep.equal(["author_pass", "repo_extend", "repo_slot"]);
expect(grants.find(g => g.planId === "repo_extend").until.getTime()).to.equal(pack.created * 1000 + 365 * 86400000);
// Live updates win over the manual-refresh slot while both fund the repository.
expect((await allowance(owner, "repo")).plan).to.equal("author_pass");
await processBillingEvent({ type: "charge.refunded", data: { object: { payment_intent: "pi_cs_pack", refunded: true } } },
{ ...stripe, invoicePayments: { list: async () => ({ data: [] }) } });
expect(await BillingGrant.countDocuments({ paymentIntentId: "pi_cs_pack", revoked: true })).to.equal(3);
expect((await allowance(owner, "repo")).plan).to.equal("free");
config.STRIPE_REPO_PACK_PRICE = "";
});
it("does not let expiration add-ons replace the storage allowance", async () => {
await BillingGrant.create([{ _id: "extend", owner, scope: "repo:repo", status: "paid", until: future(), planId: "repo_extend" },
{ _id: "never", owner, scope: "repo:repo", status: "paid", until: future(), planId: "repo_never" }]);
expect((await allowance(owner, "repo")).plan).to.equal("free");
await BillingGrant.create({ _id: "pass", owner, scope: "repo:repo", status: "paid", until: future(), planId: "author_pass" });
expect((await allowance(owner, "repo")).plan).to.equal("author_pass");
});
it("limits dated expiration by free window, add-on, subscription and conference", async () => {
const { maximumExpiration } = require("../src/core/billing/expiration");
const now = new Date("2026-01-10T12:00:00Z");
const months = (date, n) => { const d = new Date(date); d.setMonth(d.getMonth() + n); d.setHours(23, 59, 59, 999); return d.getTime(); };
expect((await maximumExpiration(owner, "repo", undefined, now)).getTime()).to.equal(months(now, 5));
const extended = new Date("2027-06-01T00:00:00Z");
await BillingGrant.create({ _id: "extend", owner, scope: "repo:repo", status: "paid", until: extended, planId: "repo_extend" });
const withAddon = await maximumExpiration(owner, "repo", undefined, now);
expect(withAddon.getTime()).to.be.greaterThan(extended.getTime() - 86400000);
expect((await maximumExpiration(owner, "other", undefined, now)).getTime()).to.equal(months(now, 5));
await BillingGrant.updateOne({ _id: "extend" }, { $set: { revoked: true } });
expect((await maximumExpiration(owner, "repo", undefined, now)).getTime()).to.equal(months(now, 5));
await Conference.create({ conferenceID: "LONG", owners: [owner], status: "ready", startDate: now, endDate: new Date("2026-09-01T00:00:00Z"), plan: { planID: "conference" } });
expect((await maximumExpiration(owner, "repo", "LONG", now)).toISOString()).to.equal("2026-09-01T00:00:00.000Z");
await BillingGrant.create({ _id: "sub", owner, scope: `user:${owner}`, status: "active", until: new Date("2026-02-10T00:00:00Z"), planId: "author_monthly" });
expect((await maximumExpiration(owner, "repo", undefined, now)).getTime()).to.equal(months(now, 12));
});
it("lets a Never expire add-on keep one repository online until it is revoked", async () => {
const { neverExpirationDeadline, reconcileNeverExpiration, NEVER_EXPIRATION_GRACE_MS } = require("../src/core/billing/expiration");
await rejects(neverExpirationDeadline(owner, undefined, "repo"), "subscription_required_for_never_expiration");
await BillingGrant.create({ _id: "never", owner, scope: "repo:repo", status: "paid", until: new Date("9999-12-31T00:00:00Z"), planId: "repo_never" });
const before = Date.now();
const deadline = await neverExpirationDeadline(owner, undefined, "repo");
expect(deadline.getTime()).to.be.at.least(before + NEVER_EXPIRATION_GRACE_MS);
await rejects(neverExpirationDeadline(owner, undefined, "other"), "subscription_required_for_never_expiration");
await Repo.create({ repoId: "repo", owner, status: "ready", options: { expirationMode: "never" }, billingNeverExpiresUntil: new Date(Date.now() + 86400000) });
// A valid add-on rolls a close deadline forward to the full grace period.
await reconcileNeverExpiration();
const rolled = (await Repo.findOne({ repoId: "repo" })).billingNeverExpiresUntil.getTime();
expect(rolled).to.be.at.least(before + NEVER_EXPIRATION_GRACE_MS);
// A recent deadline is not rewritten on every run.
await reconcileNeverExpiration(new Date(Date.now() + 86400000));
expect((await Repo.findOne({ repoId: "repo" })).billingNeverExpiresUntil.getTime()).to.equal(rolled);
// After a refund the deadline stops moving and the repository expires.
await BillingGrant.updateOne({ _id: "never" }, { $set: { revoked: true } });
await reconcileNeverExpiration(new Date(rolled), async () => {});
expect((await Repo.findOne({ repoId: "repo" })).options.expirationMode).to.equal("remove");
});
it("ignores an unfinished recurring Checkout before Stripe assigns a subscription", async () => {
const stripe = { checkout: { sessions: { retrieve: async () => ({
id: "cs_open", metadata: { application: "anonymous-github", owner, planId: "author_monthly" },
@@ -350,6 +452,18 @@ async function rejects(promise, message) {
await BillingGrant.updateOne({ _id: "cs_conf" }, { $set: { revoked: true } });
await rejects(assertConferenceAdmission(owner, "repo", "CONF"), "conference_payment_required");
});
it("activates a smaller conference package with its own capacity and only for its tier", async () => {
await Conference.create({ conferenceID: "WS", owners: [owner], status: "pending_payment", startDate: new Date(Date.now() - 1000), endDate: future(), plan: { planID: "conference_workshop" } });
await rejects(createCheckout(owner, "conference_large", "WS", {}), "billing_invalid_conference");
const value = session(); value.id = "cs_ws"; value.metadata.planId = "conference_workshop"; value.metadata.target = "WS"; value.line_items.data[0].price.id = "price_conf_small";
await fulfillCheckout("cs_ws", { checkout: { sessions: { retrieve: async () => value } } });
expect((await Conference.findOne({ conferenceID: "WS" })).status).to.equal("ready");
const limits = await allowance(owner, "repo", "WS");
expect(limits.scope).to.equal("conference:WS");
expect(limits.plan).to.equal("conference_workshop");
expect(limits.repositories).to.equal(25);
await assertConferenceAdmission(owner, "repo", "WS");
});
it("prevents two simultaneous checkout sessions and reuses an open session", async () => {
let creates = 0;
const stripe = { prices: { retrieve: async () => ({ active: true, currency: "eur", unit_amount: 500, recurring: { interval: "month", interval_count: 1 } }) }, subscriptions: { list: async () => ({ data: [] }) }, checkout: { sessions: {
@@ -389,7 +503,7 @@ async function rejects(promise, message) {
const Users = require("../src/core/model/users/users.model").default;
const routes = require("../src/server/routes/billing");
await Users.updateOne({ _id: owner }, { $set: { username: "billing-test-author", status: "active" } }, { upsert: true });
Object.assign(config, { STRIPE_SECRET_KEY: "sk_test_example", STRIPE_WEBHOOK_SECRET: "whsec_test_example", STRIPE_AUTHOR_MONTHLY_PRICE: "", STRIPE_AUTHOR_ANNUAL_PRICE: "", STRIPE_AUTHOR_PASS_PRICE: "", STRIPE_CONFERENCE_PRICE: "" });
Object.assign(config, { STRIPE_SECRET_KEY: "sk_test_example", STRIPE_WEBHOOK_SECRET: "whsec_test_example", STRIPE_AUTHOR_MONTHLY_PRICE: "", STRIPE_AUTHOR_ANNUAL_PRICE: "", STRIPE_AUTHOR_PASS_PRICE: "", STRIPE_CONFERENCE_PRICE: "", STRIPE_CONFERENCE_SMALL_PRICE: "", STRIPE_CONFERENCE_LARGE_PRICE: "" });
const app = express();
app.use("/webhook", routes.billingWebhook);
app.use(express.json());
+45
View File
@@ -35,8 +35,33 @@ describe("paid access policy", () => {
}
expect(() => validateBillingExpiration({ expirationMode: "never" })).not.to.throw();
expect(() => validateBillingExpiration({ expirationMode: "remove", expirationDate: new Date() })).not.to.throw();
// The free window is FREE_EXPIRATION_MONTHS (5) months, not a year.
const sevenMonths = new Date(); sevenMonths.setMonth(sevenMonths.getMonth() + 7);
expect(() => validateBillingExpiration({ expirationMode: "remove", expirationDate: sevenMonths })).to.throw("invalid_billing_expiration");
// An already saved later date stays valid, and a paid limit allows it.
expect(() => validateBillingExpiration({ expirationMode: "remove", expirationDate: sevenMonths }, undefined, new Date(sevenMonths))).not.to.throw();
const limit = new Date(); limit.setFullYear(limit.getFullYear() + 2);
expect(() => validateBillingExpiration({ expirationMode: "remove", expirationDate: sevenMonths }, limit)).not.to.throw();
} finally { config.BILLING_ENABLED = enabled; }
});
it("summarizes purchases without internal IDs or the no-end sentinel", async () => {
const { purchaseHistory } = require("../src/server/routes/billing");
const never = new Date("9999-12-31T00:00:00Z"), later = new Date(Date.now() + 86400000);
const rows = await purchaseHistory([
{ _id: "cs_1:repo_slot", planId: "repo_slot", scope: "repo:paper", status: "paid", until: never },
{ _id: "cs_1:author_pass", planId: "author_pass", scope: "repo:paper", status: "paid", until: later },
{ _id: "cs_1:repo_extend", planId: "repo_extend", scope: "repo:paper", status: "paid", until: later },
{ _id: "cs_2", planId: "repo_never", scope: "repo:other", status: "paid", until: never, revoked: true, revocationReason: "full_refund" },
{ _id: "sub_1", planId: "author_monthly", scope: "user:me", status: "past_due", until: later },
]);
expect(rows).to.have.length(3);
const pack = rows.find(r => r.planId === "repo_pack");
expect(pack).to.include({ repoId: "paper", status: "active", until: null });
expect(pack.parts.find(p => p.planId === "repo_slot").until).to.equal(null);
expect(rows.find(r => r.planId === "repo_never")).to.include({ status: "refunded", until: null });
expect(rows.find(r => r.planId === "author_monthly")).to.include({ status: "overdue", recurring: true, repoId: null });
expect(JSON.stringify(rows)).not.to.include("cs_");
});
it("rejects unsafe configured redirect origins", () => {
const original = config.BILLING_ORIGIN;
try {
@@ -87,6 +112,26 @@ describe("retired conference offers", () => {
expect(model.status).to.equal("ready");
expect(model.plan.planID).to.equal("conference");
});
it("only offers the paid review package when billing is enabled", async () => {
const { offeredConferencePlans } = require("../src/server/routes/conference");
config.BILLING_ENABLED = true; config.STRIPE_CONFERENCE_PRICE = "price_conf";
expect(offeredConferencePlans().map(p => p.id)).to.deep.equal(["conference"]);
config.STRIPE_CONFERENCE_SMALL_PRICE = "price_small"; config.STRIPE_CONFERENCE_LARGE_PRICE = "price_large";
expect(offeredConferencePlans().map(p => p.id)).to.deep.equal(["conference_workshop", "conference", "conference_large"]);
expect(offeredConferencePlans().find(p => p.id === "conference_workshop").repositories).to.equal(config.PAID_CONFERENCE_SMALL_REPOSITORIES);
expect(offeredConferencePlans()[0]).to.not.have.property("priceId");
config.STRIPE_CONFERENCE_SMALL_PRICE = ""; config.STRIPE_CONFERENCE_LARGE_PRICE = "";
config.BILLING_ENABLED = false;
expect(offeredConferencePlans().map(p => p.id)).to.deep.equal(["free_conference"]);
});
it("rejects new free conferences when billing is enabled", async () => {
config.BILLING_ENABLED = true; config.STRIPE_CONFERENCE_PRICE = "price_conf";
body.plan.planID = "free_conference";
let error;
try { await save({ params: {}, body }, { send: () => {} }); } catch (e) { error = e; }
expect(error?.message).to.equal("invalid_plan");
expect(saves).to.equal(0);
});
for (const change of ["new", "plan", "dates"]) {
it(`still rejects ${change} purchases or locked changes`, async () => {
const params = change === "new" ? {} : { conferenceID: "CONF" };
+11 -4
View File
@@ -66,9 +66,14 @@ describe("repository activity dates", () => {
expect(saved.source.commit).to.equal("abc123");
expect(saved).not.to.have.property("publishedAt");
});
function freeLimit() {
const expiration = require("../src/core/billing/expiration");
stub(expiration, "maximumExpiration", async () => expiration.freeExpirationLimit());
}
it("allows privacy-only saves after hosting capacity or conference admission ends", async () => {
const repo = repository(); auth(repo);
stub(require("../src/config").default, "BILLING_ENABLED", true);
freeLimit();
const quotas = require("../src/core/billing/quotas");
repo.model.billingManaged = true;
repo.model.conference = "ended-conf";
@@ -86,6 +91,7 @@ describe("repository activity dates", () => {
it("rejects an oversized edited source before removing the published files", async () => {
const repo = repository(); auth(repo); repo.model.billingManaged = true;
stub(require("../src/config").default, "BILLING_ENABLED", true);
freeLimit();
const quotas = require("../src/core/billing/quotas");
const access = require("../src/core/github-app");
const github = require("../src/core/source/GitHubRepository");
@@ -124,21 +130,22 @@ describe("repository activity dates", () => {
else expect(saved.$unset).to.have.property("billingNeverExpiresUntil", 1);
});
}
it("rejects repeated extensions beyond one year before changing stored settings", async () => {
it("caps extensions at the repository's limit before changing stored settings", async () => {
const repo = repository(); auth(repo);
stub(require("../src/config").default, "BILLING_ENABLED", true);
freeLimit();
repo.model.options.expirationMode = "remove";
repo.model.options.expirationDate = new Date();
let writes = 0;
stub(Model, "updateOne", () => ({ exec: async () => { writes++; return { matchedCount: 1 }; } }));
const extend = handler("repository-private", "/:repoId/extend", "post");
await extend({}, { json: () => {} });
await extend({}, { json: () => {} });
const savedDate = repo.model.options.expirationDate;
expect(savedDate.getTime()).to.equal(require("../src/core/billing/expiration").freeExpirationLimit().getTime());
let error;
try { await extend({}, { json: () => {} }); } catch (caught) { error = caught; }
expect(error?.message).to.equal("invalid_billing_expiration");
expect(writes).to.equal(2);
expect(error?.message).to.equal("expiration_limit_reached");
expect(writes).to.equal(1);
expect(repo.model.options.expirationDate).to.deep.equal(savedDate);
});
it("does not extend an expired submission in a conference that no longer admits it", async () => {
+64 -3
View File
@@ -244,11 +244,12 @@ describe("Vue 3 UI", function () {
ui.window.document.querySelector(".nav-upgrade").click();
await delay(10);
expect(ui.window.document.activeElement.id).to.equal("settings-plan-comparison");
expect(ui.window.document.querySelector(".settings-comparison").textContent).to.include("Scheduled or never");
expect(ui.window.document.querySelector(".settings-comparison").textContent).to.include("Latest expiration");
expect(ui.window.document.querySelector(".settings-comparison").textContent).to.include("Automatic updates");
expect(ui.window.document.querySelectorAll(".settings-nav button")[1].getAttribute("aria-current")).to.equal("page");
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("/ month");
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("10 GiB");
ui.window.document.querySelector(".billing-plan button").click();
[...ui.window.document.querySelectorAll(".billing-offer .btn")].find(b => b.textContent.trim() === "Subscribe").click();
await delay(30);
const request = ui.requests.find(r => r.url.pathname === "/api/billing/checkout");
expect(request.payload).to.deep.equal({ planId: "author_monthly", target: "" });
@@ -257,6 +258,32 @@ describe("Vue 3 UI", function () {
expect(ui.errors).to.deep.equal([]);
});
it("lists purchases in plain words, grouped as active and ended", async () => {
ui = await browser("/profile?billing=portal", {
"/api/user": { username: "author" },
"/api/billing/": { enabled: true, csrf: "billing-token", portal: true, plans: [], allowance: { repositories: 5, storage: 512 },
grants: [{ planId: "author_annual", status: "active", until: "2099-01-01", cancelAtPeriodEnd: true }],
purchases: [
{ id: "a", planId: "repo_pack", repoId: "paper-repo", status: "active", until: null, purchasedAt: "2026-01-02", amount: 500, currency: "eur",
parts: [{ planId: "repo_slot", until: null, status: "active" }, { planId: "author_pass", until: "2099-01-01", status: "active" }, { planId: "repo_extend", until: "2099-01-01", status: "active" }] },
{ id: "b", planId: "repo_never", repoId: "old-repo", status: "refunded", until: null, parts: [] },
] },
"/api/billing/refresh": { ok: true, pending: false },
});
await delay(100);
const text = ui.window.document.querySelector("#settings-billing").textContent;
expect(text).to.include("Paper pack");
expect(text).to.include("Extra repository · Live updates until");
expect(text).to.include("Refunded");
expect(text).not.to.include("9999");
expect(text).not.to.include("repo:");
// Returning from the billing portal is not a pending payment.
expect(text).not.to.include("Confirming your payment");
expect(text).to.include("Renewal cancelled. Your access continues until");
expect(ui.window.document.querySelector('a[href="/anonymize/paper-repo"]')).not.to.equal(null);
expect(ui.errors).to.deep.equal([]);
});
it("refreshes the quota panel when checkout confirmation changes the allowance", async () => {
let confirmed = false;
let quotaReads = 0;
@@ -518,7 +545,7 @@ describe("Vue 3 UI", function () {
expect(doc.querySelector('.anonymize-preview-body').textContent).to.include("MASKED");
expect(doc.querySelector('.anonymize-preview-body').textContent).not.to.include("Private Author");
state.options.expirationMode = "never"; await delay(20);
expect(doc.querySelector('.subscription-choice').textContent).to.include("20 repositories");
expect(doc.querySelector('.hosting-options').textContent).to.include("20 repositories");
expect(doc.querySelector('.anonymize-submit-bar').textContent).to.include("Continue to checkout");
doc.querySelector('.preview-expand').click(); await delay(10);
expect(doc.querySelector('.anonymize-page').classList.contains('preview-expanded')).to.equal(true);
@@ -577,6 +604,9 @@ describe("Vue 3 UI", function () {
state.hostingQuotaBlocked = true;
expect(state.hostingNeedsPayment()).to.equal(true);
state.conference = "CONF";
state.conference_data = { name: "Unpaid", hosting: false };
expect(state.hostingNeedsPayment()).to.equal(true);
state.conference_data = { name: "Paid", hosting: true };
expect(state.hostingNeedsPayment()).to.equal(false);
state.options.expirationMode = "never";
expect(state.hostingNeedsPayment()).to.equal(true);
@@ -934,6 +964,37 @@ describe("Vue 3 UI", function () {
expect(ui.errors).to.deep.equal([]);
});
it("offers a paid review package per venue size for a new conference", async () => {
ui = await browser("/conference/new", {
"/api/user": { username: "owner" },
"/api/conferences/plans": [
{ id: "conference_workshop", name: "Workshop", pricePerRepo: 0, repositories: 25, storagePerRepo: 10737418240 },
{ id: "conference", name: "Conference", pricePerRepo: 0, repositories: 100, storagePerRepo: 53687091200 },
{ id: "conference_large", name: "Large conference", pricePerRepo: 0, repositories: 400, storagePerRepo: 214748364800 }],
"/api/billing/": { enabled: true, grants: [], plans: [
{ id: "conference_workshop", amount: 4900, currency: "eur", repositories: 25, storage: 10737418240 },
{ id: "conference", amount: 14900, currency: "eur", repositories: 100, storage: 53687091200 },
{ id: "conference_large", amount: 39900, currency: "eur", repositories: 400, storage: 214748364800 }] },
});
await delay(30);
const doc = ui.window.document;
const tiers = [...doc.querySelectorAll(".conference-tier")];
expect(tiers.map(t => t.querySelector(".conference-tier-head strong").textContent)).to.deep.equal(["Workshop", "Conference", "Large conference"]);
expect(tiers[0].textContent).to.include("49").and.include("Up to 25 papers");
expect(doc.querySelector('input[name="planPicker"]:checked').value).to.equal("conference");
expect(doc.querySelector("#conf-plan").textContent).to.not.include("Free");
doc.querySelector('input[value="conference_workshop"]').click();
await delay(20);
expect(tiers[0].classList.contains("is-selected")).to.equal(true);
await ui.input("#name", "Review venue");
await ui.input("#conferenceID", "REVIEW26");
doc.querySelector("#send").click();
await delay(30);
const request = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/conferences/");
expect(request?.payload.plan.planID).to.equal("conference_workshop");
expect(ui.errors).to.deep.equal([]);
});
it("submits billing details for a new paid conference", async () => {
ui = await browser("/conference/new", {
"/api/user": { username: "owner" },