fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s

- 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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 15:09:32 +02:00
1 parent f227aeb8f9
commit 515fca1c16
97 files changed
+2979 -254

No files matched your search

@@ -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';
+3
View File
@@ -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 {
+13
View File
@@ -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<void> {
const admin = await requireAdmin();
@@ -176,8 +178,19 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
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.`);
+5 -4
View File
@@ -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<string[]> {
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<void>
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 } } } },
},
+3
View File
@@ -47,6 +47,7 @@ const PROCESS_MESSAGES: Record<ProcessResult, string> = {
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":
+20 -3
View File
@@ -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
/>
</div>
{revokedNumbers.length > 0 ? (
<p role="note" className="mb-8 border border-ink/20 bg-ink/[0.03] px-6 py-4 text-sm text-ink/80">
{getSponsorshipAdminStrings(locale).order.revokedNote(revokedNumbers.join(", "))}
</p>
) : null}
{order.giftMessage ? (
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
@@ -211,7 +228,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
{item.productName}
</Link>{" "}
<span className="text-ink/65">({item.weightGrams} g)</span>
{item.weightGrams > 0 ? <span className="text-ink/65">({item.weightGrams} g)</span> : null}
</td>
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
<td className="py-2 text-right">{item.quantity}</td>
+6 -1
View File
@@ -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 } }),
+17 -6
View File
@@ -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() }),
@@ -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 (
<div className="flex flex-col gap-4">
{issued ? (
<div className="flex flex-wrap items-center gap-3">
<a
href={`/admin/patenschaften/${id}/revision/${currentRevision}.pdf?inline=1`}
target="_blank"
rel="noopener"
className="inline-flex items-center justify-center border border-ink/20 px-4 py-2 text-xs tracking-wide-caps text-ink transition-colors hover:border-ink"
>
{t.openPrintPdf}
</a>
</div>
) : null}
<AdminActionNote>{t.noteRevoked}</AdminActionNote>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
@@ -6,6 +6,7 @@ const SPONSORSHIP_TONES: Record<SponsorshipStatus, AdminBadgeTone> = {
FEHLER: "danger",
BEREIT: "positive",
GEDRUCKT: "neutral",
WIDERRUFEN: "danger",
};
const SUBSCRIPTION_TONES: Record<SubscriptionStatus, AdminBadgeTone> = {
+5 -5
View File
@@ -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. */
@@ -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 = {
+125 -5
View File
@@ -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<unknown>>(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<Map<string, unknown>>>(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);
});
});
});
+15 -12
View File
@@ -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<string, number>();
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
+7 -1
View File
@@ -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), {
+20
View File
@@ -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();
});
});
+12
View File
@@ -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,
@@ -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<string>(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);
});
});
@@ -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<V> {
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<V>(maxEntries: number): LruCache<V> {
const entries = new Map<string, V>();
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<string, unknown>;
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");
}
+71 -12
View File
@@ -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<Buffer>(CACHE_ENTRIES);
function failure({ status, ...payload }: PreviewFailure, headers: Record<string, string> = {}): 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<Response> {
@@ -50,8 +72,11 @@ export async function POST(request: Request): Promise<Response> {
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<Response> {
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<Response> {
},
{ 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<string | null> {
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));
}
+25 -1
View File
@@ -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<typeof import("@/lib/data")>()),
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<typeof import("@/lib/sponsorship-subscription/reservation")>()),
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<string, number> };
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 }]]));
+4 -2
View File
@@ -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) —
+6 -4
View File
@@ -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;
+16
View File
@@ -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<void> {
const customer = await getCurrentCustomer();
@@ -42,10 +48,20 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
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.");
}
+19 -5
View File
@@ -86,6 +86,15 @@ function transferDialogLabels(t: AccountSponsorshipStrings): TransferDialogLabel
};
}
function RevokedPanel({ t }: { t: AccountSponsorshipStrings }) {
return (
<div className="mx-auto flex max-w-xl flex-col items-center px-6 py-16 text-center">
<p className="font-serif text-3xl text-heading">{t.detail.revokedTitle}</p>
<p className="mt-2 max-w-[52ch] text-sm text-ink/70">{t.detail.revokedText}</p>
</div>
);
}
function PendingPanel({ t }: { t: AccountSponsorshipStrings }) {
return (
<div role="status" className="mx-auto flex max-w-xl flex-col items-center px-6 py-16 text-center">
@@ -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) {
</div>
<div className="flex items-center gap-3">
<LifecycleBadge
state={isReady ? sponsorship.lifecycle : "PENDING"}
label={isReady ? t.lifecycle[sponsorship.lifecycle] : t.overview.pendingBadge}
state={isReady || isRevoked ? sponsorship.lifecycle : "PENDING"}
label={isReady || isRevoked ? t.lifecycle[sponsorship.lifecycle] : t.overview.pendingBadge}
/>
<SponsorshipActions
sponsorshipId={sponsorship.id}
@@ -165,7 +177,9 @@ export default async function SponsorshipDetailPage({ params }: Props) {
<h2 id="stage-heading" className="sr-only">
{t.detail.stageHeading}
</h2>
{isReady ? (
{isRevoked ? (
<RevokedPanel t={t} />
) : isReady ? (
<>
<Stage sponsorship={sponsorship} t={t} />
<p className="mx-auto mt-6 max-w-[60ch] text-center text-xs text-ink/60 [@media(pointer:coarse)]:hidden">
@@ -196,7 +210,7 @@ export default async function SponsorshipDetailPage({ params }: Props) {
<p className="mt-16 max-w-[70ch] text-xs leading-relaxed text-ink/60">{t.detail.symbolicNote}</p>
</Container>
{!isReady ? <PendingRefresher /> : null}
{!isReady && !isRevoked ? <PendingRefresher /> : null}
</div>
);
}
@@ -9,6 +9,7 @@ const STYLES: Record<SponsorshipLifecycle | "PENDING", string> = {
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 }) {
@@ -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`,
@@ -21,6 +21,16 @@ function PendingSeal() {
);
}
/** Ruhige, durchgestrichene Wabe fuer eine widerrufene Patenschaft (keine Medaille mehr). */
function RevokedSeal() {
return (
<svg viewBox="0 0 24 26" aria-hidden="true" className="h-full w-full text-ink/35">
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="none" stroke="currentColor" strokeWidth="0.7" strokeDasharray="2 1.6" />
<path d="M5 21 19 5" stroke="currentColor" strokeWidth="0.7" />
</svg>
);
}
/**
* 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 (
<li
@@ -50,7 +61,9 @@ export function SponsorshipRow({
className="group relative grid grid-cols-[4.5rem_minmax(0,1fr)] items-center gap-x-5 gap-y-3 border border-ink/10 bg-background p-5 transition-[transform,border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-0.5 hover:border-heading/50 hover:shadow-[0_22px_40px_-26px_rgba(36,53,43,0.4)] active:scale-[0.995] sm:grid-cols-[5.5rem_minmax(0,1fr)_auto] sm:p-6"
>
<span className="relative block h-[4.5rem] w-[4.5rem] sm:h-[5.5rem] sm:w-[5.5rem]">
{isPending ? (
{isRevoked ? (
<RevokedSeal />
) : isPending ? (
<PendingSeal />
) : (
// eslint-disable-next-line @next/next/no-img-element -- geschuetzte Besitzer-Route, kein Bildoptimierer
@@ -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<typeof import("@/lib/sponsorship/access")>()),
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();
});
});
+4 -3
View File
@@ -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<OwnedContext | null> {
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
+2 -1
View File
@@ -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 (
<div className="py-20">
@@ -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<Metadata> {
@@ -59,6 +66,7 @@ export default async function ClaimSponsorshipPage({ params }: Props) {
</Panel>
);
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.
+33 -6
View File
@@ -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<void> {
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<Response> {
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)
+3 -2
View File
@@ -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<ProductWithRelations[]> {
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
@@ -86,7 +86,7 @@ export function CancellationForm({ s, turnstileSiteKey }: { s: AboStrings["publi
<br />
<span className="tabular-nums text-heading">{result.receivedAt}</span>
</p>
<p className="mt-5 text-sm leading-relaxed text-ink/80">{result.confirmationSent ? s.successMailSent : s.successMailFailed}</p>
<p className="mt-5 text-sm leading-relaxed text-ink/80">{s.successMailNote}</p>
</div>
);
}
@@ -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<void> {
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);
}
@@ -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<Metadata> {
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 (
<div className="py-20">
<Container className="max-w-xl">
<FormPanel>
<h1 className="font-serif text-4xl leading-tight text-heading [text-wrap:balance]">{title}</h1>
{children}
</FormPanel>
</Container>
</div>
);
}
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<Record<string, string | string[] | undefined>> }) {
const raw = await searchParams;
const locale = await getLocale();
const t = getAboStrings(locale).undoPage;
const accountLink = (
<LinkButton href="/konto/patenschaft" variant="secondary" className="mt-8">
{t.toAccount}
</LinkButton>
);
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 (
<Panel title={title}>
<p role="status" className="mt-4 text-ink/75">
{text}
</p>
{accountLink}
</Panel>
);
}
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 (
<Panel title={t.invalidTitle}>
<p className="mt-4 text-ink/75">{t.invalidText}</p>
{accountLink}
</Panel>
);
}
return (
<Panel title={t.title}>
<p className="mt-4 text-ink/75">{t.intro(formatDate(cancelAt, locale))}</p>
<form action={undoCancellationAction} className="mt-8">
<input type="hidden" name="abo" value={params.subscriptionId} />
{params.cancelAtSeconds !== null ? <input type="hidden" name="bis" value={String(params.cancelAtSeconds)} /> : null}
{params.signature ? <input type="hidden" name="sig" value={params.signature} /> : null}
<Button type="submit" className="w-full sm:w-auto">
{t.button}
</Button>
</form>
</Panel>
);
}
@@ -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" } })
);
});
});
@@ -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<UndoAccess> {
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 };
}
+7 -4
View File
@@ -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
+48 -8
View File
@@ -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 {
+10
View File
@@ -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",
+14 -2
View File
@@ -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<Locale, SponsorshipAdminStrings> = { de, en };
+6 -2
View File
@@ -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<A
where: { sentAt: null, rejectedAt: null, createdAt: { lt: new Date(now.getTime() - OVERDUE_AFTER_MS) } },
}),
prisma.apiCoreStockEvent.count({ where: { rejectedAt: { not: null } } }),
prisma.product.count({ where: { isActive: true, apicoreBottlingId: null, apicoreProductId: null } }),
// Patenschafts-Traegerprodukte haben keinen Lagerbestand (eine Urkunde
// ist nie ausverkauft) und werden nie mit ApiCore verknuepft.
prisma.product.count({
where: { isActive: true, apicoreBottlingId: null, apicoreProductId: null, category: { not: "PATENSCHAFT" } },
}),
]);
return { overdue, rejected, unlinked };
}
+7 -1
View File
@@ -67,7 +67,13 @@ export async function reconcileApiCoreStock(filter: ReconcileFilter = {}): Promi
if (byTarget.length === 0) return result;
const products = await prisma.product.findMany({
where: { OR: byTarget, ...(filter.productIds ? { id: { in: filter.productIds } } : {}) },
// Patenschaften fuehren keinen Bestand; auch eine versehentliche
// Verknuepfung darf ihren Bestandswert nicht ueberschreiben.
where: {
OR: byTarget,
category: { not: "PATENSCHAFT" },
...(filter.productIds ? { id: { in: filter.productIds } } : {}),
},
select: { id: true, apicoreBottlingId: true, apicoreProductId: true },
});
if (products.length === 0) return result;
+14 -10
View File
@@ -34,8 +34,10 @@ describe("archiveCutoff", () => {
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" } } },
],
});
+18 -8
View File
@@ -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;
+20
View File
@@ -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");
});
});
+18
View File
@@ -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")}`;
}
+29
View File
@@ -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<string[]>>(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 () => {
+23 -1
View File
@@ -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<void> {
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 },
+12
View File
@@ -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);
+10 -3
View File
@@ -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)));
}
/**
+37 -5
View File
@@ -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<T extends { images: unknown }>(item: T): Omit<T, "images"> &
export type HarvestWithImages = Omit<Harvest, "images"> & { 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<T extends { id: string; category: ProductCategory; inventory: { quantity: number } | null }>(
products: T[]
): Promise<T[]> {
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<ProductWithRelations[]> {
+17 -1
View File
@@ -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 });
});
});
+23
View File
@@ -38,3 +38,26 @@ export function isDiscountCodeValid(
export function calculateDiscountCents(code: Pick<DiscountCodeLike, "type" | "value">, 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,
});
}
+40 -2
View File
@@ -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);
});
});
+6 -1
View File
@@ -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<void> {
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 } },
+14
View File
@@ -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;
@@ -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", () => {
@@ -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;
}
@@ -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<typeof import("./stripe-sync")>()),
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<string, unknown> = {}) {
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" });
});
});
@@ -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<UndoCancellationResult> {
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 };
}
@@ -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,
@@ -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 },
@@ -129,6 +129,11 @@ export async function fulfillDelivery(deliveryId: string): Promise<FulfillResult
sponsorshipRole: "ABO_DELIVERY",
},
},
// Bezahlt-Nachweis wie bei jeder Bestellung: der DHL-Ablauf
// (admin/actions/dhl.ts) erstellt Labels nur zu einer erfolgreichen
// Zahlung. Provider "abo" = mit den Monatsrechnungen vorausbezahlt,
// nichts bei Stripe zu erstatten.
payment: { create: { provider: "abo", status: "SUCCEEDED", amountCents: 0, paidAt: new Date() } },
},
select: { id: true, orderNumber: true },
});
+23 -2
View File
@@ -49,6 +49,9 @@ const strings = {
`Wir haben dein Abo gefunden. Die Kündigung wird zum Ende des bezahlten Monats wirksam, also zum <strong>${end}</strong>.`,
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 <strong>${end}</strong>.`,
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<EmailContent> {
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 ? `<p ${P}>${s.publicUndoText}</p><div>${button(input.undoUrl, s.publicUndoButton)}</div>` : "";
const html = await emailLayout(
input.locale,
`${s.receivedAt}: ${when}`,
@@ -254,9 +263,21 @@ export async function publicCancellationReceiptEmail(input: {
<p ${P}>${s.publicIntro}</p>
<div ${BOX}>${lines.map((line) => escapeHtml(line)).join("<br>")}</div>
<p ${P}><strong>${s.receivedAt}:</strong> ${escapeHtml(when)}</p>
<p style="margin:0;line-height:1.6;">${outcome}</p>
<p ${input.undoUrl ? P : 'style="margin:0;line-height:1.6;"'}>${outcome}</p>
${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 };
}
+17 -2
View File
@@ -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<number>>(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);
});
});
+7 -1
View File
@@ -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<void> {
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.`);
});
}
@@ -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");
});
});
+106
View File
@@ -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<boolean> {
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<boolean> {
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<PendingCheckoutResult> {
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<void> {
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<number> {
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;
}
@@ -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<typeof import("./emails")>();
return {
cancellationDeclarationLines: actual.cancellationDeclarationLines,
publicCancellationReceiptEmail: vi.fn(async () => ({ subject: "Eingang", html: "<p></p>", 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: "[email protected]", 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: "<p></p>", 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: "[email protected]" }, SUBSCRIPTION.customer)).toBe(true);
expect(matchesCustomer({ name: "erika MÜLLER", email: "[email protected] " }, SUBSCRIPTION.customer)).toBe(true);
expect(matchesCustomer({ name: "E. Muller", email: "[email protected]" }, 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: "[email protected] " }, SUBSCRIPTION.customer)).toBe(true);
});
test("kein Teilstring, keine Umschreibung, kein fremder Vorname", () => {
expect(matchesCustomer({ name: "Müller", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(false);
expect(matchesCustomer({ name: "E. Müller", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(false);
expect(matchesCustomer({ name: "Erika Mueller", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(false);
expect(matchesCustomer({ name: "Max Erika Müller", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(false);
expect(matchesCustomer({ name: "Erika Müller", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(false);
expect(matchesCustomer({ name: "Max Mustermann", email: "[email protected]" }, 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: "[email protected]" }));
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: "[email protected]" }));
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:[email protected]", 3, 24 * 60 * 60 * 1000);
});
test("Vertragsnummer zu oft versucht: nichts bei Stripe, gespeichert, Admin informiert", async () => {
process.env.ADMIN_EMAIL = "[email protected]";
m.rateLimit.mockImplementation((key: string) => !key.includes(":ref:"));
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: "[email protected]" }));
});
test("unpassend: nichts bei Stripe, gespeichert, Eingang an die angegebene Adresse (Pruefung bestanden) — gleiche Antwort", async () => {
const result = await submitPublicCancellation({ ...INPUT, email: "[email protected]" }, { 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: "[email protected]" }));
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: "[email protected]" }, { 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: "[email protected]" }, { 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: "[email protected]" }));
});
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: "[email protected]" }));
});
});
@@ -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<typeof publicCancellationSchema>;
export const CANCELLATION_REQUEST_ACTION = "subscription.cancellation_request";
const TRANSLITERATION: Record<string, string> = { ä: "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<PublicCancellationResult> {
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 };
}
@@ -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<typeof reservedJarsByProduct>[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 });
});
});
+69 -21
View File
@@ -24,7 +24,24 @@ const RESERVING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
type Db = Pick<typeof prisma, "sponsorshipSubscription" | "harvestWindow" | "subscriptionDelivery" | "inventory">;
export async function reservedJarsForProduct(productId: string, now: Date = new Date(), db: Db = prisma): Promise<number> {
/**
* 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<Map<string, number>> {
const ids = [...new Set(productIds)];
const result = new Map<string, number>(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<number> {
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<number> {
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<Map<string, number>> {
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 {
@@ -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<unknown>) => 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"));
});
});
@@ -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,
@@ -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();
@@ -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() },
+19 -1
View File
@@ -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: "<script>" })).toBeNull();
});
});
describe("Widerrufene Patenschaft", () => {
it("hat den eigenen Status REVOKED, egal was die Daten sonst sagen", () => {
const now = new Date("2026-12-01T12:00:00Z");
expect(computeLifecycle({ startDate: day("2026-11-01"), endDate: null, subscription: null, status: "WIDERRUFEN", now })).toBe("REVOKED");
expect(computeLifecycle({ startDate: day("2026-11-01"), endDate: null, subscription: null, status: "BEREIT", now })).toBe("ACTIVE");
});
it("ist nicht uebertragbar", () => {
const sponsorship = {
subscription: null,
revision: { revision: 1 },
status: "WIDERRUFEN",
} as unknown as OwnedSponsorship;
expect(isTransferable(sponsorship)).toBe(false);
expect(isTransferable({ ...sponsorship, status: "BEREIT" })).toBe(true);
});
});
+15 -4
View File
@@ -34,12 +34,16 @@ export type SponsorshipLifecycle =
/** Abo gekuendigt, laeuft bis zum Ende des bezahlten Monats. */
| "CANCELLING"
/** Abo beendet. */
| "ENDED";
| "ENDED"
/** Einmalkauf storniert oder erstattet (Status WIDERRUFEN): keine Downloads, keine Uebertragung. */
| "REVOKED";
export interface LifecycleInput {
startDate: Date;
endDate: Date | null;
subscription: { status: SubscriptionStatus } | null;
/** Gespeicherter Ausstellungsstatus; nur WIDERRUFEN wirkt hier. */
status?: SponsorshipStatus;
now: Date;
}
@@ -48,7 +52,8 @@ function dayNumber(date: Date): number {
return Math.floor(date.getTime() / 86_400_000);
}
export function computeLifecycle({ startDate, endDate, subscription, now }: LifecycleInput): SponsorshipLifecycle {
export function computeLifecycle({ startDate, endDate, subscription, status, now }: LifecycleInput): SponsorshipLifecycle {
if (status === "WIDERRUFEN") return "REVOKED";
const today = dayNumber(now);
if (subscription) {
if (subscription.status === "BEENDET") return "ENDED";
@@ -194,6 +199,7 @@ function toOwnedSponsorship(row: SponsorshipRow, locale: Locale, now: Date): Own
startDate: row.startDate,
endDate: row.endDate,
subscription: row.subscription,
status: row.status,
now,
}),
};
@@ -257,7 +263,12 @@ export function displayTerm(sponsorship: OwnedSponsorship): CertificateTerm {
);
}
/** Uebertragbar sind nur fertige Einmal-Patenschaften (Spec "Übertragung"). */
/** Storniert oder erstattet: die Kundschaft sieht sie nur noch mit Hinweis, ohne Dateien. */
export function isRevoked(sponsorship: Pick<OwnedSponsorship, "status">): boolean {
return sponsorship.status === "WIDERRUFEN";
}
/** Uebertragbar sind nur fertige, nicht widerrufene Einmal-Patenschaften (Spec "Übertragung"). */
export function isTransferable(sponsorship: OwnedSponsorship): boolean {
return sponsorship.subscription === null && sponsorship.revision !== null;
return sponsorship.subscription === null && sponsorship.revision !== null && !isRevoked(sponsorship);
}
+7 -8
View File
@@ -1,5 +1,5 @@
import type { Prisma, ProductCategory } from "@/generated/prisma/client";
import { personalizationSchema } from "@/lib/certificate/schema";
import { personalizationSchema, startDateRange, toIsoDate } from "@/lib/certificate/schema";
import { checkCertificateFits } from "@/lib/certificate/layout";
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
@@ -81,14 +81,13 @@ export interface ExpandInput {
now: Date;
}
/** Spaetester Patenschaftsbeginn: sechs Monate nach dem Bestelltag. */
const MAX_START_OFFSET_MONTHS = 6;
/**
* Erlaubtes Startdatum: frühestens der Bestelltag, spätestens sechs Monate
* danach — dieselbe Regel wie im Formular (startDateRange), damit Grenze und
* Klemmung auf das Monatsende (31.08. → 28./29.02.) ueberall gleich sind.
*/
export function startDateWindow(now: Date): { min: string; max: string } {
const min = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const max = new Date(min);
max.setUTCMonth(max.getUTCMonth() + MAX_START_OFFSET_MONTHS);
return { min: min.toISOString().slice(0, 10), max: max.toISOString().slice(0, 10) };
return startDateRange(toIsoDate(now));
}
export function expandCheckoutItems(input: ExpandInput): ExpandResult {
+2
View File
@@ -164,6 +164,8 @@ describe("Aufloesen von Patenschaftspaketen im Checkout", () => {
it("verlangt ein Startdatum zwischen heute und in sechs Monaten", () => {
const window = startDateWindow(now);
expect(window).toEqual({ min: "2026-11-02", max: "2027-05-02" });
// Monatsende: 31.08. + 6 Monate ist der letzte Februartag, nicht der 3. Maerz.
expect(startDateWindow(new Date("2026-08-31T09:00:00.000Z"))).toEqual({ min: "2026-08-31", max: "2027-02-28" });
expect(
expandCheckoutItems(input([{ productId: "carrier", quantity: 1, personalization: { ...personalization, startDate: "2026-11-01" } }])).ok
).toBe(false);
@@ -0,0 +1,77 @@
// @vitest-environment node
/**
* Kontoloeschung mit Patenschaften: nicht, solange ein Abo laeuft (sonst
* zoege Stripe weiter ab, ohne dass es ein Konto gibt); beim Loeschen
* verschwinden die Urkunden-Dateien der eigenen Patenschaften mit.
*/
import { mkdtemp, mkdir, writeFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
db: {
sponsorshipSubscription: { count: vi.fn() },
sponsorship: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
const root = await mkdtemp(path.join(tmpdir(), "wabenhain-certs-"));
process.env.CERTIFICATE_DIR = root;
process.env.UPLOAD_DIR = path.join(tmpdir(), "wabenhain-uploads-elsewhere");
const { BLOCKING_SUBSCRIPTION_WHERE, hasBlockingSubscription, certificateDirsOfCustomer, removeCertificateDirs } = await import(
"./customer-deletion"
);
beforeAll(async () => {
await mkdir(path.join(root, "2026", "WH-P-2026-00001"), { recursive: true });
await writeFile(path.join(root, "2026", "WH-P-2026-00001", "r1.pdf"), "pdf");
await writeFile(path.join(root, "2026", "WH-P-2026-00001", "r1-textur-2048.webp"), "webp");
await mkdir(path.join(root, "2026", "WH-P-2026-00002"), { recursive: true });
await writeFile(path.join(root, "2026", "WH-P-2026-00002", "r1.pdf"), "andere Person");
});
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
});
describe("hasBlockingSubscription", () => {
test("laufende, gekuendigte, im Verzug und bezahlt-aber-noch-offene (SEPA) Abos blockieren", () => {
expect(BLOCKING_SUBSCRIPTION_WHERE).toEqual({
OR: [{ status: { in: ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] } }, { status: "AUSSTEHEND", stripeSubscriptionId: { not: null } }],
});
});
test("fragt nur die Abos dieses Kontos ab", async () => {
m.db.sponsorshipSubscription.count.mockResolvedValue(1);
expect(await hasBlockingSubscription("cust_1")).toBe(true);
expect(m.db.sponsorshipSubscription.count).toHaveBeenCalledWith({ where: { customerId: "cust_1", ...BLOCKING_SUBSCRIPTION_WHERE } });
m.db.sponsorshipSubscription.count.mockResolvedValue(0);
expect(await hasBlockingSubscription("cust_1")).toBe(false);
});
});
describe("Urkunden-Dateien", () => {
test("sammelt die Ordner der eigenen Patenschaften (Revisionspfade und Nummernordner)", async () => {
m.db.sponsorship.findMany.mockResolvedValue([
{ number: "WH-P-2026-00001", createdAt: new Date("2026-03-01T00:00:00Z"), revisions: [{ pdfPath: "2026/WH-P-2026-00001/r1.pdf" }] },
{ number: "WH-P-2027-00009", createdAt: new Date("2027-01-02T00:00:00Z"), revisions: [] },
]);
expect(await certificateDirsOfCustomer("cust_1")).toEqual(["2026/WH-P-2026-00001", "2027/WH-P-2027-00009"]);
// Uebertragene Patenschaften gehoeren dem neuen Konto und bleiben: gefiltert wird nach ownerId.
expect(m.db.sponsorship.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { ownerId: "cust_1" } }));
});
test("loescht nur die genannten Ordner, fehlende Ordner und unsichere Pfade sind kein Fehler", async () => {
await removeCertificateDirs(["2026/WH-P-2026-00001", "2027/WH-P-2027-00009", "../ausserhalb"]);
await expect(stat(path.join(root, "2026", "WH-P-2026-00001"))).rejects.toThrow();
await expect(stat(path.join(root, "2026", "WH-P-2026-00002", "r1.pdf"))).resolves.toBeTruthy();
});
});
+58
View File
@@ -0,0 +1,58 @@
import { rm } from "node:fs/promises";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import type { Prisma } from "@/generated/prisma/client";
import { resolveCertificatePath } from "@/lib/sponsorship/storage";
/**
* Kontoloeschung (Kunde selbst: konto/actions/account.ts, Admin:
* admin/actions/customers.ts) im Zusammenspiel mit der Bienenpatenschaft.
*
* - Solange ein Abo laeuft, wird nicht geloescht: das Konto verschwaende,
* Stripe zoege aber weiter ab, und Kuendigung, Rechnungen und Lieferungen
* haetten keinen Bezug mehr. Auch ein gekuendigtes Abo laeuft bis zum Ende
* des bezahlten Monats; AUSSTEHEND mit Stripe-Abo ist eine abgeschlossene,
* noch nicht eingegangene Zahlung (SEPA).
* - Die Patenschaften des Kontos werden per Cascade mitgeloescht; ihre
* Urkunden-Dateien (PDFs, Texturen) liegen ausserhalb der Datenbank und
* werden hier entfernt. Uebertragene Patenschaften gehoeren inzwischen
* einem anderen Konto und bleiben unberuehrt.
*/
export const BLOCKING_SUBSCRIPTION_WHERE = {
OR: [{ status: { in: ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] } }, { status: "AUSSTEHEND", stripeSubscriptionId: { not: null } }],
} satisfies Prisma.SponsorshipSubscriptionWhereInput;
export async function hasBlockingSubscription(customerId: string): Promise<boolean> {
return (await prisma.sponsorshipSubscription.count({ where: { customerId, ...BLOCKING_SUBSCRIPTION_WHERE } })) > 0;
}
/**
* Relative Ordner (CERTIFICATE_DIR/<Jahr>/<Nummer>) der Patenschaften dieses
* Kontos. VOR dem Loeschen lesen — danach sind die Zeilen weg.
*/
export async function certificateDirsOfCustomer(customerId: string): Promise<string[]> {
const sponsorships = await prisma.sponsorship.findMany({
where: { ownerId: customerId },
select: { number: true, createdAt: true, revisions: { select: { pdfPath: true } } },
});
const dirs = new Set<string>();
for (const sponsorship of sponsorships) {
for (const revision of sponsorship.revisions) dirs.add(path.posix.dirname(revision.pdfPath));
// Auch ohne Revision: Ablageordner nach certificateRelativePath.
dirs.add(path.posix.join(String(sponsorship.createdAt.getUTCFullYear()), sponsorship.number));
}
return [...dirs];
}
/** Entfernt die Ordner; best effort — ein Fehler wird geloggt, die Loeschung des Kontos laeuft weiter. */
export async function removeCertificateDirs(dirs: readonly string[]): Promise<void> {
for (const dir of dirs) {
try {
await rm(resolveCertificatePath(dir), { recursive: true, force: true });
} catch (error: unknown) {
console.error(`[patenschaft] Urkunden-Ordner ${dir} konnte beim Loeschen des Kontos nicht entfernt werden:`, error);
}
}
if (dirs.length > 0) console.info(`[patenschaft] ${dirs.length} Urkunden-Ordner eines geloeschten Kontos entfernt.`);
}
+130
View File
@@ -0,0 +1,130 @@
// @vitest-environment node
/**
* Datenexport (Art. 15/20 DSGVO, api/konto/datenexport): Patenschaften,
* Abos samt Zahlungen und Uebertragungen — ohne Stripe-Kennungen und ohne
* Token-Hashes.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
db: {
sponsorship: { findMany: vi.fn() },
sponsorshipSubscription: { findMany: vi.fn() },
sponsorshipTransfer: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
const { sponsorshipExportData } = await import("./data-export");
const PERSONALIZATION = { beeName: "Summsi", recipientName: "Erika Muster", occasion: "SELBST", startDate: "2026-10-16" };
beforeEach(() => {
vi.clearAllMocks();
m.db.sponsorship.findMany.mockResolvedValue([
{
number: "WH-P-2026-00001",
startDate: new Date("2026-10-16T00:00:00.000Z"),
endDate: null,
status: "BEREIT",
currentRevision: 2,
createdAt: new Date("2026-10-16T10:00:00.000Z"),
offer: { name: "Wabenfreund", kind: "EINMALIG" },
revisions: [{ revision: 2, personalization: { ...PERSONALIZATION, beeName: "Summsi II" } }],
orderItem: { personalization: { personalization: PERSONALIZATION, templateVersion: "v1" } },
subscription: null,
},
{
number: "WH-P-2026-00002",
startDate: new Date("2026-10-17T00:00:00.000Z"),
endDate: null,
status: "AUSSTEHEND",
currentRevision: 0,
createdAt: new Date("2026-10-17T10:00:00.000Z"),
offer: { name: "Wabenfreund", kind: "EINMALIG" },
revisions: [],
orderItem: { personalization: { personalization: PERSONALIZATION, templateVersion: "v1" } },
subscription: null,
},
]);
m.db.sponsorshipSubscription.findMany.mockResolvedValue([
{
id: "sub_1",
status: "AKTIV",
createdAt: new Date("2026-09-01T10:00:00.000Z"),
startedAt: new Date("2026-09-01T10:05:00.000Z"),
currentPeriodEnd: new Date("2026-11-01T10:05:00.000Z"),
cancelAt: null,
endedAt: null,
offer: { name: "Vierteljährlich" },
stripeSubscriptionId: "sub_stripe_secret",
payments: [
{
amountCents: 1500,
paidAt: new Date("2026-10-01T10:05:00.000Z"),
periodStart: new Date("2026-10-01T10:05:00.000Z"),
periodEnd: new Date("2026-11-01T10:05:00.000Z"),
paymentMethodType: "card",
reversedAt: null,
stripeInvoiceId: "in_secret",
stripePaymentIntentId: "pi_secret",
},
],
},
]);
m.db.sponsorshipTransfer.findMany.mockResolvedValue([
{
createdById: "cust_1",
claimedById: "cust_2",
createdAt: new Date("2026-10-18T10:00:00.000Z"),
expiresAt: new Date("2026-10-25T10:00:00.000Z"),
claimedAt: new Date("2026-10-19T10:00:00.000Z"),
revokedAt: null,
tokenHash: "geheim",
sponsorship: { number: "WH-P-2026-00003" },
},
]);
});
describe("sponsorshipExportData", () => {
test("Patenschaften mit aktueller Personalisierung (Urkunde, sonst Bestellung)", async () => {
const data = await sponsorshipExportData("cust_1");
expect(data.sponsorships).toEqual([
expect.objectContaining({
number: "WH-P-2026-00001",
offer: "Wabenfreund",
status: "BEREIT",
personalization: expect.objectContaining({ beeName: "Summsi II" }),
}),
expect.objectContaining({ number: "WH-P-2026-00002", personalization: expect.objectContaining({ beeName: "Summsi" }) }),
]);
expect(m.db.sponsorship.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { ownerId: "cust_1" } }));
});
test("Abos mit Zahlungen, aber ohne Stripe-Kennungen", async () => {
const data = await sponsorshipExportData("cust_1");
expect(data.subscriptions[0]).toMatchObject({
offer: "Vierteljährlich",
status: "AKTIV",
payments: [{ amountCents: 1500, paymentMethod: "card" }],
});
const text = JSON.stringify(data);
expect(text).not.toContain("sub_stripe_secret");
expect(text).not.toContain("in_secret");
expect(text).not.toContain("pi_secret");
});
test("erstellte und eingeloeste Uebertragungen, ohne Token-Hash", async () => {
const data = await sponsorshipExportData("cust_1");
expect(m.db.sponsorshipTransfer.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { OR: [{ createdById: "cust_1" }, { claimedById: "cust_1" }] } })
);
expect(data.transfers).toEqual([
expect.objectContaining({ sponsorshipNumber: "WH-P-2026-00003", role: "created", claimedAt: new Date("2026-10-19T10:00:00.000Z") }),
]);
expect(JSON.stringify(data)).not.toContain("geheim");
});
});
+152
View File
@@ -0,0 +1,152 @@
import { prisma } from "@/lib/prisma";
import { personalizationSchema, type Personalization } from "@/lib/certificate/schema";
import { parseStoredPersonalization } from "@/lib/sponsorship/certificate-document";
/**
* Patenschafts-Daten fuer den Datenexport des Kontos (Art. 15/20 DSGVO,
* api/konto/datenexport). Bewusst ohne Stripe-Kennungen und ohne
* Token-Hashes: sie sagen der Kundschaft nichts und sind interne Schluessel.
*/
export interface SponsorshipExport {
sponsorships: {
number: string;
offer: string;
kind: string;
startDate: Date;
endDate: Date | null;
status: string;
createdAt: Date;
personalization: Personalization | null;
}[];
subscriptions: {
offer: string;
status: string;
createdAt: Date;
startedAt: Date | null;
currentPeriodEnd: Date | null;
cancelAt: Date | null;
endedAt: Date | null;
payments: {
amountCents: number;
paidAt: Date;
periodStart: Date;
periodEnd: Date;
paymentMethod: string | null;
reversedAt: Date | null;
}[];
}[];
transfers: {
sponsorshipNumber: string;
role: "created" | "claimed";
createdAt: Date;
expiresAt: Date;
claimedAt: Date | null;
revokedAt: Date | null;
}[];
}
/** Aktuelle Personalisierung: aus der aktuellen Urkunde, solange es keine gibt aus Bestellung bzw. Abo. */
function currentPersonalization(row: {
currentRevision: number;
revisions: { revision: number; personalization: unknown }[];
orderItem: { personalization: unknown } | null;
subscription: { personalization: unknown } | null;
}): Personalization | null {
const revision = row.revisions.find((r) => r.revision === row.currentRevision);
const fromRevision = revision ? personalizationSchema.safeParse(revision.personalization) : null;
if (fromRevision?.success) return fromRevision.data;
try {
return parseStoredPersonalization(row.orderItem?.personalization ?? row.subscription?.personalization).personalization;
} catch {
return null;
}
}
export async function sponsorshipExportData(customerId: string): Promise<SponsorshipExport> {
const [sponsorships, subscriptions, transfers] = await Promise.all([
prisma.sponsorship.findMany({
where: { ownerId: customerId },
orderBy: { createdAt: "asc" },
select: {
number: true,
startDate: true,
endDate: true,
status: true,
currentRevision: true,
createdAt: true,
offer: { select: { name: true, kind: true } },
revisions: { orderBy: { revision: "desc" }, take: 1, select: { revision: true, personalization: true } },
orderItem: { select: { personalization: true } },
subscription: { select: { personalization: true } },
},
}),
prisma.sponsorshipSubscription.findMany({
where: { customerId },
orderBy: { createdAt: "asc" },
select: {
status: true,
createdAt: true,
startedAt: true,
currentPeriodEnd: true,
cancelAt: true,
endedAt: true,
offer: { select: { name: true } },
payments: {
orderBy: { paidAt: "asc" },
select: { amountCents: true, paidAt: true, periodStart: true, periodEnd: true, paymentMethodType: true, reversedAt: true },
},
},
}),
prisma.sponsorshipTransfer.findMany({
where: { OR: [{ createdById: customerId }, { claimedById: customerId }] },
orderBy: { createdAt: "asc" },
select: {
createdById: true,
createdAt: true,
expiresAt: true,
claimedAt: true,
revokedAt: true,
sponsorship: { select: { number: true } },
},
}),
]);
return {
sponsorships: sponsorships.map((row) => ({
number: row.number,
offer: row.offer.name,
kind: row.offer.kind,
startDate: row.startDate,
endDate: row.endDate,
status: row.status,
createdAt: row.createdAt,
personalization: currentPersonalization(row),
})),
subscriptions: subscriptions.map((row) => ({
offer: row.offer.name,
status: row.status,
createdAt: row.createdAt,
startedAt: row.startedAt,
currentPeriodEnd: row.currentPeriodEnd,
cancelAt: row.cancelAt,
endedAt: row.endedAt,
payments: row.payments.map((payment) => ({
amountCents: payment.amountCents,
paidAt: payment.paidAt,
periodStart: payment.periodStart,
periodEnd: payment.periodEnd,
paymentMethod: payment.paymentMethodType,
reversedAt: payment.reversedAt,
})),
})),
transfers: transfers.map((row) => ({
sponsorshipNumber: row.sponsorship.number,
role: row.createdById === customerId ? "created" : "claimed",
createdAt: row.createdAt,
expiresAt: row.expiresAt,
claimedAt: row.claimedAt,
revokedAt: row.revokedAt,
})),
};
}
+8 -4
View File
@@ -188,7 +188,7 @@ async function sendReadyMailOnce(sponsorship: SponsorshipForIssue): Promise<bool
return true;
}
export type ProcessResult = "ready" | "retry" | "failed" | "missing";
export type ProcessResult = "ready" | "retry" | "failed" | "missing" | "revoked";
/**
* Bringt eine Patenschaft so weit wie moeglich: Urkunde erzeugen, Mail
@@ -199,6 +199,8 @@ export async function processSponsorship(id: string, now: Date = new Date()): Pr
const sponsorship = await loadForIssue(id);
if (!sponsorship) return "missing";
if (sponsorship.status === "BEREIT" || sponsorship.status === "GEDRUCKT") return "ready";
// Storniert/erstattet: keine Urkunde und keine Mail mehr.
if (sponsorship.status === "WIDERRUFEN") return "revoked";
try {
await ensureFirstRevision(sponsorship);
@@ -216,8 +218,10 @@ export async function processSponsorship(id: string, now: Date = new Date()): Pr
const attempts = sponsorship.pdfAttempts + 1;
const exhausted = attempts >= MAX_ISSUE_ATTEMPTS;
console.error(`[patenschaft] ${sponsorship.number}: Versuch ${attempts} fehlgeschlagen: ${message}`);
await prisma.sponsorship.update({
where: { id },
// updateMany mit Statusbedingung: ein gleichzeitiger Widerruf darf hier
// nicht auf AUSSTEHEND/FEHLER zurueckgesetzt werden.
await prisma.sponsorship.updateMany({
where: { id, status: { in: ["AUSSTEHEND", "FEHLER"] } },
data: {
pdfAttempts: attempts,
lastError: message.slice(0, 2000),
@@ -243,7 +247,7 @@ export async function processPendingSponsorships(options: { orderId?: string; li
orderBy: { createdAt: "asc" },
take: options.limit ?? 50,
});
const counts: Record<ProcessResult, number> = { ready: 0, retry: 0, failed: 0, missing: 0 };
const counts: Record<ProcessResult, number> = { ready: 0, retry: 0, failed: 0, missing: 0, revoked: 0 };
for (const { id } of pending) {
counts[await processSponsorship(id, now)]++;
}
+6 -4
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { availableForSale } from "@/lib/sponsorship-subscription/reservation";
import { availableForSaleByProduct } from "@/lib/sponsorship-subscription/reservation";
import { quotaSubscriptionWhere } from "@/lib/sponsorship-subscription/pending";
import type { Locale } from "@/i18n/config";
import type {
MedalTier,
@@ -82,8 +83,7 @@ function localize(row: OfferRow, locale: Locale, isAvailable: boolean): Sponsors
async function freeJarsByProduct(rows: OfferRow[]): Promise<Map<string, number>> {
const ids = [...new Set(rows.map((row) => row.honeyProduct?.id).filter((id): id is string => Boolean(id)))];
const entries = await Promise.all(ids.map(async (id) => [id, await availableForSale(id)] as const));
return new Map(entries);
return availableForSaleByProduct(ids);
}
async function activeSubscriptionCounts(rows: OfferRow[]): Promise<Map<string, number>> {
@@ -91,7 +91,9 @@ async function activeSubscriptionCounts(rows: OfferRow[]): Promise<Map<string, n
if (aboIds.length === 0) return new Map();
const groups = await prisma.sponsorshipSubscription.groupBy({
by: ["offerId"],
where: { offerId: { in: aboIds }, status: { in: ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] } },
// Laufende Abos plus offene Abschluesse der letzten Stunde (pending.ts):
// sonst koennten gleichzeitige Checkouts das Kontingent ueberbuchen.
where: { offerId: { in: aboIds }, ...quotaSubscriptionWhere(new Date()) },
_count: { _all: true },
});
return new Map(groups.map((group) => [group.offerId, group._count._all]));
+3
View File
@@ -150,6 +150,7 @@ export type ReviseSponsorshipError =
| RevisionInputError
| { code: "not_found" }
| { code: "no_revision" }
| { code: "revoked" }
| { code: "glyphs"; field: CertificateField; characters: string[] }
| { code: "fit"; field: CertificateField }
| { code: "conflict" };
@@ -224,6 +225,8 @@ export async function reviseSponsorship(input: ReviseSponsorshipInput): Promise<
const sponsorship = await loadForRevision(input.sponsorshipId);
if (!sponsorship) return { ok: false, error: { code: "not_found" } };
if (sponsorship.currentRevision === 0) return { ok: false, error: { code: "no_revision" } };
// Widerrufen (Storno/Erstattung): keine neue Urkunde und keine Mail an die Kundschaft.
if (sponsorship.status === "WIDERRUFEN") return { ok: false, error: { code: "revoked" } };
const currentRow = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId: sponsorship.id, revision: sponsorship.currentRevision } },
+58
View File
@@ -0,0 +1,58 @@
/**
* Storno oder volle Erstattung eines Einmalkaufs widerruft die Patenschaft:
* Status WIDERRUFEN, offene Uebertragungslinks werden ungueltig.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => {
const tx = {
sponsorship: { updateMany: vi.fn() },
sponsorshipTransfer: { updateMany: vi.fn() },
};
return {
tx,
db: {
sponsorship: { findMany: vi.fn() },
$transaction: vi.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
},
};
});
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
const { revokeOrderSponsorships } = await import("./revoke");
const NOW = new Date("2026-10-16T12:00:00.000Z");
beforeEach(() => {
vi.clearAllMocks();
});
describe("revokeOrderSponsorships", () => {
test("widerruft die Patenschaften der Hauptpositionen und sperrt offene Uebertragungen", async () => {
m.db.sponsorship.findMany.mockResolvedValue([{ id: "sp_1" }, { id: "sp_2" }]);
const revoked = await revokeOrderSponsorships("order_1", NOW);
expect(revoked).toEqual(["sp_1", "sp_2"]);
expect(m.db.sponsorship.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { orderItem: { orderId: "order_1", sponsorshipRole: "MAIN" }, status: { not: "WIDERRUFEN" } },
})
);
expect(m.tx.sponsorship.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["sp_1", "sp_2"] } },
data: { status: "WIDERRUFEN", nextAttemptAt: null },
});
expect(m.tx.sponsorshipTransfer.updateMany).toHaveBeenCalledWith({
where: { sponsorshipId: { in: ["sp_1", "sp_2"] }, claimedAt: null, revokedAt: null },
data: { revokedAt: NOW },
});
});
test("ohne Patenschaft (gewoehnliche Bestellung oder schon widerrufen) passiert nichts", async () => {
m.db.sponsorship.findMany.mockResolvedValue([]);
expect(await revokeOrderSponsorships("order_1", NOW)).toEqual([]);
expect(m.db.$transaction).not.toHaveBeenCalled();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { prisma } from "@/lib/prisma";
/**
* Widerruft die Patenschaften einer stornierten oder voll erstatteten
* Einmal-Bestellung (Ausloeser: die Stornorechnung, lib/cancellation-
* invoice.ts). Wer sein Geld zurueck hat, behaelt keine Urkunde, keine
* Downloads und kein Recht zur Uebertragung.
*
* Nur ueber die Hauptposition (MAIN) — Monatsbestellungen eines Abos haben
* keine und bleiben unberuehrt. Idempotent: bereits widerrufene werden
* uebersprungen. Eine Wiederaufnahme der Bestellung im Admin stellt NICHT
* automatisch wieder her (die Bestellseite zeigt dazu einen Hinweis).
*
* @returns IDs der jetzt widerrufenen Patenschaften
*/
export async function revokeOrderSponsorships(orderId: string, now: Date = new Date()): Promise<string[]> {
const sponsorships = await prisma.sponsorship.findMany({
where: { orderItem: { orderId, sponsorshipRole: "MAIN" }, status: { not: "WIDERRUFEN" } },
select: { id: true },
});
const ids = sponsorships.map((sponsorship) => sponsorship.id);
if (ids.length === 0) return [];
await prisma.$transaction(async (tx) => {
// nextAttemptAt leeren: die Ausstellung (issue.ts) soll eine widerrufene
// Patenschaft nicht mehr anfassen.
await tx.sponsorship.updateMany({ where: { id: { in: ids } }, data: { status: "WIDERRUFEN", nextAttemptAt: null } });
await tx.sponsorshipTransfer.updateMany({
where: { sponsorshipId: { in: ids }, claimedAt: null, revokedAt: null },
data: { revokedAt: now },
});
});
return ids;
}
+31 -1
View File
@@ -15,6 +15,7 @@ interface SponsorshipRow {
ownerId: string;
subscriptionId: string | null;
currentRevision: number;
status: string;
locale: string;
revisions: { personalization: unknown; term: unknown }[];
}
@@ -40,6 +41,9 @@ function matches(row: Record<string, unknown>, where: Where): boolean {
const sponsorship = db.sponsorships.find((s) => s.id === row.sponsorshipId);
return sponsorship ? matches(sponsorship as unknown as Record<string, unknown>, expected as Where) : false;
}
if (expected !== null && typeof expected === "object" && "not" in (expected as object)) {
return row[key] !== (expected as { not: unknown }).not;
}
if (expected !== null && typeof expected === "object" && "gt" in (expected as object)) {
return (row[key] as Date).getTime() > ((expected as { gt: Date }).gt).getTime();
}
@@ -116,6 +120,7 @@ function seed(overrides: Partial<SponsorshipRow> = {}): SponsorshipRow {
ownerId: "owner",
subscriptionId: null,
currentRevision: 1,
status: "BEREIT",
locale: "de",
revisions: [{ personalization, term }],
...overrides,
@@ -254,9 +259,34 @@ describe("Link einloesen", () => {
});
});
describe("Widerrufene Patenschaft (Storno/Erstattung)", () => {
it("laesst sich nicht uebertragen", async () => {
const sponsorship = seed({ status: "WIDERRUFEN" });
expect(await createTransfer({ ownerId: "owner", sponsorshipId: sponsorship.id, validity: "7d", now: NOW })).toEqual({
ok: false,
reason: "NOT_TRANSFERABLE",
});
});
it("ein vorher erstellter Link scheitert neutral, die Patenschaft bleibt, wo sie ist", async () => {
const sponsorship = seed();
const token = await linkFor(sponsorship.id);
db.sponsorships = db.sponsorships.map((row) => (row.id === sponsorship.id ? { ...row, status: "WIDERRUFEN" } : row));
expect((await previewClaim(token, "friend", NOW)).status).toBe("INVALID");
expect(await claimTransfer({ token, claimantId: "friend", now: NOW })).toEqual({ ok: false, reason: "INVALID" });
expect(db.sponsorships.find((row) => row.id === sponsorship.id)?.ownerId).toBe("owner");
});
});
describe("evaluateClaim", () => {
const transfer = { expiresAt: new Date(NOW.getTime() + HOUR), claimedAt: null, revokedAt: null, createdById: "owner" };
const sponsorship = { ownerId: "owner", subscriptionId: null, currentRevision: 1 };
const sponsorship = { ownerId: "owner", subscriptionId: null, currentRevision: 1, status: "BEREIT" as const };
it("eine widerrufene Patenschaft ist ungueltig", () => {
expect(evaluateClaim({ transfer, sponsorship: { ...sponsorship, status: "WIDERRUFEN" }, claimantId: "friend", now: NOW })).toBe("INVALID");
});
it("ist am Ablaufzeitpunkt selbst schon ungueltig", () => {
expect(evaluateClaim({ transfer, sponsorship, claimantId: "friend", now: NOW })).toBe("OK");
+9 -5
View File
@@ -4,6 +4,7 @@ import { isLocale, defaultLocale, type Locale } from "@/i18n/config";
import { personalizationSchema, type CertificateTerm } from "@/lib/certificate/schema";
import { parseStoredTerm } from "@/lib/sponsorship/access";
import type { TransferValidity } from "@/lib/sponsorship/transfer-validity";
import type { SponsorshipStatus } from "@/generated/prisma/client";
/**
* Uebertragung einer Einmal-Patenschaft an ein anderes Konto (Spec
@@ -60,7 +61,7 @@ export function transferClaimPath(token: string): string {
export interface ClaimCheckInput {
transfer: { expiresAt: Date; claimedAt: Date | null; revokedAt: Date | null; createdById: string } | null;
sponsorship: { ownerId: string; subscriptionId: string | null; currentRevision: number } | null;
sponsorship: { ownerId: string; subscriptionId: string | null; currentRevision: number; status: SponsorshipStatus } | null;
claimantId: string;
now: Date;
}
@@ -70,7 +71,7 @@ export type ClaimDecision = "OK" | "INVALID" | "OWN";
/**
* Reine Entscheidung, ob ein Link eingeloest werden darf. "INVALID" deckt
* bewusst alle Gruende ab (abgelaufen, widerrufen, eingeloest, unbekannt,
* Abo, Besitzer gewechselt) — die Seite zeigt dafuer dieselbe neutrale
* Abo, Besitzer gewechselt, Patenschaft storniert/erstattet) — die Seite zeigt dafuer dieselbe neutrale
* Meldung und verraet nichts ueber die Patenschaft.
*/
export function evaluateClaim({ transfer, sponsorship, claimantId, now }: ClaimCheckInput): ClaimDecision {
@@ -79,6 +80,7 @@ export function evaluateClaim({ transfer, sponsorship, claimantId, now }: ClaimC
if (transfer.expiresAt.getTime() <= now.getTime()) return "INVALID";
if (sponsorship.subscriptionId !== null) return "INVALID";
if (sponsorship.currentRevision === 0) return "INVALID";
if (sponsorship.status === "WIDERRUFEN") return "INVALID";
// Der Link gilt nur, solange die Patenschaft noch dem Konto gehoert, das
// ihn erstellt hat.
if (sponsorship.ownerId !== transfer.createdById) return "INVALID";
@@ -103,10 +105,10 @@ export async function createTransfer(input: {
const result = await prisma.$transaction(async (tx): Promise<CreateTransferResult> => {
const sponsorship = await tx.sponsorship.findFirst({
where: { id: input.sponsorshipId, ownerId: input.ownerId },
select: { id: true, subscriptionId: true, currentRevision: true },
select: { id: true, subscriptionId: true, currentRevision: true, status: true },
});
if (!sponsorship) return { ok: false, reason: "NOT_FOUND" };
if (sponsorship.subscriptionId !== null || sponsorship.currentRevision === 0) {
if (sponsorship.subscriptionId !== null || sponsorship.currentRevision === 0 || sponsorship.status === "WIDERRUFEN") {
return { ok: false, reason: "NOT_TRANSFERABLE" };
}
await tx.sponsorshipTransfer.updateMany({
@@ -178,6 +180,7 @@ const claimSelect = {
ownerId: true,
subscriptionId: true,
currentRevision: true,
status: true,
locale: true,
revisions: { orderBy: { revision: "desc" }, take: 1, select: { personalization: true, term: true } },
},
@@ -236,7 +239,8 @@ export async function claimTransfer(input: { token: string; claimantId: string;
if (claimed.count === 0) return { ok: false, reason: "INVALID" };
const moved = await tx.sponsorship.updateMany({
where: { id: transfer.sponsorship.id, ownerId: transfer.createdById, subscriptionId: null },
// Widerruf zwischen Lesen und Schreiben: nicht uebertragen.
where: { id: transfer.sponsorship.id, ownerId: transfer.createdById, subscriptionId: null, status: { not: "WIDERRUFEN" } },
data: { ownerId: input.claimantId },
});
if (moved.count === 0) {
+9 -6
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import type { ProductWithRelations } from "@/lib/data";
import { withFreeStock, type ProductWithRelations } from "@/lib/data";
export async function isProductWishlisted(productId: string, customerId: string): Promise<boolean> {
const item = await prisma.wishlistItem.findUnique({
@@ -21,9 +21,12 @@ export async function getWishlistProducts(customerId: string): Promise<ProductWi
},
},
});
return items.map((item) => ({
...item.product,
tasteTags: asStringArray(item.product.tasteTags),
images: asStringArray(item.product.images),
}));
// Freier Bestand wie im Shop (Lager minus Abo-Reservierung).
return withFreeStock(
items.map((item) => ({
...item.product,
tasteTags: asStringArray(item.product.tasteTags),
images: asStringArray(item.product.images),
}))
);
}
+9
View File
@@ -4,6 +4,7 @@ import {
migrateCartState,
cartPhysicalQuantity,
cartIsDigitalOnly,
cartDiscountBase,
type CartSponsorship,
type NewCartItem,
} from "./cart-store";
@@ -125,6 +126,14 @@ describe("cart store", () => {
expect(cartPhysicalQuantity(useCartStore.getState().items)).toBe(5);
});
test("discount base excludes sponsorship packages including their honey (like the checkout server)", () => {
const { addSponsorship, addItem } = useCartStore.getState();
addItem(honey, 2);
addSponsorship(packageItem, sponsorship("Lotte", { honeyJars: 3 }));
expect(cartDiscountBase(useCartStore.getState().items)).toEqual({ subtotalCents: 1980, quantity: 2 });
});
test("a cart with only digital sponsorships is digital-only", () => {
const { addSponsorship } = useCartStore.getState();
addSponsorship(packageItem, sponsorship("Mila", { isPhysical: false, honeyJars: 0 }));
+14
View File
@@ -174,6 +174,20 @@ export function cartPhysicalQuantity(items: CartItem[]): number {
}, 0);
}
/**
* Rabattbasis wie im Checkout-Server (discountBase): nur Ware ausserhalb von
* Patenschaftspaketen — weder die Urkunde noch der Honig eines Pakets zaehlen
* fuer Mengenrabatt-Schwelle und Rabattbetrag.
*/
export function cartDiscountBase(items: CartItem[]): { subtotalCents: number; quantity: number } {
return items
.filter((item) => !item.sponsorship)
.reduce(
(base, item) => ({ subtotalCents: base.subtotalCents + item.priceCents * item.quantity, quantity: base.quantity + item.quantity }),
{ subtotalCents: 0, quantity: 0 }
);
}
/**
* true, wenn nichts versendet werden muss: nur Patenschaften ohne Honig.
* Dieselbe Regel wie hasPhysical im Server (physicalQuantity > 0), damit die