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

+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),
}))
);
}