fix: Kuendigungslimits zaehlen nur Fehlversuche, kuerzere Kontingent-Reservierung
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 3m18s
Docker Image bauen und veroeffentlichen / test (push) Failing after 4m28s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 2m15s
Docker Image bauen und veroeffentlichen / build-and-push (push) Skipped
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 3m18s
Docker Image bauen und veroeffentlichen / test (push) Failing after 4m28s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 2m15s
Docker Image bauen und veroeffentlichen / build-and-push (push) Skipped
Passende Kuendigungen werden nicht mehr von fremden Fehlversuchen ausgesperrt. Offene Abo-Abschluesse blockieren einen Platz nur noch 35 Minuten, die Stripe-Sitzung laeuft nach 32 Minuten ab. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
515fca1c16
commit
310c5ecf8c
5 files changed
+41
-18
No files matched your search
@@ -49,10 +49,11 @@ export function subscriptionCheckoutParams(input: {
|
||||
return {
|
||||
ui_mode: "embedded_page",
|
||||
mode: "subscription",
|
||||
// 58 Minuten: kuerzer als das Zeitfenster, in dem ein offenes Abo beim
|
||||
// Kontingent mitzaehlt (pending.ts, 60 Minuten). So kann keine Sitzung
|
||||
// noch bezahlt werden, nachdem ihr Platz schon wieder frei gegeben ist.
|
||||
expires_at: Math.floor(Date.now() / 1000) + 58 * 60,
|
||||
// 32 Minuten (Stripe verlangt mindestens 30): kuerzer als das Zeitfenster,
|
||||
// in dem ein offenes Abo beim Kontingent mitzaehlt (pending.ts, 35
|
||||
// Minuten). So kann keine Sitzung noch bezahlt werden, nachdem ihr Platz
|
||||
// schon wieder frei ist.
|
||||
expires_at: Math.floor(Date.now() / 1000) + 32 * 60,
|
||||
customer: input.stripeCustomerId,
|
||||
line_items: [{ price: input.stripePriceId, quantity: 1 }],
|
||||
metadata,
|
||||
|
||||
@@ -31,11 +31,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("quotaSubscriptionWhere", () => {
|
||||
test("laufende Abos plus offene Abschluesse der letzten 60 Minuten", () => {
|
||||
test("laufende Abos plus offene Abschluesse der letzten 35 Minuten (laenger als die 32-Minuten-Sitzung)", () => {
|
||||
expect(quotaSubscriptionWhere(NOW)).toEqual({
|
||||
OR: [
|
||||
{ status: { in: ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] } },
|
||||
{ status: "AUSSTEHEND", createdAt: { gte: new Date(NOW.getTime() - HOUR) } },
|
||||
{ status: "AUSSTEHEND", createdAt: { gte: new Date(NOW.getTime() - 35 * 60 * 1000) } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,11 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
*/
|
||||
|
||||
export const MAX_PENDING_PER_CUSTOMER = 3;
|
||||
const QUOTA_PENDING_MS = 60 * 60 * 1000;
|
||||
// Kurz halten: ein offener Abschluss blockiert einen Kontingentplatz nur so
|
||||
// lange, wie seine Stripe-Sitzung bezahlbar ist (checkout.ts, 32 Minuten;
|
||||
// Stripe erlaubt mindestens 30). Wer viele Abschluesse offen laesst, haelt
|
||||
// Plaetze damit hoechstens eine gute halbe Stunde fest.
|
||||
const QUOTA_PENDING_MS = 35 * 60 * 1000;
|
||||
const STALE_PENDING_MS = 24 * 60 * 60 * 1000;
|
||||
const RUNNING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
|
||||
|
||||
|
||||
@@ -89,22 +89,32 @@ describe("submitPublicCancellation", () => {
|
||||
expect(result.lines[0]).toContain("WH-P-2026-00042");
|
||||
});
|
||||
|
||||
test("Grenzen: je Vertragsnummer und je E-Mail-Adresse drei Versuche in 24 Stunden", async () => {
|
||||
await submitPublicCancellation(INPUT, { locale: "de", verified: true, now: NOW });
|
||||
test("Grenzen: nur Fehlversuche zaehlen je Vertragsnummer und je E-Mail-Adresse (drei in 24 Stunden)", async () => {
|
||||
await submitPublicCancellation({ ...INPUT, email: "[email protected]" }, { locale: "de", verified: true, now: NOW });
|
||||
|
||||
expect(m.rateLimit).toHaveBeenCalledWith("public-cancellation:ref:WH-P-2026-00042", 3, 24 * 60 * 60 * 1000);
|
||||
expect(m.rateLimit).toHaveBeenCalledWith("public-cancellation:email:erika@example.org", 3, 24 * 60 * 60 * 1000);
|
||||
expect(m.rateLimit).toHaveBeenCalledWith("public-cancellation:email:mallory@example.org", 3, 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("Vertragsnummer zu oft versucht: nichts bei Stripe, gespeichert, Admin informiert", async () => {
|
||||
process.env.ADMIN_EMAIL = "[email protected]";
|
||||
m.rateLimit.mockImplementation((key: string) => !key.includes(":ref:"));
|
||||
test("passende Kuendigung zaehlt nicht mit und wird auch bei vollem Zaehler angenommen (kein Aussperren durch Dritte)", async () => {
|
||||
m.rateLimit.mockImplementation(() => false);
|
||||
|
||||
await submitPublicCancellation(INPUT, { locale: "de", verified: true, now: NOW });
|
||||
|
||||
expect(m.rateLimit).not.toHaveBeenCalledWith(expect.stringContaining("public-cancellation:ref:"), 3, expect.any(Number));
|
||||
expect(m.cancelSubscription).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("Fehlversuch bei vollem Zaehler: nichts bei Stripe, gespeichert, Admin informiert, kein Eingang an die Angabe", async () => {
|
||||
process.env.ADMIN_EMAIL = "[email protected]";
|
||||
m.rateLimit.mockImplementation((key: string) => !key.includes(":email:"));
|
||||
|
||||
await submitPublicCancellation({ ...INPUT, email: "[email protected]" }, { locale: "de", verified: true, now: NOW });
|
||||
|
||||
expect(m.cancelSubscription).not.toHaveBeenCalled();
|
||||
expect(m.db.auditLog.create).toHaveBeenCalledOnce();
|
||||
expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "[email protected]" }));
|
||||
expect(m.sendMail).not.toHaveBeenCalledWith(expect.objectContaining({ to: "[email protected]" }));
|
||||
});
|
||||
|
||||
test("unpassend: nichts bei Stripe, gespeichert, Eingang an die angegebene Adresse (Pruefung bestanden) — gleiche Antwort", async () => {
|
||||
|
||||
@@ -115,12 +115,20 @@ export async function submitPublicCancellation(
|
||||
context: { locale: Locale; verified: boolean; now: Date }
|
||||
): Promise<PublicCancellationResult> {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
// Beide Zaehler laufen bei jedem Versuch mit — auch wenn einer schon voll ist.
|
||||
const referenceAllowed = rateLimit(`public-cancellation:ref:${input.reference.trim().toUpperCase()}`, ATTEMPTS_PER_TARGET, TARGET_WINDOW_MS);
|
||||
const emailAllowed = rateLimit(`public-cancellation:email:${email}`, ATTEMPTS_PER_TARGET, TARGET_WINDOW_MS);
|
||||
|
||||
const subscription = referenceAllowed && emailAllowed ? await findReferencedSubscription(input.reference) : null;
|
||||
// Erst zuordnen, dann zaehlen: die Zaehler je Vertrag und je Adresse
|
||||
// erfassen nur Versuche, die NICHT passen. Sonst koennte jemand mit
|
||||
// falschen Angaben den Vertrag oder die Adresse eines anderen "vollzaehlen"
|
||||
// und so dessen eigene, korrekte Kuendigung blockieren — gerade die soll
|
||||
// nach § 312k BGB immer moeglich sein. Gegen Durchprobieren schuetzt das
|
||||
// Limit je IP vor diesem Aufruf; die Antwort verraet ohnehin nicht, ob
|
||||
// etwas passte.
|
||||
const subscription = await findReferencedSubscription(input.reference);
|
||||
const matched = subscription && matchesCustomer(input, subscription.customer) ? subscription : null;
|
||||
// Beide Zaehler laufen bei jedem Fehlversuch mit — auch wenn einer schon voll ist.
|
||||
const referenceAllowed = matched
|
||||
? true
|
||||
: rateLimit(`public-cancellation:ref:${input.reference.trim().toUpperCase()}`, ATTEMPTS_PER_TARGET, TARGET_WINDOW_MS);
|
||||
const emailAllowed = matched ? true : rateLimit(`public-cancellation:email:${email}`, ATTEMPTS_PER_TARGET, TARGET_WINDOW_MS);
|
||||
|
||||
let effectiveEnd: Date | null = null;
|
||||
if (matched && context.verified) {
|
||||
|
||||
Reference in new issue
Block a user