From 515fca1c16a2f2dbbe9f74aeb97c81405d5d23c3 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 15:09:32 +0200 Subject: [PATCH] fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft - Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC) - Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache - Rabatte gelten nicht fuer Patenschaftspositionen - Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten - Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt - Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich) - Shop zeigt freien Bestand nach Abo-Reservierung - Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN) - Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %) - Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin, Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt Co-Authored-By: Claude Opus 5.5 (1M context) --- .../migration.sql | 6 + prisma/schema.prisma | 3 + src/app/admin/actions/customers.ts | 13 ++ src/app/admin/actions/orders.ts | 9 +- src/app/admin/actions/sponsorship.ts | 3 + src/app/admin/bestellungen/[id]/page.tsx | 23 ++- src/app/admin/layout.tsx | 7 +- src/app/admin/page.tsx | 23 ++- .../_components/sponsorship-actions.tsx | 21 +++ .../_components/status-badge.tsx | 1 + src/app/admin/patenschaften/_lib/coverage.ts | 10 +- .../patenschaften/_lib/sponsorship-filters.ts | 2 +- src/app/api/checkout/route.test.ts | 130 ++++++++++++++- src/app/api/checkout/route.ts | 27 ++-- src/app/api/konto/datenexport/route.ts | 8 +- src/app/api/patenschaft/abo/route.test.ts | 20 +++ src/app/api/patenschaft/abo/route.ts | 12 ++ .../patenschaft/vorschau/render-guard.test.ts | 78 +++++++++ .../api/patenschaft/vorschau/render-guard.ts | 109 +++++++++++++ src/app/api/patenschaft/vorschau/route.ts | 83 ++++++++-- src/app/api/shop/cart-stock/route.test.ts | 26 ++- src/app/api/shop/cart-stock/route.ts | 6 +- src/app/checkout/checkout-form.tsx | 10 +- src/app/konto/actions/account.ts | 16 ++ src/app/konto/patenschaft/[id]/page.tsx | 24 ++- .../_components/lifecycle-badge.tsx | 1 + .../patenschaft/_components/medal-shelf.tsx | 3 +- .../_components/sponsorship-row.tsx | 17 +- src/app/konto/patenschaft/_lib/owner.test.ts | 43 +++++ src/app/konto/patenschaft/_lib/owner.ts | 7 +- src/app/konto/patenschaft/page.tsx | 3 +- .../patenschaft/uebernehmen/[token]/page.tsx | 8 + .../patenschaft/use-certificate-preview.ts | 39 ++++- src/app/shop/actions.ts | 5 +- .../vertraege-kuendigen/cancellation-form.tsx | 2 +- .../zuruecknehmen/actions.ts | 53 ++++++ .../zuruecknehmen/page.tsx | 109 +++++++++++++ .../zuruecknehmen/undo-access.test.ts | 68 ++++++++ .../zuruecknehmen/undo-access.ts | 47 ++++++ src/app/warenkorb/warenkorb-content.tsx | 11 +- src/i18n/sponsorship/abo.ts | 56 ++++++- src/i18n/sponsorship/account.ts | 10 ++ src/i18n/sponsorship/admin.ts | 16 +- src/lib/apicore-stock/dashboard.ts | 8 +- src/lib/apicore-stock/reconcile.ts | 8 +- src/lib/archive.test.ts | 24 +-- src/lib/archive.ts | 26 ++- src/lib/berlin-date.test.ts | 20 +++ src/lib/berlin-date.ts | 18 +++ src/lib/cancellation-invoice.test.ts | 29 ++++ src/lib/cancellation-invoice.ts | 24 ++- src/lib/certificate/schema.test.ts | 12 ++ src/lib/certificate/schema.ts | 13 +- src/lib/data.ts | 42 ++++- src/lib/discount-calc.test.ts | 18 ++- src/lib/discount-calc.ts | 23 +++ src/lib/inventory.test.ts | 42 ++++- src/lib/inventory.ts | 7 +- src/lib/sales.ts | 14 ++ .../billing-rules.test.ts | 6 + .../sponsorship-subscription/billing-rules.ts | 8 +- .../cancellation-undo.test.ts | 122 ++++++++++++++ .../cancellation-undo.ts | 104 ++++++++++++ src/lib/sponsorship-subscription/checkout.ts | 4 + .../deliveries.test.ts | 2 + .../sponsorship-subscription/deliveries.ts | 5 + src/lib/sponsorship-subscription/emails.ts | 25 ++- src/lib/sponsorship-subscription/jobs.test.ts | 19 ++- src/lib/sponsorship-subscription/jobs.ts | 8 +- .../sponsorship-subscription/pending.test.ts | 124 ++++++++++++++ src/lib/sponsorship-subscription/pending.ts | 106 ++++++++++++ .../public-cancellation.test.ts | 104 ++++++++---- .../public-cancellation.ts | 99 ++++++++---- .../reservation.test.ts | 119 ++++++++++++++ .../sponsorship-subscription/reservation.ts | 90 ++++++++--- .../sponsorship.test.ts | 76 +++++++++ .../sponsorship-subscription/sponsorship.ts | 11 +- .../webhook-handlers.test.ts | 27 ++++ .../webhook-handlers.ts | 4 + src/lib/sponsorship/access.test.ts | 20 ++- src/lib/sponsorship/access.ts | 19 ++- src/lib/sponsorship/checkout-expand.ts | 15 +- src/lib/sponsorship/core.test.ts | 2 + src/lib/sponsorship/customer-deletion.test.ts | 77 +++++++++ src/lib/sponsorship/customer-deletion.ts | 58 +++++++ src/lib/sponsorship/data-export.test.ts | 130 +++++++++++++++ src/lib/sponsorship/data-export.ts | 152 ++++++++++++++++++ src/lib/sponsorship/issue.ts | 12 +- src/lib/sponsorship/offers.ts | 10 +- src/lib/sponsorship/revise.ts | 3 + src/lib/sponsorship/revoke.test.ts | 58 +++++++ src/lib/sponsorship/revoke.ts | 34 ++++ src/lib/sponsorship/transfer.test.ts | 32 +++- src/lib/sponsorship/transfer.ts | 14 +- src/lib/wishlist.ts | 15 +- src/store/cart-store.test.ts | 9 ++ src/store/cart-store.ts | 14 ++ 97 files changed, 2979 insertions(+), 254 deletions(-) create mode 100644 prisma/migrations/20261016020000_sponsorship_revoked/migration.sql create mode 100644 src/app/api/patenschaft/vorschau/render-guard.test.ts create mode 100644 src/app/api/patenschaft/vorschau/render-guard.ts create mode 100644 src/app/konto/patenschaft/_lib/owner.test.ts create mode 100644 src/app/vertraege-kuendigen/zuruecknehmen/actions.ts create mode 100644 src/app/vertraege-kuendigen/zuruecknehmen/page.tsx create mode 100644 src/app/vertraege-kuendigen/zuruecknehmen/undo-access.test.ts create mode 100644 src/app/vertraege-kuendigen/zuruecknehmen/undo-access.ts create mode 100644 src/lib/berlin-date.test.ts create mode 100644 src/lib/berlin-date.ts create mode 100644 src/lib/sponsorship-subscription/cancellation-undo.test.ts create mode 100644 src/lib/sponsorship-subscription/cancellation-undo.ts create mode 100644 src/lib/sponsorship-subscription/pending.test.ts create mode 100644 src/lib/sponsorship-subscription/pending.ts create mode 100644 src/lib/sponsorship-subscription/reservation.test.ts create mode 100644 src/lib/sponsorship-subscription/sponsorship.test.ts create mode 100644 src/lib/sponsorship/customer-deletion.test.ts create mode 100644 src/lib/sponsorship/customer-deletion.ts create mode 100644 src/lib/sponsorship/data-export.test.ts create mode 100644 src/lib/sponsorship/data-export.ts create mode 100644 src/lib/sponsorship/revoke.test.ts create mode 100644 src/lib/sponsorship/revoke.ts diff --git a/prisma/migrations/20261016020000_sponsorship_revoked/migration.sql b/prisma/migrations/20261016020000_sponsorship_revoked/migration.sql new file mode 100644 index 0000000..69aef7d --- /dev/null +++ b/prisma/migrations/20261016020000_sponsorship_revoked/migration.sql @@ -0,0 +1,6 @@ +-- Widerrufene Patenschaft: Einmalkauf storniert oder voll erstattet +-- (Stornorechnung ausgestellt). Rein additiv; der neue Wert wird in dieser +-- Migration nicht benutzt, ALTER TYPE ADD VALUE ist deshalb auch innerhalb +-- der Migrationstransaktion zulaessig. +-- AlterEnum +ALTER TYPE "SponsorshipStatus" ADD VALUE 'WIDERRUFEN'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b49904..6a3af14 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1026,6 +1026,9 @@ enum SponsorshipStatus { BEREIT /// Nur physische Pakete: Urkunde gedruckt. GEDRUCKT + /// Einmalkauf storniert oder voll erstattet (Stornorechnung): keine + /// Downloads, keine Uebertragung, keine Ausstellung mehr. + WIDERRUFEN } enum SubscriptionStatus { diff --git a/src/app/admin/actions/customers.ts b/src/app/admin/actions/customers.ts index 96adb03..0d12c5d 100644 --- a/src/app/admin/actions/customers.ts +++ b/src/app/admin/actions/customers.ts @@ -10,6 +10,8 @@ import { logAdminAction } from "@/lib/audit-log"; import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation"; import { requireAdmin, fail, isLastAdmin, str } from "./_shared"; import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash"; +import { certificateDirsOfCustomer, hasBlockingSubscription, removeCertificateDirs } from "@/lib/sponsorship/customer-deletion"; +import { discardPendingCheckouts } from "@/lib/sponsorship-subscription/pending"; export async function createCustomerAction(formData: FormData): Promise { const admin = await requireAdmin(); @@ -176,8 +178,19 @@ export async function deleteCustomerAction(formData: FormData): Promise { if (await isLastAdmin(id)) { return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden."); } + // Laufendes Abo: erst kuendigen bzw. bei Stripe beenden — sonst zoege + // Stripe weiter ab, ohne dass es ein Konto gibt (lib/sponsorship/customer-deletion.ts). + if (await hasBlockingSubscription(id)) { + return fail( + "/admin/benutzer", + "Dieses Konto hat ein laufendes Patenschafts-Abo. Bitte das Abo zuerst kündigen und sein Ende abwarten (oder bei Stripe beenden) — erst danach lässt sich das Konto löschen." + ); + } + const certificateDirs = await certificateDirsOfCustomer(id); + await discardPendingCheckouts(id); const deleted = await prisma.customer.delete({ where: { id } }); + await removeCertificateDirs(certificateDirs); await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email }); revalidatePath("/admin/benutzer"); return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`); diff --git a/src/app/admin/actions/orders.ts b/src/app/admin/actions/orders.ts index c878215..01e47ea 100644 --- a/src/app/admin/actions/orders.ts +++ b/src/app/admin/actions/orders.ts @@ -66,14 +66,15 @@ async function settleCancelledOrder(order: { orderNumber: string; previousStatus: OrderStatus; stripeCheckoutSessionId: string | null; - payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null; + payment: { status: PaymentStatus; stripePaymentIntentId: string | null; amountCents: number } | null; }): Promise { const warnings: string[] = []; // Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es // nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben // Betrags, und "PENDING" ist noch gar nicht abgeschlossen. - if (order.payment?.status === "SUCCEEDED") { + // 0-€-Zahlungen (Abo-Honiglieferung, Provider "abo") haben nichts zu erstatten. + if (order.payment?.status === "SUCCEEDED" && order.payment.amountCents > 0) { const paymentIntentId = order.payment.stripePaymentIntentId; if (!paymentIntentId) { warnings.push( @@ -149,8 +150,8 @@ export async function updateOrderStatusAction(formData: FormData): Promise firstName: true, locale: true, stripeCheckoutSessionId: true, - payment: { select: { status: true, stripePaymentIntentId: true } }, - items: { select: { productId: true, quantity: true, productName: true } }, + payment: { select: { status: true, stripePaymentIntentId: true, amountCents: true } }, + items: { select: { productId: true, quantity: true, productName: true, product: { select: { category: true } } } }, returnRequest: { select: { status: true } }, invoice: { select: { cancellation: { select: { number: true } } } }, }, diff --git a/src/app/admin/actions/sponsorship.ts b/src/app/admin/actions/sponsorship.ts index fc04e19..39b63b0 100644 --- a/src/app/admin/actions/sponsorship.ts +++ b/src/app/admin/actions/sponsorship.ts @@ -47,6 +47,7 @@ const PROCESS_MESSAGES: Record = { retry: "Der Versuch ist fehlgeschlagen. Der nächste automatische Versuch ist eingeplant — Fehler siehe unten.", failed: "Der Versuch ist fehlgeschlagen. Alle automatischen Versuche sind aufgebraucht — Fehler siehe unten.", missing: "Die Patenschaft wurde nicht gefunden.", + revoked: "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und wird nicht mehr ausgestellt.", }; /** @@ -152,6 +153,8 @@ function revisionErrorMessage(error: ReviseSponsorshipError): string { return "Die Patenschaft wurde nicht gefunden."; case "no_revision": return "Die Urkunde wurde noch nicht erzeugt — korrigieren lässt sich erst eine bestehende Urkunde."; + case "revoked": + return "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und lässt sich nicht mehr korrigieren."; case "reason": return `Bitte einen Änderungsgrund angeben (${error.min}–${error.max} Zeichen).`; case "unknown_template": diff --git a/src/app/admin/bestellungen/[id]/page.tsx b/src/app/admin/bestellungen/[id]/page.tsx index fc013e7..82bb3ad 100644 --- a/src/app/admin/bestellungen/[id]/page.tsx +++ b/src/app/admin/bestellungen/[id]/page.tsx @@ -17,15 +17,23 @@ import { getDictionary } from "@/i18n/get-dictionary"; import { isDhlConfigured } from "@/lib/dhl"; import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui"; import { Button } from "@/components/ui"; +import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin"; export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) { - const t = getDictionary(await getLocale()); + const locale = await getLocale(); + const t = getDictionary(locale); const statusLabels = t.orderStatusBadge; const { id } = await props.params; const order = await prisma.order.findUnique({ where: { id }, include: { - items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } }, + items: { + include: { + product: { select: { slug: true } }, + batch: { select: { code: true } }, + sponsorship: { select: { number: true, status: true } }, + }, + }, payment: true, shipment: true, pickup: true, @@ -36,6 +44,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best }); if (!order) notFound(); const dhlConfigured = isDhlConfigured(); + // Storno/Erstattung widerruft Patenschaften (lib/sponsorship/revoke.ts); + // ein Wiederoeffnen stellt sie bewusst nicht von selbst wieder her. + const revokedNumbers = order.items.flatMap((item) => (item.sponsorship?.status === "WIDERRUFEN" ? [item.sponsorship.number] : [])); // Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt // ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht // auseinanderlaufen koennen. @@ -88,6 +99,12 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best /> + {revokedNumbers.length > 0 ? ( +

+ {getSponsorshipAdminStrings(locale).order.revokedNote(revokedNumbers.join(", "))} +

+ ) : null} + {order.giftMessage ? (

{t.adminOrderDetailPage.giftHeading}

@@ -211,7 +228,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best {item.productName} {" "} - ({item.weightGrams} g) + {item.weightGrams > 0 ? ({item.weightGrams} g) : null} {item.batch?.code ?? "—"} {item.quantity} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 0b62c18..3878ae1 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -55,7 +55,12 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) { const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value); const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([ prisma.order.count({ - where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null }, + // DIGITAL (nur Patenschaften, Abo-Monatsrechnungen): nichts zu packen. + where: { + status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, + archivedAt: null, + fulfillmentMethod: { not: "DIGITAL" }, + }, }), prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }), prisma.withdrawalDeclaration.count({ where: { processedAt: null } }), diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 2e0377f..2b8ad9a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3,7 +3,7 @@ import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-p import { prisma } from "@/lib/prisma"; import { pendingReviewWhere } from "@/lib/review-status"; import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard"; -import { COUNTED_SALE_WHERE } from "@/lib/sales"; +import { COUNTED_SALE_WHERE, SHOP_SALE_WHERE } from "@/lib/sales"; import { formatPrice } from "@/lib/format"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -130,14 +130,20 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) { where: { createdAt: { gte: startOfMonth() }, ...COUNTED_SALE_WHERE }, }), prisma.order.count({ - where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } }, + where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] }, fulfillmentMethod: { not: "DIGITAL" } }, }), prisma.inventory.aggregate({ _sum: { quantity: true } }), - prisma.orderItem.aggregate({ _sum: { quantity: true }, where: { order: COUNTED_SALE_WHERE } }), + // Glaeser: Patenschafts-Positionen (Urkunde, Abo-Monatszeilen) sind keine. + prisma.orderItem.aggregate({ + _sum: { quantity: true }, + where: { order: COUNTED_SALE_WHERE, product: { category: { not: "PATENSCHAFT" } } }, + }), + // Warenkorbwert nur aus echten Shop-Bestellungen: Abo-Monatsrechnungen + // und 0-€-Abo-Lieferungen sind keine Warenkoerbe. prisma.order.aggregate({ _avg: { totalCents: true }, _count: true, - where: COUNTED_SALE_WHERE, + where: SHOP_SALE_WHERE, }), // Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage // JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher, @@ -168,7 +174,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) { // machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann. prisma.order.groupBy({ by: ["customerId"], - where: { customerId: { not: null }, ...COUNTED_SALE_WHERE }, + where: { customerId: { not: null }, ...SHOP_SALE_WHERE }, having: { customerId: { _count: { gt: 1 } } }, }), prisma.discountCode.findMany({ @@ -270,7 +276,12 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) { const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries, sponsorshipItems] = await Promise.all([ getCurrentAdmin(), prisma.order.count({ - where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null }, + // DIGITAL (nur Patenschaften, Abo-Monatsrechnungen): nichts zu packen. + where: { + status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, + archivedAt: null, + fulfillmentMethod: { not: "DIGITAL" }, + }, }), prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }), prisma.review.count({ where: pendingReviewWhere() }), diff --git a/src/app/admin/patenschaften/_components/sponsorship-actions.tsx b/src/app/admin/patenschaften/_components/sponsorship-actions.tsx index 219bbbc..7ef5593 100644 --- a/src/app/admin/patenschaften/_components/sponsorship-actions.tsx +++ b/src/app/admin/patenschaften/_components/sponsorship-actions.tsx @@ -27,6 +27,27 @@ const buttonClass = "px-4 py-2 text-xs"; export function SponsorshipActions({ id, status, currentRevision, isPhysical, printed, ownerEmail, t }: SponsorshipActionsProps) { const issued = currentRevision > 0; const delivered = status === "BEREIT" || status === "GEDRUCKT"; + // Widerrufen (Storno/Erstattung): nur noch das Druck-PDF zur Ansicht, keine + // Aktion, die der Kundschaft wieder etwas zustellen oder aendern wuerde. + if (status === "WIDERRUFEN") { + return ( +
+ {issued ? ( + + ) : null} + {t.noteRevoked} +
+ ); + } return (
diff --git a/src/app/admin/patenschaften/_components/status-badge.tsx b/src/app/admin/patenschaften/_components/status-badge.tsx index a0fe531..69b1d75 100644 --- a/src/app/admin/patenschaften/_components/status-badge.tsx +++ b/src/app/admin/patenschaften/_components/status-badge.tsx @@ -6,6 +6,7 @@ const SPONSORSHIP_TONES: Record = { FEHLER: "danger", BEREIT: "positive", GEDRUCKT: "neutral", + WIDERRUFEN: "danger", }; const SUBSCRIPTION_TONES: Record = { diff --git a/src/app/admin/patenschaften/_lib/coverage.ts b/src/app/admin/patenschaften/_lib/coverage.ts index a177c71..dd519f0 100644 --- a/src/app/admin/patenschaften/_lib/coverage.ts +++ b/src/app/admin/patenschaften/_lib/coverage.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/prisma"; -import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation"; +import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation"; /** * Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und @@ -41,14 +41,14 @@ export async function getHoneyStock(productIds: string[], now: Date = new Date() select: { productId: true, quantity: true }, }); const stockById = new Map(inventories.map((row) => [row.productId, row.quantity])); - const entries = await Promise.all( - ids.map(async (id) => { + const reservedById = await reservedJarsByProduct(ids, now); + return new Map( + ids.map((id) => { const stock = stockById.get(id) ?? 0; - const reserved = await reservedJarsForProduct(id, now); + const reserved = reservedById.get(id) ?? 0; return [id, { stock, reserved, free: Math.max(0, stock - reserved) }] as const; }) ); - return new Map(entries); } /** Deckung je Honigprodukt, das mit mindestens einem Abo-Angebot verknuepft ist. */ diff --git a/src/app/admin/patenschaften/_lib/sponsorship-filters.ts b/src/app/admin/patenschaften/_lib/sponsorship-filters.ts index 042baf9..6015110 100644 --- a/src/app/admin/patenschaften/_lib/sponsorship-filters.ts +++ b/src/app/admin/patenschaften/_lib/sponsorship-filters.ts @@ -8,7 +8,7 @@ import { beeNameFromJson } from "./revision-data"; * sondern die Arbeitsliste des Druckablaufs: physisches Paket, Urkunde * bereit, noch nicht gedruckt. */ -export const LIST_STATUSES = ["AUSSTEHEND", "FEHLER", "BEREIT", "GEDRUCKT", "ZU_DRUCKEN"] as const; +export const LIST_STATUSES = ["AUSSTEHEND", "FEHLER", "BEREIT", "GEDRUCKT", "WIDERRUFEN", "ZU_DRUCKEN"] as const; export type ListStatus = (typeof LIST_STATUSES)[number]; export const TO_PRINT_FILTER = { diff --git a/src/app/api/checkout/route.test.ts b/src/app/api/checkout/route.test.ts index 6ccf506..606887a 100644 --- a/src/app/api/checkout/route.test.ts +++ b/src/app/api/checkout/route.test.ts @@ -26,7 +26,8 @@ vi.mock("@/lib/stripe", () => ({ const getProductsByIds = vi.fn(); vi.mock("@/lib/data", () => ({ getProductsByIds: (...args: unknown[]) => getProductsByIds(...args) })); -vi.mock("@/lib/auth", () => ({ getCurrentCustomer: vi.fn(async () => null) })); +const getCurrentCustomer = vi.fn<() => Promise>(async () => null); +vi.mock("@/lib/auth", () => ({ getCurrentCustomer: () => getCurrentCustomer() })); const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true); vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) })); @@ -67,10 +68,12 @@ const db = { }; vi.mock("@/lib/prisma", () => ({ prisma: db })); -// Patenschaften spielen in diesen Faellen keine Rolle: keine Angebote zu den -// Produkten, keine Abo-Reservierung (eigene Tests in lib/sponsorship). -vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: vi.fn(async () => new Map()) })); -vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsForProduct: vi.fn(async () => 0) })); +// Patenschaften spielen in den meisten Faellen keine Rolle: keine Angebote zu +// den Produkten, keine Abo-Reservierung (eigene Tests in lib/sponsorship). +// Die Rabatt-Tests unten legen ein Paket-Angebot an. +const getOffersByProductIds = vi.fn<(ids: string[]) => Promise>>(async () => new Map()); +vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: (ids: string[]) => getOffersByProductIds(ids) })); +vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsByProduct: vi.fn(async () => new Map()) })); const { POST } = await import("./route"); @@ -116,6 +119,8 @@ beforeEach(() => { rateLimit.mockReturnValue(true); verifyTurnstileToken.mockResolvedValue(true); getProductsByIds.mockResolvedValue([honey]); + getCurrentCustomer.mockResolvedValue(null); + getOffersByProductIds.mockResolvedValue(new Map()); getShippingRegion.mockResolvedValue("DE"); db.discountCode.findMany.mockResolvedValue([]); db.inventory.updateMany.mockResolvedValue({ count: 1 }); @@ -418,4 +423,119 @@ describe("Checkout-Route", () => { expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel"); }); }); + + describe("Rabatte und Patenschaften", () => { + // Paket "Wabenfreund": 38,00 € Patenschaft (MAIN) + 3 Glaeser Honiganteil + // zu je 7,00 € (HONEY) = 59,00 €. Beides zaehlt nicht als Rabattbasis. + const carrier = { ...honey, id: "carrier", name: "Wabenfreund", slug: "wabenfreund", priceCents: 3800, weightGrams: 0, category: "PATENSCHAFT" }; + const offer = { + id: "offer", + slug: "wabenfreund", + kind: "EINMALIG", + name: "Wabenfreund", + description: "", + features: [], + priceCents: 5900, + isPhysical: true, + honeyJars: 3, + honeyShareCentsPerJar: 700, + honeyShareCentsPerMonth: 0, + cycle: null, + medalTier: "GOLD", + productId: "carrier", + honeyProduct: { id: "p1", name: honey.name, weightGrams: 500 }, + isAvailable: true, + }; + const personalization = { + beeName: "Summsi", + recipientName: "Erika Mustermann", + occasion: "SELBST", + startDate: new Date().toISOString().slice(0, 10), + }; + const packageItem = { productId: "carrier", quantity: 1, personalization }; + const discountCode = { + id: "dc_1", + code: "SOMMER10", + isActive: true, + type: "PERCENT" as const, + value: 10, + minOrderCents: 0, + minQuantity: null, + maxUses: null, + usedCount: 0, + validFrom: null, + validUntil: null, + }; + + beforeEach(() => { + getCurrentCustomer.mockResolvedValue({ id: "cust_1", emailVerifiedAt: new Date() }); + getOffersByProductIds.mockResolvedValue(new Map([["carrier", offer]])); + getProductsByIds.mockImplementation(async (ids: string[]) => [honey, carrier].filter((product) => ids.includes(product.id))); + }); + + test("Mengenrabatt: Glaeser aus einem Patenschaftspaket zaehlen nicht zur Schwelle", async () => { + // 2 einzelne Glaeser + 3 Paketglaeser = 5 physische, aber nur 2 rabattfaehige. + db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]); + + const response = await post({ ...validPayload, items: [{ productId: "p1", quantity: 2 }, packageItem] }); + + expect(response.status).toBe(200); + expect(db.order.create.mock.calls[0][0].data).toMatchObject({ discountCents: 0, subtotalCents: 1980 + 5900 }); + expect(couponsCreate).not.toHaveBeenCalled(); + }); + + test("Mengenrabatt: Betrag nur auf die Ware ausserhalb des Pakets, Stripe-Coupon gleich hoch", async () => { + db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]); + couponsCreate.mockResolvedValue({ id: "coupon_1" }); + + const response = await post({ ...validPayload, items: [{ productId: "p1", quantity: 5 }, packageItem] }); + + expect(response.status).toBe(200); + // 10 % von 5 × 9,90 € = 4,95 € — nicht von 108,50 €. + expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(495); + expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 495 })); + }); + + test("Gutscheincode: Mindestbestellwert zaehlt nur die Ware ausserhalb von Patenschaften", async () => { + db.discountCode.findUnique.mockResolvedValue({ ...discountCode, minOrderCents: 3000 }); + + const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining("ungültig") }); + expect(db.order.create).not.toHaveBeenCalled(); + }); + + test("Gutscheincode: Rabatt nur auf die Ware ausserhalb von Patenschaften", async () => { + db.discountCode.findUnique.mockResolvedValue(discountCode); + couponsCreate.mockResolvedValue({ id: "coupon_1" }); + + const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] }); + + expect(response.status).toBe(200); + expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(198); + expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 198 })); + }); + + test("Gutscheincode bei reinem Patenschafts-Warenkorb ist ungueltig", async () => { + db.discountCode.findUnique.mockResolvedValue(discountCode); + + const response = await post({ ...validPayload, discountCode: "sommer10", items: [packageItem] }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining("ungültig") }); + expect(db.order.create).not.toHaveBeenCalled(); + }); + + test("Stripe-Positionsname nennt das Gewicht nur bei Ware mit Gewicht", async () => { + await post({ ...validPayload, items: [{ productId: "p1", quantity: 1 }, packageItem] }); + + const names = sessionsCreate.mock.calls[0][0].line_items.map( + (line: { price_data: { product_data: { name: string } } }) => line.price_data.product_data.name + ); + expect(names).toContain("Fruehtracht 2026 (500 g)"); + expect(names).toContain("Wabenfreund"); + expect(names.some((name: string) => name.includes("(0 g)"))).toBe(false); + }); + }); }); diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index 55139ff..7c16076 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -11,7 +11,7 @@ import { isTrustedOrigin } from "@/lib/csrf"; import { verifyTurnstileToken } from "@/lib/turnstile"; import { getShippingCostCents, getShippingRegion } from "@/lib/shipping"; import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region"; -import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc"; +import { isDiscountCodeValid, calculateDiscountCents, discountBase } from "@/lib/discount-calc"; import { ensureBulkDiscountCode } from "@/lib/bulk-discount"; import { releaseUnpaidOrder } from "@/lib/order-release"; import { @@ -24,7 +24,7 @@ import { vatRateForProduct } from "@/lib/vat"; import { getLocale } from "@/i18n/locale"; import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand"; import { getOffersByProductIds } from "@/lib/sponsorship/offers"; -import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation"; +import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation"; class OutOfStockError extends Error {} // Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion @@ -332,13 +332,13 @@ export async function POST(request: Request) { // Transaktion gelesen: die Reservierung aendert sich nur mit Abos, nicht // durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung // bleibt das bedingte Update in der Transaktion. - const reservedByProduct = new Map(); - for (const line of expanded.reserve) { - reservedByProduct.set(line.productId, await reservedJarsForProduct(line.productId)); - } + const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId)); // Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand — - // haelt die Rabattlogik einfach nachvollziehbar. + // haelt die Rabattlogik einfach nachvollziehbar. Und nur auf Ware + // ausserhalb von Patenschaften (discountBase): Schwelle, Mindestwert und + // Betrag rechnen ohne Paketpositionen. + const base = discountBase(orderItemsData); let discountCents = 0; let discountCode: { id: string; code: string } | null = null; if (input.discountCode) { @@ -347,7 +347,9 @@ export async function POST(request: Request) { where: { code: input.discountCode.trim().toUpperCase() }, }); - if (!code || !isDiscountCodeValid(code, subtotalCents, now, totalQuantity)) { + // Ein reiner Patenschafts-Warenkorb hat keine Rabattbasis — der Code + // gilt dann als nicht einloesbar statt still 0 € abzuziehen. + if (!code || base.subtotalCents <= 0 || !isDiscountCodeValid(code, base.subtotalCents, now, base.quantity)) { return NextResponse.json( { error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." }, { status: 400 } @@ -372,7 +374,7 @@ export async function POST(request: Request) { } } - discountCents = calculateDiscountCents(code, subtotalCents); + discountCents = calculateDiscountCents(code, base.subtotalCents); discountCode = code; } else { // Kein manuell eingegebener Code — unter allen im Admin-Bereich @@ -383,10 +385,10 @@ export async function POST(request: Request) { const candidates = await prisma.discountCode.findMany({ where: { minQuantity: { not: null } } }); const now = new Date(); const code = candidates - .filter((candidate) => isDiscountCodeValid(candidate, subtotalCents, now, totalQuantity)) + .filter((candidate) => base.subtotalCents > 0 && isDiscountCodeValid(candidate, base.subtotalCents, now, base.quantity)) .sort((a, b) => (b.minQuantity ?? 0) - (a.minQuantity ?? 0))[0]; if (code) { - discountCents = calculateDiscountCents(code, subtotalCents); + discountCents = calculateDiscountCents(code, base.subtotalCents); discountCode = code; } } @@ -584,7 +586,8 @@ export async function POST(request: Request) { price_data: { currency: "eur", unit_amount: item.unitPriceCents, - product_data: { name: `${item.productName} (${item.weightGrams} g)` }, + // Gewicht nur bei Ware mit Gewicht — eine Urkunde "(0 g)" waere Unsinn. + product_data: { name: item.weightGrams > 0 ? `${item.productName} (${item.weightGrams} g)` : item.productName }, }, })), ...(shippingCents > 0 diff --git a/src/app/api/konto/datenexport/route.ts b/src/app/api/konto/datenexport/route.ts index ed6e9c7..286ac2e 100644 --- a/src/app/api/konto/datenexport/route.ts +++ b/src/app/api/konto/datenexport/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getCurrentCustomer } from "@/lib/auth"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; +import { sponsorshipExportData } from "@/lib/sponsorship/data-export"; const EXPORT_ATTEMPTS_PER_WINDOW = 5; const EXPORT_WINDOW_MS = 60 * 60 * 1000; @@ -17,7 +18,7 @@ export async function GET() { return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 }); } - const [addresses, orders, reviews, wishlistItems, newsletter] = await Promise.all([ + const [addresses, orders, reviews, wishlistItems, newsletter, sponsorship] = await Promise.all([ prisma.address.findMany({ where: { customerId: customer.id } }), prisma.order.findMany({ where: { customerId: customer.id }, @@ -33,6 +34,8 @@ export async function GET() { include: { product: { select: { name: true } } }, }), prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }), + // Patenschaften, Abos samt Zahlungen und Uebertragungen (ohne Stripe-Kennungen). + sponsorshipExportData(customer.id), ]); const exportData = { @@ -82,6 +85,9 @@ export async function GET() { newsletter: newsletter ? { subscribed: newsletter.unsubscribedAt === null, confirmed: newsletter.confirmed } : { subscribed: false, confirmed: false }, + sponsorships: sponsorship.sponsorships, + sponsorshipSubscriptions: sponsorship.subscriptions, + sponsorshipTransfers: sponsorship.transfers, }; return new NextResponse(JSON.stringify(exportData, null, 2), { diff --git a/src/app/api/patenschaft/abo/route.test.ts b/src/app/api/patenschaft/abo/route.test.ts index 06ea835..6d86791 100644 --- a/src/app/api/patenschaft/abo/route.test.ts +++ b/src/app/api/patenschaft/abo/route.test.ts @@ -5,6 +5,7 @@ const m = vi.hoisted(() => ({ getCurrentCustomer: vi.fn(), getOfferBySlug: vi.fn(), createSubscriptionCheckout: vi.fn(), + preparePendingCheckout: vi.fn(), isTrustedOrigin: vi.fn(), })); @@ -14,6 +15,7 @@ vi.mock("@/lib/rate-limit", () => ({ rateLimit: () => true, getClientIp: async ( vi.mock("@/i18n/locale", () => ({ getLocale: async () => "de" })); vi.mock("@/lib/sponsorship/offers", () => ({ getOfferBySlug: m.getOfferBySlug })); vi.mock("@/lib/sponsorship-subscription/checkout", () => ({ createSubscriptionCheckout: m.createSubscriptionCheckout })); +vi.mock("@/lib/sponsorship-subscription/pending", () => ({ preparePendingCheckout: m.preparePendingCheckout })); vi.mock("@/lib/certificate/layout", () => ({ checkCertificateFits: () => ({ ok: true }) })); const { POST } = await import("./route"); @@ -38,6 +40,7 @@ beforeEach(() => { m.getCurrentCustomer.mockResolvedValue(CUSTOMER); m.getOfferBySlug.mockResolvedValue({ id: "offer_1", slug: "vierteljaehrlich", kind: "ABO", isAvailable: true }); m.createSubscriptionCheckout.mockResolvedValue({ clientSecret: "cs_secret", subscriptionId: "sub_local" }); + m.preparePendingCheckout.mockResolvedValue({ ok: true }); }); describe("POST /api/patenschaft/abo", () => { @@ -87,4 +90,21 @@ describe("POST /api/patenschaft/abo", () => { expect(response.status).toBe(502); expect(JSON.stringify(await response.json())).not.toContain("sk_test"); }); + + test("bereitet den Abschluss vor (alter offener Abschluss desselben Angebots wird ersetzt)", async () => { + await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION }); + + expect(m.preparePendingCheckout).toHaveBeenCalledWith({ customerId: "cust_1", offerId: "offer_1" }); + expect(m.preparePendingCheckout.mock.invocationCallOrder[0]).toBeLessThan(m.createSubscriptionCheckout.mock.invocationCallOrder[0]); + }); + + test("mehr als drei offene Abschluesse je Konto: klare Ablehnung, kein neuer Checkout", async () => { + m.preparePendingCheckout.mockResolvedValue({ ok: false, reason: "too_many_pending" }); + + const response = await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION }); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "too_many_pending", error: expect.stringContaining("offene Abo-Abschlüsse") }); + expect(m.createSubscriptionCheckout).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/patenschaft/abo/route.ts b/src/app/api/patenschaft/abo/route.ts index 856b2c6..77e8d5f 100644 --- a/src/app/api/patenschaft/abo/route.ts +++ b/src/app/api/patenschaft/abo/route.ts @@ -9,6 +9,7 @@ import { personalizationSchema, toIsoDate } from "@/lib/certificate/schema"; import { checkCertificateFits } from "@/lib/certificate/layout"; import { getOfferBySlug } from "@/lib/sponsorship/offers"; import { createSubscriptionCheckout } from "@/lib/sponsorship-subscription/checkout"; +import { preparePendingCheckout } from "@/lib/sponsorship-subscription/pending"; /** * Abschluss eines Patenschafts-Abos: prueft Konto, Personalisierung und @@ -81,6 +82,17 @@ export async function POST(request: Request) { // "Honigbestand und Kontingent"). if (!offer.isAvailable) return error("Dieses Abo ist derzeit ausgebucht.", 409); + // Ein alter offener Abschluss desselben Angebots wird ersetzt; mehr als + // drei offene je Konto nimmt der Shop nicht an (pending.ts). + const pending = await preparePendingCheckout({ customerId: customer.id, offerId: offer.id }); + if (!pending.ok) { + return error( + "Du hast bereits mehrere offene Abo-Abschlüsse. Bitte schließe einen davon ab oder versuche es morgen erneut.", + 409, + { code: "too_many_pending" } + ); + } + try { const { clientSecret } = await createSubscriptionCheckout({ customer, diff --git a/src/app/api/patenschaft/vorschau/render-guard.test.ts b/src/app/api/patenschaft/vorschau/render-guard.test.ts new file mode 100644 index 0000000..1e1360f --- /dev/null +++ b/src/app/api/patenschaft/vorschau/render-guard.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "vitest"; +import { createLruCache, createRenderGate, previewCacheKey } from "./render-guard"; + +describe("createRenderGate", () => { + test("laesst hoechstens maxConcurrent Renderings gleichzeitig zu und gibt Plaetze wieder frei", () => { + const gate = createRenderGate({ maxConcurrent: 3, maxPerMinute: 300, now: () => 0 }); + const slots = [gate.tryAcquire(), gate.tryAcquire(), gate.tryAcquire()]; + expect(slots.every((slot) => slot.ok)).toBe(true); + expect(gate.tryAcquire()).toEqual({ ok: false, reason: "busy" }); + + const first = slots[0]; + if (first.ok) first.release(); + expect(gate.tryAcquire().ok).toBe(true); + }); + + test("doppeltes release gibt nicht zwei Plaetze frei", () => { + const gate = createRenderGate({ maxConcurrent: 1, maxPerMinute: 300, now: () => 0 }); + const slot = gate.tryAcquire(); + if (slot.ok) { + slot.release(); + slot.release(); + } + expect(gate.tryAcquire().ok).toBe(true); + expect(gate.tryAcquire()).toEqual({ ok: false, reason: "busy" }); + }); + + test("begrenzt die Renderings je Minute und setzt das Fenster danach zurueck", () => { + let clock = 0; + const gate = createRenderGate({ maxConcurrent: 10, maxPerMinute: 2, now: () => clock }); + for (let i = 0; i < 2; i++) { + const slot = gate.tryAcquire(); + if (slot.ok) slot.release(); + } + expect(gate.tryAcquire()).toEqual({ ok: false, reason: "minute_cap" }); + + clock = 60_000; + expect(gate.tryAcquire().ok).toBe(true); + }); +}); + +describe("createLruCache", () => { + test("verdraengt den am laengsten nicht genutzten Eintrag", () => { + const cache = createLruCache(2); + cache.set("a", "A"); + cache.set("b", "B"); + expect(cache.get("a")).toBe("A"); // a ist jetzt frischer als b + cache.set("c", "C"); + + expect(cache.get("b")).toBeUndefined(); + expect(cache.get("a")).toBe("A"); + expect(cache.get("c")).toBe("C"); + expect(cache.size).toBe(2); + }); +}); + +describe("previewCacheKey", () => { + const personalization = { beeName: "Summsi", recipientName: "Erika", occasion: "SELBST" as const, startDate: "2026-10-16" }; + const term = { kind: "FIXED" as const, start: "2026-10-16", end: "2027-10-15" }; + + test("gleiche Eingabe in anderer Schluesselreihenfolge ergibt denselben Schluessel", () => { + const a = previewCacheKey({ personalization, locale: "de", term, templateVersion: "v1" }); + const b = previewCacheKey({ + personalization: { startDate: "2026-10-16", occasion: "SELBST", recipientName: "Erika", beeName: "Summsi" }, + locale: "de", + term: { end: "2027-10-15", start: "2026-10-16", kind: "FIXED" }, + templateVersion: "v1", + }); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + }); + + test("Sprache, Laufzeit und Vorlage gehoeren zum Schluessel", () => { + const base = previewCacheKey({ personalization, locale: "de", term, templateVersion: "v1" }); + expect(previewCacheKey({ personalization, locale: "en", term, templateVersion: "v1" })).not.toBe(base); + expect(previewCacheKey({ personalization, locale: "de", term: { ...term, end: "2027-10-14" }, templateVersion: "v1" })).not.toBe(base); + expect(previewCacheKey({ personalization, locale: "de", term, templateVersion: "v2" })).not.toBe(base); + }); +}); diff --git a/src/app/api/patenschaft/vorschau/render-guard.ts b/src/app/api/patenschaft/vorschau/render-guard.ts new file mode 100644 index 0000000..d09cbcd --- /dev/null +++ b/src/app/api/patenschaft/vorschau/render-guard.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; + +/** + * Schutz der Vorschau-Ausgabe vor Ueberlast. Das Rate-Limit je Besucher + * allein reicht nicht: viele Besucher (oder ein verteilter Angriff) koennen + * gemeinsam beliebig viele sharp-Renderings gleichzeitig anstossen und den + * Container lahmlegen. Deshalb zusaetzlich global: + * + * - hoechstens N Renderings gleichzeitig (sonst 503 mit Retry-After), + * - hoechstens M Renderings je Minute, + * - ein kleiner LRU-Zwischenspeicher fuer identische Eingaben (Zurueck- + * Klicken im Formular, Doppelklick, mehrere Tabs) — kostet kein Rendering. + * + * In-Memory wie lib/rate-limit.ts: ausreichend fuer den Single-Container- + * Betrieb. Als eigenes Modul, weil route.ts nur Next.js-Exporte haben darf. + */ + +export type GateResult = { ok: true; release: () => void } | { ok: false; reason: "busy" | "minute_cap" }; + +export interface RenderGate { + tryAcquire(): GateResult; +} + +const MINUTE_MS = 60_000; + +export function createRenderGate(options: { maxConcurrent: number; maxPerMinute: number; now?: () => number }): RenderGate { + const now = options.now ?? Date.now; + let running = 0; + let windowStart = now(); + let startedInWindow = 0; + return { + tryAcquire(): GateResult { + const time = now(); + if (time - windowStart >= MINUTE_MS) { + windowStart = time; + startedInWindow = 0; + } + if (running >= options.maxConcurrent) return { ok: false, reason: "busy" }; + if (startedInWindow >= options.maxPerMinute) return { ok: false, reason: "minute_cap" }; + running += 1; + startedInWindow += 1; + let released = false; + return { + ok: true, + release: () => { + // Ein zweiter Aufruf (z.B. aus finally und catch) darf keinen fremden Platz freigeben. + if (released) return; + released = true; + running -= 1; + }, + }; + }, + }; +} + +export interface LruCache { + get(key: string): V | undefined; + set(key: string, value: V): void; + readonly size: number; +} + +/** LRU ueber die Einfuegereihenfolge einer Map: ein Treffer wandert ans Ende, verdraengt wird vorn. */ +export function createLruCache(maxEntries: number): LruCache { + const entries = new Map(); + return { + get(key) { + const value = entries.get(key); + if (value === undefined) return undefined; + entries.delete(key); + entries.set(key, value); + return value; + }, + set(key, value) { + entries.delete(key); + entries.set(key, value); + while (entries.size > maxEntries) { + const oldest = entries.keys().next(); + if (oldest.done) break; + entries.delete(oldest.value); + } + }, + get size() { + return entries.size; + }, + }; +} + +/** JSON mit sortierten Schluesseln, damit die Reihenfolge im Objekt den Schluessel nicht aendert. */ +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +/** + * Schluessel aus der bereits GEPRUEFTEN und normalisierten Eingabe, nicht aus + * dem Roh-Body: "Summsi " und "Summsi" ergeben dasselbe Bild. Als SHA-256, + * damit keine Widmungstexte als Map-Schluessel im Speicher liegen. + */ +export function previewCacheKey(input: { personalization: object; locale: string; term: object; templateVersion: string }): string { + return createHash("sha256").update(stableStringify(input)).digest("hex"); +} diff --git a/src/app/api/patenschaft/vorschau/route.ts b/src/app/api/patenschaft/vorschau/route.ts index cade0ba..64f199a 100644 --- a/src/app/api/patenschaft/vorschau/route.ts +++ b/src/app/api/patenschaft/vorschau/route.ts @@ -4,6 +4,7 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { getLocale } from "@/i18n/locale"; import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates"; import { validatePreviewRequest, type PreviewFailure } from "./validate"; +import { createLruCache, createRenderGate, previewCacheKey } from "./render-guard"; import { PREVIEW_NUMBER, previewWatermark, @@ -28,9 +29,30 @@ const PREVIEW_WIDTH_PX = 900; // Grosszuegig ueber dem, was die Feldlimits je erreichen koennen; alles // darueber ist kein Formular mehr, sondern ein Versuch, den Parser zu beschaeftigen. const MAX_BODY_BYTES = 16 * 1024; +// Globale Grenzen ueber alle Besucher (render-guard.ts): ein Rendering kostet +// spuerbar CPU und Speicher, drei gleichzeitig traegt der Container sicher. +const MAX_CONCURRENT_RENDERS = 3; +const MAX_RENDERS_PER_MINUTE = 300; +const RETRY_AFTER_SECONDS = 2; +const CACHE_ENTRIES = 200; -function failure({ status, ...payload }: PreviewFailure): NextResponse { - return NextResponse.json(payload, { status, headers: { "Cache-Control": "no-store" } }); +const gate = createRenderGate({ maxConcurrent: MAX_CONCURRENT_RENDERS, maxPerMinute: MAX_RENDERS_PER_MINUTE }); +const imageCache = createLruCache(CACHE_ENTRIES); + +function failure({ status, ...payload }: PreviewFailure, headers: Record = {}): NextResponse { + return NextResponse.json(payload, { status, headers: { "Cache-Control": "no-store", ...headers } }); +} + +function imageResponse(image: Buffer): Response { + return new Response(new Uint8Array(image), { + status: 200, + headers: { + "Content-Type": "image/webp", + // Personalisierte Bilder gehoeren in keinen Zwischenspeicher (nur in + // den eigenen im Prozess, der nie an Dritte ausliefert). + "Cache-Control": "no-store", + }, + }); } export async function POST(request: Request): Promise { @@ -50,8 +72,11 @@ export async function POST(request: Request): Promise { if (declaredLength > MAX_BODY_BYTES) { return failure({ status: 413, error: "generic", field: null }); } - const raw = await request.text().catch(() => ""); - if (raw.length > MAX_BODY_BYTES) { + // Content-Length ist eine Angabe des Absenders, keine Grenze: bei + // "chunked" fehlt sie ganz. Deshalb mitzaehlend lesen und abbrechen, sobald + // mehr kommt als erlaubt — sonst puffert request.text() beliebig viel. + const raw = await readBodyCapped(request, MAX_BODY_BYTES); + if (raw === null) { return failure({ status: 413, error: "generic", field: null }); } let body: unknown = null; @@ -64,6 +89,21 @@ export async function POST(request: Request): Promise { const input = validatePreviewRequest(body, new Date(), await getLocale()); if (!input.ok) return failure(input.failure); + const cacheKey = previewCacheKey({ + personalization: input.personalization, + locale: input.locale, + term: input.term, + templateVersion: CURRENT_TEMPLATE_VERSION, + }); + const cached = imageCache.get(cacheKey); + if (cached) return imageResponse(cached); + + const slot = gate.tryAcquire(); + if (!slot.ok) { + // 503 statt 429: nicht dieser Besucher ist zu schnell, sondern der + // Server gerade voll. Das Formular versucht es nach Retry-After erneut. + return failure({ status: 503, error: "busy", field: null }, { "Retry-After": String(RETRY_AFTER_SECONDS) }); + } try { const image = await renderCertificateDocumentImage( { @@ -75,16 +115,35 @@ export async function POST(request: Request): Promise { }, { widthPx: PREVIEW_WIDTH_PX, watermark: previewWatermark(input.locale), format: "webp" } ); - return new Response(new Uint8Array(image), { - status: 200, - headers: { - "Content-Type": "image/webp", - // Personalisierte Bilder gehoeren in keinen Zwischenspeicher. - "Cache-Control": "no-store", - }, - }); + imageCache.set(cacheKey, image); + return imageResponse(image); } catch (error: unknown) { console.error("[patenschaft/vorschau] Vorschau konnte nicht erzeugt werden:", error); return failure({ status: 500, error: "generic", field: null }); + } finally { + slot.release(); } } + +/** Liest den Body hoechstens bis maxBytes; null, wenn er groesser ist oder nicht lesbar. */ +async function readBodyCapped(request: Request, maxBytes: number): Promise { + if (!request.body) return ""; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + } catch { + return null; + } + return new TextDecoder().decode(Buffer.concat(chunks)); +} diff --git a/src/app/api/shop/cart-stock/route.test.ts b/src/app/api/shop/cart-stock/route.test.ts index 9372bf0..5d81e7e 100644 --- a/src/app/api/shop/cart-stock/route.test.ts +++ b/src/app/api/shop/cart-stock/route.test.ts @@ -1,7 +1,17 @@ import { describe, test, expect, vi, beforeEach } from "vitest"; const getProductsByIds = vi.fn(); -vi.mock("@/lib/data", () => ({ getProductsByIds: (ids: string[]) => getProductsByIds(ids) })); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); +// Echtes withFreeStock aus lib/data, nur die Datenbankzugriffe sind ersetzt. +vi.mock("@/lib/data", async (importOriginal) => ({ + ...(await importOriginal()), + getProductsByIds: (ids: string[]) => getProductsByIds(ids), +})); +const reservedJarsByProduct = vi.fn(async (ids: readonly string[]) => new Map(ids.map((id) => [id, 0]))); +vi.mock("@/lib/sponsorship-subscription/reservation", async (importOriginal) => ({ + ...(await importOriginal()), + reservedJarsByProduct: (ids: readonly string[]) => reservedJarsByProduct(ids), +})); const getOffersByProductIds = vi.fn(); vi.mock("@/lib/sponsorship/offers", () => ({ @@ -38,6 +48,20 @@ describe("GET /api/shop/cart-stock", () => { expect(getOffersByProductIds).toHaveBeenCalledWith(["pkg"], "de"); }); + test("reports the FREE stock: jars reserved for subscriptions are not orderable", async () => { + getProductsByIds.mockResolvedValue([ + { id: "honey", category: "HONIG", isActive: true, inventory: { quantity: 5 } }, + { id: "linde", category: "HONIG", isActive: true, inventory: { quantity: 2 } }, + ]); + getOffersByProductIds.mockResolvedValue(new Map()); + reservedJarsByProduct.mockResolvedValueOnce(new Map([["honey", 3], ["linde", 4]])); + + const body = (await (await get("honey,linde")).json()) as { stock: Record }; + + expect(body.stock).toEqual({ honey: 2, linde: 0 }); + expect(reservedJarsByProduct).toHaveBeenCalledOnce(); + }); + test("marks a sponsorship as sold out when its offer is not available", async () => { getProductsByIds.mockResolvedValue([{ id: "pkg", category: "PATENSCHAFT", isActive: true, inventory: null }]); getOffersByProductIds.mockResolvedValue(new Map([["pkg", { isAvailable: false }]])); diff --git a/src/app/api/shop/cart-stock/route.ts b/src/app/api/shop/cart-stock/route.ts index 1d24b6c..bd497f1 100644 --- a/src/app/api/shop/cart-stock/route.ts +++ b/src/app/api/shop/cart-stock/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getProductsByIds } from "@/lib/data"; +import { getProductsByIds, withFreeStock } from "@/lib/data"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { getOffersByProductIds } from "@/lib/sponsorship/offers"; import { defaultLocale } from "@/i18n/config"; @@ -41,7 +41,9 @@ export async function GET(request: Request) { return NextResponse.json({ stock: {} }); } - const products = await getProductsByIds(ids); + // Freier Bestand: fuer Abos reservierte Glaeser sind nicht bestellbar + // (der Checkout wuerde sie ablehnen), also auch hier nicht anzeigen. + const products = await withFreeStock(await getProductsByIds(ids)); // Patenschaften haben am Traegerprodukt keinen eigenen Bestand. Ob sie // bestellbar sind, entscheidet das Angebot (bei Paketen der freie // Honigbestand abzueglich Abo-Reservierung, siehe lib/sponsorship/offers.ts) — diff --git a/src/app/checkout/checkout-form.tsx b/src/app/checkout/checkout-form.tsx index f0b79ce..7b971ba 100644 --- a/src/app/checkout/checkout-form.tsx +++ b/src/app/checkout/checkout-form.tsx @@ -5,7 +5,7 @@ import dynamic from "next/dynamic"; import { Container, SectionHeading, Button } from "@/components/ui"; import { TrustBadges } from "@/components/trust-badges"; import Link from "next/link"; -import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartIsDigitalOnly } from "@/store/cart-store"; +import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartIsDigitalOnly, cartDiscountBase } from "@/store/cart-store"; import { SponsorshipLineDetails } from "@/components/sponsorship-cart-line"; import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop"; import { loginPathWithNext } from "@/lib/safe-next-path"; @@ -124,7 +124,7 @@ export function CheckoutForm({ const items = useCartStore((s) => s.items); const hasHydrated = useCartStore((s) => s.hasHydrated); const subtotal = cartTotalCents(items); - // Versandstaffel und Mengenrabatt zaehlen nur Glaeser — eine Urkunde ohne + // Die Versandstaffel zaehlt nur Glaeser — eine Urkunde ohne // Paket wiegt nichts (wie physicalQuantity im Checkout-Server). const totalQuantity = cartPhysicalQuantity(items); // Nur Patenschaften ohne Honig: nichts zu versenden oder abzuholen. Dann @@ -200,9 +200,11 @@ export function CheckoutForm({ // die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt. const shipping = digitalOnly || fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity); - const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity); + // Mengenrabatt nur auf Ware ausserhalb von Patenschaftspaketen (wie discountBase im Server). + const discountable = cartDiscountBase(items); + const activeBulkDiscount = discountable.quantity > 0 ? resolveBulkDiscount(bulkDiscountTiers, discountable.quantity) : null; const bulkDiscountCents = activeBulkDiscount - ? Math.round((subtotal * activeBulkDiscount.percent) / 100) + ? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100) : 0; const total = subtotal - bulkDiscountCents + shipping; diff --git a/src/app/konto/actions/account.ts b/src/app/konto/actions/account.ts index 7d223b5..8ac97dd 100644 --- a/src/app/konto/actions/account.ts +++ b/src/app/konto/actions/account.ts @@ -8,6 +8,8 @@ import { securityAlertEmail } from "@/lib/email-templates"; import { getLocale } from "@/i18n/locale"; import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared"; import { succeed } from "@/lib/flash"; +import { certificateDirsOfCustomer, hasBlockingSubscription, removeCertificateDirs } from "@/lib/sponsorship/customer-deletion"; +import { discardPendingCheckouts } from "@/lib/sponsorship-subscription/pending"; /** * Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus @@ -16,6 +18,10 @@ import { succeed } from "@/lib/flash"; * Rechnung auch ohne verknuepftes Kundenkonto vollstaendig bleibt. Alle * anderen persoenlichen Daten (Adressen, Wunschliste, Bewertungen, * Newsletter, Backup-Codes) werden per Cascade mitgeloescht. + * + * Mit laufendem Patenschafts-Abo wird nicht geloescht (erst kuendigen), und + * die Urkunden-Dateien der eigenen Patenschaften werden mit entfernt + * (lib/sponsorship/customer-deletion.ts). */ export async function deleteOwnAccountAction(formData: FormData): Promise { const customer = await getCurrentCustomer(); @@ -42,10 +48,20 @@ export async function deleteOwnAccountAction(formData: FormData): Promise return fail("/konto/einstellungen", "Passwort ist falsch."); } + if (await hasBlockingSubscription(customer.id)) { + return fail( + "/konto/einstellungen", + "Bitte kündige zuerst dein Abo (Konto → Patenschaften). Dein Konto kannst du löschen, sobald das Abo beendet ist." + ); + } + const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted"); await sendMail({ to: customer.email, subject, html, text }); + const certificateDirs = await certificateDirsOfCustomer(customer.id); + await discardPendingCheckouts(customer.id); await prisma.customer.delete({ where: { id: customer.id } }); + await removeCertificateDirs(certificateDirs); await destroySession(); return succeed("/konto", "Dein Konto wurde endgültig gelöscht."); } diff --git a/src/app/konto/patenschaft/[id]/page.tsx b/src/app/konto/patenschaft/[id]/page.tsx index e372f2c..aaea4ef 100644 --- a/src/app/konto/patenschaft/[id]/page.tsx +++ b/src/app/konto/patenschaft/[id]/page.tsx @@ -86,6 +86,15 @@ function transferDialogLabels(t: AccountSponsorshipStrings): TransferDialogLabel }; } +function RevokedPanel({ t }: { t: AccountSponsorshipStrings }) { + return ( +
+

{t.detail.revokedTitle}

+

{t.detail.revokedText}

+
+ ); +} + function PendingPanel({ t }: { t: AccountSponsorshipStrings }) { return (
@@ -111,7 +120,10 @@ export default async function SponsorshipDetailPage({ params }: Props) { if (!loaded) notFound(); const { sponsorship, locale } = loaded; const t = getAccountSponsorshipStrings(locale); - const isReady = sponsorship.revision !== null; + // Widerrufen (Storno/Erstattung): nur noch Angaben und Hinweis — keine + // 3D-Ansicht, keine Downloads, keine Uebertragung. + const isRevoked = sponsorship.lifecycle === "REVOKED"; + const isReady = !isRevoked && sponsorship.revision !== null; const canTransfer = isTransferable(sponsorship); const openTransfer = canTransfer ? await getOpenTransfer(sponsorship.id) : null; const name = sponsorship.beeName || sponsorship.number; @@ -134,8 +146,8 @@ export default async function SponsorshipDetailPage({ params }: Props) {
{t.detail.stageHeading} - {isReady ? ( + {isRevoked ? ( + + ) : isReady ? ( <>

@@ -196,7 +210,7 @@ export default async function SponsorshipDetailPage({ params }: Props) {

{t.detail.symbolicNote}

- {!isReady ? : null} + {!isReady && !isRevoked ? : null}
); } diff --git a/src/app/konto/patenschaft/_components/lifecycle-badge.tsx b/src/app/konto/patenschaft/_components/lifecycle-badge.tsx index 1c828d9..2b360f5 100644 --- a/src/app/konto/patenschaft/_components/lifecycle-badge.tsx +++ b/src/app/konto/patenschaft/_components/lifecycle-badge.tsx @@ -9,6 +9,7 @@ const STYLES: Record = { CANCELLING: "border-ink/25 text-ink/75", EXPIRED: "border-ink/15 text-ink/65", ENDED: "border-ink/15 text-ink/65", + REVOKED: "border-ink/25 text-ink/65 line-through decoration-ink/40", }; export function LifecycleBadge({ state, label }: { state: SponsorshipLifecycle | "PENDING"; label: string }) { diff --git a/src/app/konto/patenschaft/_components/medal-shelf.tsx b/src/app/konto/patenschaft/_components/medal-shelf.tsx index 14bea35..c293ee4 100644 --- a/src/app/konto/patenschaft/_components/medal-shelf.tsx +++ b/src/app/konto/patenschaft/_components/medal-shelf.tsx @@ -22,7 +22,8 @@ function collectMedals( t: AccountSponsorshipStrings ): ShelfMedal[] { const medals: ShelfMedal[] = sponsorships - .filter((sponsorship) => sponsorship.revision !== null) + // Widerrufene Patenschaften haben keine Medaille mehr (die Datei-Routen antworten 404). + .filter((sponsorship) => sponsorship.revision !== null && sponsorship.lifecycle !== "REVOKED") .map((sponsorship) => ({ key: sponsorship.id, imageUrl: `${sponsorshipFilePath(sponsorship.id, "medaille.png")}?groesse=256`, diff --git a/src/app/konto/patenschaft/_components/sponsorship-row.tsx b/src/app/konto/patenschaft/_components/sponsorship-row.tsx index 01b0b84..8b94f80 100644 --- a/src/app/konto/patenschaft/_components/sponsorship-row.tsx +++ b/src/app/konto/patenschaft/_components/sponsorship-row.tsx @@ -21,6 +21,16 @@ function PendingSeal() { ); } +/** Ruhige, durchgestrichene Wabe fuer eine widerrufene Patenschaft (keine Medaille mehr). */ +function RevokedSeal() { + return ( + + ); +} + /** * Eine Patenschaft in der Uebersicht. Die ganze Karte ist der Link zur * Detailseite; "Abo verwalten" steht darunter ausserhalb des Links, damit @@ -37,7 +47,8 @@ export function SponsorshipRow({ t: AccountSponsorshipStrings; index: number; }) { - const isPending = sponsorship.revision === null; + const isRevoked = sponsorship.lifecycle === "REVOKED"; + const isPending = !isRevoked && sponsorship.revision === null; const name = sponsorship.beeName || sponsorship.number; return (
  • - {isPending ? ( + {isRevoked ? ( + + ) : isPending ? ( ) : ( // eslint-disable-next-line @next/next/no-img-element -- geschuetzte Besitzer-Route, kein Bildoptimierer diff --git a/src/app/konto/patenschaft/_lib/owner.test.ts b/src/app/konto/patenschaft/_lib/owner.test.ts new file mode 100644 index 0000000..3080d62 --- /dev/null +++ b/src/app/konto/patenschaft/_lib/owner.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment node +/** + * Alle Datei-Routen einer Patenschaft (PDF, Textur, Rueckseite, Medaille, + * ZIP) laden sie ueber loadOwnedSponsorship. Eine widerrufene Patenschaft + * (Storno/Erstattung) liefert dort nichts mehr — 404 wie eine fremde. + */ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const m = vi.hoisted(() => ({ getCurrentCustomer: vi.fn(), getOwnedSponsorship: vi.fn() })); +vi.mock("@/lib/auth", () => ({ getCurrentCustomer: m.getCurrentCustomer })); +vi.mock("@/i18n/locale", () => ({ getLocale: async () => "de" })); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); +vi.mock("@/lib/sponsorship/access", async (importOriginal) => ({ + ...(await importOriginal()), + getOwnedSponsorship: m.getOwnedSponsorship, +})); + +const { loadOwnedSponsorship } = await import("./owner"); + +beforeEach(() => { + vi.clearAllMocks(); + m.getCurrentCustomer.mockResolvedValue({ id: "cust_1" }); +}); + +describe("loadOwnedSponsorship", () => { + test("liefert die eigene Patenschaft", async () => { + m.getOwnedSponsorship.mockResolvedValue({ id: "sp_1", status: "BEREIT" }); + + expect((await loadOwnedSponsorship("sp_1"))?.sponsorship.id).toBe("sp_1"); + }); + + test("eine widerrufene Patenschaft gibt es fuer die Datei-Routen nicht", async () => { + m.getOwnedSponsorship.mockResolvedValue({ id: "sp_1", status: "WIDERRUFEN" }); + + expect(await loadOwnedSponsorship("sp_1")).toBeNull(); + }); + + test("ohne Anmeldung nichts", async () => { + m.getCurrentCustomer.mockResolvedValue(null); + + expect(await loadOwnedSponsorship("sp_1")).toBeNull(); + }); +}); diff --git a/src/app/konto/patenschaft/_lib/owner.ts b/src/app/konto/patenschaft/_lib/owner.ts index 0f758e0..3a93670 100644 --- a/src/app/konto/patenschaft/_lib/owner.ts +++ b/src/app/konto/patenschaft/_lib/owner.ts @@ -1,5 +1,5 @@ import { getCurrentCustomer } from "@/lib/auth"; -import { getOwnedSponsorship, type OwnedSponsorship } from "@/lib/sponsorship/access"; +import { getOwnedSponsorship, isRevoked, type OwnedSponsorship } from "@/lib/sponsorship/access"; import { rateLimit } from "@/lib/rate-limit"; import { getLocale } from "@/i18n/locale"; import type { Locale } from "@/i18n/config"; @@ -14,14 +14,15 @@ export interface OwnedContext { /** * Anmeldung und Besitz in einem Schritt. null heisst fuer jede Route: 404 — * auch ohne Anmeldung, damit eine Route nicht verraet, ob es die Patenschaft - * gibt. + * gibt. Widerrufene Patenschaften (Storno/Erstattung) liefern ebenfalls + * nichts mehr: wer das Geld zurueck hat, bekommt keine Dateien. */ export async function loadOwnedSponsorship(id: string): Promise { const customer = await getCurrentCustomer(); if (!customer) return null; const locale = await getLocale(); const sponsorship = await getOwnedSponsorship(customer.id, id, locale); - return sponsorship ? { customer, sponsorship, locale } : null; + return sponsorship && !isRevoked(sponsorship) ? { customer, sponsorship, locale } : null; } // Rendern (Textur, Medaille, ZIP) kostet spuerbar CPU. Zwischengespeicherte diff --git a/src/app/konto/patenschaft/page.tsx b/src/app/konto/patenschaft/page.tsx index 024ef74..d171e12 100644 --- a/src/app/konto/patenschaft/page.tsx +++ b/src/app/konto/patenschaft/page.tsx @@ -30,7 +30,8 @@ export default async function KontoPatenschaftPage() { listOwnedSponsorships(customer.id, locale), getCustomerLoyalty(customer.id, locale), ]); - const hasPending = sponsorships.some((sponsorship) => sponsorship.revision === null); + // Widerrufene ohne Urkunde werden nie mehr fertig — kein Dauer-Neuladen. + const hasPending = sponsorships.some((sponsorship) => sponsorship.revision === null && sponsorship.lifecycle !== "REVOKED"); return (
    diff --git a/src/app/patenschaft/uebernehmen/[token]/page.tsx b/src/app/patenschaft/uebernehmen/[token]/page.tsx index a697372..b794cc6 100644 --- a/src/app/patenschaft/uebernehmen/[token]/page.tsx +++ b/src/app/patenschaft/uebernehmen/[token]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { Container, FormPanel, LinkButton, Button } from "@/components/ui"; import { getCurrentCustomer } from "@/lib/auth"; +import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { noIndexPrivate } from "@/lib/seo"; import { formatCertificateDate } from "@/lib/certificate/text"; import { previewClaim, transferClaimPath, isWellFormedTransferToken } from "@/lib/sponsorship/transfer"; @@ -13,6 +14,12 @@ import { claimSponsorshipAction } from "./actions"; export const dynamic = "force-dynamic"; +// Jeder Aufruf schlaegt das Token in der Datenbank nach. Je Adresse begrenzt, +// damit sich Links nicht durchprobieren lassen; die Seite sieht beim Limit +// genauso aus wie bei einem ungueltigen Link (kein Hinweis fuer Angreifer). +const LOOKUPS_PER_WINDOW = 30; +const LOOKUP_WINDOW_MS = 10 * 60 * 1000; + type Props = { params: Promise<{ token: string }> }; export async function generateMetadata(): Promise { @@ -59,6 +66,7 @@ export default async function ClaimSponsorshipPage({ params }: Props) { ); if (!isWellFormedTransferToken(token)) return invalid; + if (!rateLimit(`transfer-claim-preview:${await getClientIp()}`, LOOKUPS_PER_WINDOW, LOOKUP_WINDOW_MS)) return invalid; // Ohne Konto zaehlt nur "gueltig oder nicht"; "eigene Patenschaft" kann es // hier noch nicht geben. diff --git a/src/app/patenschaft/use-certificate-preview.ts b/src/app/patenschaft/use-certificate-preview.ts index 04dbe94..8e33746 100644 --- a/src/app/patenschaft/use-certificate-preview.ts +++ b/src/app/patenschaft/use-certificate-preview.ts @@ -14,6 +14,38 @@ import type { PreviewErrorCode } from "@/i18n/sponsorship/shop"; */ const DEBOUNCE_MS = 400; +/** Der Server meldet 503, wenn gerade zu viele Vorschauen gleichzeitig laufen. */ +const BUSY_RETRIES = 3; +const DEFAULT_RETRY_AFTER_MS = 2000; + +function retryAfterMs(response: Response): number { + const seconds = Number(response.headers.get("retry-after")); + return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds, 10) * 1000 : DEFAULT_RETRY_AFTER_MS; +} + +function wait(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }); + }); +} + +/** Schickt die Anfrage und wiederholt sie bei "Server voll" (503) nach Retry-After. */ +async function fetchPreview(body: string, signal: AbortSignal): Promise { + for (let attempt = 0; ; attempt++) { + const response = await fetch("/api/patenschaft/vorschau", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal, + }); + if (response.status !== 503 || attempt >= BUSY_RETRIES) return response; + await wait(retryAfterMs(response), signal); + } +} export interface PreviewServerError { code: PreviewErrorCode; @@ -52,12 +84,7 @@ export function useCertificatePreview(request: PreviewRequest | null): Certifica const timer = setTimeout(async () => { setState((current) => ({ ...current, loading: true })); try { - const response = await fetch("/api/patenschaft/vorschau", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: key, - signal: controller.signal, - }); + const response = await fetchPreview(key, controller.signal); if (!response.ok) { const body: unknown = await response.json().catch(() => null); const error: PreviewServerError = isPreviewErrorBody(body) diff --git a/src/app/shop/actions.ts b/src/app/shop/actions.ts index 307f11d..12e2f61 100644 --- a/src/app/shop/actions.ts +++ b/src/app/shop/actions.ts @@ -1,6 +1,6 @@ "use server"; -import { getProductsByIds, type ProductWithRelations } from "@/lib/data"; +import { getProductsByIds, withFreeStock, type ProductWithRelations } from "@/lib/data"; /** * Loest lokal im Browser gespeicherte Produkt-IDs ("zuletzt angesehen") in @@ -12,7 +12,8 @@ import { getProductsByIds, type ProductWithRelations } from "@/lib/data"; export async function getRecentlyViewedProducts(ids: string[]): Promise { if (ids.length === 0) return []; - const products = await getProductsByIds(ids); + // Freier Bestand (Lager minus Abo-Reservierung), wie ueberall im Shop. + const products = await withFreeStock(await getProductsByIds(ids)); const byId = new Map(products.map((product) => [product.id, product])); return ids diff --git a/src/app/vertraege-kuendigen/cancellation-form.tsx b/src/app/vertraege-kuendigen/cancellation-form.tsx index 1e8c574..b8f7013 100644 --- a/src/app/vertraege-kuendigen/cancellation-form.tsx +++ b/src/app/vertraege-kuendigen/cancellation-form.tsx @@ -86,7 +86,7 @@ export function CancellationForm({ s, turnstileSiteKey }: { s: AboStrings["publi
    {result.receivedAt}

    -

    {result.confirmationSent ? s.successMailSent : s.successMailFailed}

    +

    {s.successMailNote}

    ); } diff --git a/src/app/vertraege-kuendigen/zuruecknehmen/actions.ts b/src/app/vertraege-kuendigen/zuruecknehmen/actions.ts new file mode 100644 index 0000000..ce6b7bf --- /dev/null +++ b/src/app/vertraege-kuendigen/zuruecknehmen/actions.ts @@ -0,0 +1,53 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { rateLimit, getClientIp } from "@/lib/rate-limit"; +import { undoCancellation } from "@/lib/sponsorship-subscription/cancellation-undo"; +import { parseUndoParams, resolveUndoAccess } from "./undo-access"; + +const UNDO_ATTEMPTS_PER_WINDOW = 10; +const UNDO_WINDOW_MS = 60 * 60 * 1000; + +/** Ergebnis als Suchparameter — die Seite zeigt den passenden Text. */ +export type UndoOutcome = "ok" | "ungueltig" | "nicht-gekuendigt" | "abgelaufen" | "fehler"; + +/** + * "Kündigung zurücknehmen": prueft erneut Besitz oder signierten Link + * (die Seite ist keine Sicherheitsgrenze) und nimmt die Kuendigung bei + * Stripe zurueck. Server-Actions pruefen die Herkunft selbst (kein fremder + * Cross-Site-POST). + */ +export async function undoCancellationAction(formData: FormData): Promise { + const params = parseUndoParams({ abo: formData.get("abo"), bis: formData.get("bis"), sig: formData.get("sig") }); + const back = (outcome: UndoOutcome): never => { + const query = new URLSearchParams({ ergebnis: outcome }); + if (params) { + query.set("abo", params.subscriptionId); + if (params.cancelAtSeconds !== null) query.set("bis", String(params.cancelAtSeconds)); + if (params.signature) query.set("sig", params.signature); + } + redirect(`/vertraege-kuendigen/zuruecknehmen?${query.toString()}`); + }; + + if (!params) return back("ungueltig"); + if (!rateLimit(`cancellation-undo:${await getClientIp()}`, UNDO_ATTEMPTS_PER_WINDOW, UNDO_WINDOW_MS)) return back("fehler"); + + const now = new Date(); + const access = await resolveUndoAccess(params, now); + if (!access.ok) return back("ungueltig"); + + let outcome: UndoOutcome; + try { + const result = await undoCancellation({ + subscriptionId: access.subscriptionId, + expectedCancelAt: access.expectedCancelAt, + ownerId: access.ownerId, + now, + }); + outcome = result.ok ? "ok" : result.reason === "expired" ? "abgelaufen" : result.reason === "not_found" ? "ungueltig" : "nicht-gekuendigt"; + } catch (error: unknown) { + console.error(`[abo] Kuendigung von Abo ${access.subscriptionId} konnte nicht zurueckgenommen werden:`, error); + outcome = "fehler"; + } + return back(outcome); +} diff --git a/src/app/vertraege-kuendigen/zuruecknehmen/page.tsx b/src/app/vertraege-kuendigen/zuruecknehmen/page.tsx new file mode 100644 index 0000000..59cbe2e --- /dev/null +++ b/src/app/vertraege-kuendigen/zuruecknehmen/page.tsx @@ -0,0 +1,109 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import { Container, FormPanel, LinkButton, Button } from "@/components/ui"; +import { prisma } from "@/lib/prisma"; +import { noIndexPrivate } from "@/lib/seo"; +import { formatDate } from "@/lib/format"; +import { getLocale } from "@/i18n/locale"; +import { getAboStrings, type AboStrings } from "@/i18n/sponsorship/abo"; +import { undoCancellationAction, type UndoOutcome } from "./actions"; +import { parseUndoParams, resolveUndoAccess } from "./undo-access"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = getAboStrings(await getLocale()).undoPage; + // Der Link ist ein Geheimnis: nie in den Index, nie als Referrer weitergeben. + return { title: t.metaTitle, robots: noIndexPrivate, referrer: "same-origin" }; +} + +function Panel({ title, children }: { title: string; children: ReactNode }) { + return ( +
    + + +

    {title}

    + {children} +
    +
    +
    + ); +} + +const OUTCOMES: readonly UndoOutcome[] = ["ok", "ungueltig", "nicht-gekuendigt", "abgelaufen", "fehler"]; + +function outcomeText(outcome: UndoOutcome, t: AboStrings["undoPage"]): { title: string; text: string } { + switch (outcome) { + case "ok": + return { title: t.successTitle, text: t.successText }; + case "nicht-gekuendigt": + return { title: t.invalidTitle, text: t.notCancelledText }; + case "abgelaufen": + return { title: t.invalidTitle, text: t.expiredText }; + case "fehler": + return { title: t.title, text: t.errorText }; + default: + return { title: t.invalidTitle, text: t.invalidText }; + } +} + +/** + * "Kündigung zurücknehmen" aus der Bestaetigungsmail der oeffentlichen + * Kuendigung. Zugang: angemeldeter Besitzer oder signierter Link + * (undo-access.ts). Die Seite aendert beim Aufruf nichts — erst der Knopf, + * damit Mail-Scanner, die Links vorab oeffnen, keine Kuendigung + * zuruecknehmen. + */ +export default async function UndoCancellationPage({ searchParams }: { searchParams: Promise> }) { + const raw = await searchParams; + const locale = await getLocale(); + const t = getAboStrings(locale).undoPage; + const accountLink = ( + + {t.toAccount} + + ); + + const result = typeof raw.ergebnis === "string" && (OUTCOMES as readonly string[]).includes(raw.ergebnis) ? (raw.ergebnis as UndoOutcome) : null; + if (result) { + const { title, text } = outcomeText(result, t); + return ( + +

    + {text} +

    + {accountLink} +
    + ); + } + + const params = parseUndoParams(raw); + const access = params ? await resolveUndoAccess(params, new Date()) : { ok: false as const }; + const subscription = + access.ok && params + ? await prisma.sponsorshipSubscription.findUnique({ where: { id: access.subscriptionId }, select: { status: true, cancelAt: true } }) + : null; + const cancelAt = (access.ok ? access.expectedCancelAt : undefined) ?? subscription?.cancelAt ?? null; + if (!params || !access.ok || !subscription || subscription.status !== "GEKUENDIGT" || !cancelAt) { + return ( + +

    {t.invalidText}

    + {accountLink} +
    + ); + } + + return ( + +

    {t.intro(formatDate(cancelAt, locale))}

    +
    + + {params.cancelAtSeconds !== null ? : null} + {params.signature ? : null} + +
    +
    + ); +} diff --git a/src/app/vertraege-kuendigen/zuruecknehmen/undo-access.test.ts b/src/app/vertraege-kuendigen/zuruecknehmen/undo-access.test.ts new file mode 100644 index 0000000..a5df702 --- /dev/null +++ b/src/app/vertraege-kuendigen/zuruecknehmen/undo-access.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment node +/** Zugang zur Ruecknahme einer Kuendigung: signierter Link ODER angemeldeter Besitzer. */ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const m = vi.hoisted(() => ({ + getCurrentCustomer: vi.fn(), + db: { sponsorshipSubscription: { findFirst: vi.fn() } }, +})); +vi.mock("@/lib/auth", () => ({ getCurrentCustomer: m.getCurrentCustomer })); +vi.mock("@/lib/prisma", () => ({ prisma: m.db })); +vi.mock("@/lib/stripe", () => ({ getStripeClient: () => ({}) })); +vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" })); + +process.env.SESSION_SECRET = "test-secret-mit-genug-laenge-fuer-hmac-0123456789"; + +const { signCancellationUndo } = await import("@/lib/sponsorship-subscription/cancellation-undo"); +const { parseUndoParams, resolveUndoAccess } = await import("./undo-access"); + +const NOW = new Date("2026-10-16T12:00:00.000Z"); +const CANCEL_AT = new Date("2026-11-16T10:00:00.000Z"); +const SECONDS = CANCEL_AT.getTime() / 1000; + +beforeEach(() => { + vi.clearAllMocks(); + m.getCurrentCustomer.mockResolvedValue(null); + m.db.sponsorshipSubscription.findFirst.mockResolvedValue(null); +}); + +describe("parseUndoParams", () => { + test("liest Abo, Termin und Signatur und weist Unsinn ab", () => { + expect(parseUndoParams({ abo: "cm1abcdefghijk", bis: String(SECONDS), sig: "abc" })).toEqual({ + subscriptionId: "cm1abcdefghijk", + cancelAtSeconds: SECONDS, + signature: "abc", + }); + expect(parseUndoParams({ abo: "../x", bis: "1", sig: "abc" })).toBeNull(); + expect(parseUndoParams({ abo: "cm1abcdefghijk", bis: "nope" })).toEqual({ subscriptionId: "cm1abcdefghijk", cancelAtSeconds: null, signature: "" }); + }); +}); + +describe("resolveUndoAccess", () => { + test("gueltiger Link genuegt, ohne Anmeldung", async () => { + const signature = signCancellationUndo("cm1abcdefghijk", CANCEL_AT); + + const access = await resolveUndoAccess({ subscriptionId: "cm1abcdefghijk", cancelAtSeconds: SECONDS, signature }, NOW); + + expect(access).toEqual({ ok: true, subscriptionId: "cm1abcdefghijk", expectedCancelAt: CANCEL_AT }); + expect(m.getCurrentCustomer).not.toHaveBeenCalled(); + }); + + test("falsche Signatur ohne Anmeldung: kein Zugang", async () => { + expect(await resolveUndoAccess({ subscriptionId: "cm1abcdefghijk", cancelAtSeconds: SECONDS, signature: "falsch" }, NOW)).toEqual({ ok: false }); + }); + + test("angemeldet: nur das eigene Abo", async () => { + m.getCurrentCustomer.mockResolvedValue({ id: "cust_1" }); + m.db.sponsorshipSubscription.findFirst.mockResolvedValue({ id: "cm1abcdefghijk" }); + + expect(await resolveUndoAccess({ subscriptionId: "cm1abcdefghijk", cancelAtSeconds: null, signature: "" }, NOW)).toEqual({ + ok: true, + subscriptionId: "cm1abcdefghijk", + ownerId: "cust_1", + }); + expect(m.db.sponsorshipSubscription.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "cm1abcdefghijk", customerId: "cust_1" } }) + ); + }); +}); diff --git a/src/app/vertraege-kuendigen/zuruecknehmen/undo-access.ts b/src/app/vertraege-kuendigen/zuruecknehmen/undo-access.ts new file mode 100644 index 0000000..ad218a4 --- /dev/null +++ b/src/app/vertraege-kuendigen/zuruecknehmen/undo-access.ts @@ -0,0 +1,47 @@ +import { getCurrentCustomer } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { verifyCancellationUndo } from "@/lib/sponsorship-subscription/cancellation-undo"; + +/** + * Wer darf eine Kuendigung zuruecknehmen? Der angemeldete Besitzer des Abos + * ODER wer den signierten Link aus der Bestaetigungsmail hat (HMAC ueber Abo + * und Kuendigungstermin, gueltig bis zum Termin). Gemeinsam fuer Seite und + * Server-Action, damit beide dieselbe Regel pruefen. + */ + +export interface UndoLinkParams { + subscriptionId: string; + cancelAtSeconds: number | null; + signature: string; +} + +export type UndoAccess = + | { ok: true; subscriptionId: string; expectedCancelAt?: Date; ownerId?: string } + | { ok: false }; + +const ID_PATTERN = /^[a-z0-9]{10,40}$/i; + +export function parseUndoParams(raw: { abo?: unknown; bis?: unknown; sig?: unknown }): UndoLinkParams | null { + const subscriptionId = typeof raw.abo === "string" ? raw.abo : ""; + if (!ID_PATTERN.test(subscriptionId)) return null; + const bis = typeof raw.bis === "string" && /^\d{1,12}$/.test(raw.bis) ? Number(raw.bis) : null; + const signature = typeof raw.sig === "string" ? raw.sig.slice(0, 200) : ""; + return { subscriptionId, cancelAtSeconds: bis, signature }; +} + +export async function resolveUndoAccess(params: UndoLinkParams, now: Date): Promise { + if ( + params.cancelAtSeconds !== null && + params.signature && + verifyCancellationUndo({ subscriptionId: params.subscriptionId, cancelAtSeconds: params.cancelAtSeconds, signature: params.signature, now }) + ) { + return { ok: true, subscriptionId: params.subscriptionId, expectedCancelAt: new Date(params.cancelAtSeconds * 1000) }; + } + const customer = await getCurrentCustomer(); + if (!customer) return { ok: false }; + const owned = await prisma.sponsorshipSubscription.findFirst({ + where: { id: params.subscriptionId, customerId: customer.id }, + select: { id: true }, + }); + return owned ? { ok: true, subscriptionId: owned.id, ownerId: customer.id } : { ok: false }; +} diff --git a/src/app/warenkorb/warenkorb-content.tsx b/src/app/warenkorb/warenkorb-content.tsx index 78bee07..04f9af7 100644 --- a/src/app/warenkorb/warenkorb-content.tsx +++ b/src/app/warenkorb/warenkorb-content.tsx @@ -8,7 +8,7 @@ import { TrustBadges } from "@/components/trust-badges"; import { CartItemThumbnail } from "@/components/cart-item-thumbnail"; import { QuantityStepper } from "@/components/quantity-stepper"; import { ProductCard } from "@/components/product-card"; -import { useCartStore, cartTotalCents, cartPhysicalQuantity } from "@/store/cart-store"; +import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartDiscountBase } from "@/store/cart-store"; import { SponsorshipLineDetails, SponsorshipThumb } from "@/components/sponsorship-cart-line"; import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop"; import WarenkorbLoading from "./loading"; @@ -52,11 +52,14 @@ export function WarenkorbContent({ // Live-Vorschau des serverseitig in api/checkout/route.ts automatisch // angewendeten Mengenrabatts — Hoehe/Menge kommen aus den im Admin-Bereich // (Gutscheine) gepflegten DiscountCode-Zeilen, nicht mehr aus Konstanten. - const activeBulkDiscount = !hasPhysical ? null : resolveBulkDiscount(bulkDiscountTiers, totalQuantity); + // Basis ohne Patenschaftspakete, wie im Server (discountBase). + const discountable = cartDiscountBase(items); + const hasDiscountable = discountable.quantity > 0; + const activeBulkDiscount = !hasDiscountable ? null : resolveBulkDiscount(bulkDiscountTiers, discountable.quantity); const bulkDiscountCents = activeBulkDiscount - ? Math.round((subtotal * activeBulkDiscount.percent) / 100) + ? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100) : 0; - const nextBulkDiscount = !hasPhysical ? null : getNextBulkDiscountTier(bulkDiscountTiers, totalQuantity); + const nextBulkDiscount = !hasDiscountable ? null : getNextBulkDiscountTier(bulkDiscountTiers, discountable.quantity); const cartProductIds = new Set(items.map((item) => item.productId)); const crossSellProducts = availableProducts diff --git a/src/i18n/sponsorship/abo.ts b/src/i18n/sponsorship/abo.ts index 9f611cf..bc4f13c 100644 --- a/src/i18n/sponsorship/abo.ts +++ b/src/i18n/sponsorship/abo.ts @@ -92,12 +92,26 @@ export interface AboStrings { submitting: string; successHeading: string; receivedAtLabel: string; - successMailSent: string; - successMailFailed: string; + /** Fuer passende und unpassende Angaben gleich (kein Hinweis, ob eine Mail rausging). */ + successMailNote: string; errorMessage: string; rateLimited: string; invalid: string; }; + undoPage: { + metaTitle: string; + title: string; + intro: (end: string) => string; + button: string; + successTitle: string; + successText: string; + invalidTitle: string; + invalidText: string; + notCancelledText: string; + expiredText: string; + errorText: string; + toAccount: string; + }; } const de: AboStrings = { @@ -193,13 +207,26 @@ const de: AboStrings = { submitting: "Wird gesendet …", successHeading: "Deine Kündigung ist eingegangen", receivedAtLabel: "Eingegangen am", - successMailSent: "Wir haben dir eine Eingangsbestätigung per Mail geschickt.", - successMailFailed: - "Die Bestätigungsmail konnte gerade nicht versendet werden. Deine Kündigung ist trotzdem eingegangen; bitte bewahre diese Seite als Nachweis auf.", + successMailNote: + "Die Eingangsbestätigung schicken wir an die E-Mail-Adresse deines Kundenkontos. Bitte bewahre diese Seite als Nachweis auf (Ausdruck oder Bildschirmfoto).", errorMessage: "Deine Kündigung konnte nicht gesendet werden. Bitte versuche es erneut oder schreib uns eine Mail.", rateLimited: "Zu viele Anfragen. Bitte versuche es später erneut oder schreib uns eine Mail.", invalid: "Bitte überprüfe deine Angaben.", }, + undoPage: { + metaTitle: "Kündigung zurücknehmen", + title: "Kündigung zurücknehmen", + intro: (end) => `Dein Abo ist zum ${end} gekündigt. Wenn du die Kündigung zurücknimmst, läuft es einfach weiter wie bisher.`, + button: "Kündigung zurücknehmen", + successTitle: "Dein Abo läuft weiter", + successText: "Die Kündigung ist zurückgenommen. Es ändert sich nichts an deinem Abo.", + invalidTitle: "Dieser Link gilt nicht (mehr)", + invalidText: "Der Link ist abgelaufen, unvollständig oder die Kündigung wurde schon zurückgenommen. Im Kundenkonto siehst du den aktuellen Stand.", + notCancelledText: "Für dieses Abo ist keine Kündigung (mehr) vorgemerkt.", + expiredText: "Der Kündigungstermin ist schon erreicht; zurücknehmen lässt sich die Kündigung nicht mehr.", + errorText: "Das hat gerade nicht geklappt. Bitte versuche es gleich noch einmal oder schreib uns eine Mail.", + toAccount: "Zum Kundenkonto", + }, }; const en: AboStrings = { @@ -293,13 +320,26 @@ const en: AboStrings = { submitting: "Sending …", successHeading: "We have received your cancellation", receivedAtLabel: "Received on", - successMailSent: "We have sent you a confirmation of receipt by email.", - successMailFailed: - "The confirmation email could not be sent right now. Your cancellation has still been received; please keep this page as proof.", + successMailNote: + "We send the confirmation of receipt to the email address of your customer account. Please keep this page as proof (print or screenshot).", errorMessage: "Your cancellation could not be sent. Please try again or email us.", rateLimited: "Too many requests. Please try again later or email us.", invalid: "Please check your details.", }, + undoPage: { + metaTitle: "Withdraw cancellation", + title: "Withdraw cancellation", + intro: (end) => `Your subscription is cancelled as of ${end}. If you withdraw the cancellation, it simply continues as before.`, + button: "Withdraw cancellation", + successTitle: "Your subscription continues", + successText: "The cancellation has been withdrawn. Nothing changes about your subscription.", + invalidTitle: "This link is not (or no longer) valid", + invalidText: "The link has expired, is incomplete or the cancellation has already been withdrawn. Your customer account shows the current status.", + notCancelledText: "No cancellation is (any longer) scheduled for this subscription.", + expiredText: "The cancellation date has already been reached; the cancellation can no longer be withdrawn.", + errorText: "That did not work just now. Please try again shortly or email us.", + toAccount: "To your account", + }, }; export function getAboStrings(locale: Locale): AboStrings { diff --git a/src/i18n/sponsorship/account.ts b/src/i18n/sponsorship/account.ts index 86d305e..65f5892 100644 --- a/src/i18n/sponsorship/account.ts +++ b/src/i18n/sponsorship/account.ts @@ -63,6 +63,8 @@ export interface AccountSponsorshipStrings { }; pendingTitle: string; pendingText: string; + revokedTitle: string; + revokedText: string; detailsHeading: string; fields: { number: string; @@ -192,6 +194,7 @@ const de: AccountSponsorshipStrings = { EXPIRED: "Abgelaufen", CANCELLING: "Gekündigt", ENDED: "Beendet", + REVOKED: "Widerrufen", }, term: { range: (start, end) => `${start} bis ${end}`, @@ -231,6 +234,9 @@ const de: AccountSponsorshipStrings = { }, pendingTitle: "Deine Urkunde wird gerade erstellt", pendingText: "Sobald sie fertig ist, erscheinen hier Urkunde, Medaille und Downloads. Diese Seite aktualisiert sich von selbst.", + revokedTitle: "Diese Patenschaft wurde widerrufen", + revokedText: + "Die Bestellung wurde storniert oder erstattet. Urkunde, Medaille und Downloads stehen deshalb nicht mehr zur Verfügung. Bei Fragen schreib uns gern.", detailsHeading: "Angaben", fields: { number: "Patenschaftsnummer", @@ -365,6 +371,7 @@ const en: AccountSponsorshipStrings = { EXPIRED: "Expired", CANCELLING: "Cancelled", ENDED: "Ended", + REVOKED: "Revoked", }, term: { range: (start, end) => `${start} to ${end}`, @@ -402,6 +409,9 @@ const en: AccountSponsorshipStrings = { }, pendingTitle: "Your certificate is being created", pendingText: "As soon as it is ready, the certificate, medal and downloads appear here. This page refreshes on its own.", + revokedTitle: "This sponsorship has been revoked", + revokedText: + "The order was cancelled or refunded, so the certificate, medal and downloads are no longer available. Feel free to contact us with any questions.", detailsHeading: "Details", fields: { number: "Sponsorship number", diff --git a/src/i18n/sponsorship/admin.ts b/src/i18n/sponsorship/admin.ts index fdbae47..3e7b167 100644 --- a/src/i18n/sponsorship/admin.ts +++ b/src/i18n/sponsorship/admin.ts @@ -34,7 +34,7 @@ const de = { kinds: { EINMALIG: "Einmalig", ABO: "Abo" }, cycles: { VIERTELJAEHRLICH: "Vierteljährlich", ERNTE: "Nach der Ernte" }, medals: { BRONZE: "Bronze", SILBER: "Silber", GOLD: "Gold", ABO: "Abo-Prägung" }, - statuses: { AUSSTEHEND: "Ausstehend", FEHLER: "Fehler", BEREIT: "Bereit", GEDRUCKT: "Gedruckt" }, + statuses: { AUSSTEHEND: "Ausstehend", FEHLER: "Fehler", BEREIT: "Bereit", GEDRUCKT: "Gedruckt", WIDERRUFEN: "Widerrufen" }, subscriptionStatuses: { AUSSTEHEND: "Checkout offen", AKTIV: "Aktiv", @@ -110,6 +110,8 @@ const de = { notePrintPhysical: "Nur physische Pakete werden gedruckt.", notePrintReady: "Druckbar, sobald die Urkunde bereit ist.", noteMailReady: "Erneuter Versand erst nach der ersten Zustellung.", + noteRevoked: + "Widerrufen: die Bestellung wurde storniert oder erstattet. Die Kundschaft sieht keine Dateien mehr, Übertragung und Ausstellung sind gesperrt.", revisionsHeading: "Revisionen", colRevision: "Rev.", colCreatedAt: "Erstellt", @@ -324,6 +326,10 @@ const de = { coverage: (n: number) => `${n} ${plural(n, "Honigprodukt deckt", "Honigprodukte decken")} keine volle Abo-Lieferung mehr`, delinquent: (n: number) => `${n} ${plural(n, "Abo ist", "Abos sind")} im Verzug`, }, + order: { + revokedNote: (numbers: string) => + `Patenschaft widerrufen (${numbers}): die Bestellung wurde storniert oder erstattet. Ein Wiederöffnen stellt die Patenschaft NICHT automatisch wieder her.`, + }, }; export type SponsorshipAdminStrings = typeof de; @@ -352,7 +358,7 @@ const en: SponsorshipAdminStrings = { kinds: { EINMALIG: "One-time", ABO: "Subscription" }, cycles: { VIERTELJAEHRLICH: "Quarterly", ERNTE: "After the harvest" }, medals: { BRONZE: "Bronze", SILBER: "Silver", GOLD: "Gold", ABO: "Subscription stamp" }, - statuses: { AUSSTEHEND: "Pending", FEHLER: "Error", BEREIT: "Ready", GEDRUCKT: "Printed" }, + statuses: { AUSSTEHEND: "Pending", FEHLER: "Error", BEREIT: "Ready", GEDRUCKT: "Printed", WIDERRUFEN: "Revoked" }, subscriptionStatuses: { AUSSTEHEND: "Checkout open", AKTIV: "Active", @@ -428,6 +434,8 @@ const en: SponsorshipAdminStrings = { notePrintPhysical: "Only physical packages are printed.", notePrintReady: "Printable once the certificate is ready.", noteMailReady: "Resending is possible after the first delivery.", + noteRevoked: + "Revoked: the order was cancelled or refunded. The customer no longer sees any files; transfer and issuance are blocked.", revisionsHeading: "Revisions", colRevision: "Rev.", colCreatedAt: "Created", @@ -640,6 +648,10 @@ const en: SponsorshipAdminStrings = { coverage: (n) => `${n} ${plural(n, "honey product no longer covers", "honey products no longer cover")} a full subscription delivery`, delinquent: (n) => `${n} ${plural(n, "subscription is", "subscriptions are")} delinquent`, }, + order: { + revokedNote: (numbers) => + `Sponsorship revoked (${numbers}): the order was cancelled or refunded. Reopening the order does NOT restore the sponsorship automatically.`, + }, }; const STRINGS: Record = { de, en }; diff --git a/src/lib/apicore-stock/dashboard.ts b/src/lib/apicore-stock/dashboard.ts index bbc3f7d..d9bbc5d 100644 --- a/src/lib/apicore-stock/dashboard.ts +++ b/src/lib/apicore-stock/dashboard.ts @@ -6,7 +6,7 @@ export interface ApiCoreStockIssues { overdue: number; /** Von ApiCore abgelehnt — braucht einen Menschen. */ rejected: number; - /** Aktive Produkte, deren Bestand noch von Hand gepflegt wird. */ + /** Aktive Produkte (ohne Patenschaften), deren Bestand noch von Hand gepflegt wird. */ unlinked: number; } @@ -22,7 +22,11 @@ export async function countApiCoreStockIssues(now: Date = new Date()): Promise { describe("orderArchiveWhere", () => { const where = orderArchiveWhere(archiveCutoff(NOW)); - it("archiviert nur versendete, abgeholte und stornierte Bestellungen", () => { - expect(where.status).toEqual({ in: ["VERSENDET", "ABGEHOLT", "STORNIERT"] }); + it("archiviert versendete, abgeholte und stornierte Bestellungen sowie bezahlte digitale (nichts zu versenden)", () => { + expect(where.AND[0]).toEqual({ + OR: [{ status: { in: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } }, { fulfillmentMethod: "DIGITAL", status: "BEZAHLT" }], + }); }); it("verlangt, dass die letzte Bearbeitung mehr als einen Monat zurueckliegt", () => { @@ -44,14 +46,16 @@ describe("orderArchiveWhere", () => { }); it("laesst Bestellungen mit offener oder frisch bearbeiteter Retoure aktiv", () => { - expect(where.OR).toEqual([ - { returnRequest: { is: null } }, - { - returnRequest: { - is: { status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, updatedAt: { lte: archiveCutoff(NOW) } }, + expect(where.AND[1]).toEqual({ + OR: [ + { returnRequest: { is: null } }, + { + returnRequest: { + is: { status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, updatedAt: { lte: archiveCutoff(NOW) } }, + }, }, - }, - ]); + ], + }); }); }); @@ -61,7 +65,7 @@ describe("orderUnarchiveWhere", () => { archivedAt: { not: null }, archivedManually: false, OR: [ - { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } }, + { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] }, NOT: { fulfillmentMethod: "DIGITAL", status: "BEZAHLT" } }, { returnRequest: { is: { status: "ANGEFRAGT" } } }, ], }); diff --git a/src/lib/archive.ts b/src/lib/archive.ts index 8873a27..bc93e8a 100644 --- a/src/lib/archive.ts +++ b/src/lib/archive.ts @@ -3,7 +3,9 @@ * * Abgeschlossene Eintraege wandern nach einem Monat ohne Bearbeitung in den * Tab "Archiv", damit die aktiven Listen nur zeigen, was noch zu tun ist: - * - Bestellungen: Status VERSENDET/ABGEHOLT/STORNIERT und keine offene Retoure + * - Bestellungen: Status VERSENDET/ABGEHOLT/STORNIERT und keine offene Retoure; + * digitale Bestellungen (nur Patenschaften, Abo-Monatsrechnungen) schon mit + * BEZAHLT — es gibt nichts zu versenden, sie erreichen nie VERSENDET * - Retouren: Status ANGENOMMEN/ABGELEHNT/ERSTATTET * - Produkte: Bestand 0 — Inventory.updatedAt ist dabei der Zeitpunkt der * letzten Bestandsaenderung, also der Moment, seit dem der Bestand 0 ist. @@ -42,16 +44,23 @@ export function archiveCutoff(now: Date): Date { return new Date(now.getTime() - ARCHIVE_AFTER_DAYS * DAY_MS); } +/** Bezahlte digitale Bestellung: abgeschlossen, obwohl nie versendet. */ +const DIGITAL_DONE = { fulfillmentMethod: "DIGITAL", status: "BEZAHLT" } as const; + export function orderArchiveWhere(cutoff: Date) { return { archivedAt: null, - status: { in: [...ORDER_DONE_STATUSES] }, updatedAt: { lte: cutoff }, - // Eine Retoure haengt in eigener Tabelle — ihre Bearbeitung aendert - // Order.updatedAt nicht und muss deshalb eigens abgewartet werden. - OR: [ - { returnRequest: { is: null } }, - { returnRequest: { is: { status: { in: [...RETURN_DONE_STATUSES] }, updatedAt: { lte: cutoff } } } }, + AND: [ + { OR: [{ status: { in: [...ORDER_DONE_STATUSES] } }, DIGITAL_DONE] }, + // Eine Retoure haengt in eigener Tabelle — ihre Bearbeitung aendert + // Order.updatedAt nicht und muss deshalb eigens abgewartet werden. + { + OR: [ + { returnRequest: { is: null } }, + { returnRequest: { is: { status: { in: [...RETURN_DONE_STATUSES] }, updatedAt: { lte: cutoff } } } }, + ], + }, ], } satisfies Prisma.OrderWhereInput; } @@ -61,7 +70,8 @@ export function orderUnarchiveWhere() { archivedAt: { not: null }, archivedManually: false, OR: [ - { status: { notIn: [...ORDER_DONE_STATUSES] } }, + // Wieder offen — eine bezahlte digitale Bestellung ist dagegen fertig. + { status: { notIn: [...ORDER_DONE_STATUSES] }, NOT: DIGITAL_DONE }, { returnRequest: { is: { status: "ANGEFRAGT" } } }, ], } satisfies Prisma.OrderWhereInput; diff --git a/src/lib/berlin-date.test.ts b/src/lib/berlin-date.test.ts new file mode 100644 index 0000000..7fb1d8b --- /dev/null +++ b/src/lib/berlin-date.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; +import { berlinIsoDate } from "./berlin-date"; + +describe("berlinIsoDate", () => { + test("Sommerzeit (UTC+2): ab 22:00 UTC ist in Berlin schon der naechste Tag", () => { + expect(berlinIsoDate(new Date("2026-07-15T21:59:59.000Z"))).toBe("2026-07-15"); + expect(berlinIsoDate(new Date("2026-07-15T22:00:00.000Z"))).toBe("2026-07-16"); + expect(berlinIsoDate(new Date("2026-07-15T23:30:00.000Z"))).toBe("2026-07-16"); + }); + + test("Winterzeit (UTC+1): erst ab 23:00 UTC ist in Berlin der naechste Tag", () => { + expect(berlinIsoDate(new Date("2026-12-31T22:30:00.000Z"))).toBe("2026-12-31"); + expect(berlinIsoDate(new Date("2026-12-31T23:00:00.000Z"))).toBe("2027-01-01"); + expect(berlinIsoDate(new Date("2026-12-31T23:59:59.000Z"))).toBe("2027-01-01"); + }); + + test("tagsueber gleich dem UTC-Tag", () => { + expect(berlinIsoDate(new Date("2026-10-16T12:00:00.000Z"))).toBe("2026-10-16"); + }); +}); diff --git a/src/lib/berlin-date.ts b/src/lib/berlin-date.ts new file mode 100644 index 0000000..b701c7a --- /dev/null +++ b/src/lib/berlin-date.ts @@ -0,0 +1,18 @@ +/** + * Kalendertag in Berlin als JJJJ-MM-TT. Der Shop rechnet Leistungszeitraeume + * (formatServicePeriod) und Urkundendaten in Berliner Zeit; ein Zeitpunkt + * nach 22:00/23:00 UTC gehoert dort schon zum naechsten Tag. Ohne + * Node-Abhaengigkeiten, damit es auch im Client laeuft. + */ +const BERLIN_DAY = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +export function berlinIsoDate(date: Date): string { + const parts = BERLIN_DAY.formatToParts(date); + const part = (type: "year" | "month" | "day") => parts.find((p) => p.type === type)?.value ?? ""; + return `${part("year")}-${part("month")}-${part("day")}`; +} diff --git a/src/lib/cancellation-invoice.test.ts b/src/lib/cancellation-invoice.test.ts index 4af43ed..7051552 100644 --- a/src/lib/cancellation-invoice.test.ts +++ b/src/lib/cancellation-invoice.test.ts @@ -32,6 +32,9 @@ vi.mock("@/lib/email-templates", () => ({ const sendInvoiceCopy = vi.fn(async () => true); vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) })); +const revokeOrderSponsorships = vi.fn<(orderId: string) => Promise>(async () => []); +vi.mock("@/lib/sponsorship/revoke", () => ({ revokeOrderSponsorships: (orderId: string) => revokeOrderSponsorships(orderId) })); + const { issueCancellationInvoice, isCancellationNumber } = await import("./cancellation-invoice"); const order = { @@ -63,6 +66,32 @@ describe("issueCancellationInvoice", () => { expect(await issueCancellationInvoice("order1", "STORNIERUNG")).toBeNull(); expect(db.$transaction).not.toHaveBeenCalled(); + expect(revokeOrderSponsorships).not.toHaveBeenCalled(); + }); + + test("widerruft mit der Stornorechnung die Patenschaften der Bestellung", async () => { + await issueCancellationInvoice("order1", "STORNIERUNG"); + + expect(revokeOrderSponsorships).toHaveBeenCalledWith("order1"); + }); + + test("widerruft auch beim zweiten Ausloeser (idempotent, falls der erste daran scheiterte)", async () => { + db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: { number: "ST-2026-0001" } }); + + await issueCancellationInvoice("order1", "ERSTATTUNG"); + + expect(revokeOrderSponsorships).toHaveBeenCalledWith("order1"); + }); + + test("ein Fehler beim Widerruf laesst die Stornorechnung bestehen", async () => { + revokeOrderSponsorships.mockRejectedValueOnce(new Error("DB weg")); + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const result = await issueCancellationInvoice("order1", "STORNIERUNG"); + + expect(result?.created).toBe(true); + expect(errors).toHaveBeenCalled(); + errors.mockRestore(); }); test("legt die Stornorechnung mit eigener Nummer an und verschickt sie mit Kopie", async () => { diff --git a/src/lib/cancellation-invoice.ts b/src/lib/cancellation-invoice.ts index e6e8dff..629d640 100644 --- a/src/lib/cancellation-invoice.ts +++ b/src/lib/cancellation-invoice.ts @@ -7,6 +7,7 @@ import { sendMail } from "@/lib/mail"; import { cancellationInvoiceEmail } from "@/lib/email-templates"; import { sendInvoiceCopy } from "@/lib/invoice-mail"; import { isLocale, defaultLocale } from "@/i18n/config"; +import { revokeOrderSponsorships } from "@/lib/sponsorship/revoke"; /** * Stornorechnung: der Gegenbeleg, wenn eine Bestellung mit Rechnung @@ -54,7 +55,12 @@ export async function issueCancellationInvoice(orderId: string, reason: Cancella select: { id: true, totalCents: true, cancellation: { select: { number: true } } }, }); if (!invoice) return null; - if (invoice.cancellation) return { number: invoice.cancellation.number, created: false }; + // Auch beim zweiten Ausloeser: der Widerruf ist idempotent und holt nach, + // falls er beim ersten Mal gescheitert ist. + if (invoice.cancellation) { + await revokeSponsorships(orderId); + return { number: invoice.cancellation.number, created: false }; + } let number: string; try { @@ -81,13 +87,29 @@ export async function issueCancellationInvoice(orderId: string, reason: Cancella if (!isUniqueConstraintError(error)) throw error; const existing = await prisma.cancellationInvoice.findUnique({ where: { invoiceId: invoice.id }, select: { number: true } }); if (!existing) throw error; + await revokeSponsorships(orderId); return { number: existing.number, created: false }; } + await revokeSponsorships(orderId); await deliverCancellationInvoice(number); return { number, created: true }; } +/** + * Storniert oder voll erstattet = Patenschaft widerrufen (lib/sponsorship/ + * revoke.ts). Wirft nicht: die Stornorechnung steht bereits fest; ein + * Fehlschlag wird laut geloggt und beim naechsten Ausloeser nachgeholt. + */ +async function revokeSponsorships(orderId: string): Promise { + try { + const revoked = await revokeOrderSponsorships(orderId); + if (revoked.length > 0) console.info(`[cancellation] ${revoked.length} Patenschaft(en) der Bestellung ${orderId} widerrufen.`); + } catch (error: unknown) { + console.error(`[cancellation] Patenschaften der Bestellung ${orderId} konnten NICHT widerrufen werden — bitte im Admin pruefen:`, error); + } +} + async function loadCancellation(number: string) { return prisma.cancellationInvoice.findUnique({ where: { number }, diff --git a/src/lib/certificate/schema.test.ts b/src/lib/certificate/schema.test.ts index 83b62ba..2ce8e29 100644 --- a/src/lib/certificate/schema.test.ts +++ b/src/lib/certificate/schema.test.ts @@ -143,10 +143,22 @@ describe("Daten", () => { expect(fixedTermFor("2026-01-01")).toEqual({ kind: "FIXED", start: "2026-01-01", end: "2026-12-31" }); }); + test("Einmal-Laufzeit ab 29. Februar endet am Monatsende des Folgejahres minus einen Tag, nicht im Maerz", () => { + // 29.02.2028 + 12 Monate = 28.02.2029 (geklemmt), minus ein Tag = 27.02.2029. + expect(fixedTermFor("2028-02-29")).toEqual({ kind: "FIXED", start: "2028-02-29", end: "2029-02-27" }); + expect(fixedTermFor("2027-01-31")).toEqual({ kind: "FIXED", start: "2027-01-31", end: "2028-01-30" }); + }); + test("Startdatum: frühestens heute, spätestens sechs Monate danach", () => { expect(startDateRange("2026-10-16")).toEqual({ min: "2026-10-16", max: "2027-04-16" }); }); + test("Startdatum-Grenze wird auf das Monatsende geklemmt statt in den Folgemonat zu rollen", () => { + expect(startDateRange("2026-08-31")).toEqual({ min: "2026-08-31", max: "2027-02-28" }); + expect(startDateRange("2027-08-31")).toEqual({ min: "2027-08-31", max: "2028-02-29" }); + expect(startDateRange("2026-12-31")).toEqual({ min: "2026-12-31", max: "2027-06-30" }); + }); + test("isIsoDate erkennt echte Kalendertage", () => { expect(isIsoDate("2028-02-29")).toBe(true); expect(isIsoDate("2027-02-29")).toBe(false); diff --git a/src/lib/certificate/schema.ts b/src/lib/certificate/schema.ts index 5259e56..937f299 100644 --- a/src/lib/certificate/schema.ts +++ b/src/lib/certificate/schema.ts @@ -147,10 +147,17 @@ export function toIsoDate(date: Date): string { return date.toISOString().slice(0, 10); } -function addMonths(isoDate: string, months: number, dayOffset = 0): string { +/** + * Kalendermonate addieren, geklemmt auf das Monatsende: 31.08. + 6 Monate ist + * der letzte Februartag, nicht der 3. Maerz (Date.UTC wuerde den Ueberlauf + * in den Folgemonat rollen). `dayOffset` wird erst danach angewendet — + * 29.02. + 12 Monate − 1 Tag = 27.02. des Folgejahres. + */ +export function addMonths(isoDate: string, months: number, dayOffset = 0): string { const [year, month, day] = isoDate.split("-").map(Number); - // Date.UTC rollt Ueberlaeufe selbst (Tag 0 = letzter Tag des Vormonats). - return toIsoDate(new Date(Date.UTC(year, month - 1 + months, day + dayOffset))); + // Tag 0 des Folgemonats = letzter Tag des Zielmonats. + const lastDayOfTarget = new Date(Date.UTC(year, month + months, 0)).getUTCDate(); + return toIsoDate(new Date(Date.UTC(year, month - 1 + months, Math.min(day, lastDayOfTarget) + dayOffset))); } /** diff --git a/src/lib/data.ts b/src/lib/data.ts index fac1d18..b9ae7fb 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import { asStringArray } from "@/lib/json-array"; import { matchesSearchQuery } from "@/lib/search-match"; +import { freeStock, reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation"; import type { Product, Harvest, Batch, ProductCategory, Prisma } from "@/generated/prisma/client"; // tasteTags/images sind im Schema als Json modelliert (MySQL-Kompatibilitaet, @@ -32,13 +33,34 @@ function withImages(item: T): Omit & export type HarvestWithImages = Omit & { images: string[] }; +/** + * Ersetzt den Lagerbestand durch den FREIEN Bestand (Lager minus Abo- + * Reservierung, lib/sponsorship-subscription/reservation.ts) — ueberall, wo + * Kundschaft Bestand sieht oder durch ihn begrenzt wird. Sonst zeigte der + * Shop "Nur noch 3" fuer Glaeser, die laengst fuer Abos reserviert sind, und + * der Checkout lehnte erst beim Bezahlen ab. Eine Abfrage-Runde fuer die + * ganze Liste (reservedJarsByProduct), nicht je Produkt. + */ +export async function withFreeStock( + products: T[] +): Promise { + const ids = products.filter((p) => p.inventory && p.category !== "PATENSCHAFT").map((p) => p.id); + if (ids.length === 0) return products; + const reserved = await reservedJarsByProduct(ids); + return products.map((product) => { + const jars = reserved.get(product.id) ?? 0; + if (!product.inventory || jars === 0) return product; + return { ...product, inventory: { ...product.inventory, quantity: freeStock(product.inventory.quantity, jars) } }; + }); +} + export type ProductSort = "preis-auf" | "preis-ab" | "verfuegbarkeit"; export interface GetActiveProductsOptions { query?: string; category?: ProductCategory; sort?: ProductSort; - /** Nur Produkte mit Lagerbestand > 0 anzeigen. */ + /** Nur Produkte mit freiem Bestand > 0 anzeigen (Lager minus Abo-Reservierung). */ onlyAvailable?: boolean; } @@ -65,8 +87,16 @@ export async function getActiveProducts( orderBy, }) ).map(normalizeProduct); + const withFree = await withFreeStock(products); + const available = options.onlyAvailable ? withFree.filter((p) => (p.inventory?.quantity ?? 0) > 0) : withFree; + // Die Datenbank sortiert nach dem Lagerbestand; die Reservierung kann die + // Reihenfolge verschieben, also nach freiem Bestand nachsortieren. + const sorted = + options.sort === "verfuegbarkeit" + ? [...available].sort((a, b) => (b.inventory?.quantity ?? 0) - (a.inventory?.quantity ?? 0)) + : available; - if (!trimmed) return products; + if (!trimmed) return sorted; // Teilstring-Suche auf einzelnen tasteTags-Elementen unterstuetzt Prisma // fuer Postgres-Arrays nicht (nur exakte Gleichheit via "has") — bei der @@ -76,7 +106,7 @@ export async function getActiveProducts( // matchesSearchQuery gleicht zusaetzlich Umlaute/ASCII-Transliteration ab // (z.B. "Bluete" <-> "Blüte"), da Produktnamen/-tags durchgaengig echte // Umlaute verwenden, aber nicht jede Kundin/jeder Kunde sie eintippt. - return products.filter((product) => { + return sorted.filter((product) => { const haystack = [product.name, product.description, ...product.tasteTags].join(" "); return matchesSearchQuery(haystack, trimmed); }); @@ -94,7 +124,7 @@ export async function getRelatedProducts( orderBy: { createdAt: "desc" }, take: limit, }); - return products.map(normalizeProduct); + return withFreeStock(products.map(normalizeProduct)); } export async function getProductBySlug( @@ -106,9 +136,11 @@ export async function getProductBySlug( }); // Eine Patenschaft hat keine Produktseite (sie braucht eine Personalisierung). if (!product || product.category === "PATENSCHAFT") return null; - return normalizeProduct(product); + const [withFree] = await withFreeStock([normalizeProduct(product)]); + return withFree ?? null; } +/** Rohdaten mit LAGERbestand (der Checkout prueft die Reservierung selbst) — fuer Kundenanzeigen withFreeStock anwenden. */ export async function getProductsByIds( ids: string[] ): Promise { diff --git a/src/lib/discount-calc.test.ts b/src/lib/discount-calc.test.ts index ce8d91a..5c7985d 100644 --- a/src/lib/discount-calc.test.ts +++ b/src/lib/discount-calc.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { isDiscountCodeValid, calculateDiscountCents, type DiscountCodeLike } from "./discount-calc"; +import { isDiscountCodeValid, calculateDiscountCents, discountBase, type DiscountCodeLike } from "./discount-calc"; const now = new Date("2026-08-12T12:00:00Z"); @@ -88,3 +88,19 @@ describe("calculateDiscountCents", () => { expect(calculateDiscountCents({ type: "PERCENT", value: 100 }, 5450)).toBe(5450); }); }); + +describe("discountBase", () => { + test("zaehlt nur Positionen ohne Patenschaftsrolle (MAIN und HONEY bleiben draussen)", () => { + const base = discountBase([ + { totalCents: 1980, quantity: 2 }, + { totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" }, + { totalCents: 2100, quantity: 3, sponsorshipRole: "HONEY" }, + { totalCents: 1200, quantity: 1, sponsorshipRole: null }, + ]); + expect(base).toEqual({ subtotalCents: 3180, quantity: 3 }); + }); + + test("reiner Patenschafts-Warenkorb hat keine Rabattbasis", () => { + expect(discountBase([{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" }])).toEqual({ subtotalCents: 0, quantity: 0 }); + }); +}); diff --git a/src/lib/discount-calc.ts b/src/lib/discount-calc.ts index b4902bc..4585a99 100644 --- a/src/lib/discount-calc.ts +++ b/src/lib/discount-calc.ts @@ -38,3 +38,26 @@ export function isDiscountCodeValid( export function calculateDiscountCents(code: Pick, subtotalCents: number): number { return code.type === "PERCENT" ? Math.round((subtotalCents * code.value) / 100) : Math.min(code.value, subtotalCents); } + +export interface DiscountBase { + subtotalCents: number; + quantity: number; +} + +/** + * Rabattbasis einer Bestellung: nur Ware ausserhalb von Patenschaften. + * Patenschaftspositionen (MAIN und der Honiganteil HONEY) haben einen fest + * kalkulierten Paketpreis — weder Mengenrabatt-Schwelle noch Gutschein- + * Mindestwert noch Rabattbetrag duerfen sie einrechnen, sonst liesse sich mit + * einem Paket ein Mengenrabatt auf andere Ware freischalten. + */ +export function discountBase( + items: ReadonlyArray<{ totalCents: number; quantity: number; sponsorshipRole?: string | null }> +): DiscountBase { + return items + .filter((item) => !item.sponsorshipRole) + .reduce((base, item) => ({ subtotalCents: base.subtotalCents + item.totalCents, quantity: base.quantity + item.quantity }), { + subtotalCents: 0, + quantity: 0, + }); +} diff --git a/src/lib/inventory.test.ts b/src/lib/inventory.test.ts index 00407eb..8d4ef7a 100644 --- a/src/lib/inventory.test.ts +++ b/src/lib/inventory.test.ts @@ -1,5 +1,13 @@ -import { describe, test, expect } from "vitest"; -import { needsConfirmToChangeStatus, needsReserveOnReopen, needsRestockOnCancel, planInventoryEdit } from "./inventory"; +import { describe, test, expect, vi } from "vitest"; +import type { Prisma } from "@/generated/prisma/client"; +import { + InsufficientStockError, + needsConfirmToChangeStatus, + needsReserveOnReopen, + needsRestockOnCancel, + planInventoryEdit, + reserveOrderItems, +} from "./inventory"; describe("planInventoryEdit", () => { test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => { @@ -70,3 +78,33 @@ describe("needsReserveOnReopen", () => { expect(needsReserveOnReopen(null)).toBe(true); }); }); + +describe("reserveOrderItems", () => { + function fakeTx(count: number) { + const updateMany = vi.fn(async () => ({ count })); + return { tx: { inventory: { updateMany } } as unknown as Prisma.TransactionClient, updateMany }; + } + + test("ueberspringt Patenschafts-Traegerprodukte (kein Bestand), der Honig des Pakets wird reserviert", async () => { + const { tx, updateMany } = fakeTx(1); + + await reserveOrderItems(tx, [ + { productId: "carrier", quantity: 1, productName: "Wabenfreund", product: { category: "PATENSCHAFT" } }, + { productId: "honey", quantity: 3, productName: "Waldhonig", product: { category: "HONIG" } }, + ]); + + expect(updateMany).toHaveBeenCalledOnce(); + expect(updateMany).toHaveBeenCalledWith({ + where: { productId: "honey", quantity: { gte: 3 } }, + data: { quantity: { decrement: 3 } }, + }); + }); + + test("wirft weiterhin, wenn echte Ware nicht mehr reicht", async () => { + const { tx } = fakeTx(0); + + await expect( + reserveOrderItems(tx, [{ productId: "honey", quantity: 3, productName: "Waldhonig", product: { category: "HONIG" } }]) + ).rejects.toBeInstanceOf(InsufficientStockError); + }); +}); diff --git a/src/lib/inventory.ts b/src/lib/inventory.ts index b9bf1e0..f25a517 100644 --- a/src/lib/inventory.ts +++ b/src/lib/inventory.ts @@ -77,9 +77,14 @@ export class InsufficientStockError extends Error { */ export async function reserveOrderItems( tx: Prisma.TransactionClient, - items: { productId: string; quantity: number; productName: string }[] + items: { productId: string; quantity: number; productName: string; product?: { category: string } | null }[] ): Promise { for (const item of items) { + // Patenschafts-Traegerprodukte fuehren keinen Bestand (eine Urkunde ist + // nie ausverkauft) — ohne diese Ausnahme scheiterte das Wiederoeffnen + // jeder Bestellung mit Patenschaft. Der Honig des Pakets ist eine eigene + // Position und wird normal reserviert. + if (item.product?.category === "PATENSCHAFT") continue; const reserved = await tx.inventory.updateMany({ where: { productId: item.productId, quantity: { gte: item.quantity } }, data: { quantity: { decrement: item.quantity } }, diff --git a/src/lib/sales.ts b/src/lib/sales.ts index 3d58354..4dcea1e 100644 --- a/src/lib/sales.ts +++ b/src/lib/sales.ts @@ -16,3 +16,17 @@ export const COUNTED_SALE_WHERE = { status: { notIn: ["NEU", "STORNIERT"] }, NOT: { returnRequest: { is: { status: "ERSTATTET" } } }, } satisfies Prisma.OrderWhereInput; + +/** + * Verkaeufe aus dem Shop-Warenkorb: wie COUNTED_SALE_WHERE, aber ohne + * Abo-Monatsrechnungen und 0-€-Abo-Honiglieferungen. Fuer Kennzahlen, die + * "eine Bestellung" meinen (durchschnittlicher Warenkorb, Stammkundschaft) — + * sonst druecken die Lieferungen den Durchschnitt und jedes Abo machte seine + * Kundschaft nach dem zweiten Monat zur Stammkundschaft. Der Umsatz zaehlt + * die Monatsrechnungen weiter (das Geld ist eingegangen). + */ +export const SHOP_SALE_WHERE = { + ...COUNTED_SALE_WHERE, + subscriptionPayment: { is: null }, + subscriptionDelivery: { is: null }, +} satisfies Prisma.OrderWhereInput; diff --git a/src/lib/sponsorship-subscription/billing-rules.test.ts b/src/lib/sponsorship-subscription/billing-rules.test.ts index da0ef62..e619bf3 100644 --- a/src/lib/sponsorship-subscription/billing-rules.test.ts +++ b/src/lib/sponsorship-subscription/billing-rules.test.ts @@ -125,6 +125,12 @@ describe("lastPaidDay", () => { test("ohne gueltige Zahlung ist es der Starttag", () => { expect(lastPaidDay([], start)).toEqual(start); }); + + test("zaehlt den Berliner Kalendertag wie der Leistungszeitraum der Rechnung", () => { + // Zeitraumende 16.09. 00:30 Berlin (15.09. 22:30 UTC): letzter bezahlter Tag ist der 15.09., nicht der 14.09. + const payments = [{ paidAt: start, periodEnd: new Date("2026-09-15T22:30:00.000Z"), reversedAt: null }]; + expect(lastPaidDay(payments, start)).toEqual(new Date("2026-09-15T00:00:00.000Z")); + }); }); describe("isDisputeDelinquencyExpired", () => { diff --git a/src/lib/sponsorship-subscription/billing-rules.ts b/src/lib/sponsorship-subscription/billing-rules.ts index 4e1ceee..be9e13a 100644 --- a/src/lib/sponsorship-subscription/billing-rules.ts +++ b/src/lib/sponsorship-subscription/billing-rules.ts @@ -1,5 +1,6 @@ import type { SubscriptionStatus } from "@/generated/prisma/client"; import type { Locale } from "@/i18n/config"; +import { berlinIsoDate } from "@/lib/berlin-date"; /** * Reine Regeln der Abo-Abrechnung — ohne Datenbank und ohne Stripe, damit sie @@ -153,11 +154,14 @@ function utcDay(date: Date): Date { /** * Letzter bezahlter Tag eines beendeten Abos fuer die Urkunde "vom … bis …": * der Tag vor dem Ende des letzten nicht zurueckgebuchten Zeitraums, - * frühestens der Starttag. + * frühestens der Starttag. Gezaehlt wird der Berliner Kalendertag — genau wie + * im Leistungszeitraum der Rechnung (formatServicePeriod), sonst stuende auf + * Urkunde und Rechnung bei einem Zeitraumende kurz nach Mitternacht ein + * anderer letzter Tag. */ export function lastPaidDay(payments: readonly RulePayment[], startDate: Date): Date { const ends = payments.filter((p) => p.reversedAt === null).map((p) => p.periodEnd.getTime()); if (ends.length === 0) return utcDay(startDate); - const day = utcDay(new Date(Math.max(...ends) - DAY_MS)); + const day = new Date(`${berlinIsoDate(new Date(Math.max(...ends) - DAY_MS))}T00:00:00.000Z`); return day < utcDay(startDate) ? utcDay(startDate) : day; } diff --git a/src/lib/sponsorship-subscription/cancellation-undo.test.ts b/src/lib/sponsorship-subscription/cancellation-undo.test.ts new file mode 100644 index 0000000..9b160db --- /dev/null +++ b/src/lib/sponsorship-subscription/cancellation-undo.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment node +/** + * "Kündigung zurücknehmen" aus der Bestaetigungsmail der oeffentlichen + * Kuendigung: der Link traegt eine HMAC ueber Abo und Kuendigungstermin und + * gilt bis zu diesem Termin. Zuruecknehmen heisst bei Stripe + * cancel_at_period_end=false — kein eigener Speicher noetig. + */ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const m = vi.hoisted(() => ({ + db: { sponsorshipSubscription: { findUnique: vi.fn() } }, + stripe: { subscriptions: { retrieve: vi.fn(), update: vi.fn() } }, + applyStripeSubscription: vi.fn(), +})); +vi.mock("@/lib/prisma", () => ({ prisma: m.db })); +vi.mock("@/lib/stripe", () => ({ getStripeClient: () => m.stripe })); +vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" })); +vi.mock("@/lib/sponsorship-subscription/stripe-sync", async (importOriginal) => ({ + ...(await importOriginal()), + applyStripeSubscription: m.applyStripeSubscription, +})); + +process.env.SESSION_SECRET = "test-secret-mit-genug-laenge-fuer-hmac-0123456789"; + +const { signCancellationUndo, verifyCancellationUndo, cancellationUndoUrl, undoCancellation } = await import("./cancellation-undo"); + +const NOW = new Date("2026-10-16T12:00:00.000Z"); +const CANCEL_AT = new Date("2026-11-16T10:00:00.000Z"); +const CANCEL_AT_SECONDS = CANCEL_AT.getTime() / 1000; + +function stripeSubscription(overrides: Record = {}) { + return { + id: "sub_stripe", + status: "active", + cancel_at_period_end: true, + cancel_at: CANCEL_AT_SECONDS, + items: { data: [{ current_period_end: CANCEL_AT_SECONDS }] }, + metadata: {}, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + m.db.sponsorshipSubscription.findUnique.mockResolvedValue({ + id: "sub_local", + customerId: "cust_1", + status: "GEKUENDIGT", + stripeSubscriptionId: "sub_stripe", + }); + m.stripe.subscriptions.retrieve.mockResolvedValue(stripeSubscription()); + m.stripe.subscriptions.update.mockResolvedValue(stripeSubscription({ cancel_at_period_end: false, cancel_at: null })); +}); + +describe("Signatur", () => { + test("gilt fuer genau dieses Abo und diesen Termin, bis zum Termin", () => { + const signature = signCancellationUndo("sub_local", CANCEL_AT); + + expect(verifyCancellationUndo({ subscriptionId: "sub_local", cancelAtSeconds: CANCEL_AT_SECONDS, signature, now: NOW })).toBe(true); + expect(verifyCancellationUndo({ subscriptionId: "sub_other", cancelAtSeconds: CANCEL_AT_SECONDS, signature, now: NOW })).toBe(false); + expect(verifyCancellationUndo({ subscriptionId: "sub_local", cancelAtSeconds: CANCEL_AT_SECONDS + 1, signature, now: NOW })).toBe(false); + expect(verifyCancellationUndo({ subscriptionId: "sub_local", cancelAtSeconds: CANCEL_AT_SECONDS, signature: "x", now: NOW })).toBe(false); + expect(verifyCancellationUndo({ subscriptionId: "sub_local", cancelAtSeconds: CANCEL_AT_SECONDS, signature, now: CANCEL_AT })).toBe(false); + }); + + test("der Link enthaelt Abo, Termin und Signatur", () => { + const url = new URL(cancellationUndoUrl("sub_local", CANCEL_AT)); + + expect(url.origin + url.pathname).toBe("https://wabenhain.test/vertraege-kuendigen/zuruecknehmen"); + expect(url.searchParams.get("abo")).toBe("sub_local"); + expect(url.searchParams.get("bis")).toBe(String(CANCEL_AT_SECONDS)); + expect(url.searchParams.get("sig")).toBe(signCancellationUndo("sub_local", CANCEL_AT)); + }); +}); + +describe("undoCancellation", () => { + test("nimmt die vorgemerkte Kuendigung bei Stripe zurueck und uebernimmt den Stand", async () => { + const result = await undoCancellation({ subscriptionId: "sub_local", expectedCancelAt: CANCEL_AT, now: NOW }); + + expect(result).toEqual({ ok: true }); + expect(m.stripe.subscriptions.update).toHaveBeenCalledWith("sub_stripe", { cancel_at_period_end: false }); + expect(m.applyStripeSubscription).toHaveBeenCalledWith(expect.objectContaining({ cancel_at_period_end: false })); + }); + + test("ein Link zu einem anderen (aelteren) Kuendigungstermin wirkt nicht", async () => { + const result = await undoCancellation({ subscriptionId: "sub_local", expectedCancelAt: new Date("2026-10-30T10:00:00.000Z"), now: NOW }); + + expect(result).toEqual({ ok: false, reason: "not_cancelled" }); + expect(m.stripe.subscriptions.update).not.toHaveBeenCalled(); + }); + + test("nichts zu tun, wenn keine Kuendigung vorgemerkt ist", async () => { + m.stripe.subscriptions.retrieve.mockResolvedValue(stripeSubscription({ cancel_at_period_end: false, cancel_at: null })); + + expect(await undoCancellation({ subscriptionId: "sub_local", now: NOW })).toEqual({ ok: false, reason: "not_cancelled" }); + }); + + test("ein beendetes Abo laesst sich nicht wiederbeleben", async () => { + m.db.sponsorshipSubscription.findUnique.mockResolvedValue({ id: "sub_local", customerId: "cust_1", status: "BEENDET", stripeSubscriptionId: "sub_stripe" }); + + expect(await undoCancellation({ subscriptionId: "sub_local", now: NOW })).toEqual({ ok: false, reason: "not_cancelled" }); + expect(m.stripe.subscriptions.retrieve).not.toHaveBeenCalled(); + }); + + test("nach dem Kuendigungstermin ist es zu spaet", async () => { + expect(await undoCancellation({ subscriptionId: "sub_local", expectedCancelAt: CANCEL_AT, now: CANCEL_AT })).toEqual({ + ok: false, + reason: "expired", + }); + }); + + test("angemeldet: nur das eigene Abo", async () => { + expect(await undoCancellation({ subscriptionId: "sub_local", ownerId: "cust_2", now: NOW })).toEqual({ ok: false, reason: "not_found" }); + expect(await undoCancellation({ subscriptionId: "sub_local", ownerId: "cust_1", now: NOW })).toEqual({ ok: true }); + }); + + test("unbekanntes Abo", async () => { + m.db.sponsorshipSubscription.findUnique.mockResolvedValue(null); + + expect(await undoCancellation({ subscriptionId: "sub_x", now: NOW })).toEqual({ ok: false, reason: "not_found" }); + }); +}); diff --git a/src/lib/sponsorship-subscription/cancellation-undo.ts b/src/lib/sponsorship-subscription/cancellation-undo.ts new file mode 100644 index 0000000..99a7135 --- /dev/null +++ b/src/lib/sponsorship-subscription/cancellation-undo.ts @@ -0,0 +1,104 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type Stripe from "stripe"; +import { prisma } from "@/lib/prisma"; +import { getStripeClient } from "@/lib/stripe"; +import { getBaseUrl } from "@/lib/url"; +import { applyStripeSubscription, currentPeriodEndOf } from "@/lib/sponsorship-subscription/stripe-sync"; + +/** + * "Kündigung zurücknehmen" (Link in der Bestaetigungsmail der oeffentlichen + * Kuendigung, /vertraege-kuendigen/zuruecknehmen). + * + * Wer Vertragsnummer, E-Mail und Namen eines Kontos kennt, kann ueber die + * oeffentliche Seite kuendigen (§ 312k BGB verlangt das ohne Anmeldung). + * War das nicht der Kontoinhaber, nimmt dieser es mit einem Klick zurueck — + * die Bestaetigung geht nur an die Adresse des Kontos. + * + * Kein eigener Speicher: der Link traegt eine HMAC ueber Abo-ID und + * Kuendigungstermin (SESSION_SECRET) und gilt bis zu diesem Termin. + * Zuruecknehmen heisst bei Stripe cancel_at_period_end=false. Ein Link zu + * einer frueheren, inzwischen anders terminierten Kuendigung wirkt nicht. + */ + +const UNDO_PATH = "/vertraege-kuendigen/zuruecknehmen"; +const PURPOSE = "subscription-cancellation-undo"; + +function secret(): string { + const value = process.env.SESSION_SECRET; + if (!value) throw new Error("SESSION_SECRET ist nicht gesetzt."); + return value; +} + +function toSeconds(date: Date): number { + return Math.floor(date.getTime() / 1000); +} + +function hmac(subscriptionId: string, cancelAtSeconds: number): string { + return createHmac("sha256", secret()).update(`${PURPOSE}:${subscriptionId}:${cancelAtSeconds}`).digest("base64url"); +} + +export function signCancellationUndo(subscriptionId: string, cancelAt: Date): string { + return hmac(subscriptionId, toSeconds(cancelAt)); +} + +export function cancellationUndoUrl(subscriptionId: string, cancelAt: Date): string { + const params = new URLSearchParams({ + abo: subscriptionId, + bis: String(toSeconds(cancelAt)), + sig: signCancellationUndo(subscriptionId, cancelAt), + }); + return `${getBaseUrl()}${UNDO_PATH}?${params.toString()}`; +} + +/** Signatur stimmt und der Kuendigungstermin liegt noch in der Zukunft. */ +export function verifyCancellationUndo(input: { + subscriptionId: string; + cancelAtSeconds: number; + signature: string; + now: Date; +}): boolean { + if (!Number.isSafeInteger(input.cancelAtSeconds) || input.cancelAtSeconds * 1000 <= input.now.getTime()) return false; + const expected = Buffer.from(hmac(input.subscriptionId, input.cancelAtSeconds)); + const given = Buffer.from(input.signature); + return given.length === expected.length && timingSafeEqual(given, expected); +} + +/** Vorgemerkter Kuendigungstermin bei Stripe — dieselbe Regel wie applyStripeSubscription. */ +function scheduledCancelAt(subscription: Stripe.Subscription): Date | null { + if (typeof subscription.cancel_at === "number") return new Date(subscription.cancel_at * 1000); + return subscription.cancel_at_period_end ? currentPeriodEndOf(subscription) : null; +} + +export type UndoCancellationResult = { ok: true } | { ok: false; reason: "not_found" | "not_cancelled" | "expired" }; + +export async function undoCancellation(input: { + subscriptionId: string; + /** Termin aus dem signierten Link; ohne (angemeldeter Besitzer) gilt die aktuelle Kuendigung. */ + expectedCancelAt?: Date; + /** Angemeldeter Besitzer statt Link: das Abo muss diesem Konto gehoeren. */ + ownerId?: string; + now: Date; +}): Promise { + if (input.expectedCancelAt && input.expectedCancelAt.getTime() <= input.now.getTime()) return { ok: false, reason: "expired" }; + const local = await prisma.sponsorshipSubscription.findUnique({ + where: { id: input.subscriptionId }, + select: { id: true, customerId: true, status: true, stripeSubscriptionId: true }, + }); + if (!local || (input.ownerId !== undefined && local.customerId !== input.ownerId)) return { ok: false, reason: "not_found" }; + if (local.status === "BEENDET" || local.status === "AUSSTEHEND" || !local.stripeSubscriptionId) { + return { ok: false, reason: "not_cancelled" }; + } + + const stripe = getStripeClient(); + const current = await stripe.subscriptions.retrieve(local.stripeSubscriptionId); + const cancelAt = scheduledCancelAt(current); + if (!cancelAt || current.status === "canceled") return { ok: false, reason: "not_cancelled" }; + if (input.expectedCancelAt && toSeconds(cancelAt) !== toSeconds(input.expectedCancelAt)) return { ok: false, reason: "not_cancelled" }; + if (cancelAt.getTime() <= input.now.getTime()) return { ok: false, reason: "expired" }; + + // Wie die Kuendigung selbst ohne Idempotenzschluessel: setzt nur ein Kennzeichen. + const updated = await stripe.subscriptions.update(local.stripeSubscriptionId, { cancel_at_period_end: false }); + await applyStripeSubscription(updated); + console.info(`[abo] Kuendigung von Abo ${local.id} zurueckgenommen.`); + return { ok: true }; +} diff --git a/src/lib/sponsorship-subscription/checkout.ts b/src/lib/sponsorship-subscription/checkout.ts index bafecc4..33a3ed6 100644 --- a/src/lib/sponsorship-subscription/checkout.ts +++ b/src/lib/sponsorship-subscription/checkout.ts @@ -49,6 +49,10 @@ 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, customer: input.stripeCustomerId, line_items: [{ price: input.stripePriceId, quantity: 1 }], metadata, diff --git a/src/lib/sponsorship-subscription/deliveries.test.ts b/src/lib/sponsorship-subscription/deliveries.test.ts index 257351c..c50a5ad 100644 --- a/src/lib/sponsorship-subscription/deliveries.test.ts +++ b/src/lib/sponsorship-subscription/deliveries.test.ts @@ -112,6 +112,8 @@ describe("createDueDeliveries", () => { const order = m.db.order.create.mock.calls[0][0].data; expect(order).toMatchObject({ status: "BEZAHLT", fulfillmentMethod: "VERSAND", totalCents: 0, street: "Wiesenweg 1" }); expect(order.items.create).toMatchObject({ productId: "honey_1", quantity: 2, unitPriceCents: 0, totalCents: 0 }); + // Bezahlt-Nachweis fuer den DHL-Ablauf (admin/actions/dhl.ts verlangt eine erfolgreiche Zahlung). + expect(order.payment.create).toMatchObject({ provider: "abo", status: "SUCCEEDED", amountCents: 0, paidAt: expect.any(Date) }); expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({ where: { id: "del_1", orderId: null }, data: { orderId: "order_9", status: "ANGELEGT", note: null }, diff --git a/src/lib/sponsorship-subscription/deliveries.ts b/src/lib/sponsorship-subscription/deliveries.ts index 1e8b9f2..9ac9edc 100644 --- a/src/lib/sponsorship-subscription/deliveries.ts +++ b/src/lib/sponsorship-subscription/deliveries.ts @@ -129,6 +129,11 @@ export async function fulfillDelivery(deliveryId: string): Promise${end}.`, publicUnmatched: "Wir ordnen deine Kündigung jetzt deinem Vertrag zu und melden uns, falls wir dazu noch etwas von dir brauchen. Wirksam wird sie zum Ende des bezahlten Monats.", + publicUndoText: + "Du hast diese Kündigung nicht abgegeben oder es dir anders überlegt? Mit einem Klick nimmst du sie zurück – das geht bis zum Kündigungstermin.", + publicUndoButton: "Kündigung zurücknehmen", declaration: (reference: string) => `Hiermit kündige ich meinen Vertrag ${reference} zum nächstmöglichen Zeitpunkt.`, name: "Name", email: "E-Mail", @@ -88,6 +91,9 @@ const strings = { `We have found your subscription. The cancellation takes effect at the end of the paid month, on ${end}.`, publicUnmatched: "We are now matching your cancellation to your contract and will get in touch if we need anything else from you. It takes effect at the end of the paid month.", + publicUndoText: + "You did not submit this cancellation or changed your mind? Withdraw it with one click – this is possible until the cancellation date.", + publicUndoButton: "Withdraw cancellation", declaration: (reference: string) => `I hereby cancel my contract ${reference} at the earliest possible date.`, name: "Name", email: "Email", @@ -241,11 +247,14 @@ export async function publicCancellationReceiptEmail(input: { declaration: PublicCancellationDeclaration; receivedAt: Date; effectiveEnd: Date | null; + /** Link "Kündigung zurücknehmen" — nur bei automatisch vorgemerkter Kuendigung, nur an die Kontoadresse. */ + undoUrl?: string | null; }): Promise { const s = strings[input.locale]; const lines = cancellationDeclarationLines(input.declaration, input.locale); const when = formatReceivedAt(input.receivedAt, input.locale); const outcome = input.effectiveEnd ? s.publicMatched(formatDate(input.effectiveEnd, input.locale)) : s.publicUnmatched; + const undo = input.undoUrl ? `

    ${s.publicUndoText}

    ${button(input.undoUrl, s.publicUndoButton)}
    ` : ""; const html = await emailLayout( input.locale, `${s.receivedAt}: ${when}`, @@ -254,9 +263,21 @@ export async function publicCancellationReceiptEmail(input: {

    ${s.publicIntro}

    ${lines.map((line) => escapeHtml(line)).join("
    ")}

    ${s.receivedAt}: ${escapeHtml(when)}

    -

    ${outcome}

    +

    ${outcome}

    + ${undo} ` ); - const text = [s.publicHeading, "", s.publicIntro, "", ...lines, "", `${s.receivedAt}: ${when}`, "", stripTags(outcome)].join("\n"); + const text = [ + s.publicHeading, + "", + s.publicIntro, + "", + ...lines, + "", + `${s.receivedAt}: ${when}`, + "", + stripTags(outcome), + ...(input.undoUrl ? ["", s.publicUndoText, `${s.publicUndoButton}: ${input.undoUrl}`] : []), + ].join("\n"); return { subject: s.publicSubject, html, text }; } diff --git a/src/lib/sponsorship-subscription/jobs.test.ts b/src/lib/sponsorship-subscription/jobs.test.ts index dd5ef96..9cf1eca 100644 --- a/src/lib/sponsorship-subscription/jobs.test.ts +++ b/src/lib/sponsorship-subscription/jobs.test.ts @@ -13,9 +13,13 @@ vi.mock("@/lib/sponsorship-subscription/ending", () => ({ endSubscription: m.endSubscription, ensureEndedCertificate: vi.fn(), })); -vi.mock("@/lib/sponsorship-subscription/deliveries", () => ({ createDueDeliveries: vi.fn() })); +vi.mock("@/lib/sponsorship-subscription/deliveries", () => ({ createDueDeliveries: vi.fn(async () => ({ created: 0, deferred: 0 })) })); +const cleanupStalePendingSubscriptions = vi.fn<(now: Date) => Promise>(async () => 0); +vi.mock("@/lib/sponsorship-subscription/pending", () => ({ + cleanupStalePendingSubscriptions: (now: Date) => cleanupStalePendingSubscriptions(now), +})); -const { endExpiredDisputes } = await import("./jobs"); +const { endExpiredDisputes, runSubscriptionJobs } = await import("./jobs"); const NOW = new Date("2026-10-30T12:00:00.000Z"); const DAY = 24 * 60 * 60 * 1000; @@ -84,3 +88,14 @@ describe("endExpiredDisputes", () => { expect(await endExpiredDisputes(NOW)).toBe(0); }); }); + +describe("runSubscriptionJobs", () => { + test("raeumt offene Abschluesse aelter als 24 Stunden auf, auch wenn ein frueherer Schritt scheitert", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + m.db.sponsorshipSubscription.findMany.mockRejectedValue(new Error("DB kurz weg")); + + await runSubscriptionJobs(NOW); + + expect(cleanupStalePendingSubscriptions).toHaveBeenCalledWith(NOW); + }); +}); diff --git a/src/lib/sponsorship-subscription/jobs.ts b/src/lib/sponsorship-subscription/jobs.ts index 990930c..04a28ba 100644 --- a/src/lib/sponsorship-subscription/jobs.ts +++ b/src/lib/sponsorship-subscription/jobs.ts @@ -3,13 +3,15 @@ import { getStripeClient } from "@/lib/stripe"; import { DISPUTE_GRACE_DAYS, isDisputeDelinquencyExpired } from "@/lib/sponsorship-subscription/billing-rules"; import { createDueDeliveries } from "@/lib/sponsorship-subscription/deliveries"; import { endSubscription, ensureEndedCertificate } from "@/lib/sponsorship-subscription/ending"; +import { cleanupStalePendingSubscriptions } from "@/lib/sponsorship-subscription/pending"; /** * Abo-Aufgaben im Hintergrundtakt (lib/background-jobs.ts, alle zehn Minuten): * * 1. faellige Honiglieferungen anlegen (deliveries.ts), * 2. Abos nach einer Rueckbuchung ohne Ausgleich nach 14 Tagen beenden, - * 3. liegengebliebene Enden nachholen (Urkunde "vom … bis …"). + * 3. liegengebliebene Enden nachholen (Urkunde "vom … bis …"), + * 4. offene Abschluesse nach 24 Stunden samt Stripe-Sitzung aufraeumen. * * Jede Aufgabe ist idempotent und fuer sich abgesichert: ein Fehler in einer * haelt die anderen nicht auf. @@ -78,4 +80,8 @@ export async function runSubscriptionJobs(now: Date): Promise { await endExpiredDisputes(now); }); await runStep("Urkunden beendeter Abos", repairEndedCertificates); + await runStep("Offene Abschluesse", async () => { + const removed = await cleanupStalePendingSubscriptions(now); + if (removed > 0) console.info(`[abo] ${removed} offene Abo-Abschluesse aelter als 24 Stunden entfernt.`); + }); } diff --git a/src/lib/sponsorship-subscription/pending.test.ts b/src/lib/sponsorship-subscription/pending.test.ts new file mode 100644 index 0000000..94293dc --- /dev/null +++ b/src/lib/sponsorship-subscription/pending.test.ts @@ -0,0 +1,124 @@ +/** + * Offene Abo-Abschluesse (AUSSTEHEND ohne Stripe-Abo): zaehlen eine Stunde + * lang zum Kontingent, je Konto hoechstens drei, nach 24 Stunden werden sie + * samt Stripe-Sitzung aufgeraeumt. + */ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const m = vi.hoisted(() => ({ + db: { + sponsorshipSubscription: { findMany: vi.fn(), count: vi.fn(), deleteMany: vi.fn() }, + }, + stripe: { checkout: { sessions: { expire: vi.fn(), retrieve: vi.fn() } } }, +})); +vi.mock("@/lib/prisma", () => ({ prisma: m.db })); +vi.mock("@/lib/stripe", () => ({ getStripeClient: () => m.stripe })); + +const { quotaSubscriptionWhere, preparePendingCheckout, cleanupStalePendingSubscriptions, discardPendingCheckouts, MAX_PENDING_PER_CUSTOMER } = + await import("./pending"); + +const NOW = new Date("2026-10-16T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "info").mockImplementation(() => {}); + m.db.sponsorshipSubscription.findMany.mockResolvedValue([]); + m.db.sponsorshipSubscription.count.mockResolvedValue(0); + m.db.sponsorshipSubscription.deleteMany.mockResolvedValue({ count: 1 }); + m.stripe.checkout.sessions.expire.mockResolvedValue({}); +}); + +describe("quotaSubscriptionWhere", () => { + test("laufende Abos plus offene Abschluesse der letzten 60 Minuten", () => { + expect(quotaSubscriptionWhere(NOW)).toEqual({ + OR: [ + { status: { in: ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] } }, + { status: "AUSSTEHEND", createdAt: { gte: new Date(NOW.getTime() - HOUR) } }, + ], + }); + }); +}); + +describe("preparePendingCheckout", () => { + test("ersetzt einen offenen Abschluss desselben Angebots (Sitzung schliessen, Zeile loeschen)", async () => { + m.db.sponsorshipSubscription.findMany.mockResolvedValue([{ id: "sub_old", stripeCheckoutSessionId: "cs_old" }]); + + expect(await preparePendingCheckout({ customerId: "cust_1", offerId: "offer_1" })).toEqual({ ok: true }); + + expect(m.db.sponsorshipSubscription.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { customerId: "cust_1", offerId: "offer_1", status: "AUSSTEHEND", stripeSubscriptionId: null }, + }) + ); + expect(m.stripe.checkout.sessions.expire).toHaveBeenCalledWith("cs_old"); + expect(m.db.sponsorshipSubscription.deleteMany).toHaveBeenCalledWith({ + where: { id: "sub_old", status: "AUSSTEHEND", stripeSubscriptionId: null }, + }); + }); + + test("laesst eine Zeile stehen, deren Sitzung sich nicht schliessen laesst (vielleicht gerade bezahlt)", async () => { + m.db.sponsorshipSubscription.findMany.mockResolvedValue([{ id: "sub_old", stripeCheckoutSessionId: "cs_old" }]); + m.stripe.checkout.sessions.expire.mockRejectedValue(new Error("session is complete")); + + await preparePendingCheckout({ customerId: "cust_1", offerId: "offer_1" }); + + expect(m.db.sponsorshipSubscription.deleteMany).not.toHaveBeenCalled(); + }); + + test("lehnt ab, wenn das Konto schon drei offene Abschluesse hat", async () => { + m.db.sponsorshipSubscription.count.mockResolvedValue(MAX_PENDING_PER_CUSTOMER); + + expect(await preparePendingCheckout({ customerId: "cust_1", offerId: "offer_2" })).toEqual({ ok: false, reason: "too_many_pending" }); + expect(m.db.sponsorshipSubscription.count).toHaveBeenCalledWith({ + where: { customerId: "cust_1", status: "AUSSTEHEND", stripeSubscriptionId: null }, + }); + }); +}); + +describe("cleanupStalePendingSubscriptions", () => { + test("schliesst die Sitzungen und loescht offene Abschluesse aelter als 24 Stunden ohne Stripe-Abo", async () => { + m.db.sponsorshipSubscription.findMany.mockResolvedValue([ + { id: "sub_a", stripeCheckoutSessionId: "cs_a" }, + { id: "sub_b", stripeCheckoutSessionId: null }, + ]); + + expect(await cleanupStalePendingSubscriptions(NOW)).toBe(2); + + expect(m.db.sponsorshipSubscription.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: "AUSSTEHEND", stripeSubscriptionId: null, createdAt: { lt: new Date(NOW.getTime() - 24 * HOUR) } }, + }) + ); + expect(m.stripe.checkout.sessions.expire).toHaveBeenCalledWith("cs_a"); + expect(m.db.sponsorshipSubscription.deleteMany).toHaveBeenCalledTimes(2); + }); + + test("eine bereits abgelaufene Sitzung hindert das Loeschen nicht, eine abgeschlossene schon", async () => { + m.db.sponsorshipSubscription.findMany.mockResolvedValue([ + { id: "sub_expired", stripeCheckoutSessionId: "cs_expired" }, + { id: "sub_complete", stripeCheckoutSessionId: "cs_complete" }, + ]); + m.stripe.checkout.sessions.expire.mockRejectedValue(new Error("not open")); + m.stripe.checkout.sessions.retrieve.mockImplementation(async (id: string) => ({ status: id === "cs_expired" ? "expired" : "complete" })); + + expect(await cleanupStalePendingSubscriptions(NOW)).toBe(1); + expect(m.db.sponsorshipSubscription.deleteMany).toHaveBeenCalledWith({ + where: { id: "sub_expired", status: "AUSSTEHEND", stripeSubscriptionId: null }, + }); + }); +}); + +describe("discardPendingCheckouts", () => { + test("schliesst bei der Kontoloeschung alle offenen Sitzungen des Kontos", async () => { + m.db.sponsorshipSubscription.findMany.mockResolvedValue([{ id: "sub_a", stripeCheckoutSessionId: "cs_a" }]); + + await discardPendingCheckouts("cust_1"); + + expect(m.db.sponsorshipSubscription.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { customerId: "cust_1", status: "AUSSTEHEND", stripeSubscriptionId: null } }) + ); + expect(m.stripe.checkout.sessions.expire).toHaveBeenCalledWith("cs_a"); + }); +}); diff --git a/src/lib/sponsorship-subscription/pending.ts b/src/lib/sponsorship-subscription/pending.ts new file mode 100644 index 0000000..f10065d --- /dev/null +++ b/src/lib/sponsorship-subscription/pending.ts @@ -0,0 +1,106 @@ +import { prisma } from "@/lib/prisma"; +import { getStripeClient } from "@/lib/stripe"; +import type { Prisma } from "@/generated/prisma/client"; + +/** + * Offene Abo-Abschluesse: das lokale Abo entsteht als AUSSTEHEND vor der + * Stripe-Sitzung (checkout.ts). Ohne Grenzen koennte ein Konto damit + * beliebig viele Zeilen anlegen und — weil sie nicht zum Kontingent + * zaehlten — mehr Abos abschliessen, als das Kontingent erlaubt. + * + * - Zum Kontingent zaehlen offene Abschluesse der letzten 60 Minuten mit. + * - Je Konto hoechstens drei offene Abschluesse; ein erneuter Versuch fuer + * dasselbe Angebot ersetzt den alten (Sitzung schliessen, Zeile loeschen), + * statt eine weitere Zeile anzulegen — die Personalisierung kann sich + * inzwischen geaendert haben, deshalb kein Wiederverwenden. + * - Der Hintergrundlauf (jobs.ts) raeumt offene Abschluesse nach 24 Stunden + * auf. Nur ohne stripeSubscriptionId: AUSSTEHEND MIT Stripe-Abo ist eine + * abgeschlossene, aber noch nicht eingegangene Zahlung (SEPA) und bleibt. + */ + +export const MAX_PENDING_PER_CUSTOMER = 3; +const QUOTA_PENDING_MS = 60 * 60 * 1000; +const STALE_PENDING_MS = 24 * 60 * 60 * 1000; +const RUNNING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const; + +/** Abos, die das Kontingent eines Angebots belegen. */ +export function quotaSubscriptionWhere(now: Date): Prisma.SponsorshipSubscriptionWhereInput { + return { + OR: [ + { status: { in: [...RUNNING_STATUSES] } }, + { status: "AUSSTEHEND", createdAt: { gte: new Date(now.getTime() - QUOTA_PENDING_MS) } }, + ], + }; +} + +/** Bedingung fuer das Loeschen: nur ein noch offener Abschluss ohne Stripe-Abo. */ +function openPendingWhere(id: string): Prisma.SponsorshipSubscriptionWhereInput { + return { id, status: "AUSSTEHEND", stripeSubscriptionId: null }; +} + +/** + * Schliesst die Stripe-Sitzung. true = sie ist danach nicht mehr bezahlbar + * (geschlossen oder schon abgelaufen); false = sie ist abgeschlossen oder + * unklar — dann bleibt die Zeile, der Webhook ordnet die Zahlung zu. + */ +async function closeSession(sessionId: string | null): Promise { + if (!sessionId) return true; + const stripe = getStripeClient(); + try { + await stripe.checkout.sessions.expire(sessionId); + return true; + } catch { + try { + const session = await stripe.checkout.sessions.retrieve(sessionId); + return session?.status === "expired"; + } catch (error: unknown) { + console.error(`[abo] Sitzung ${sessionId} liess sich weder schliessen noch lesen:`, error); + return false; + } + } +} + +async function removePending(row: { id: string; stripeCheckoutSessionId: string | null }): Promise { + if (!(await closeSession(row.stripeCheckoutSessionId))) return false; + const deleted = await prisma.sponsorshipSubscription.deleteMany({ where: openPendingWhere(row.id) }); + return deleted.count > 0; +} + +export type PendingCheckoutResult = { ok: true } | { ok: false; reason: "too_many_pending" }; + +/** Vor einem neuen Abschluss: alten Abschluss desselben Angebots ersetzen, Obergrenze pruefen. */ +export async function preparePendingCheckout(input: { customerId: string; offerId: string }): Promise { + const sameOffer = await prisma.sponsorshipSubscription.findMany({ + where: { customerId: input.customerId, offerId: input.offerId, status: "AUSSTEHEND", stripeSubscriptionId: null }, + select: { id: true, stripeCheckoutSessionId: true }, + }); + for (const row of sameOffer) await removePending(row); + + const open = await prisma.sponsorshipSubscription.count({ + where: { customerId: input.customerId, status: "AUSSTEHEND", stripeSubscriptionId: null }, + }); + return open >= MAX_PENDING_PER_CUSTOMER ? { ok: false, reason: "too_many_pending" } : { ok: true }; +} + +/** Kontoloeschung: offene Abschluesse des Kontos schliessen, damit niemand mehr ein Abo ohne Konto bezahlt. */ +export async function discardPendingCheckouts(customerId: string): Promise { + const open = await prisma.sponsorshipSubscription.findMany({ + where: { customerId, status: "AUSSTEHEND", stripeSubscriptionId: null }, + select: { id: true, stripeCheckoutSessionId: true }, + }); + for (const row of open) await removePending(row); +} + +/** Hintergrundlauf: offene Abschluesse aelter als 24 Stunden samt Sitzung entfernen. */ +export async function cleanupStalePendingSubscriptions(now: Date): Promise { + const stale = await prisma.sponsorshipSubscription.findMany({ + where: { status: "AUSSTEHEND", stripeSubscriptionId: null, createdAt: { lt: new Date(now.getTime() - STALE_PENDING_MS) } }, + select: { id: true, stripeCheckoutSessionId: true }, + take: 100, + }); + let removed = 0; + for (const row of stale) { + if (await removePending(row)) removed++; + } + return removed; +} diff --git a/src/lib/sponsorship-subscription/public-cancellation.test.ts b/src/lib/sponsorship-subscription/public-cancellation.test.ts index 58114ce..d5dd7c9 100644 --- a/src/lib/sponsorship-subscription/public-cancellation.test.ts +++ b/src/lib/sponsorship-subscription/public-cancellation.test.ts @@ -1,7 +1,9 @@ /** * Oeffentliche Kuendigung (/vertraege-kuendigen): Die Erklaerung wird IMMER - * gespeichert und bestaetigt; bei Stripe vorgemerkt wird sie nur, wenn Name, - * E-Mail und Vertragsnummer zum Abo passen. + * gespeichert. Bei Stripe vorgemerkt wird sie nur, wenn Vertragsnummer, + * E-Mail und Name exakt zum Konto passen; die Bestaetigung (mit Link zum + * Zuruecknehmen) geht dann nur an die Adresse des Kontos. Grenzen je + * Vertragsnummer und je E-Mail-Adresse verhindern das Durchprobieren. */ import { beforeEach, describe, expect, test, vi } from "vitest"; @@ -13,52 +15,64 @@ const m = vi.hoisted(() => ({ }, cancelSubscription: vi.fn(), sendMail: vi.fn(), + rateLimit: vi.fn(), + receiptEmail: vi.fn(), + undoUrl: vi.fn(), })); vi.mock("@/lib/prisma", () => ({ prisma: m.db })); vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail })); +vi.mock("@/lib/rate-limit", () => ({ rateLimit: m.rateLimit })); vi.mock("@/lib/sponsorship-subscription/cancellation", () => ({ cancelSubscription: m.cancelSubscription })); +vi.mock("@/lib/sponsorship-subscription/cancellation-undo", () => ({ cancellationUndoUrl: m.undoUrl })); vi.mock("@/lib/sponsorship-subscription/emails", async (importOriginal) => { const actual = await importOriginal(); - return { - cancellationDeclarationLines: actual.cancellationDeclarationLines, - publicCancellationReceiptEmail: vi.fn(async () => ({ subject: "Eingang", html: "

    ", text: "" })), - }; + return { cancellationDeclarationLines: actual.cancellationDeclarationLines, publicCancellationReceiptEmail: m.receiptEmail }; }); -const { matchesCustomer, submitPublicCancellation } = await import("./public-cancellation"); +const { matchesCustomer, normalizeName, submitPublicCancellation } = await import("./public-cancellation"); const NOW = new Date("2026-10-16T12:00:00.000Z"); +const END = new Date("2026-11-16T10:00:00.000Z"); const SUBSCRIPTION = { id: "cm1abcdefghijklmnop", - customer: { email: "erika@example.org", firstName: "Erika", lastName: "Müller" }, + customer: { email: "Erika@Example.org", firstName: "Erika", lastName: "Müller" }, }; const INPUT = { name: "Erika Müller", email: "erika@example.org", reference: "WH-P-2026-00042", message: null }; beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); m.db.sponsorship.findUnique.mockResolvedValue({ subscription: SUBSCRIPTION }); m.db.sponsorshipSubscription.findUnique.mockResolvedValue(null); - m.cancelSubscription.mockResolvedValue({ ok: true, effectiveEnd: new Date("2026-11-16T10:00:00.000Z"), alreadyCancelled: false }); + m.cancelSubscription.mockResolvedValue({ ok: true, effectiveEnd: END, alreadyCancelled: false }); m.sendMail.mockResolvedValue(true); + m.rateLimit.mockReturnValue(true); + m.receiptEmail.mockResolvedValue({ subject: "Eingang", html: "

    ", text: "" }); + m.undoUrl.mockReturnValue("https://wabenhain.test/vertraege-kuendigen/zuruecknehmen?abo=x&bis=1&sig=y"); delete process.env.ADMIN_EMAIL; }); -describe("matchesCustomer", () => { - test("passt bei gleicher E-Mail und enthaltenem Nachnamen, unabhaengig von Gross-/Kleinschreibung und Akzenten", () => { - expect(matchesCustomer({ name: "erika MUELLER", email: "Erika@Example.org" }, SUBSCRIPTION.customer)).toBe(true); - expect(matchesCustomer({ name: "erika MÜLLER", email: "Erika@Example.org " }, SUBSCRIPTION.customer)).toBe(true); - expect(matchesCustomer({ name: "E. Muller", email: "erika@example.org" }, SUBSCRIPTION.customer)).toBe(true); +describe("normalizeName / matchesCustomer", () => { + test("normalisiert NFC, Gross-/Kleinschreibung und Leerraum", () => { + expect(normalizeName(" ERIKA Müller ")).toBe("erika müller"); }); - test("passt nicht bei fremder E-Mail oder fremdem Namen", () => { + test("passt nur bei exakt gleichem Vor- und Nachnamen und gleicher E-Mail (ohne Gross-/Kleinschreibung)", () => { + expect(matchesCustomer({ name: "erika MÜLLER", email: "ERIKA@example.org " }, SUBSCRIPTION.customer)).toBe(true); + }); + + test("kein Teilstring, keine Umschreibung, kein fremder Vorname", () => { + expect(matchesCustomer({ name: "Müller", email: "erika@example.org" }, SUBSCRIPTION.customer)).toBe(false); + expect(matchesCustomer({ name: "E. Müller", email: "erika@example.org" }, SUBSCRIPTION.customer)).toBe(false); + expect(matchesCustomer({ name: "Erika Mueller", email: "erika@example.org" }, SUBSCRIPTION.customer)).toBe(false); + expect(matchesCustomer({ name: "Max Erika Müller", email: "erika@example.org" }, SUBSCRIPTION.customer)).toBe(false); expect(matchesCustomer({ name: "Erika Müller", email: "mallory@example.org" }, SUBSCRIPTION.customer)).toBe(false); - expect(matchesCustomer({ name: "Max Mustermann", email: "erika@example.org" }, SUBSCRIPTION.customer)).toBe(false); }); }); describe("submitPublicCancellation", () => { - test("passende Angaben: Kuendigung bei Stripe vormerken, speichern, Eingang mit Enddatum bestaetigen", async () => { + test("passend: bei Stripe vormerken, speichern, Bestaetigung mit Ruecknahme-Link NUR an die Kontoadresse", async () => { const result = await submitPublicCancellation(INPUT, { locale: "de", verified: true, now: NOW }); expect(m.cancelSubscription).toHaveBeenCalledWith({ subscriptionId: SUBSCRIPTION.id, receivedAt: NOW, sendConfirmation: false }); @@ -67,26 +81,63 @@ describe("submitPublicCancellation", () => { entityType: "SponsorshipSubscription", entityId: SUBSCRIPTION.id, }); - expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "erika@example.org" })); - expect(result.confirmationSent).toBe(true); + expect(m.undoUrl).toHaveBeenCalledWith(SUBSCRIPTION.id, END); + expect(m.receiptEmail).toHaveBeenCalledWith(expect.objectContaining({ effectiveEnd: END, undoUrl: expect.stringContaining("zuruecknehmen") })); + expect(m.sendMail).toHaveBeenCalledOnce(); + expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "Erika@Example.org" })); + expect(Object.keys(result).sort()).toEqual(["lines", "receivedAt"]); expect(result.lines[0]).toContain("WH-P-2026-00042"); }); - test("unpassende Angaben: nichts bei Stripe, aber gespeichert und bestaetigt — gleiche Antwort", async () => { + test("Grenzen: je Vertragsnummer und je E-Mail-Adresse drei Versuche in 24 Stunden", async () => { + await submitPublicCancellation(INPUT, { 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); + }); + + 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:")); + + await submitPublicCancellation(INPUT, { 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" })); + }); + + test("unpassend: nichts bei Stripe, gespeichert, Eingang an die angegebene Adresse (Pruefung bestanden) — gleiche Antwort", async () => { const result = await submitPublicCancellation({ ...INPUT, email: "mallory@example.org" }, { locale: "de", verified: true, now: NOW }); expect(m.cancelSubscription).not.toHaveBeenCalled(); expect(m.db.auditLog.create.mock.calls[0][0].data.entityId).toBeNull(); + expect(m.receiptEmail).toHaveBeenCalledWith(expect.objectContaining({ effectiveEnd: null, undoUrl: null })); expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "mallory@example.org" })); - expect(Object.keys(result).sort()).toEqual(["confirmationSent", "lines", "receivedAt"]); + expect(Object.keys(result).sort()).toEqual(["lines", "receivedAt"]); }); - test("ohne bestandene Sicherheitspruefung wird nichts automatisch gekuendigt, die Erklaerung aber gespeichert", async () => { + test("unpassend ohne bestandene Sicherheitspruefung: gespeichert, aber keine Mail an die angegebene Adresse", async () => { + await submitPublicCancellation({ ...INPUT, email: "opfer@example.org" }, { locale: "de", verified: false, now: NOW }); + + expect(m.db.auditLog.create).toHaveBeenCalledOnce(); + expect(m.sendMail).not.toHaveBeenCalled(); + }); + + test("unpassend, E-Mail-Adresse zu oft angegeben: keine weitere Mail an sie", async () => { + m.rateLimit.mockImplementation((key: string) => !key.includes(":email:")); + + await submitPublicCancellation({ ...INPUT, email: "opfer@example.org" }, { locale: "de", verified: true, now: NOW }); + + expect(m.sendMail).not.toHaveBeenCalled(); + }); + + test("passend, aber Sicherheitspruefung nicht bestanden: nichts bei Stripe, Eingangsbestaetigung ohne Link an die Kontoadresse", async () => { await submitPublicCancellation(INPUT, { locale: "de", verified: false, now: NOW }); expect(m.cancelSubscription).not.toHaveBeenCalled(); - expect(m.db.auditLog.create).toHaveBeenCalledOnce(); - expect(m.sendMail).toHaveBeenCalledOnce(); + expect(m.receiptEmail).toHaveBeenCalledWith(expect.objectContaining({ effectiveEnd: null, undoUrl: null })); + expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "Erika@Example.org" })); }); test("findet das Abo auch ueber die Vertragsnummer aus dem Konto (Abo-ID)", async () => { @@ -99,12 +150,11 @@ describe("submitPublicCancellation", () => { }); test("ein Fehler bei Stripe verhindert weder Speicherung noch Eingangsbestaetigung", async () => { - vi.spyOn(console, "error").mockImplementation(() => {}); m.cancelSubscription.mockRejectedValue(new Error("Stripe nicht erreichbar")); - const result = await submitPublicCancellation(INPUT, { locale: "de", verified: true, now: NOW }); + await submitPublicCancellation(INPUT, { locale: "de", verified: true, now: NOW }); expect(m.db.auditLog.create).toHaveBeenCalledOnce(); - expect(result.confirmationSent).toBe(true); + expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "Erika@Example.org" })); }); }); diff --git a/src/lib/sponsorship-subscription/public-cancellation.ts b/src/lib/sponsorship-subscription/public-cancellation.ts index cbb4ba2..4cac1a0 100644 --- a/src/lib/sponsorship-subscription/public-cancellation.ts +++ b/src/lib/sponsorship-subscription/public-cancellation.ts @@ -3,8 +3,10 @@ import { prisma } from "@/lib/prisma"; import { sendMail } from "@/lib/mail"; import { escapeHtml } from "@/lib/escape-html"; import { formatReceivedAt } from "@/lib/withdrawal"; +import { rateLimit } from "@/lib/rate-limit"; import type { Locale } from "@/i18n/config"; import { cancelSubscription } from "@/lib/sponsorship-subscription/cancellation"; +import { cancellationUndoUrl } from "@/lib/sponsorship-subscription/cancellation-undo"; import { cancellationDeclarationLines, publicCancellationReceiptEmail, @@ -15,14 +17,25 @@ import { * Kuendigung ohne Anmeldung (§ 312k BGB, Seite /vertraege-kuendigen), nach * dem Muster der Widerrufsfunktion (api/widerruf): * - * - Die Erklaerung wird IMMER gespeichert und ihr Eingang per Mail - * bestaetigt: sie ist mit dem Zugang abgegeben, die Zuordnung zum Vertrag + * - Die Erklaerung wird IMMER gespeichert (Audit-Log) und dem Admin + * gemeldet: sie ist mit dem Zugang abgegeben, die Zuordnung zum Vertrag * ist unsere Aufgabe. - * - Automatisch bei Stripe vorgemerkt wird die Kuendigung nur, wenn E-Mail, - * Name und Vertragsnummer zum Abo passen und die Sicherheitspruefung - * bestanden ist. Sonst bearbeitet der Admin sie von Hand. - * - Die Antwort ist fuer passende und unpassende Angaben gleich — sonst - * liessen sich Vertragsnummern samt E-Mail-Adresse durchprobieren. + * - Automatisch bei Stripe vorgemerkt (zum Ende des bezahlten Monats) wird + * sie nur, wenn Vertragsnummer, E-Mail (ohne Gross-/Kleinschreibung) und + * Vor- plus Nachname EXAKT zum Konto passen (nach Normalisierung, kein + * Teilstring) und die Sicherheitspruefung bestanden ist. + * - Die Bestaetigung einer passenden Kuendigung geht NUR an die Adresse des + * Kontos, mit einem Link zum Zuruecknehmen (cancellation-undo.ts) — wer + * fremde Daten kennt, kann so nichts unbemerkt kuendigen. + * - Je Vertragsnummer und je E-Mail-Adresse hoechstens drei Versuche in 24 + * Stunden (zusaetzlich zum Limit je IP in der Server-Action). Darueber wird + * nichts mehr automatisch zugeordnet; die Erklaerung geht an den Admin. + * - Eine Eingangsbestaetigung an eine NICHT passende Adresse nur mit + * bestandener Sicherheitspruefung und innerhalb des Limits je Adresse — + * sonst liesse sich die Seite als Mail-Kanone gegen Dritte nutzen. + * - Die Antwort ist fuer passende und unpassende Angaben gleich (auch kein + * "Mail versendet ja/nein") — sonst liessen sich Vertragsnummern samt + * E-Mail-Adresse durchprobieren. * * Gespeichert wird im Audit-Log (adminId leer, Aktion * "subscription.cancellation_request"): eine eigene Tabelle wie @@ -44,26 +57,22 @@ export type PublicCancellationInput = z.infer; export const CANCELLATION_REQUEST_ACTION = "subscription.cancellation_request"; -const TRANSLITERATION: Record = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" }; +const ATTEMPTS_PER_TARGET = 3; +const TARGET_WINDOW_MS = 24 * 60 * 60 * 1000; -function stripMarks(value: string): string { - return value.normalize("NFKD").replace(/\p{M}/gu, ""); +/** Name fuer den Vergleich: Unicode NFC, Kleinschreibung, Leerraum zusammengefasst. */ +export function normalizeName(value: string): string { + return value.normalize("NFC").toLowerCase().replace(/\s+/gu, " ").trim(); } -/** Zwei Lesarten je Name: ohne Akzente ("muller") und umschrieben ("mueller"). */ -function nameForms(value: string): string[] { - const lower = value.toLowerCase().trim(); - return [stripMarks(lower), stripMarks(lower.replace(/[äöüß]/g, (char) => TRANSLITERATION[char] ?? char))]; -} - -/** Passt die Angabe zum Konto? Gleiche E-Mail und der Nachname des Kontos steckt im angegebenen Namen. */ +/** Passt die Angabe zum Konto? Gleiche E-Mail und exakt Vor- plus Nachname des Kontos (nach Normalisierung). */ export function matchesCustomer( input: { name: string; email: string }, customer: { email: string; firstName: string; lastName: string } ): boolean { if (input.email.trim().toLowerCase() !== customer.email.trim().toLowerCase()) return false; - const given = nameForms(input.name); - return nameForms(customer.lastName).some((last) => last.length > 0 && given.some((form) => form.includes(last))); + const expected = normalizeName(`${customer.firstName} ${customer.lastName}`); + return expected.length > 0 && normalizeName(input.name) === expected; } const subscriptionSelect = { @@ -87,14 +96,30 @@ async function findReferencedSubscription(reference: string) { export interface PublicCancellationResult { receivedAt: string; lines: string[]; - confirmationSent: boolean; +} + +/** Zeile fuer Audit-Log und Admin-Mail: was mit der Erklaerung geschah. */ +function outcomeText(input: { effectiveEnd: Date | null; matched: boolean; limited: boolean }): string { + if (input.effectiveEnd) { + return `Bei Stripe zum ${input.effectiveEnd.toISOString().slice(0, 10)} vorgemerkt. Bestätigung mit Rücknahme-Link an die Kontoadresse.`; + } + if (input.matched) return "Zugeordnet, aber NICHT automatisch vorgemerkt (Sicherheitsprüfung oder Stripe) — bitte von Hand kündigen."; + if (input.limited) { + return "Zu viele Versuche zu dieser Vertragsnummer oder E-Mail-Adresse — nicht automatisch zugeordnet, bitte manuell prüfen."; + } + return "Keinem Abo zugeordnet — bitte manuell prüfen."; } export async function submitPublicCancellation( input: PublicCancellationDeclaration, context: { locale: Locale; verified: boolean; now: Date } ): Promise { - const subscription = await findReferencedSubscription(input.reference); + 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; const matched = subscription && matchesCustomer(input, subscription.customer) ? subscription : null; let effectiveEnd: Date | null = null; @@ -108,11 +133,7 @@ export async function submitPublicCancellation( } const lines = cancellationDeclarationLines(input, context.locale); - const outcome = effectiveEnd - ? `Bei Stripe zum ${effectiveEnd.toISOString().slice(0, 10)} vorgemerkt.` - : matched - ? "Zugeordnet, aber NICHT automatisch vorgemerkt — bitte von Hand kündigen." - : "Keinem Abo zugeordnet — bitte manuell prüfen."; + const outcome = outcomeText({ effectiveEnd, matched: matched !== null, limited: !referenceAllowed || !emailAllowed }); await prisma.auditLog.create({ data: { adminId: null, @@ -124,19 +145,27 @@ export async function submitPublicCancellation( }, }); - const receipt = await publicCancellationReceiptEmail({ - locale: context.locale, - declaration: input, - receivedAt: context.now, - effectiveEnd, - }); - const confirmationSent = await sendMail({ to: input.email, ...receipt }); + // Passend: Bestaetigung an das Konto (nie an eine abweichende Angabe). + // Unpassend: nur mit bestandener Pruefung und innerhalb des Limits je Adresse. + const recipient = matched ? matched.customer.email : context.verified && emailAllowed ? input.email : null; + let confirmationSent = false; + if (recipient) { + const receipt = await publicCancellationReceiptEmail({ + locale: context.locale, + declaration: input, + receivedAt: context.now, + effectiveEnd, + undoUrl: matched && effectiveEnd ? cancellationUndoUrl(matched.id, effectiveEnd) : null, + }); + confirmationSent = await sendMail({ to: recipient, ...receipt }); + } // Interne Benachrichtigung wie beim Widerruf: eine nicht automatisch // vorgemerkte Kuendigung muss rechtzeitig von Hand erledigt werden. const adminEmail = process.env.ADMIN_EMAIL; if (adminEmail) { - const notice = [...cancellationDeclarationLines(input, "de"), "", outcome, `Eingangsbestätigung: ${confirmationSent ? "versendet" : "NICHT versendet"}`]; + const receiptState = confirmationSent ? "versendet" : recipient ? "NICHT versendet (Mailfehler)" : "nicht versendet (Sicherheitsprüfung/Limit)"; + const notice = [...cancellationDeclarationLines(input, "de"), "", outcome, `Eingangsbestätigung: ${receiptState}`]; await sendMail({ to: adminEmail, subject: `Neue Kündigung zu ${input.reference.replace(/[\r\n]+/g, " ")}`, @@ -145,5 +174,5 @@ export async function submitPublicCancellation( }); } - return { receivedAt: formatReceivedAt(context.now, context.locale), lines, confirmationSent }; + return { receivedAt: formatReceivedAt(context.now, context.locale), lines }; } diff --git a/src/lib/sponsorship-subscription/reservation.test.ts b/src/lib/sponsorship-subscription/reservation.test.ts new file mode 100644 index 0000000..46cd946 --- /dev/null +++ b/src/lib/sponsorship-subscription/reservation.test.ts @@ -0,0 +1,119 @@ +/** + * Abo-Reservierung im Stapel: Produktlisten brauchen den freien Bestand + * vieler Produkte auf einmal — mit festen drei Abfragen statt drei je Produkt. + */ +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +const { reservedJarsByProduct, reservedJarsForProduct, availableForSale, availableForSaleByProduct } = await import("./reservation"); + +const NOW = new Date("2026-10-16T12:00:00.000Z"); + +function fakeDb(data: { + quarterly?: { offer: { honeyProductId: string | null; honeyJars: number } }[]; + windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number; offer: { honeyProductId: string | null } }[]; + deliveries?: { jars: number; dueAt: Date; subscription: { offerId: string } }[]; + inventory?: { productId: string; quantity: number }[]; +}) { + return { + sponsorshipSubscription: { findMany: vi.fn(async () => data.quarterly ?? []) }, + harvestWindow: { findMany: vi.fn(async () => data.windows ?? []) }, + subscriptionDelivery: { findMany: vi.fn(async () => data.deliveries ?? []) }, + inventory: { + findMany: vi.fn(async () => data.inventory ?? []), + findUnique: vi.fn(async ({ where }: { where: { productId: string } }) => + (data.inventory ?? []).find((row) => row.productId === where.productId) ?? null + ), + }, + }; +} + +type Db = Parameters[2]; + +describe("reservedJarsByProduct", () => { + test("summiert vierteljaehrliche Abos und offene Erntefenster je Produkt in drei Abfragen", async () => { + const db = fakeDb({ + quarterly: [ + { offer: { honeyProductId: "wald", honeyJars: 2 } }, + { offer: { honeyProductId: "wald", honeyJars: 2 } }, + { offer: { honeyProductId: "raps", honeyJars: 3 } }, + ], + windows: [ + { + offerId: "ernte", + startsOn: new Date("2026-10-01T00:00:00.000Z"), + endsOn: new Date("2026-10-31T00:00:00.000Z"), + plannedJars: 10, + offer: { honeyProductId: "wald" }, + }, + ], + deliveries: [ + { jars: 4, dueAt: new Date("2026-10-10T08:00:00.000Z"), subscription: { offerId: "ernte" } }, + // Ausserhalb des Fensters: zaehlt nicht. + { jars: 6, dueAt: new Date("2026-09-10T08:00:00.000Z"), subscription: { offerId: "ernte" } }, + ], + }); + + const result = await reservedJarsByProduct(["wald", "raps", "linde"], NOW, db as unknown as Db); + + expect(result.get("wald")).toBe(2 + 2 + (10 - 4)); + expect(result.get("raps")).toBe(3); + expect(result.get("linde")).toBe(0); + expect(db.sponsorshipSubscription.findMany).toHaveBeenCalledOnce(); + expect(db.harvestWindow.findMany).toHaveBeenCalledOnce(); + expect(db.subscriptionDelivery.findMany).toHaveBeenCalledOnce(); + }); + + test("ein ueberliefertes Erntefenster reserviert nichts mehr (nie negativ)", async () => { + const db = fakeDb({ + windows: [ + { + offerId: "ernte", + startsOn: new Date("2026-10-01T00:00:00.000Z"), + endsOn: new Date("2026-10-31T00:00:00.000Z"), + plannedJars: 3, + offer: { honeyProductId: "wald" }, + }, + ], + deliveries: [{ jars: 5, dueAt: new Date("2026-10-31T20:00:00.000Z"), subscription: { offerId: "ernte" } }], + }); + + const result = await reservedJarsByProduct(["wald"], NOW, db as unknown as Db); + + expect(result.get("wald")).toBe(0); + }); + + test("ohne Produkte keine Abfrage", async () => { + const db = fakeDb({}); + + const result = await reservedJarsByProduct([], NOW, db as unknown as Db); + + expect(result.size).toBe(0); + expect(db.sponsorshipSubscription.findMany).not.toHaveBeenCalled(); + }); + + test("Einzelabfrage und freier Bestand bauen auf dem Stapel auf", async () => { + const db = fakeDb({ + quarterly: [{ offer: { honeyProductId: "wald", honeyJars: 2 } }], + inventory: [{ productId: "wald", quantity: 5 }], + }); + + expect(await reservedJarsForProduct("wald", NOW, db as unknown as Db)).toBe(2); + expect(await availableForSale("wald", NOW, db as unknown as Db)).toBe(3); + }); + + test("freier Bestand im Stapel: Lager minus Reservierung, ohne Lagerzeile 0", async () => { + const db = fakeDb({ + quarterly: [{ offer: { honeyProductId: "wald", honeyJars: 4 } }], + inventory: [ + { productId: "wald", quantity: 3 }, + { productId: "raps", quantity: 7 }, + ], + }); + + const result = await availableForSaleByProduct(["wald", "raps", "linde"], NOW, db as unknown as Db); + + expect(Object.fromEntries(result)).toEqual({ wald: 0, raps: 7, linde: 0 }); + }); +}); diff --git a/src/lib/sponsorship-subscription/reservation.ts b/src/lib/sponsorship-subscription/reservation.ts index bf76ee5..a6f0553 100644 --- a/src/lib/sponsorship-subscription/reservation.ts +++ b/src/lib/sponsorship-subscription/reservation.ts @@ -24,7 +24,24 @@ const RESERVING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const; type Db = Pick; -export async function reservedJarsForProduct(productId: string, now: Date = new Date(), db: Db = prisma): Promise { +/** + * Reservierte Glaeser fuer mehrere Produkte auf einmal — fuer Produktlisten + * und den Checkout. Immer drei Abfragen (Abos, Erntefenster, Lieferungen), + * egal wie viele Produkte: eine Liste mit 30 Honigsorten darf nicht 90 + * Abfragen ausloesen. Produkte ohne Reservierung stehen mit 0 in der Map. + */ +export async function reservedJarsByProduct( + productIds: readonly string[], + now: Date = new Date(), + db: Db = prisma +): Promise> { + const ids = [...new Set(productIds)]; + const result = new Map(ids.map((id) => [id, 0])); + if (ids.length === 0) return result; + const add = (productId: string | null, jars: number) => { + if (productId && result.has(productId)) result.set(productId, (result.get(productId) ?? 0) + jars); + }; + const quarterly = await db.sponsorshipSubscription.findMany({ where: { status: { in: [...RESERVING_STATUSES] }, @@ -33,39 +50,70 @@ export async function reservedJarsForProduct(productId: string, now: Date = new // Verzug wegen einer fehlgeschlagenen Zahlung haelt sie dagegen: Stripe // versucht es erneut, und meist klappt es. NOT: { status: "IM_VERZUG", payments: { some: { reversedAt: { not: null } } } }, - offer: { honeyProductId: productId, cycle: "VIERTELJAEHRLICH" }, + offer: { honeyProductId: { in: ids }, cycle: "VIERTELJAEHRLICH" }, }, - select: { offer: { select: { honeyJars: true } } }, + select: { offer: { select: { honeyProductId: true, honeyJars: true } } }, }); - const quarterlyJars = quarterly.reduce((sum, sub) => sum + sub.offer.honeyJars, 0); + for (const subscription of quarterly) add(subscription.offer.honeyProductId, subscription.offer.honeyJars); const today = startOfUtcDay(now); const windows = await db.harvestWindow.findMany({ - where: { endsOn: { gte: today }, offer: { honeyProductId: productId, cycle: "ERNTE" } }, - select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true }, + where: { endsOn: { gte: today }, offer: { honeyProductId: { in: ids }, cycle: "ERNTE" } }, + select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true, offer: { select: { honeyProductId: true } } }, }); - let harvestJars = 0; - for (const window of windows) { - const delivered = await db.subscriptionDelivery.aggregate({ - where: { - status: "ANGELEGT", - subscription: { offerId: window.offerId }, - dueAt: { gte: window.startsOn, lte: endOfUtcDay(window.endsOn) }, - }, - _sum: { jars: true }, - }); - harvestJars += Math.max(0, window.plannedJars - (delivered._sum.jars ?? 0)); - } + if (windows.length === 0) return result; - return quarterlyJars + harvestJars; + // Alle in den offenen Fenstern bereits angelegten Lieferungen in einer + // Abfrage; die Zuordnung zum Fenster geschieht hier im Speicher. + const earliest = new Date(Math.min(...windows.map((window) => window.startsOn.getTime()))); + const deliveries = await db.subscriptionDelivery.findMany({ + where: { + status: "ANGELEGT", + subscription: { offerId: { in: [...new Set(windows.map((window) => window.offerId))] } }, + dueAt: { gte: earliest }, + }, + select: { jars: true, dueAt: true, subscription: { select: { offerId: true } } }, + }); + for (const window of windows) { + const until = endOfUtcDay(window.endsOn); + const delivered = deliveries + .filter((d) => d.subscription.offerId === window.offerId && d.dueAt >= window.startsOn && d.dueAt <= until) + .reduce((sum, d) => sum + d.jars, 0); + add(window.offer.honeyProductId, Math.max(0, window.plannedJars - delivered)); + } + return result; +} + +export async function reservedJarsForProduct(productId: string, now: Date = new Date(), db: Db = prisma): Promise { + return (await reservedJarsByProduct([productId], now, db)).get(productId) ?? 0; +} + +/** Freier Bestand aus Lagerbestand und Reservierung (nie negativ). */ +export function freeStock(quantity: number, reserved: number): number { + return Math.max(0, quantity - reserved); } /** Freier Bestand eines Produkts fuer Einzelverkauf und Pakete (nie negativ). */ export async function availableForSale(productId: string, now: Date = new Date(), db: Db = prisma): Promise { const inventory = await db.inventory.findUnique({ where: { productId }, select: { quantity: true } }); if (!inventory) return 0; - const reserved = await reservedJarsForProduct(productId, now, db); - return Math.max(0, inventory.quantity - reserved); + return freeStock(inventory.quantity, await reservedJarsForProduct(productId, now, db)); +} + +/** Freier Bestand mehrerer Produkte; Produkte ohne Lagerzeile haben 0. */ +export async function availableForSaleByProduct( + productIds: readonly string[], + now: Date = new Date(), + db: Db = prisma +): Promise> { + const ids = [...new Set(productIds)]; + if (ids.length === 0) return new Map(); + const [inventories, reserved] = await Promise.all([ + db.inventory.findMany({ where: { productId: { in: ids } }, select: { productId: true, quantity: true } }), + reservedJarsByProduct(ids, now, db), + ]); + const quantity = new Map(inventories.map((row) => [row.productId, row.quantity])); + return new Map(ids.map((id) => [id, quantity.has(id) ? freeStock(quantity.get(id) ?? 0, reserved.get(id) ?? 0) : 0])); } function startOfUtcDay(date: Date): Date { diff --git a/src/lib/sponsorship-subscription/sponsorship.test.ts b/src/lib/sponsorship-subscription/sponsorship.test.ts new file mode 100644 index 0000000..43354f0 --- /dev/null +++ b/src/lib/sponsorship-subscription/sponsorship.test.ts @@ -0,0 +1,76 @@ +/** + * Die Patenschaft eines Abos beginnt am Berliner Kalendertag der ersten + * Zahlung — eine Zahlung um 23:30 Uhr Berliner Zeit (21:30/22:30 UTC) darf + * nicht auf den Vortag fallen, und eine kurz nach Mitternacht nicht auf den + * UTC-Vortag. + */ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const m = vi.hoisted(() => { + const tx = { + sponsorshipCounter: { upsert: vi.fn() }, + sponsorship: { create: vi.fn() }, + sponsorshipSubscription: { update: vi.fn() }, + }; + return { + tx, + db: { + sponsorshipSubscription: { findUnique: vi.fn() }, + sponsorship: { findUnique: vi.fn() }, + $transaction: vi.fn(async (fn: (client: typeof tx) => Promise) => fn(tx)), + }, + processSponsorship: vi.fn(), + }; +}); + +vi.mock("@/lib/prisma", () => ({ prisma: m.db })); +vi.mock("@/lib/sponsorship/issue", () => ({ processSponsorship: m.processSponsorship })); + +const { ensureSubscriptionSponsorship } = await import("./sponsorship"); + +const PERSONALIZATION = { + personalization: { beeName: "Summsi", recipientName: "Erika Muster", occasion: "SELBST", startDate: "2026-12-20" }, + templateVersion: "classic-v1", +}; + +function subscriptionPaidAt(paidAt: Date) { + return { + id: "sub_1", + customerId: "cus_1", + offerId: "offer_1", + locale: "de", + personalization: PERSONALIZATION, + sponsorship: null, + payments: [{ paidAt }], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + m.tx.sponsorshipCounter.upsert.mockResolvedValue({ lastNumber: 7 }); + m.tx.sponsorship.create.mockResolvedValue({ id: "sp_1" }); +}); + +describe("ensureSubscriptionSponsorship", () => { + test("Startdatum und Nummernjahr folgen dem Berliner Kalendertag der ersten Zahlung", async () => { + // 31.12.2026 23:30 UTC = 01.01.2027 00:30 Berlin. + m.db.sponsorshipSubscription.findUnique.mockResolvedValue(subscriptionPaidAt(new Date("2026-12-31T23:30:00.000Z"))); + + await ensureSubscriptionSponsorship("sub_1"); + + const data = m.tx.sponsorship.create.mock.calls[0][0].data; + expect(data.startDate).toEqual(new Date("2027-01-01T00:00:00.000Z")); + expect(data.number).toBe("WH-P-2027-00007"); + expect(m.tx.sponsorshipCounter.upsert).toHaveBeenCalledWith(expect.objectContaining({ where: { year: 2027 } })); + const stored = m.tx.sponsorshipSubscription.update.mock.calls[0][0].data.personalization; + expect(stored.personalization.startDate).toBe("2027-01-01"); + }); + + test("Sommerzeit: 22:15 UTC ist in Berlin schon der Folgetag", async () => { + m.db.sponsorshipSubscription.findUnique.mockResolvedValue(subscriptionPaidAt(new Date("2026-07-15T22:15:00.000Z"))); + + await ensureSubscriptionSponsorship("sub_1"); + + expect(m.tx.sponsorship.create.mock.calls[0][0].data.startDate).toEqual(new Date("2026-07-16T00:00:00.000Z")); + }); +}); diff --git a/src/lib/sponsorship-subscription/sponsorship.ts b/src/lib/sponsorship-subscription/sponsorship.ts index bf1b2ca..eab9835 100644 --- a/src/lib/sponsorship-subscription/sponsorship.ts +++ b/src/lib/sponsorship-subscription/sponsorship.ts @@ -5,16 +5,17 @@ import { processSponsorship } from "@/lib/sponsorship/issue"; import { fromIsoDate, parseStoredPersonalization, - toIsoDate, toStoredPersonalization, } from "@/lib/sponsorship/certificate-document"; +import { berlinIsoDate } from "@/lib/berlin-date"; import { isUniqueViolation } from "@/lib/sponsorship-subscription/lookup"; /** * Die Patenschaft eines Abos entsteht mit der ersten bezahlten Rechnung * (Spec "Ablauf nach der Zahlung": "Beim Abo entsteht sie mit der ersten - * bezahlten Rechnung"). Startdatum ist der Tag dieser Zahlung — das beim - * Abschluss gewaehlte Datum wird dabei ersetzt. + * bezahlten Rechnung"). Startdatum ist der Berliner Kalendertag dieser + * Zahlung (wie der Leistungszeitraum der Rechnung) — das beim Abschluss + * gewaehlte Datum wird dabei ersetzt. * * Idempotent ueber das eindeutige subscriptionId an der Patenschaft: ein * paralleler oder wiederholter Lauf scheitert an der Datenbank und uebernimmt @@ -35,10 +36,10 @@ export async function ensureSubscriptionSponsorship(subscriptionId: string): Pro const first = subscription.payments[0]; if (!first) return null; const stored = parseStoredPersonalization(subscription.personalization); - const startDate = toIsoDate(first.paidAt); + const startDate = berlinIsoDate(first.paidAt); try { sponsorshipId = await prisma.$transaction(async (tx) => { - const number = await allocateSponsorshipNumber(tx, first.paidAt.getUTCFullYear()); + const number = await allocateSponsorshipNumber(tx, Number(startDate.slice(0, 4))); const created = await tx.sponsorship.create({ data: { number, diff --git a/src/lib/sponsorship-subscription/webhook-handlers.test.ts b/src/lib/sponsorship-subscription/webhook-handlers.test.ts index b566a60..02fff78 100644 --- a/src/lib/sponsorship-subscription/webhook-handlers.test.ts +++ b/src/lib/sponsorship-subscription/webhook-handlers.test.ts @@ -104,6 +104,7 @@ describe("checkout.session.completed (Abo)", () => { id: "cs_1", mode: "subscription", subscription: "sub_stripe", + payment_status: "paid", metadata: { sponsorshipSubscriptionId: "sub_local" }, }) ); @@ -118,6 +119,32 @@ describe("checkout.session.completed (Abo)", () => { }); }); + test("verzoegerte Zahlart (SEPA, noch unbezahlt): verknuepft, aber aktiviert erst mit invoice.paid", async () => { + m.db.sponsorshipSubscription.findUnique + .mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND", stripeSubscriptionId: null }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND", stripeSubscriptionId: null }) + .mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND" }); + + await handleSubscriptionEvent( + event("checkout.session.completed", { + id: "cs_1", + mode: "subscription", + subscription: "sub_stripe", + payment_status: "unpaid", + metadata: { sponsorshipSubscriptionId: "sub_local" }, + }) + ); + + expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({ + where: { id: "sub_local", stripeSubscriptionId: null }, + data: { stripeSubscriptionId: "sub_stripe" }, + }); + expect(m.db.sponsorshipSubscription.updateMany).not.toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: "AKTIV" }) }) + ); + }); + test("laesst Einmal-Checkouts unberuehrt", async () => { await handleSubscriptionEvent(event("checkout.session.completed", { id: "cs_1", mode: "payment", metadata: { orderId: "o1" } })); expect(m.db.sponsorshipSubscription.findUnique).not.toHaveBeenCalled(); diff --git a/src/lib/sponsorship-subscription/webhook-handlers.ts b/src/lib/sponsorship-subscription/webhook-handlers.ts index d55b52f..df172bd 100644 --- a/src/lib/sponsorship-subscription/webhook-handlers.ts +++ b/src/lib/sponsorship-subscription/webhook-handlers.ts @@ -30,6 +30,10 @@ export async function handleSubscriptionCheckoutCompleted(session: Stripe.Checko const stripeSubscriptionId = stripeIdOf(session.subscription); if (stripeSubscriptionId) await findLocalSubscription(stripeSubscriptionId, local.id); + // Aktiv erst mit bestaetigter Zahlung. Bei verzoegerten Zahlarten (SEPA) + // ist die Sitzung hier abgeschlossen, das Geld aber noch nicht da — dann + // aktiviert erst invoice.paid (invoice-paid.ts) das Abo. + if (session.payment_status !== "paid" && session.payment_status !== "no_payment_required") return; await prisma.sponsorshipSubscription.updateMany({ where: { id: local.id, status: "AUSSTEHEND" }, data: { status: "AKTIV", startedAt: new Date() }, diff --git a/src/lib/sponsorship/access.test.ts b/src/lib/sponsorship/access.test.ts index 048682c..c50ffd6 100644 --- a/src/lib/sponsorship/access.test.ts +++ b/src/lib/sponsorship/access.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@/lib/prisma", () => ({ prisma: {} })); -import { computeLifecycle, parseStoredTerm } from "@/lib/sponsorship/access"; +import { computeLifecycle, isTransferable, parseStoredTerm, type OwnedSponsorship } from "@/lib/sponsorship/access"; const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`); @@ -58,3 +58,21 @@ describe("Gespeicherte Laufzeit", () => { expect(parseStoredTerm({ kind: "OPEN", start: "