mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
fix: protect reactivation claims and retain historical purchase prices
This commit is contained in:
1 parent
afd7a8ff3b
commit
4dc4454459
8 files changed
+165
-13
No files matched your search
@@ -189,7 +189,7 @@ HTTP endpoints.
|
||||
### Restore and launch checks
|
||||
|
||||
Back up `billingaccounts`, `billinggrants`, `billingquotas`, `billinglocks`, `billingevents`,
|
||||
`billingrevocations`, and `billingaudits` with repository data. Test a restore into an
|
||||
`billingrevocations`, `billingprices`, and `billingaudits` with repository data. Test a restore into an
|
||||
isolated environment with sandbox credentials. Keep destructive repository maintenance
|
||||
stopped during restore, discover customer mappings, reconcile restored owners, drain
|
||||
queued events, and inspect health before re-enabling normal scheduling. Never connect a
|
||||
@@ -263,3 +263,8 @@ Reservations remain conservative across generations and failed fills and require
|
||||
operator reconciliation to release unused capacity. This also covers public raw
|
||||
downloads and expanded submodule content. Existing cached content is not retroactively
|
||||
measured by this write-time guard.
|
||||
|
||||
Accepted one-time prices are stored in `billingprices` when checkout starts and when
|
||||
existing purchases are fulfilled. Hiding an offer by removing its configured price
|
||||
does not remove this mapping or invalidate paid grants. Include the mapping in backups
|
||||
so reconciliation can reconstruct grants for retired offers and process their refunds.
|
||||
@@ -11,12 +11,13 @@ 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,
|
||||
priceId: String,
|
||||
revision: { type: Number, default: 0 }, revoked: { type: Boolean, default: false },
|
||||
revocationReason: String, revokedAt: Date,
|
||||
}));
|
||||
export const BillingQuota = model("billingQuota", new Schema({
|
||||
_id: String,
|
||||
allocations: [{ _id: false, repoId: String, bytes: Number, baseBytes: Number,
|
||||
allocations: [{ _id: false, repoId: String, reservationId: String, bytes: Number, baseBytes: Number,
|
||||
expansions: [{ _id: false, key: String, bytes: Number }], createdAt: Date }],
|
||||
}));
|
||||
// A short lease serializes Stripe checkout creation across API instances.
|
||||
@@ -45,3 +46,6 @@ export const BillingDraft = model("billingDraft", new Schema({
|
||||
_id: String, owner: { type: String, required: true, index: true }, path: String, values: Schema.Types.Mixed,
|
||||
expiresAt: { type: Date, required: true, expires: 0 },
|
||||
}));
|
||||
|
||||
// Accepted prices survive removal from the advertised catalog.
|
||||
export const BillingPrice = model("billingPrice", new Schema({ _id: String, planId: String }));
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import ConferenceModel from "../model/conference/conferences.model";
|
||||
import config from "../../config";
|
||||
import { BillingGrant, BillingQuota } from "./models";
|
||||
@@ -35,14 +36,16 @@ export async function reserveRepository(owner: string, repoId: string, bytes: nu
|
||||
for (const repository of existing) {
|
||||
if ((await allowance(String(repository.owner), repository.repoId, repository.conference)).scope === limits.scope) funded.push(repository);
|
||||
}
|
||||
try { await BillingQuota.create({ _id: limits.scope, allocations: funded.map(r => ({ repoId: r.repoId, bytes: r.size?.storage || 0, createdAt: new Date() })) }); }
|
||||
try { await BillingQuota.create({ _id: limits.scope, allocations: funded.map(r => ({ repoId: r.repoId, reservationId: randomUUID(), bytes: r.size?.storage || 0, createdAt: new Date() })) }); }
|
||||
catch (error) { if ((error as { code?: number }).code !== 11000) throw error; }
|
||||
}
|
||||
// Only confirmed terminal repositories release capacity. Unknown reservations
|
||||
// remain until reconciled by an operator, so a crash cannot oversell capacity.
|
||||
const ledger = await BillingQuota.findById(limits.scope).lean();
|
||||
const terminal = await RepositoryModel.find({ repoId: { $in: ledger?.allocations.map(a => a.repoId) || [] }, status: "removed" }).select("repoId").lean();
|
||||
if (terminal.length) await BillingQuota.updateOne({ _id: limits.scope }, { $pull: { allocations: { repoId: { $in: terminal.map(r => r.repoId) } } } });
|
||||
if (terminal.length) await BillingQuota.updateOne({ _id: limits.scope }, { $pull: { allocations: { $or: terminal.map(r => ({
|
||||
repoId: r.repoId, reservationId: ledger?.allocations.find(a => a.repoId === r.repoId)?.reservationId || { $exists: false },
|
||||
})) } } });
|
||||
const others = allocationExpression(repoId);
|
||||
// Never shrink a reservation during refresh. Concurrent generations and
|
||||
// failed builds cannot free bytes still needed by the published generation.
|
||||
@@ -61,7 +64,7 @@ export async function reserveRepository(owner: string, repoId: string, bytes: nu
|
||||
const result = await BillingQuota.updateOne({ _id: limits.scope, $expr: { $and: [
|
||||
{ $lt: [{ $size: others }, limits.repositories] },
|
||||
{ $lte: [{ $add: [{ $sum: { $map: { input: others, as: "a", in: "$$a.bytes" } } }, reservedBytes] }, limits.storage] },
|
||||
] } }, [{ $set: { allocations: { $concatArrays: [others, [{ repoId: { $literal: repoId }, bytes: reservedBytes, baseBytes, expansions, createdAt: new Date() }]] } } }]);
|
||||
] } }, [{ $set: { allocations: { $concatArrays: [others, [{ repoId: { $literal: repoId }, reservationId: randomUUID(), bytes: reservedBytes, baseBytes, expansions, createdAt: new Date() }]] } } }]);
|
||||
if (!result.matchedCount) throw billingError("quota_exceeded", 402);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Stripe = require("stripe");
|
||||
import { randomUUID } from "crypto";
|
||||
import config from "../../config";
|
||||
import { BillingAccount, BillingGrant, BillingLock, BillingRevocation } from "./models";
|
||||
import { BillingAccount, BillingGrant, BillingLock, BillingRevocation, BillingPrice } from "./models";
|
||||
import { billingError, billingOrigin, billingPlans, requireBilling } from "./config";
|
||||
import ConferenceModel from "../model/conference/conferences.model";
|
||||
import RepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
@@ -68,7 +68,16 @@ export async function fulfillCheckout(id: string, stripe = stripeClient()) {
|
||||
if (session.status !== "complete") return;
|
||||
if (session.subscription) { await syncSubscription(objectId(session.subscription)!, stripe); return; }
|
||||
const plan = billingPlans().find(p => p.id === planId && !p.recurring);
|
||||
if (!plan || session.line_items?.data.length !== 1 || session.line_items.data[0].price?.id !== plan.priceId || session.line_items.data[0].quantity !== 1) throw billingError("billing_invalid_purchase");
|
||||
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 acceptedGrant = previousGrant?.owner === owner && previousGrant?.customerId === account.customerId
|
||||
&& previousGrant?.planId === planId && previousGrant?.scope === previousScope
|
||||
&& (!previousGrant.priceId || previousGrant.priceId === priceId);
|
||||
if (!plan || !priceId || session.line_items?.data.length !== 1 || session.line_items.data[0].quantity !== 1 ||
|
||||
!(priceId === plan.priceId || historicalPrice?.planId === planId || acceptedGrant)) throw billingError("billing_invalid_purchase");
|
||||
await BillingPrice.updateOne({ _id: priceId }, { $setOnInsert: { planId } }, { upsert: true });
|
||||
if (session.payment_status !== "paid" || session.status !== "complete") return;
|
||||
const intent = session.payment_intent as Stripe.PaymentIntent | null;
|
||||
const charge = intent?.latest_charge as Stripe.Charge | null;
|
||||
@@ -82,7 +91,7 @@ export async function fulfillCheckout(id: string, stripe = stripeClient()) {
|
||||
until = conference.endDate;
|
||||
}
|
||||
await BillingGrant.updateOne({ _id: id }, { $setOnInsert: { owner, scope, planId, customerId: account.customerId,
|
||||
paymentIntentId: objectId(session.payment_intent), status: "paid", until }, ...(revoked ? { $set: { revoked: true } } : {}) }, { upsert: true });
|
||||
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 } });
|
||||
@@ -223,6 +232,10 @@ export async function createCheckout(owner: string, planId: string, target = "",
|
||||
account.checkout.params = params;
|
||||
await account.save();
|
||||
}
|
||||
if (!plan.recurring) {
|
||||
const acceptedPrice = (account.checkout.params as Stripe.Checkout.SessionCreateParams).line_items?.[0]?.price;
|
||||
if (acceptedPrice) await BillingPrice.updateOne({ _id: acceptedPrice }, { $setOnInsert: { planId } }, { upsert: true });
|
||||
}
|
||||
const session = await stripe.checkout.sessions.create(account.checkout.params as Stripe.Checkout.SessionCreateParams, { idempotencyKey: account.checkout.key });
|
||||
account.checkout.id = session.id;
|
||||
await account.save();
|
||||
|
||||
@@ -207,6 +207,7 @@ router.post(
|
||||
);
|
||||
validateBillingExpiration({ ...repo.model.options, expirationDate: newExpiration });
|
||||
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> = {
|
||||
"options.expirationDate": newExpiration,
|
||||
settingsSavedAt: new Date(),
|
||||
@@ -498,6 +499,7 @@ export function shouldReactivateInactiveRepository(
|
||||
router.post(
|
||||
"/:repoId/",
|
||||
async (req, res) => {
|
||||
let recoverReactivation: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
const repo = await getRepo(req, res, {
|
||||
nocheck: true,
|
||||
@@ -551,6 +553,22 @@ router.post(
|
||||
const needsAdmission = sourceChanged || reactivating || repoUpdate.conference !== repo.model.conference;
|
||||
if (needsAdmission) {
|
||||
await assertConferenceAdmission(String(repo.model.owner), repo.repoId, repoUpdate.conference);
|
||||
if (reactivating && config.BILLING_ENABLED && repo.model.billingManaged) {
|
||||
const previousStatus = repo.status;
|
||||
const previousStatusDate = repo.model.statusDate;
|
||||
const claimedAt = new Date();
|
||||
const claim = await AnonymizedRepositoryModel.updateOne({ _id: repo.model._id,
|
||||
status: previousStatus, statusDate: previousStatusDate,
|
||||
}, { $set: { status: RepositoryStatus.PREPARING, statusDate: claimedAt } }).exec();
|
||||
if (!claim.matchedCount) throw appError("invalid_status", 409);
|
||||
repo.model.status = RepositoryStatus.PREPARING;
|
||||
repo.model.statusDate = claimedAt;
|
||||
recoverReactivation = async () => {
|
||||
await AnonymizedRepositoryModel.updateOne({ _id: repo.model._id,
|
||||
status: RepositoryStatus.PREPARING, statusDate: claimedAt,
|
||||
}, { $set: { status: previousStatus, statusDate: previousStatusDate || new Date() } }).exec();
|
||||
};
|
||||
}
|
||||
if (repo.model.billingManaged && !sourceChanged) await reserveRepository(String(repo.model.owner), repo.repoId, repo.model.size?.storage || 0, repoUpdate.conference);
|
||||
}
|
||||
|
||||
@@ -673,6 +691,7 @@ router.post(
|
||||
}
|
||||
).exec();
|
||||
if (!saved.matchedCount) throw appError("connection_changed", 409);
|
||||
recoverReactivation = undefined;
|
||||
if (user.id === repo.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "repository", id: String(repo.model._id) });
|
||||
if (!sourceChanged && !reactivating) {
|
||||
return res.json({ status: repo.status });
|
||||
@@ -684,6 +703,7 @@ router.post(
|
||||
await enqueueSavedSnapshot(repo.model);
|
||||
res.json({ status: repo.status });
|
||||
} catch (error) {
|
||||
if (recoverReactivation) await recoverReactivation();
|
||||
return handleError(error, res, req);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ const { expect } = require("chai");
|
||||
require("ts-node/register/transpile-only");
|
||||
const mongoose = require("mongoose");
|
||||
const config = require("../src/config").default;
|
||||
const { BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft } = require("../src/core/billing/models");
|
||||
const { BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft } = require("../src/core/billing/models");
|
||||
const Repo = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
|
||||
const Conference = require("../src/core/model/conference/conferences.model").default;
|
||||
const { reserveRepository, allowance, assertConferenceAdmission } = require("../src/core/billing/quotas");
|
||||
@@ -21,12 +21,12 @@ async function rejects(promise, message) {
|
||||
before(async () => {
|
||||
if (!/^billing_test_/.test(new URL(uri).pathname.slice(1))) throw new Error("Use a disposable billing_test_ database");
|
||||
await mongoose.connect(uri);
|
||||
await Promise.all([BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.init()));
|
||||
await Promise.all([BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.init()));
|
||||
});
|
||||
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" });
|
||||
await Promise.all([BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.deleteMany({})));
|
||||
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" });
|
||||
});
|
||||
after(async () => { Object.assign(config, original); await mongoose.disconnect(); });
|
||||
@@ -108,6 +108,61 @@ async function rejects(promise, message) {
|
||||
} else await reserveRepository(owner, "competitor", 1);
|
||||
});
|
||||
}
|
||||
for (const fails of [false, true]) {
|
||||
it(`claims removed restoration before admission and ${fails ? "recovers a failed admission" : "protects it from a competitor"}`, async () => {
|
||||
const Repository = require("../src/core/Repository").default;
|
||||
const utils = require("../src/server/routes/route-utils");
|
||||
const quotas = require("../src/core/billing/quotas");
|
||||
const queue = require("../src/queue");
|
||||
const saved = { getRepo: utils.getRepo, getUser: utils.getUser, handleError: utils.handleError, reserve: quotas.reserveRepository, queue: queue.downloadQueue };
|
||||
config.FREE_AUTHOR_REPOSITORIES = 1;
|
||||
const model = await Repo.create({ repoId: "restore", owner, billingManaged: true, status: "removed", statusDate: new Date(0),
|
||||
source: { type: "GitHubStream", repositoryName: "owner/repo", commit: "abc123", branch: "main" }, size: { storage: 10 } });
|
||||
utils.getRepo = async () => new Repository(model);
|
||||
utils.getUser = async () => ({ isAdmin: true });
|
||||
utils.handleError = error => { throw error; };
|
||||
let queued = false;
|
||||
queue.downloadQueue = { add: async () => { queued = true; } };
|
||||
quotas.reserveRepository = async (...args) => {
|
||||
expect((await Repo.findById(model._id)).status).to.equal("preparing");
|
||||
if (fails) throw Error("quota_exceeded");
|
||||
await saved.reserve(...args);
|
||||
await rejects(saved.reserve(owner, "competitor", 1), "quota_exceeded");
|
||||
};
|
||||
try {
|
||||
const router = require("../src/server/routes/repository-private").default;
|
||||
const handler = router.stack.find(layer => layer.route?.path === "/:repoId/" && layer.route.methods.post).route.stack[0].handle;
|
||||
const work = handler({ body: { repoId: "restore", fullName: "owner/repo", source: { commit: "abc123", branch: "main" }, terms: [],
|
||||
options: { expirationMode: "remove", expirationDate: future() } } }, { json() {} });
|
||||
if (fails) await rejects(work, "quota_exceeded"); else await work;
|
||||
expect(queued).to.equal(!fails);
|
||||
expect((await Repo.findById(model._id)).status).to.equal(fails ? "removed" : "preparing");
|
||||
} finally {
|
||||
utils.getRepo = saved.getRepo; utils.getUser = saved.getUser; utils.handleError = saved.handleError;
|
||||
quotas.reserveRepository = saved.reserve; queue.downloadQueue = saved.queue;
|
||||
}
|
||||
});
|
||||
}
|
||||
it("does not let stale removal cleanup delete a fresh reservation", async () => {
|
||||
config.FREE_AUTHOR_REPOSITORIES = 1;
|
||||
await Repo.create({ repoId: "restoring", owner, status: "ready", size: { storage: 10 } });
|
||||
await reserveRepository(owner, "restoring", 10);
|
||||
await Repo.updateOne({ repoId: "restoring" }, { $set: { status: "removed" } });
|
||||
const update = BillingQuota.updateOne;
|
||||
let interleaved = false;
|
||||
BillingQuota.updateOne = async function (...args) {
|
||||
if (args[1].$pull && !interleaved) {
|
||||
interleaved = true;
|
||||
await Repo.updateOne({ repoId: "restoring" }, { $set: { status: "preparing" } });
|
||||
await reserveRepository(owner, "restoring", 20);
|
||||
}
|
||||
return update.apply(this, args);
|
||||
};
|
||||
try { await rejects(reserveRepository(owner, "competitor", 1), "quota_exceeded"); }
|
||||
finally { BillingQuota.updateOne = update; }
|
||||
expect(interleaved).to.equal(true);
|
||||
expect((await BillingQuota.findById(`user:${owner}`)).allocations[0].bytes).to.equal(20);
|
||||
});
|
||||
it("counts legacy repositories and releases removed repositories", async () => {
|
||||
await Repo.create(["one", "two", "three"].map(repoId => ({ repoId, owner, status: "ready", size: { storage: 10 } })));
|
||||
await rejects(reserveRepository(owner, "four", 1), "quota_exceeded");
|
||||
@@ -199,6 +254,29 @@ async function rejects(promise, message) {
|
||||
await fulfillCheckout("cs_pass", stripe);
|
||||
expect((await BillingGrant.findById("cs_pass")).revoked).to.equal(true);
|
||||
});
|
||||
it("reconciles retired one-time prices and continues refund recovery", async () => {
|
||||
const { reconcileBillingAccount } = require("../src/core/billing/reconciliation");
|
||||
const iterable = values => ({ async *[Symbol.asyncIterator]() { yield* values; } });
|
||||
const value = session();
|
||||
const stripe = { checkout: { sessions: { retrieve: async () => value, list: () => iterable([value]) } },
|
||||
subscriptions: { list: () => iterable([]) }, charges: { list: () => iterable([{ payment_intent: "pi_pass", refunded: true }]) },
|
||||
invoicePayments: { list: async () => ({ data: [] }) } };
|
||||
await fulfillCheckout(value.id, stripe);
|
||||
config.STRIPE_AUTHOR_PASS_PRICE = "";
|
||||
await BillingGrant.deleteMany({});
|
||||
await reconcileBillingAccount(owner, stripe);
|
||||
expect((await BillingGrant.findById(value.id)).revoked).to.equal(true);
|
||||
expect((await BillingAccount.findById(owner)).reconciledAt).to.be.instanceOf(Date);
|
||||
value.line_items.data[0].price.id = "price_unrecognized";
|
||||
await rejects(fulfillCheckout(value.id, stripe), "billing_invalid_purchase");
|
||||
});
|
||||
it("preserves previously accepted grants without historical price fields", async () => {
|
||||
const value = session();
|
||||
await BillingGrant.create({ _id: value.id, owner, customerId: "cus_owner", scope: "repo:repo", planId: "author_pass", status: "paid" });
|
||||
config.STRIPE_AUTHOR_PASS_PRICE = "";
|
||||
await fulfillCheckout(value.id, { checkout: { sessions: { retrieve: async () => value } } });
|
||||
expect((await BillingPrice.findById("price_pass")).planId).to.equal("author_pass");
|
||||
});
|
||||
it("rejects a checkout for a different Stripe customer", async () => {
|
||||
const value = session(); value.customer = "cus_other";
|
||||
await rejects(fulfillCheckout("cs_pass", { checkout: { sessions: { retrieve: async () => value } } }), "billing_customer_mismatch");
|
||||
|
||||
@@ -3,7 +3,7 @@ const { expect } = require("chai");
|
||||
require("ts-node/register/transpile-only");
|
||||
const mongoose = require("mongoose");
|
||||
const config = require("../src/config").default;
|
||||
const { BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, BillingDraft } = require("../src/core/billing/models");
|
||||
const { BillingPrice, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, BillingDraft } = require("../src/core/billing/models");
|
||||
const { receiveBillingEvent, processBillingInbox, billingHealth } = require("../src/core/billing/events");
|
||||
const { reconcileBillingAccount } = require("../src/core/billing/reconciliation");
|
||||
const { createCheckout, processBillingEvent } = require("../src/core/billing/stripe");
|
||||
@@ -12,7 +12,7 @@ const Repo = require("../src/core/model/anonymizedRepositories/anonymizedReposit
|
||||
const uri = process.env.BILLING_TEST_MONGODB_URI;
|
||||
const owner = "507f1f77bcf86cd799439011";
|
||||
const { saveBillingDraft, getBillingDraft } = require("../src/core/billing/drafts");
|
||||
const models = [BillingDraft, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, Repo];
|
||||
const models = [BillingDraft, BillingPrice, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, Repo];
|
||||
const iterable = values => ({ async *[Symbol.asyncIterator]() { yield* values; } });
|
||||
const event = id => ({ id, livemode: false, type: "customer.created", data: { object: { id: "cus_test", email: "private@example.org" } } });
|
||||
(uri ? describe : describe.skip)("billing failure recovery", function () {
|
||||
@@ -158,6 +158,22 @@ const event = id => ({ id, livemode: false, type: "customer.created", data: { ob
|
||||
retrieve: async () => ({ status: "open", url: "https://checkout.stripe.com/recovered" }),
|
||||
} } };
|
||||
}
|
||||
it("records an accepted one-time price before an uncertain checkout response", async () => {
|
||||
config.STRIPE_AUTHOR_PASS_PRICE = "price_retiring";
|
||||
await Repo.create({ repoId: "pass-target", owner, status: "ready" });
|
||||
const stripe = checkoutStripe();
|
||||
stripe.prices.retrieve = async () => ({ active: true, currency: "eur", unit_amount: 1500 });
|
||||
try { await createCheckout(owner, "author_pass", "pass-target", stripe); throw Error("expected network failure"); }
|
||||
catch (error) { expect(error.message).to.equal("connection lost"); }
|
||||
expect((await BillingPrice.findById("price_retiring")).planId).to.equal("author_pass");
|
||||
config.STRIPE_AUTHOR_PASS_PRICE = "";
|
||||
const { fulfillCheckout } = require("../src/core/billing/stripe");
|
||||
stripe.checkout.sessions.retrieve = async () => ({ id: "cs_retired", status: "complete", payment_status: "paid", customer: "cus_owner",
|
||||
created: Math.floor(Date.now() / 1000), metadata: { application: "anonymous-github", owner, planId: "author_pass", target: "pass-target" },
|
||||
line_items: { data: [{ price: { id: "price_retiring" }, quantity: 1 }] }, payment_intent: null });
|
||||
await fulfillCheckout("cs_retired", stripe);
|
||||
expect((await BillingGrant.findById("cs_retired")).status).to.equal("paid");
|
||||
});
|
||||
it("keeps checkout drafts private, bounded and expired even before the TTL sweep", async () => {
|
||||
const draft = await saveBillingDraft(owner, "/anonymize", { terms: "Private Author", options: { expirationMode: "never" }, customerId: "untrusted" });
|
||||
expect((await getBillingDraft(owner, draft.id)).values).not.to.have.property("customerId");
|
||||
|
||||
@@ -141,6 +141,19 @@ describe("repository activity dates", () => {
|
||||
expect(writes).to.equal(2);
|
||||
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 () => {
|
||||
const repo = repository("expired"); auth(repo);
|
||||
repo.model.conference = "ended";
|
||||
const quotas = require("../src/core/billing/quotas");
|
||||
stub(quotas, "assertConferenceAdmission", async (_owner, _id, conference) => {
|
||||
expect(conference).to.equal("ended"); throw Error("conf_not_activated");
|
||||
});
|
||||
stub(Model, "updateOne", () => { throw Error("must not persist extension"); });
|
||||
let error;
|
||||
try { await handler("repository-private", "/:repoId/extend", "post")({}, {}); } catch (caught) { error = caught; }
|
||||
expect(error?.message).to.equal("conf_not_activated");
|
||||
expect(repo.status).to.equal("expired");
|
||||
});
|
||||
it("dates a dashboard expiration extension", async () => {
|
||||
const repo = repository(); auth(repo); let saved;
|
||||
stub(Model, "updateOne", (_filter, update) => ({ exec: async () => { saved = update.$set; } }));
|
||||
|
||||
Reference in new issue
Block a user