From 310c5ecf8cda364a4f32f905ee0898ec3ec80633 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 15:13:48 +0200 Subject: [PATCH] fix: Kuendigungslimits zaehlen nur Fehlversuche, kuerzere Kontingent-Reservierung 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) --- src/lib/sponsorship-subscription/checkout.ts | 9 ++++---- .../sponsorship-subscription/pending.test.ts | 4 ++-- src/lib/sponsorship-subscription/pending.ts | 6 ++++- .../public-cancellation.test.ts | 22 ++++++++++++++----- .../public-cancellation.ts | 18 ++++++++++----- 5 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/lib/sponsorship-subscription/checkout.ts b/src/lib/sponsorship-subscription/checkout.ts index 33a3ed6..5cba8b8 100644 --- a/src/lib/sponsorship-subscription/checkout.ts +++ b/src/lib/sponsorship-subscription/checkout.ts @@ -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, diff --git a/src/lib/sponsorship-subscription/pending.test.ts b/src/lib/sponsorship-subscription/pending.test.ts index 94293dc..3d6e2c4 100644 --- a/src/lib/sponsorship-subscription/pending.test.ts +++ b/src/lib/sponsorship-subscription/pending.test.ts @@ -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) } }, ], }); }); diff --git a/src/lib/sponsorship-subscription/pending.ts b/src/lib/sponsorship-subscription/pending.ts index f10065d..1634600 100644 --- a/src/lib/sponsorship-subscription/pending.ts +++ b/src/lib/sponsorship-subscription/pending.ts @@ -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; diff --git a/src/lib/sponsorship-subscription/public-cancellation.test.ts b/src/lib/sponsorship-subscription/public-cancellation.test.ts index d5dd7c9..a6db31c 100644 --- a/src/lib/sponsorship-subscription/public-cancellation.test.ts +++ b/src/lib/sponsorship-subscription/public-cancellation.test.ts @@ -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: "mallory@example.org" }, { 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 = "admin@wabenhain.test"; - 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 = "admin@wabenhain.test"; + m.rateLimit.mockImplementation((key: string) => !key.includes(":email:")); + + await submitPublicCancellation({ ...INPUT, email: "mallory@example.org" }, { locale: "de", verified: true, now: NOW }); + expect(m.cancelSubscription).not.toHaveBeenCalled(); expect(m.db.auditLog.create).toHaveBeenCalledOnce(); expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "admin@wabenhain.test" })); + expect(m.sendMail).not.toHaveBeenCalledWith(expect.objectContaining({ to: "mallory@example.org" })); }); test("unpassend: nichts bei Stripe, gespeichert, Eingang an die angegebene Adresse (Pruefung bestanden) — gleiche Antwort", async () => { diff --git a/src/lib/sponsorship-subscription/public-cancellation.ts b/src/lib/sponsorship-subscription/public-cancellation.ts index 4cac1a0..317b5cf 100644 --- a/src/lib/sponsorship-subscription/public-cancellation.ts +++ b/src/lib/sponsorship-subscription/public-cancellation.ts @@ -115,12 +115,20 @@ export async function submitPublicCancellation( context: { locale: Locale; verified: boolean; now: Date } ): Promise { 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) {