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