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