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
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:
1 parent
f227aeb8f9
commit
515fca1c16
97 files changed
+2979
-254
No files matched your search
@@ -0,0 +1,6 @@
|
||||
-- Widerrufene Patenschaft: Einmalkauf storniert oder voll erstattet
|
||||
-- (Stornorechnung ausgestellt). Rein additiv; der neue Wert wird in dieser
|
||||
-- Migration nicht benutzt, ALTER TYPE ADD VALUE ist deshalb auch innerhalb
|
||||
-- der Migrationstransaktion zulaessig.
|
||||
-- AlterEnum
|
||||
ALTER TYPE "SponsorshipStatus" ADD VALUE 'WIDERRUFEN';
|
||||
@@ -1026,6 +1026,9 @@ enum SponsorshipStatus {
|
||||
BEREIT
|
||||
/// Nur physische Pakete: Urkunde gedruckt.
|
||||
GEDRUCKT
|
||||
/// Einmalkauf storniert oder voll erstattet (Stornorechnung): keine
|
||||
/// Downloads, keine Uebertragung, keine Ausstellung mehr.
|
||||
WIDERRUFEN
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { logAdminAction } from "@/lib/audit-log";
|
||||
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
||||
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||
import { certificateDirsOfCustomer, hasBlockingSubscription, removeCertificateDirs } from "@/lib/sponsorship/customer-deletion";
|
||||
import { discardPendingCheckouts } from "@/lib/sponsorship-subscription/pending";
|
||||
|
||||
export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
@@ -176,8 +178,19 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
||||
if (await isLastAdmin(id)) {
|
||||
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
||||
}
|
||||
// Laufendes Abo: erst kuendigen bzw. bei Stripe beenden — sonst zoege
|
||||
// Stripe weiter ab, ohne dass es ein Konto gibt (lib/sponsorship/customer-deletion.ts).
|
||||
if (await hasBlockingSubscription(id)) {
|
||||
return fail(
|
||||
"/admin/benutzer",
|
||||
"Dieses Konto hat ein laufendes Patenschafts-Abo. Bitte das Abo zuerst kündigen und sein Ende abwarten (oder bei Stripe beenden) — erst danach lässt sich das Konto löschen."
|
||||
);
|
||||
}
|
||||
|
||||
const certificateDirs = await certificateDirsOfCustomer(id);
|
||||
await discardPendingCheckouts(id);
|
||||
const deleted = await prisma.customer.delete({ where: { id } });
|
||||
await removeCertificateDirs(certificateDirs);
|
||||
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
||||
revalidatePath("/admin/benutzer");
|
||||
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
|
||||
|
||||
@@ -66,14 +66,15 @@ async function settleCancelledOrder(order: {
|
||||
orderNumber: string;
|
||||
previousStatus: OrderStatus;
|
||||
stripeCheckoutSessionId: string | null;
|
||||
payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null;
|
||||
payment: { status: PaymentStatus; stripePaymentIntentId: string | null; amountCents: number } | null;
|
||||
}): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
|
||||
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
|
||||
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
|
||||
if (order.payment?.status === "SUCCEEDED") {
|
||||
// 0-€-Zahlungen (Abo-Honiglieferung, Provider "abo") haben nichts zu erstatten.
|
||||
if (order.payment?.status === "SUCCEEDED" && order.payment.amountCents > 0) {
|
||||
const paymentIntentId = order.payment.stripePaymentIntentId;
|
||||
if (!paymentIntentId) {
|
||||
warnings.push(
|
||||
@@ -149,8 +150,8 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
firstName: true,
|
||||
locale: true,
|
||||
stripeCheckoutSessionId: true,
|
||||
payment: { select: { status: true, stripePaymentIntentId: true } },
|
||||
items: { select: { productId: true, quantity: true, productName: true } },
|
||||
payment: { select: { status: true, stripePaymentIntentId: true, amountCents: true } },
|
||||
items: { select: { productId: true, quantity: true, productName: true, product: { select: { category: true } } } },
|
||||
returnRequest: { select: { status: true } },
|
||||
invoice: { select: { cancellation: { select: { number: true } } } },
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ const PROCESS_MESSAGES: Record<ProcessResult, string> = {
|
||||
retry: "Der Versuch ist fehlgeschlagen. Der nächste automatische Versuch ist eingeplant — Fehler siehe unten.",
|
||||
failed: "Der Versuch ist fehlgeschlagen. Alle automatischen Versuche sind aufgebraucht — Fehler siehe unten.",
|
||||
missing: "Die Patenschaft wurde nicht gefunden.",
|
||||
revoked: "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und wird nicht mehr ausgestellt.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -152,6 +153,8 @@ function revisionErrorMessage(error: ReviseSponsorshipError): string {
|
||||
return "Die Patenschaft wurde nicht gefunden.";
|
||||
case "no_revision":
|
||||
return "Die Urkunde wurde noch nicht erzeugt — korrigieren lässt sich erst eine bestehende Urkunde.";
|
||||
case "revoked":
|
||||
return "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und lässt sich nicht mehr korrigieren.";
|
||||
case "reason":
|
||||
return `Bitte einen Änderungsgrund angeben (${error.min}–${error.max} Zeichen).`;
|
||||
case "unknown_template":
|
||||
|
||||
@@ -17,15 +17,23 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { isDhlConfigured } from "@/lib/dhl";
|
||||
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||||
import { Button } from "@/components/ui";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
|
||||
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const { id } = await props.params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } },
|
||||
items: {
|
||||
include: {
|
||||
product: { select: { slug: true } },
|
||||
batch: { select: { code: true } },
|
||||
sponsorship: { select: { number: true, status: true } },
|
||||
},
|
||||
},
|
||||
payment: true,
|
||||
shipment: true,
|
||||
pickup: true,
|
||||
@@ -36,6 +44,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
});
|
||||
if (!order) notFound();
|
||||
const dhlConfigured = isDhlConfigured();
|
||||
// Storno/Erstattung widerruft Patenschaften (lib/sponsorship/revoke.ts);
|
||||
// ein Wiederoeffnen stellt sie bewusst nicht von selbst wieder her.
|
||||
const revokedNumbers = order.items.flatMap((item) => (item.sponsorship?.status === "WIDERRUFEN" ? [item.sponsorship.number] : []));
|
||||
// Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt
|
||||
// ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht
|
||||
// auseinanderlaufen koennen.
|
||||
@@ -88,6 +99,12 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
/>
|
||||
</div>
|
||||
|
||||
{revokedNumbers.length > 0 ? (
|
||||
<p role="note" className="mb-8 border border-ink/20 bg-ink/[0.03] px-6 py-4 text-sm text-ink/80">
|
||||
{getSponsorshipAdminStrings(locale).order.revokedNote(revokedNumbers.join(", "))}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{order.giftMessage ? (
|
||||
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
|
||||
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
|
||||
@@ -211,7 +228,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
<Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
|
||||
{item.productName}
|
||||
</Link>{" "}
|
||||
<span className="text-ink/65">({item.weightGrams} g)</span>
|
||||
{item.weightGrams > 0 ? <span className="text-ink/65">({item.weightGrams} g)</span> : null}
|
||||
</td>
|
||||
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
|
||||
@@ -55,7 +55,12 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
||||
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
|
||||
const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([
|
||||
prisma.order.count({
|
||||
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
||||
// DIGITAL (nur Patenschaften, Abo-Monatsrechnungen): nichts zu packen.
|
||||
where: {
|
||||
status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] },
|
||||
archivedAt: null,
|
||||
fulfillmentMethod: { not: "DIGITAL" },
|
||||
},
|
||||
}),
|
||||
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
|
||||
|
||||
+17
-6
@@ -3,7 +3,7 @@ import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-p
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { pendingReviewWhere } from "@/lib/review-status";
|
||||
import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard";
|
||||
import { COUNTED_SALE_WHERE } from "@/lib/sales";
|
||||
import { COUNTED_SALE_WHERE, SHOP_SALE_WHERE } from "@/lib/sales";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -130,14 +130,20 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
where: { createdAt: { gte: startOfMonth() }, ...COUNTED_SALE_WHERE },
|
||||
}),
|
||||
prisma.order.count({
|
||||
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
|
||||
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] }, fulfillmentMethod: { not: "DIGITAL" } },
|
||||
}),
|
||||
prisma.inventory.aggregate({ _sum: { quantity: true } }),
|
||||
prisma.orderItem.aggregate({ _sum: { quantity: true }, where: { order: COUNTED_SALE_WHERE } }),
|
||||
// Glaeser: Patenschafts-Positionen (Urkunde, Abo-Monatszeilen) sind keine.
|
||||
prisma.orderItem.aggregate({
|
||||
_sum: { quantity: true },
|
||||
where: { order: COUNTED_SALE_WHERE, product: { category: { not: "PATENSCHAFT" } } },
|
||||
}),
|
||||
// Warenkorbwert nur aus echten Shop-Bestellungen: Abo-Monatsrechnungen
|
||||
// und 0-€-Abo-Lieferungen sind keine Warenkoerbe.
|
||||
prisma.order.aggregate({
|
||||
_avg: { totalCents: true },
|
||||
_count: true,
|
||||
where: COUNTED_SALE_WHERE,
|
||||
where: SHOP_SALE_WHERE,
|
||||
}),
|
||||
// Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
|
||||
// JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher,
|
||||
@@ -168,7 +174,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
// machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann.
|
||||
prisma.order.groupBy({
|
||||
by: ["customerId"],
|
||||
where: { customerId: { not: null }, ...COUNTED_SALE_WHERE },
|
||||
where: { customerId: { not: null }, ...SHOP_SALE_WHERE },
|
||||
having: { customerId: { _count: { gt: 1 } } },
|
||||
}),
|
||||
prisma.discountCode.findMany({
|
||||
@@ -270,7 +276,12 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries, sponsorshipItems] = await Promise.all([
|
||||
getCurrentAdmin(),
|
||||
prisma.order.count({
|
||||
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
||||
// DIGITAL (nur Patenschaften, Abo-Monatsrechnungen): nichts zu packen.
|
||||
where: {
|
||||
status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] },
|
||||
archivedAt: null,
|
||||
fulfillmentMethod: { not: "DIGITAL" },
|
||||
},
|
||||
}),
|
||||
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||
prisma.review.count({ where: pendingReviewWhere() }),
|
||||
|
||||
@@ -27,6 +27,27 @@ const buttonClass = "px-4 py-2 text-xs";
|
||||
export function SponsorshipActions({ id, status, currentRevision, isPhysical, printed, ownerEmail, t }: SponsorshipActionsProps) {
|
||||
const issued = currentRevision > 0;
|
||||
const delivered = status === "BEREIT" || status === "GEDRUCKT";
|
||||
// Widerrufen (Storno/Erstattung): nur noch das Druck-PDF zur Ansicht, keine
|
||||
// Aktion, die der Kundschaft wieder etwas zustellen oder aendern wuerde.
|
||||
if (status === "WIDERRUFEN") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{issued ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={`/admin/patenschaften/${id}/revision/${currentRevision}.pdf?inline=1`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="inline-flex items-center justify-center border border-ink/20 px-4 py-2 text-xs tracking-wide-caps text-ink transition-colors hover:border-ink"
|
||||
>
|
||||
{t.openPrintPdf}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminActionNote>{t.noteRevoked}</AdminActionNote>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -6,6 +6,7 @@ const SPONSORSHIP_TONES: Record<SponsorshipStatus, AdminBadgeTone> = {
|
||||
FEHLER: "danger",
|
||||
BEREIT: "positive",
|
||||
GEDRUCKT: "neutral",
|
||||
WIDERRUFEN: "danger",
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_TONES: Record<SubscriptionStatus, AdminBadgeTone> = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
|
||||
/**
|
||||
* Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und
|
||||
@@ -41,14 +41,14 @@ export async function getHoneyStock(productIds: string[], now: Date = new Date()
|
||||
select: { productId: true, quantity: true },
|
||||
});
|
||||
const stockById = new Map(inventories.map((row) => [row.productId, row.quantity]));
|
||||
const entries = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const reservedById = await reservedJarsByProduct(ids, now);
|
||||
return new Map(
|
||||
ids.map((id) => {
|
||||
const stock = stockById.get(id) ?? 0;
|
||||
const reserved = await reservedJarsForProduct(id, now);
|
||||
const reserved = reservedById.get(id) ?? 0;
|
||||
return [id, { stock, reserved, free: Math.max(0, stock - reserved) }] as const;
|
||||
})
|
||||
);
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
/** Deckung je Honigprodukt, das mit mindestens einem Abo-Angebot verknuepft ist. */
|
||||
|
||||
@@ -8,7 +8,7 @@ import { beeNameFromJson } from "./revision-data";
|
||||
* sondern die Arbeitsliste des Druckablaufs: physisches Paket, Urkunde
|
||||
* bereit, noch nicht gedruckt.
|
||||
*/
|
||||
export const LIST_STATUSES = ["AUSSTEHEND", "FEHLER", "BEREIT", "GEDRUCKT", "ZU_DRUCKEN"] as const;
|
||||
export const LIST_STATUSES = ["AUSSTEHEND", "FEHLER", "BEREIT", "GEDRUCKT", "WIDERRUFEN", "ZU_DRUCKEN"] as const;
|
||||
export type ListStatus = (typeof LIST_STATUSES)[number];
|
||||
|
||||
export const TO_PRINT_FILTER = {
|
||||
|
||||
@@ -26,7 +26,8 @@ vi.mock("@/lib/stripe", () => ({
|
||||
const getProductsByIds = vi.fn();
|
||||
vi.mock("@/lib/data", () => ({ getProductsByIds: (...args: unknown[]) => getProductsByIds(...args) }));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: vi.fn(async () => null) }));
|
||||
const getCurrentCustomer = vi.fn<() => Promise<unknown>>(async () => null);
|
||||
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: () => getCurrentCustomer() }));
|
||||
|
||||
const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true);
|
||||
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
|
||||
@@ -67,10 +68,12 @@ const db = {
|
||||
};
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||
// Patenschaften spielen in diesen Faellen keine Rolle: keine Angebote zu den
|
||||
// Produkten, keine Abo-Reservierung (eigene Tests in lib/sponsorship).
|
||||
vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: vi.fn(async () => new Map()) }));
|
||||
vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsForProduct: vi.fn(async () => 0) }));
|
||||
// Patenschaften spielen in den meisten Faellen keine Rolle: keine Angebote zu
|
||||
// den Produkten, keine Abo-Reservierung (eigene Tests in lib/sponsorship).
|
||||
// Die Rabatt-Tests unten legen ein Paket-Angebot an.
|
||||
const getOffersByProductIds = vi.fn<(ids: string[]) => Promise<Map<string, unknown>>>(async () => new Map());
|
||||
vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: (ids: string[]) => getOffersByProductIds(ids) }));
|
||||
vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsByProduct: vi.fn(async () => new Map()) }));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
|
||||
@@ -116,6 +119,8 @@ beforeEach(() => {
|
||||
rateLimit.mockReturnValue(true);
|
||||
verifyTurnstileToken.mockResolvedValue(true);
|
||||
getProductsByIds.mockResolvedValue([honey]);
|
||||
getCurrentCustomer.mockResolvedValue(null);
|
||||
getOffersByProductIds.mockResolvedValue(new Map());
|
||||
getShippingRegion.mockResolvedValue("DE");
|
||||
db.discountCode.findMany.mockResolvedValue([]);
|
||||
db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
||||
@@ -418,4 +423,119 @@ describe("Checkout-Route", () => {
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rabatte und Patenschaften", () => {
|
||||
// Paket "Wabenfreund": 38,00 € Patenschaft (MAIN) + 3 Glaeser Honiganteil
|
||||
// zu je 7,00 € (HONEY) = 59,00 €. Beides zaehlt nicht als Rabattbasis.
|
||||
const carrier = { ...honey, id: "carrier", name: "Wabenfreund", slug: "wabenfreund", priceCents: 3800, weightGrams: 0, category: "PATENSCHAFT" };
|
||||
const offer = {
|
||||
id: "offer",
|
||||
slug: "wabenfreund",
|
||||
kind: "EINMALIG",
|
||||
name: "Wabenfreund",
|
||||
description: "",
|
||||
features: [],
|
||||
priceCents: 5900,
|
||||
isPhysical: true,
|
||||
honeyJars: 3,
|
||||
honeyShareCentsPerJar: 700,
|
||||
honeyShareCentsPerMonth: 0,
|
||||
cycle: null,
|
||||
medalTier: "GOLD",
|
||||
productId: "carrier",
|
||||
honeyProduct: { id: "p1", name: honey.name, weightGrams: 500 },
|
||||
isAvailable: true,
|
||||
};
|
||||
const personalization = {
|
||||
beeName: "Summsi",
|
||||
recipientName: "Erika Mustermann",
|
||||
occasion: "SELBST",
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
const packageItem = { productId: "carrier", quantity: 1, personalization };
|
||||
const discountCode = {
|
||||
id: "dc_1",
|
||||
code: "SOMMER10",
|
||||
isActive: true,
|
||||
type: "PERCENT" as const,
|
||||
value: 10,
|
||||
minOrderCents: 0,
|
||||
minQuantity: null,
|
||||
maxUses: null,
|
||||
usedCount: 0,
|
||||
validFrom: null,
|
||||
validUntil: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getCurrentCustomer.mockResolvedValue({ id: "cust_1", emailVerifiedAt: new Date() });
|
||||
getOffersByProductIds.mockResolvedValue(new Map([["carrier", offer]]));
|
||||
getProductsByIds.mockImplementation(async (ids: string[]) => [honey, carrier].filter((product) => ids.includes(product.id)));
|
||||
});
|
||||
|
||||
test("Mengenrabatt: Glaeser aus einem Patenschaftspaket zaehlen nicht zur Schwelle", async () => {
|
||||
// 2 einzelne Glaeser + 3 Paketglaeser = 5 physische, aber nur 2 rabattfaehige.
|
||||
db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]);
|
||||
|
||||
const response = await post({ ...validPayload, items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.order.create.mock.calls[0][0].data).toMatchObject({ discountCents: 0, subtotalCents: 1980 + 5900 });
|
||||
expect(couponsCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Mengenrabatt: Betrag nur auf die Ware ausserhalb des Pakets, Stripe-Coupon gleich hoch", async () => {
|
||||
db.discountCode.findMany.mockResolvedValue([{ ...discountCode, id: "bulk", code: "MENGE5", minQuantity: 5 }]);
|
||||
couponsCreate.mockResolvedValue({ id: "coupon_1" });
|
||||
|
||||
const response = await post({ ...validPayload, items: [{ productId: "p1", quantity: 5 }, packageItem] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// 10 % von 5 × 9,90 € = 4,95 € — nicht von 108,50 €.
|
||||
expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(495);
|
||||
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 495 }));
|
||||
});
|
||||
|
||||
test("Gutscheincode: Mindestbestellwert zaehlt nur die Ware ausserhalb von Patenschaften", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue({ ...discountCode, minOrderCents: 3000 });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining("ungültig") });
|
||||
expect(db.order.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Gutscheincode: Rabatt nur auf die Ware ausserhalb von Patenschaften", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue(discountCode);
|
||||
couponsCreate.mockResolvedValue({ id: "coupon_1" });
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "sommer10", items: [{ productId: "p1", quantity: 2 }, packageItem] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.order.create.mock.calls[0][0].data.discountCents).toBe(198);
|
||||
expect(couponsCreate).toHaveBeenCalledWith(expect.objectContaining({ amount_off: 198 }));
|
||||
});
|
||||
|
||||
test("Gutscheincode bei reinem Patenschafts-Warenkorb ist ungueltig", async () => {
|
||||
db.discountCode.findUnique.mockResolvedValue(discountCode);
|
||||
|
||||
const response = await post({ ...validPayload, discountCode: "sommer10", items: [packageItem] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining("ungültig") });
|
||||
expect(db.order.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Stripe-Positionsname nennt das Gewicht nur bei Ware mit Gewicht", async () => {
|
||||
await post({ ...validPayload, items: [{ productId: "p1", quantity: 1 }, packageItem] });
|
||||
|
||||
const names = sessionsCreate.mock.calls[0][0].line_items.map(
|
||||
(line: { price_data: { product_data: { name: string } } }) => line.price_data.product_data.name
|
||||
);
|
||||
expect(names).toContain("Fruehtracht 2026 (500 g)");
|
||||
expect(names).toContain("Wabenfreund");
|
||||
expect(names.some((name: string) => name.includes("(0 g)"))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { isTrustedOrigin } from "@/lib/csrf";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { getShippingCostCents, getShippingRegion } from "@/lib/shipping";
|
||||
import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region";
|
||||
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||
import { isDiscountCodeValid, calculateDiscountCents, discountBase } from "@/lib/discount-calc";
|
||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ import { vatRateForProduct } from "@/lib/vat";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
|
||||
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
|
||||
import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
|
||||
class OutOfStockError extends Error {}
|
||||
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
|
||||
@@ -332,13 +332,13 @@ export async function POST(request: Request) {
|
||||
// Transaktion gelesen: die Reservierung aendert sich nur mit Abos, nicht
|
||||
// durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung
|
||||
// bleibt das bedingte Update in der Transaktion.
|
||||
const reservedByProduct = new Map<string, number>();
|
||||
for (const line of expanded.reserve) {
|
||||
reservedByProduct.set(line.productId, await reservedJarsForProduct(line.productId));
|
||||
}
|
||||
const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId));
|
||||
|
||||
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
||||
// haelt die Rabattlogik einfach nachvollziehbar.
|
||||
// haelt die Rabattlogik einfach nachvollziehbar. Und nur auf Ware
|
||||
// ausserhalb von Patenschaften (discountBase): Schwelle, Mindestwert und
|
||||
// Betrag rechnen ohne Paketpositionen.
|
||||
const base = discountBase(orderItemsData);
|
||||
let discountCents = 0;
|
||||
let discountCode: { id: string; code: string } | null = null;
|
||||
if (input.discountCode) {
|
||||
@@ -347,7 +347,9 @@ export async function POST(request: Request) {
|
||||
where: { code: input.discountCode.trim().toUpperCase() },
|
||||
});
|
||||
|
||||
if (!code || !isDiscountCodeValid(code, subtotalCents, now, totalQuantity)) {
|
||||
// Ein reiner Patenschafts-Warenkorb hat keine Rabattbasis — der Code
|
||||
// gilt dann als nicht einloesbar statt still 0 € abzuziehen.
|
||||
if (!code || base.subtotalCents <= 0 || !isDiscountCodeValid(code, base.subtotalCents, now, base.quantity)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||
{ status: 400 }
|
||||
@@ -372,7 +374,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
||||
discountCents = calculateDiscountCents(code, base.subtotalCents);
|
||||
discountCode = code;
|
||||
} else {
|
||||
// Kein manuell eingegebener Code — unter allen im Admin-Bereich
|
||||
@@ -383,10 +385,10 @@ export async function POST(request: Request) {
|
||||
const candidates = await prisma.discountCode.findMany({ where: { minQuantity: { not: null } } });
|
||||
const now = new Date();
|
||||
const code = candidates
|
||||
.filter((candidate) => isDiscountCodeValid(candidate, subtotalCents, now, totalQuantity))
|
||||
.filter((candidate) => base.subtotalCents > 0 && isDiscountCodeValid(candidate, base.subtotalCents, now, base.quantity))
|
||||
.sort((a, b) => (b.minQuantity ?? 0) - (a.minQuantity ?? 0))[0];
|
||||
if (code) {
|
||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
||||
discountCents = calculateDiscountCents(code, base.subtotalCents);
|
||||
discountCode = code;
|
||||
}
|
||||
}
|
||||
@@ -584,7 +586,8 @@ export async function POST(request: Request) {
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
unit_amount: item.unitPriceCents,
|
||||
product_data: { name: `${item.productName} (${item.weightGrams} g)` },
|
||||
// Gewicht nur bei Ware mit Gewicht — eine Urkunde "(0 g)" waere Unsinn.
|
||||
product_data: { name: item.weightGrams > 0 ? `${item.productName} (${item.weightGrams} g)` : item.productName },
|
||||
},
|
||||
})),
|
||||
...(shippingCents > 0
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { sponsorshipExportData } from "@/lib/sponsorship/data-export";
|
||||
|
||||
const EXPORT_ATTEMPTS_PER_WINDOW = 5;
|
||||
const EXPORT_WINDOW_MS = 60 * 60 * 1000;
|
||||
@@ -17,7 +18,7 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
|
||||
}
|
||||
|
||||
const [addresses, orders, reviews, wishlistItems, newsletter] = await Promise.all([
|
||||
const [addresses, orders, reviews, wishlistItems, newsletter, sponsorship] = await Promise.all([
|
||||
prisma.address.findMany({ where: { customerId: customer.id } }),
|
||||
prisma.order.findMany({
|
||||
where: { customerId: customer.id },
|
||||
@@ -33,6 +34,8 @@ export async function GET() {
|
||||
include: { product: { select: { name: true } } },
|
||||
}),
|
||||
prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }),
|
||||
// Patenschaften, Abos samt Zahlungen und Uebertragungen (ohne Stripe-Kennungen).
|
||||
sponsorshipExportData(customer.id),
|
||||
]);
|
||||
|
||||
const exportData = {
|
||||
@@ -82,6 +85,9 @@ export async function GET() {
|
||||
newsletter: newsletter
|
||||
? { subscribed: newsletter.unsubscribedAt === null, confirmed: newsletter.confirmed }
|
||||
: { subscribed: false, confirmed: false },
|
||||
sponsorships: sponsorship.sponsorships,
|
||||
sponsorshipSubscriptions: sponsorship.subscriptions,
|
||||
sponsorshipTransfers: sponsorship.transfers,
|
||||
};
|
||||
|
||||
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||
|
||||
@@ -5,6 +5,7 @@ const m = vi.hoisted(() => ({
|
||||
getCurrentCustomer: vi.fn(),
|
||||
getOfferBySlug: vi.fn(),
|
||||
createSubscriptionCheckout: vi.fn(),
|
||||
preparePendingCheckout: vi.fn(),
|
||||
isTrustedOrigin: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -14,6 +15,7 @@ vi.mock("@/lib/rate-limit", () => ({ rateLimit: () => true, getClientIp: async (
|
||||
vi.mock("@/i18n/locale", () => ({ getLocale: async () => "de" }));
|
||||
vi.mock("@/lib/sponsorship/offers", () => ({ getOfferBySlug: m.getOfferBySlug }));
|
||||
vi.mock("@/lib/sponsorship-subscription/checkout", () => ({ createSubscriptionCheckout: m.createSubscriptionCheckout }));
|
||||
vi.mock("@/lib/sponsorship-subscription/pending", () => ({ preparePendingCheckout: m.preparePendingCheckout }));
|
||||
vi.mock("@/lib/certificate/layout", () => ({ checkCertificateFits: () => ({ ok: true }) }));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
@@ -38,6 +40,7 @@ beforeEach(() => {
|
||||
m.getCurrentCustomer.mockResolvedValue(CUSTOMER);
|
||||
m.getOfferBySlug.mockResolvedValue({ id: "offer_1", slug: "vierteljaehrlich", kind: "ABO", isAvailable: true });
|
||||
m.createSubscriptionCheckout.mockResolvedValue({ clientSecret: "cs_secret", subscriptionId: "sub_local" });
|
||||
m.preparePendingCheckout.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("POST /api/patenschaft/abo", () => {
|
||||
@@ -87,4 +90,21 @@ describe("POST /api/patenschaft/abo", () => {
|
||||
expect(response.status).toBe(502);
|
||||
expect(JSON.stringify(await response.json())).not.toContain("sk_test");
|
||||
});
|
||||
|
||||
test("bereitet den Abschluss vor (alter offener Abschluss desselben Angebots wird ersetzt)", async () => {
|
||||
await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
|
||||
|
||||
expect(m.preparePendingCheckout).toHaveBeenCalledWith({ customerId: "cust_1", offerId: "offer_1" });
|
||||
expect(m.preparePendingCheckout.mock.invocationCallOrder[0]).toBeLessThan(m.createSubscriptionCheckout.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
test("mehr als drei offene Abschluesse je Konto: klare Ablehnung, kein neuer Checkout", async () => {
|
||||
m.preparePendingCheckout.mockResolvedValue({ ok: false, reason: "too_many_pending" });
|
||||
|
||||
const response = await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toMatchObject({ code: "too_many_pending", error: expect.stringContaining("offene Abo-Abschlüsse") });
|
||||
expect(m.createSubscriptionCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { personalizationSchema, toIsoDate } from "@/lib/certificate/schema";
|
||||
import { checkCertificateFits } from "@/lib/certificate/layout";
|
||||
import { getOfferBySlug } from "@/lib/sponsorship/offers";
|
||||
import { createSubscriptionCheckout } from "@/lib/sponsorship-subscription/checkout";
|
||||
import { preparePendingCheckout } from "@/lib/sponsorship-subscription/pending";
|
||||
|
||||
/**
|
||||
* Abschluss eines Patenschafts-Abos: prueft Konto, Personalisierung und
|
||||
@@ -81,6 +82,17 @@ export async function POST(request: Request) {
|
||||
// "Honigbestand und Kontingent").
|
||||
if (!offer.isAvailable) return error("Dieses Abo ist derzeit ausgebucht.", 409);
|
||||
|
||||
// Ein alter offener Abschluss desselben Angebots wird ersetzt; mehr als
|
||||
// drei offene je Konto nimmt der Shop nicht an (pending.ts).
|
||||
const pending = await preparePendingCheckout({ customerId: customer.id, offerId: offer.id });
|
||||
if (!pending.ok) {
|
||||
return error(
|
||||
"Du hast bereits mehrere offene Abo-Abschlüsse. Bitte schließe einen davon ab oder versuche es morgen erneut.",
|
||||
409,
|
||||
{ code: "too_many_pending" }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { clientSecret } = await createSubscriptionCheckout({
|
||||
customer,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createLruCache, createRenderGate, previewCacheKey } from "./render-guard";
|
||||
|
||||
describe("createRenderGate", () => {
|
||||
test("laesst hoechstens maxConcurrent Renderings gleichzeitig zu und gibt Plaetze wieder frei", () => {
|
||||
const gate = createRenderGate({ maxConcurrent: 3, maxPerMinute: 300, now: () => 0 });
|
||||
const slots = [gate.tryAcquire(), gate.tryAcquire(), gate.tryAcquire()];
|
||||
expect(slots.every((slot) => slot.ok)).toBe(true);
|
||||
expect(gate.tryAcquire()).toEqual({ ok: false, reason: "busy" });
|
||||
|
||||
const first = slots[0];
|
||||
if (first.ok) first.release();
|
||||
expect(gate.tryAcquire().ok).toBe(true);
|
||||
});
|
||||
|
||||
test("doppeltes release gibt nicht zwei Plaetze frei", () => {
|
||||
const gate = createRenderGate({ maxConcurrent: 1, maxPerMinute: 300, now: () => 0 });
|
||||
const slot = gate.tryAcquire();
|
||||
if (slot.ok) {
|
||||
slot.release();
|
||||
slot.release();
|
||||
}
|
||||
expect(gate.tryAcquire().ok).toBe(true);
|
||||
expect(gate.tryAcquire()).toEqual({ ok: false, reason: "busy" });
|
||||
});
|
||||
|
||||
test("begrenzt die Renderings je Minute und setzt das Fenster danach zurueck", () => {
|
||||
let clock = 0;
|
||||
const gate = createRenderGate({ maxConcurrent: 10, maxPerMinute: 2, now: () => clock });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const slot = gate.tryAcquire();
|
||||
if (slot.ok) slot.release();
|
||||
}
|
||||
expect(gate.tryAcquire()).toEqual({ ok: false, reason: "minute_cap" });
|
||||
|
||||
clock = 60_000;
|
||||
expect(gate.tryAcquire().ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLruCache", () => {
|
||||
test("verdraengt den am laengsten nicht genutzten Eintrag", () => {
|
||||
const cache = createLruCache<string>(2);
|
||||
cache.set("a", "A");
|
||||
cache.set("b", "B");
|
||||
expect(cache.get("a")).toBe("A"); // a ist jetzt frischer als b
|
||||
cache.set("c", "C");
|
||||
|
||||
expect(cache.get("b")).toBeUndefined();
|
||||
expect(cache.get("a")).toBe("A");
|
||||
expect(cache.get("c")).toBe("C");
|
||||
expect(cache.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewCacheKey", () => {
|
||||
const personalization = { beeName: "Summsi", recipientName: "Erika", occasion: "SELBST" as const, startDate: "2026-10-16" };
|
||||
const term = { kind: "FIXED" as const, start: "2026-10-16", end: "2027-10-15" };
|
||||
|
||||
test("gleiche Eingabe in anderer Schluesselreihenfolge ergibt denselben Schluessel", () => {
|
||||
const a = previewCacheKey({ personalization, locale: "de", term, templateVersion: "v1" });
|
||||
const b = previewCacheKey({
|
||||
personalization: { startDate: "2026-10-16", occasion: "SELBST", recipientName: "Erika", beeName: "Summsi" },
|
||||
locale: "de",
|
||||
term: { end: "2027-10-15", start: "2026-10-16", kind: "FIXED" },
|
||||
templateVersion: "v1",
|
||||
});
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("Sprache, Laufzeit und Vorlage gehoeren zum Schluessel", () => {
|
||||
const base = previewCacheKey({ personalization, locale: "de", term, templateVersion: "v1" });
|
||||
expect(previewCacheKey({ personalization, locale: "en", term, templateVersion: "v1" })).not.toBe(base);
|
||||
expect(previewCacheKey({ personalization, locale: "de", term: { ...term, end: "2027-10-14" }, templateVersion: "v1" })).not.toBe(base);
|
||||
expect(previewCacheKey({ personalization, locale: "de", term, templateVersion: "v2" })).not.toBe(base);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Schutz der Vorschau-Ausgabe vor Ueberlast. Das Rate-Limit je Besucher
|
||||
* allein reicht nicht: viele Besucher (oder ein verteilter Angriff) koennen
|
||||
* gemeinsam beliebig viele sharp-Renderings gleichzeitig anstossen und den
|
||||
* Container lahmlegen. Deshalb zusaetzlich global:
|
||||
*
|
||||
* - hoechstens N Renderings gleichzeitig (sonst 503 mit Retry-After),
|
||||
* - hoechstens M Renderings je Minute,
|
||||
* - ein kleiner LRU-Zwischenspeicher fuer identische Eingaben (Zurueck-
|
||||
* Klicken im Formular, Doppelklick, mehrere Tabs) — kostet kein Rendering.
|
||||
*
|
||||
* In-Memory wie lib/rate-limit.ts: ausreichend fuer den Single-Container-
|
||||
* Betrieb. Als eigenes Modul, weil route.ts nur Next.js-Exporte haben darf.
|
||||
*/
|
||||
|
||||
export type GateResult = { ok: true; release: () => void } | { ok: false; reason: "busy" | "minute_cap" };
|
||||
|
||||
export interface RenderGate {
|
||||
tryAcquire(): GateResult;
|
||||
}
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
export function createRenderGate(options: { maxConcurrent: number; maxPerMinute: number; now?: () => number }): RenderGate {
|
||||
const now = options.now ?? Date.now;
|
||||
let running = 0;
|
||||
let windowStart = now();
|
||||
let startedInWindow = 0;
|
||||
return {
|
||||
tryAcquire(): GateResult {
|
||||
const time = now();
|
||||
if (time - windowStart >= MINUTE_MS) {
|
||||
windowStart = time;
|
||||
startedInWindow = 0;
|
||||
}
|
||||
if (running >= options.maxConcurrent) return { ok: false, reason: "busy" };
|
||||
if (startedInWindow >= options.maxPerMinute) return { ok: false, reason: "minute_cap" };
|
||||
running += 1;
|
||||
startedInWindow += 1;
|
||||
let released = false;
|
||||
return {
|
||||
ok: true,
|
||||
release: () => {
|
||||
// Ein zweiter Aufruf (z.B. aus finally und catch) darf keinen fremden Platz freigeben.
|
||||
if (released) return;
|
||||
released = true;
|
||||
running -= 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface LruCache<V> {
|
||||
get(key: string): V | undefined;
|
||||
set(key: string, value: V): void;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** LRU ueber die Einfuegereihenfolge einer Map: ein Treffer wandert ans Ende, verdraengt wird vorn. */
|
||||
export function createLruCache<V>(maxEntries: number): LruCache<V> {
|
||||
const entries = new Map<string, V>();
|
||||
return {
|
||||
get(key) {
|
||||
const value = entries.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
entries.delete(key);
|
||||
entries.set(key, value);
|
||||
return value;
|
||||
},
|
||||
set(key, value) {
|
||||
entries.delete(key);
|
||||
entries.set(key, value);
|
||||
while (entries.size > maxEntries) {
|
||||
const oldest = entries.keys().next();
|
||||
if (oldest.done) break;
|
||||
entries.delete(oldest.value);
|
||||
}
|
||||
},
|
||||
get size() {
|
||||
return entries.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** JSON mit sortierten Schluesseln, damit die Reihenfolge im Objekt den Schluessel nicht aendert. */
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.filter((key) => record[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schluessel aus der bereits GEPRUEFTEN und normalisierten Eingabe, nicht aus
|
||||
* dem Roh-Body: "Summsi " und "Summsi" ergeben dasselbe Bild. Als SHA-256,
|
||||
* damit keine Widmungstexte als Map-Schluessel im Speicher liegen.
|
||||
*/
|
||||
export function previewCacheKey(input: { personalization: object; locale: string; term: object; templateVersion: string }): string {
|
||||
return createHash("sha256").update(stableStringify(input)).digest("hex");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
|
||||
import { validatePreviewRequest, type PreviewFailure } from "./validate";
|
||||
import { createLruCache, createRenderGate, previewCacheKey } from "./render-guard";
|
||||
import {
|
||||
PREVIEW_NUMBER,
|
||||
previewWatermark,
|
||||
@@ -28,9 +29,30 @@ const PREVIEW_WIDTH_PX = 900;
|
||||
// Grosszuegig ueber dem, was die Feldlimits je erreichen koennen; alles
|
||||
// darueber ist kein Formular mehr, sondern ein Versuch, den Parser zu beschaeftigen.
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
// Globale Grenzen ueber alle Besucher (render-guard.ts): ein Rendering kostet
|
||||
// spuerbar CPU und Speicher, drei gleichzeitig traegt der Container sicher.
|
||||
const MAX_CONCURRENT_RENDERS = 3;
|
||||
const MAX_RENDERS_PER_MINUTE = 300;
|
||||
const RETRY_AFTER_SECONDS = 2;
|
||||
const CACHE_ENTRIES = 200;
|
||||
|
||||
function failure({ status, ...payload }: PreviewFailure): NextResponse {
|
||||
return NextResponse.json(payload, { status, headers: { "Cache-Control": "no-store" } });
|
||||
const gate = createRenderGate({ maxConcurrent: MAX_CONCURRENT_RENDERS, maxPerMinute: MAX_RENDERS_PER_MINUTE });
|
||||
const imageCache = createLruCache<Buffer>(CACHE_ENTRIES);
|
||||
|
||||
function failure({ status, ...payload }: PreviewFailure, headers: Record<string, string> = {}): NextResponse {
|
||||
return NextResponse.json(payload, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
||||
}
|
||||
|
||||
function imageResponse(image: Buffer): Response {
|
||||
return new Response(new Uint8Array(image), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/webp",
|
||||
// Personalisierte Bilder gehoeren in keinen Zwischenspeicher (nur in
|
||||
// den eigenen im Prozess, der nie an Dritte ausliefert).
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
@@ -50,8 +72,11 @@ export async function POST(request: Request): Promise<Response> {
|
||||
if (declaredLength > MAX_BODY_BYTES) {
|
||||
return failure({ status: 413, error: "generic", field: null });
|
||||
}
|
||||
const raw = await request.text().catch(() => "");
|
||||
if (raw.length > MAX_BODY_BYTES) {
|
||||
// Content-Length ist eine Angabe des Absenders, keine Grenze: bei
|
||||
// "chunked" fehlt sie ganz. Deshalb mitzaehlend lesen und abbrechen, sobald
|
||||
// mehr kommt als erlaubt — sonst puffert request.text() beliebig viel.
|
||||
const raw = await readBodyCapped(request, MAX_BODY_BYTES);
|
||||
if (raw === null) {
|
||||
return failure({ status: 413, error: "generic", field: null });
|
||||
}
|
||||
let body: unknown = null;
|
||||
@@ -64,6 +89,21 @@ export async function POST(request: Request): Promise<Response> {
|
||||
const input = validatePreviewRequest(body, new Date(), await getLocale());
|
||||
if (!input.ok) return failure(input.failure);
|
||||
|
||||
const cacheKey = previewCacheKey({
|
||||
personalization: input.personalization,
|
||||
locale: input.locale,
|
||||
term: input.term,
|
||||
templateVersion: CURRENT_TEMPLATE_VERSION,
|
||||
});
|
||||
const cached = imageCache.get(cacheKey);
|
||||
if (cached) return imageResponse(cached);
|
||||
|
||||
const slot = gate.tryAcquire();
|
||||
if (!slot.ok) {
|
||||
// 503 statt 429: nicht dieser Besucher ist zu schnell, sondern der
|
||||
// Server gerade voll. Das Formular versucht es nach Retry-After erneut.
|
||||
return failure({ status: 503, error: "busy", field: null }, { "Retry-After": String(RETRY_AFTER_SECONDS) });
|
||||
}
|
||||
try {
|
||||
const image = await renderCertificateDocumentImage(
|
||||
{
|
||||
@@ -75,16 +115,35 @@ export async function POST(request: Request): Promise<Response> {
|
||||
},
|
||||
{ widthPx: PREVIEW_WIDTH_PX, watermark: previewWatermark(input.locale), format: "webp" }
|
||||
);
|
||||
return new Response(new Uint8Array(image), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/webp",
|
||||
// Personalisierte Bilder gehoeren in keinen Zwischenspeicher.
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
imageCache.set(cacheKey, image);
|
||||
return imageResponse(image);
|
||||
} catch (error: unknown) {
|
||||
console.error("[patenschaft/vorschau] Vorschau konnte nicht erzeugt werden:", error);
|
||||
return failure({ status: 500, error: "generic", field: null });
|
||||
} finally {
|
||||
slot.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Liest den Body hoechstens bis maxBytes; null, wenn er groesser ist oder nicht lesbar. */
|
||||
async function readBodyCapped(request: Request, maxBytes: number): Promise<string | null> {
|
||||
if (!request.body) return "";
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return new TextDecoder().decode(Buffer.concat(chunks));
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const getProductsByIds = vi.fn();
|
||||
vi.mock("@/lib/data", () => ({ getProductsByIds: (ids: string[]) => getProductsByIds(ids) }));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
// Echtes withFreeStock aus lib/data, nur die Datenbankzugriffe sind ersetzt.
|
||||
vi.mock("@/lib/data", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/data")>()),
|
||||
getProductsByIds: (ids: string[]) => getProductsByIds(ids),
|
||||
}));
|
||||
const reservedJarsByProduct = vi.fn(async (ids: readonly string[]) => new Map(ids.map((id) => [id, 0])));
|
||||
vi.mock("@/lib/sponsorship-subscription/reservation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/sponsorship-subscription/reservation")>()),
|
||||
reservedJarsByProduct: (ids: readonly string[]) => reservedJarsByProduct(ids),
|
||||
}));
|
||||
|
||||
const getOffersByProductIds = vi.fn();
|
||||
vi.mock("@/lib/sponsorship/offers", () => ({
|
||||
@@ -38,6 +48,20 @@ describe("GET /api/shop/cart-stock", () => {
|
||||
expect(getOffersByProductIds).toHaveBeenCalledWith(["pkg"], "de");
|
||||
});
|
||||
|
||||
test("reports the FREE stock: jars reserved for subscriptions are not orderable", async () => {
|
||||
getProductsByIds.mockResolvedValue([
|
||||
{ id: "honey", category: "HONIG", isActive: true, inventory: { quantity: 5 } },
|
||||
{ id: "linde", category: "HONIG", isActive: true, inventory: { quantity: 2 } },
|
||||
]);
|
||||
getOffersByProductIds.mockResolvedValue(new Map());
|
||||
reservedJarsByProduct.mockResolvedValueOnce(new Map([["honey", 3], ["linde", 4]]));
|
||||
|
||||
const body = (await (await get("honey,linde")).json()) as { stock: Record<string, number> };
|
||||
|
||||
expect(body.stock).toEqual({ honey: 2, linde: 0 });
|
||||
expect(reservedJarsByProduct).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("marks a sponsorship as sold out when its offer is not available", async () => {
|
||||
getProductsByIds.mockResolvedValue([{ id: "pkg", category: "PATENSCHAFT", isActive: true, inventory: null }]);
|
||||
getOffersByProductIds.mockResolvedValue(new Map([["pkg", { isAvailable: false }]]));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProductsByIds } from "@/lib/data";
|
||||
import { getProductsByIds, withFreeStock } from "@/lib/data";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
|
||||
import { defaultLocale } from "@/i18n/config";
|
||||
@@ -41,7 +41,9 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ stock: {} });
|
||||
}
|
||||
|
||||
const products = await getProductsByIds(ids);
|
||||
// Freier Bestand: fuer Abos reservierte Glaeser sind nicht bestellbar
|
||||
// (der Checkout wuerde sie ablehnen), also auch hier nicht anzeigen.
|
||||
const products = await withFreeStock(await getProductsByIds(ids));
|
||||
// Patenschaften haben am Traegerprodukt keinen eigenen Bestand. Ob sie
|
||||
// bestellbar sind, entscheidet das Angebot (bei Paketen der freie
|
||||
// Honigbestand abzueglich Abo-Reservierung, siehe lib/sponsorship/offers.ts) —
|
||||
|
||||
@@ -5,7 +5,7 @@ import dynamic from "next/dynamic";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import Link from "next/link";
|
||||
import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartIsDigitalOnly } from "@/store/cart-store";
|
||||
import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartIsDigitalOnly, cartDiscountBase } from "@/store/cart-store";
|
||||
import { SponsorshipLineDetails } from "@/components/sponsorship-cart-line";
|
||||
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
|
||||
import { loginPathWithNext } from "@/lib/safe-next-path";
|
||||
@@ -124,7 +124,7 @@ export function CheckoutForm({
|
||||
const items = useCartStore((s) => s.items);
|
||||
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
||||
const subtotal = cartTotalCents(items);
|
||||
// Versandstaffel und Mengenrabatt zaehlen nur Glaeser — eine Urkunde ohne
|
||||
// Die Versandstaffel zaehlt nur Glaeser — eine Urkunde ohne
|
||||
// Paket wiegt nichts (wie physicalQuantity im Checkout-Server).
|
||||
const totalQuantity = cartPhysicalQuantity(items);
|
||||
// Nur Patenschaften ohne Honig: nichts zu versenden oder abzuholen. Dann
|
||||
@@ -200,9 +200,11 @@ export function CheckoutForm({
|
||||
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
||||
const shipping =
|
||||
digitalOnly || fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
|
||||
const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
|
||||
// Mengenrabatt nur auf Ware ausserhalb von Patenschaftspaketen (wie discountBase im Server).
|
||||
const discountable = cartDiscountBase(items);
|
||||
const activeBulkDiscount = discountable.quantity > 0 ? resolveBulkDiscount(bulkDiscountTiers, discountable.quantity) : null;
|
||||
const bulkDiscountCents = activeBulkDiscount
|
||||
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
|
||||
? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100)
|
||||
: 0;
|
||||
const total = subtotal - bulkDiscountCents + shipping;
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
import { certificateDirsOfCustomer, hasBlockingSubscription, removeCertificateDirs } from "@/lib/sponsorship/customer-deletion";
|
||||
import { discardPendingCheckouts } from "@/lib/sponsorship-subscription/pending";
|
||||
|
||||
/**
|
||||
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
|
||||
@@ -16,6 +18,10 @@ import { succeed } from "@/lib/flash";
|
||||
* Rechnung auch ohne verknuepftes Kundenkonto vollstaendig bleibt. Alle
|
||||
* anderen persoenlichen Daten (Adressen, Wunschliste, Bewertungen,
|
||||
* Newsletter, Backup-Codes) werden per Cascade mitgeloescht.
|
||||
*
|
||||
* Mit laufendem Patenschafts-Abo wird nicht geloescht (erst kuendigen), und
|
||||
* die Urkunden-Dateien der eigenen Patenschaften werden mit entfernt
|
||||
* (lib/sponsorship/customer-deletion.ts).
|
||||
*/
|
||||
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
@@ -42,10 +48,20 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
||||
return fail("/konto/einstellungen", "Passwort ist falsch.");
|
||||
}
|
||||
|
||||
if (await hasBlockingSubscription(customer.id)) {
|
||||
return fail(
|
||||
"/konto/einstellungen",
|
||||
"Bitte kündige zuerst dein Abo (Konto → Patenschaften). Dein Konto kannst du löschen, sobald das Abo beendet ist."
|
||||
);
|
||||
}
|
||||
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
||||
await sendMail({ to: customer.email, subject, html, text });
|
||||
|
||||
const certificateDirs = await certificateDirsOfCustomer(customer.id);
|
||||
await discardPendingCheckouts(customer.id);
|
||||
await prisma.customer.delete({ where: { id: customer.id } });
|
||||
await removeCertificateDirs(certificateDirs);
|
||||
await destroySession();
|
||||
return succeed("/konto", "Dein Konto wurde endgültig gelöscht.");
|
||||
}
|
||||
|
||||
@@ -86,6 +86,15 @@ function transferDialogLabels(t: AccountSponsorshipStrings): TransferDialogLabel
|
||||
};
|
||||
}
|
||||
|
||||
function RevokedPanel({ t }: { t: AccountSponsorshipStrings }) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center px-6 py-16 text-center">
|
||||
<p className="font-serif text-3xl text-heading">{t.detail.revokedTitle}</p>
|
||||
<p className="mt-2 max-w-[52ch] text-sm text-ink/70">{t.detail.revokedText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingPanel({ t }: { t: AccountSponsorshipStrings }) {
|
||||
return (
|
||||
<div role="status" className="mx-auto flex max-w-xl flex-col items-center px-6 py-16 text-center">
|
||||
@@ -111,7 +120,10 @@ export default async function SponsorshipDetailPage({ params }: Props) {
|
||||
if (!loaded) notFound();
|
||||
const { sponsorship, locale } = loaded;
|
||||
const t = getAccountSponsorshipStrings(locale);
|
||||
const isReady = sponsorship.revision !== null;
|
||||
// Widerrufen (Storno/Erstattung): nur noch Angaben und Hinweis — keine
|
||||
// 3D-Ansicht, keine Downloads, keine Uebertragung.
|
||||
const isRevoked = sponsorship.lifecycle === "REVOKED";
|
||||
const isReady = !isRevoked && sponsorship.revision !== null;
|
||||
const canTransfer = isTransferable(sponsorship);
|
||||
const openTransfer = canTransfer ? await getOpenTransfer(sponsorship.id) : null;
|
||||
const name = sponsorship.beeName || sponsorship.number;
|
||||
@@ -134,8 +146,8 @@ export default async function SponsorshipDetailPage({ params }: Props) {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<LifecycleBadge
|
||||
state={isReady ? sponsorship.lifecycle : "PENDING"}
|
||||
label={isReady ? t.lifecycle[sponsorship.lifecycle] : t.overview.pendingBadge}
|
||||
state={isReady || isRevoked ? sponsorship.lifecycle : "PENDING"}
|
||||
label={isReady || isRevoked ? t.lifecycle[sponsorship.lifecycle] : t.overview.pendingBadge}
|
||||
/>
|
||||
<SponsorshipActions
|
||||
sponsorshipId={sponsorship.id}
|
||||
@@ -165,7 +177,9 @@ export default async function SponsorshipDetailPage({ params }: Props) {
|
||||
<h2 id="stage-heading" className="sr-only">
|
||||
{t.detail.stageHeading}
|
||||
</h2>
|
||||
{isReady ? (
|
||||
{isRevoked ? (
|
||||
<RevokedPanel t={t} />
|
||||
) : isReady ? (
|
||||
<>
|
||||
<Stage sponsorship={sponsorship} t={t} />
|
||||
<p className="mx-auto mt-6 max-w-[60ch] text-center text-xs text-ink/60 [@media(pointer:coarse)]:hidden">
|
||||
@@ -196,7 +210,7 @@ export default async function SponsorshipDetailPage({ params }: Props) {
|
||||
<p className="mt-16 max-w-[70ch] text-xs leading-relaxed text-ink/60">{t.detail.symbolicNote}</p>
|
||||
</Container>
|
||||
|
||||
{!isReady ? <PendingRefresher /> : null}
|
||||
{!isReady && !isRevoked ? <PendingRefresher /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const STYLES: Record<SponsorshipLifecycle | "PENDING", string> = {
|
||||
CANCELLING: "border-ink/25 text-ink/75",
|
||||
EXPIRED: "border-ink/15 text-ink/65",
|
||||
ENDED: "border-ink/15 text-ink/65",
|
||||
REVOKED: "border-ink/25 text-ink/65 line-through decoration-ink/40",
|
||||
};
|
||||
|
||||
export function LifecycleBadge({ state, label }: { state: SponsorshipLifecycle | "PENDING"; label: string }) {
|
||||
|
||||
@@ -22,7 +22,8 @@ function collectMedals(
|
||||
t: AccountSponsorshipStrings
|
||||
): ShelfMedal[] {
|
||||
const medals: ShelfMedal[] = sponsorships
|
||||
.filter((sponsorship) => sponsorship.revision !== null)
|
||||
// Widerrufene Patenschaften haben keine Medaille mehr (die Datei-Routen antworten 404).
|
||||
.filter((sponsorship) => sponsorship.revision !== null && sponsorship.lifecycle !== "REVOKED")
|
||||
.map((sponsorship) => ({
|
||||
key: sponsorship.id,
|
||||
imageUrl: `${sponsorshipFilePath(sponsorship.id, "medaille.png")}?groesse=256`,
|
||||
|
||||
@@ -21,6 +21,16 @@ function PendingSeal() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Ruhige, durchgestrichene Wabe fuer eine widerrufene Patenschaft (keine Medaille mehr). */
|
||||
function RevokedSeal() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 26" aria-hidden="true" className="h-full w-full text-ink/35">
|
||||
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="none" stroke="currentColor" strokeWidth="0.7" strokeDasharray="2 1.6" />
|
||||
<path d="M5 21 19 5" stroke="currentColor" strokeWidth="0.7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Patenschaft in der Uebersicht. Die ganze Karte ist der Link zur
|
||||
* Detailseite; "Abo verwalten" steht darunter ausserhalb des Links, damit
|
||||
@@ -37,7 +47,8 @@ export function SponsorshipRow({
|
||||
t: AccountSponsorshipStrings;
|
||||
index: number;
|
||||
}) {
|
||||
const isPending = sponsorship.revision === null;
|
||||
const isRevoked = sponsorship.lifecycle === "REVOKED";
|
||||
const isPending = !isRevoked && sponsorship.revision === null;
|
||||
const name = sponsorship.beeName || sponsorship.number;
|
||||
return (
|
||||
<li
|
||||
@@ -50,7 +61,9 @@ export function SponsorshipRow({
|
||||
className="group relative grid grid-cols-[4.5rem_minmax(0,1fr)] items-center gap-x-5 gap-y-3 border border-ink/10 bg-background p-5 transition-[transform,border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-0.5 hover:border-heading/50 hover:shadow-[0_22px_40px_-26px_rgba(36,53,43,0.4)] active:scale-[0.995] sm:grid-cols-[5.5rem_minmax(0,1fr)_auto] sm:p-6"
|
||||
>
|
||||
<span className="relative block h-[4.5rem] w-[4.5rem] sm:h-[5.5rem] sm:w-[5.5rem]">
|
||||
{isPending ? (
|
||||
{isRevoked ? (
|
||||
<RevokedSeal />
|
||||
) : isPending ? (
|
||||
<PendingSeal />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- geschuetzte Besitzer-Route, kein Bildoptimierer
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* Alle Datei-Routen einer Patenschaft (PDF, Textur, Rueckseite, Medaille,
|
||||
* ZIP) laden sie ueber loadOwnedSponsorship. Eine widerrufene Patenschaft
|
||||
* (Storno/Erstattung) liefert dort nichts mehr — 404 wie eine fremde.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const m = vi.hoisted(() => ({ getCurrentCustomer: vi.fn(), getOwnedSponsorship: vi.fn() }));
|
||||
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: m.getCurrentCustomer }));
|
||||
vi.mock("@/i18n/locale", () => ({ getLocale: async () => "de" }));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/sponsorship/access", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/sponsorship/access")>()),
|
||||
getOwnedSponsorship: m.getOwnedSponsorship,
|
||||
}));
|
||||
|
||||
const { loadOwnedSponsorship } = await import("./owner");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m.getCurrentCustomer.mockResolvedValue({ id: "cust_1" });
|
||||
});
|
||||
|
||||
describe("loadOwnedSponsorship", () => {
|
||||
test("liefert die eigene Patenschaft", async () => {
|
||||
m.getOwnedSponsorship.mockResolvedValue({ id: "sp_1", status: "BEREIT" });
|
||||
|
||||
expect((await loadOwnedSponsorship("sp_1"))?.sponsorship.id).toBe("sp_1");
|
||||
});
|
||||
|
||||
test("eine widerrufene Patenschaft gibt es fuer die Datei-Routen nicht", async () => {
|
||||
m.getOwnedSponsorship.mockResolvedValue({ id: "sp_1", status: "WIDERRUFEN" });
|
||||
|
||||
expect(await loadOwnedSponsorship("sp_1")).toBeNull();
|
||||
});
|
||||
|
||||
test("ohne Anmeldung nichts", async () => {
|
||||
m.getCurrentCustomer.mockResolvedValue(null);
|
||||
|
||||
expect(await loadOwnedSponsorship("sp_1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getOwnedSponsorship, type OwnedSponsorship } from "@/lib/sponsorship/access";
|
||||
import { getOwnedSponsorship, isRevoked, type OwnedSponsorship } from "@/lib/sponsorship/access";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
@@ -14,14 +14,15 @@ export interface OwnedContext {
|
||||
/**
|
||||
* Anmeldung und Besitz in einem Schritt. null heisst fuer jede Route: 404 —
|
||||
* auch ohne Anmeldung, damit eine Route nicht verraet, ob es die Patenschaft
|
||||
* gibt.
|
||||
* gibt. Widerrufene Patenschaften (Storno/Erstattung) liefern ebenfalls
|
||||
* nichts mehr: wer das Geld zurueck hat, bekommt keine Dateien.
|
||||
*/
|
||||
export async function loadOwnedSponsorship(id: string): Promise<OwnedContext | null> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) return null;
|
||||
const locale = await getLocale();
|
||||
const sponsorship = await getOwnedSponsorship(customer.id, id, locale);
|
||||
return sponsorship ? { customer, sponsorship, locale } : null;
|
||||
return sponsorship && !isRevoked(sponsorship) ? { customer, sponsorship, locale } : null;
|
||||
}
|
||||
|
||||
// Rendern (Textur, Medaille, ZIP) kostet spuerbar CPU. Zwischengespeicherte
|
||||
|
||||
@@ -30,7 +30,8 @@ export default async function KontoPatenschaftPage() {
|
||||
listOwnedSponsorships(customer.id, locale),
|
||||
getCustomerLoyalty(customer.id, locale),
|
||||
]);
|
||||
const hasPending = sponsorships.some((sponsorship) => sponsorship.revision === null);
|
||||
// Widerrufene ohne Urkunde werden nie mehr fertig — kein Dauer-Neuladen.
|
||||
const hasPending = sponsorships.some((sponsorship) => sponsorship.revision === null && sponsorship.lifecycle !== "REVOKED");
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Container, FormPanel, LinkButton, Button } from "@/components/ui";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { noIndexPrivate } from "@/lib/seo";
|
||||
import { formatCertificateDate } from "@/lib/certificate/text";
|
||||
import { previewClaim, transferClaimPath, isWellFormedTransferToken } from "@/lib/sponsorship/transfer";
|
||||
@@ -13,6 +14,12 @@ import { claimSponsorshipAction } from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Jeder Aufruf schlaegt das Token in der Datenbank nach. Je Adresse begrenzt,
|
||||
// damit sich Links nicht durchprobieren lassen; die Seite sieht beim Limit
|
||||
// genauso aus wie bei einem ungueltigen Link (kein Hinweis fuer Angreifer).
|
||||
const LOOKUPS_PER_WINDOW = 30;
|
||||
const LOOKUP_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
type Props = { params: Promise<{ token: string }> };
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
@@ -59,6 +66,7 @@ export default async function ClaimSponsorshipPage({ params }: Props) {
|
||||
</Panel>
|
||||
);
|
||||
if (!isWellFormedTransferToken(token)) return invalid;
|
||||
if (!rateLimit(`transfer-claim-preview:${await getClientIp()}`, LOOKUPS_PER_WINDOW, LOOKUP_WINDOW_MS)) return invalid;
|
||||
|
||||
// Ohne Konto zaehlt nur "gueltig oder nicht"; "eigene Patenschaft" kann es
|
||||
// hier noch nicht geben.
|
||||
|
||||
@@ -14,6 +14,38 @@ import type { PreviewErrorCode } from "@/i18n/sponsorship/shop";
|
||||
*/
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
/** Der Server meldet 503, wenn gerade zu viele Vorschauen gleichzeitig laufen. */
|
||||
const BUSY_RETRIES = 3;
|
||||
const DEFAULT_RETRY_AFTER_MS = 2000;
|
||||
|
||||
function retryAfterMs(response: Response): number {
|
||||
const seconds = Number(response.headers.get("retry-after"));
|
||||
return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds, 10) * 1000 : DEFAULT_RETRY_AFTER_MS;
|
||||
}
|
||||
|
||||
function wait(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Schickt die Anfrage und wiederholt sie bei "Server voll" (503) nach Retry-After. */
|
||||
async function fetchPreview(body: string, signal: AbortSignal): Promise<Response> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await fetch("/api/patenschaft/vorschau", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
if (response.status !== 503 || attempt >= BUSY_RETRIES) return response;
|
||||
await wait(retryAfterMs(response), signal);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreviewServerError {
|
||||
code: PreviewErrorCode;
|
||||
@@ -52,12 +84,7 @@ export function useCertificatePreview(request: PreviewRequest | null): Certifica
|
||||
const timer = setTimeout(async () => {
|
||||
setState((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const response = await fetch("/api/patenschaft/vorschau", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: key,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await fetchPreview(key, controller.signal);
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
const error: PreviewServerError = isPreviewErrorBody(body)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { getProductsByIds, type ProductWithRelations } from "@/lib/data";
|
||||
import { getProductsByIds, withFreeStock, type ProductWithRelations } from "@/lib/data";
|
||||
|
||||
/**
|
||||
* Loest lokal im Browser gespeicherte Produkt-IDs ("zuletzt angesehen") in
|
||||
@@ -12,7 +12,8 @@ import { getProductsByIds, type ProductWithRelations } from "@/lib/data";
|
||||
export async function getRecentlyViewedProducts(ids: string[]): Promise<ProductWithRelations[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const products = await getProductsByIds(ids);
|
||||
// Freier Bestand (Lager minus Abo-Reservierung), wie ueberall im Shop.
|
||||
const products = await withFreeStock(await getProductsByIds(ids));
|
||||
const byId = new Map(products.map((product) => [product.id, product]));
|
||||
|
||||
return ids
|
||||
|
||||
@@ -86,7 +86,7 @@ export function CancellationForm({ s, turnstileSiteKey }: { s: AboStrings["publi
|
||||
<br />
|
||||
<span className="tabular-nums text-heading">{result.receivedAt}</span>
|
||||
</p>
|
||||
<p className="mt-5 text-sm leading-relaxed text-ink/80">{result.confirmationSent ? s.successMailSent : s.successMailFailed}</p>
|
||||
<p className="mt-5 text-sm leading-relaxed text-ink/80">{s.successMailNote}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { undoCancellation } from "@/lib/sponsorship-subscription/cancellation-undo";
|
||||
import { parseUndoParams, resolveUndoAccess } from "./undo-access";
|
||||
|
||||
const UNDO_ATTEMPTS_PER_WINDOW = 10;
|
||||
const UNDO_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Ergebnis als Suchparameter — die Seite zeigt den passenden Text. */
|
||||
export type UndoOutcome = "ok" | "ungueltig" | "nicht-gekuendigt" | "abgelaufen" | "fehler";
|
||||
|
||||
/**
|
||||
* "Kündigung zurücknehmen": prueft erneut Besitz oder signierten Link
|
||||
* (die Seite ist keine Sicherheitsgrenze) und nimmt die Kuendigung bei
|
||||
* Stripe zurueck. Server-Actions pruefen die Herkunft selbst (kein fremder
|
||||
* Cross-Site-POST).
|
||||
*/
|
||||
export async function undoCancellationAction(formData: FormData): Promise<void> {
|
||||
const params = parseUndoParams({ abo: formData.get("abo"), bis: formData.get("bis"), sig: formData.get("sig") });
|
||||
const back = (outcome: UndoOutcome): never => {
|
||||
const query = new URLSearchParams({ ergebnis: outcome });
|
||||
if (params) {
|
||||
query.set("abo", params.subscriptionId);
|
||||
if (params.cancelAtSeconds !== null) query.set("bis", String(params.cancelAtSeconds));
|
||||
if (params.signature) query.set("sig", params.signature);
|
||||
}
|
||||
redirect(`/vertraege-kuendigen/zuruecknehmen?${query.toString()}`);
|
||||
};
|
||||
|
||||
if (!params) return back("ungueltig");
|
||||
if (!rateLimit(`cancellation-undo:${await getClientIp()}`, UNDO_ATTEMPTS_PER_WINDOW, UNDO_WINDOW_MS)) return back("fehler");
|
||||
|
||||
const now = new Date();
|
||||
const access = await resolveUndoAccess(params, now);
|
||||
if (!access.ok) return back("ungueltig");
|
||||
|
||||
let outcome: UndoOutcome;
|
||||
try {
|
||||
const result = await undoCancellation({
|
||||
subscriptionId: access.subscriptionId,
|
||||
expectedCancelAt: access.expectedCancelAt,
|
||||
ownerId: access.ownerId,
|
||||
now,
|
||||
});
|
||||
outcome = result.ok ? "ok" : result.reason === "expired" ? "abgelaufen" : result.reason === "not_found" ? "ungueltig" : "nicht-gekuendigt";
|
||||
} catch (error: unknown) {
|
||||
console.error(`[abo] Kuendigung von Abo ${access.subscriptionId} konnte nicht zurueckgenommen werden:`, error);
|
||||
outcome = "fehler";
|
||||
}
|
||||
return back(outcome);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Container, FormPanel, LinkButton, Button } from "@/components/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { noIndexPrivate } from "@/lib/seo";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getAboStrings, type AboStrings } from "@/i18n/sponsorship/abo";
|
||||
import { undoCancellationAction, type UndoOutcome } from "./actions";
|
||||
import { parseUndoParams, resolveUndoAccess } from "./undo-access";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getAboStrings(await getLocale()).undoPage;
|
||||
// Der Link ist ein Geheimnis: nie in den Index, nie als Referrer weitergeben.
|
||||
return { title: t.metaTitle, robots: noIndexPrivate, referrer: "same-origin" };
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-xl">
|
||||
<FormPanel>
|
||||
<h1 className="font-serif text-4xl leading-tight text-heading [text-wrap:balance]">{title}</h1>
|
||||
{children}
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OUTCOMES: readonly UndoOutcome[] = ["ok", "ungueltig", "nicht-gekuendigt", "abgelaufen", "fehler"];
|
||||
|
||||
function outcomeText(outcome: UndoOutcome, t: AboStrings["undoPage"]): { title: string; text: string } {
|
||||
switch (outcome) {
|
||||
case "ok":
|
||||
return { title: t.successTitle, text: t.successText };
|
||||
case "nicht-gekuendigt":
|
||||
return { title: t.invalidTitle, text: t.notCancelledText };
|
||||
case "abgelaufen":
|
||||
return { title: t.invalidTitle, text: t.expiredText };
|
||||
case "fehler":
|
||||
return { title: t.title, text: t.errorText };
|
||||
default:
|
||||
return { title: t.invalidTitle, text: t.invalidText };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Kündigung zurücknehmen" aus der Bestaetigungsmail der oeffentlichen
|
||||
* Kuendigung. Zugang: angemeldeter Besitzer oder signierter Link
|
||||
* (undo-access.ts). Die Seite aendert beim Aufruf nichts — erst der Knopf,
|
||||
* damit Mail-Scanner, die Links vorab oeffnen, keine Kuendigung
|
||||
* zuruecknehmen.
|
||||
*/
|
||||
export default async function UndoCancellationPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const raw = await searchParams;
|
||||
const locale = await getLocale();
|
||||
const t = getAboStrings(locale).undoPage;
|
||||
const accountLink = (
|
||||
<LinkButton href="/konto/patenschaft" variant="secondary" className="mt-8">
|
||||
{t.toAccount}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
const result = typeof raw.ergebnis === "string" && (OUTCOMES as readonly string[]).includes(raw.ergebnis) ? (raw.ergebnis as UndoOutcome) : null;
|
||||
if (result) {
|
||||
const { title, text } = outcomeText(result, t);
|
||||
return (
|
||||
<Panel title={title}>
|
||||
<p role="status" className="mt-4 text-ink/75">
|
||||
{text}
|
||||
</p>
|
||||
{accountLink}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
const params = parseUndoParams(raw);
|
||||
const access = params ? await resolveUndoAccess(params, new Date()) : { ok: false as const };
|
||||
const subscription =
|
||||
access.ok && params
|
||||
? await prisma.sponsorshipSubscription.findUnique({ where: { id: access.subscriptionId }, select: { status: true, cancelAt: true } })
|
||||
: null;
|
||||
const cancelAt = (access.ok ? access.expectedCancelAt : undefined) ?? subscription?.cancelAt ?? null;
|
||||
if (!params || !access.ok || !subscription || subscription.status !== "GEKUENDIGT" || !cancelAt) {
|
||||
return (
|
||||
<Panel title={t.invalidTitle}>
|
||||
<p className="mt-4 text-ink/75">{t.invalidText}</p>
|
||||
{accountLink}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={t.title}>
|
||||
<p className="mt-4 text-ink/75">{t.intro(formatDate(cancelAt, locale))}</p>
|
||||
<form action={undoCancellationAction} className="mt-8">
|
||||
<input type="hidden" name="abo" value={params.subscriptionId} />
|
||||
{params.cancelAtSeconds !== null ? <input type="hidden" name="bis" value={String(params.cancelAtSeconds)} /> : null}
|
||||
{params.signature ? <input type="hidden" name="sig" value={params.signature} /> : null}
|
||||
<Button type="submit" className="w-full sm:w-auto">
|
||||
{t.button}
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment node
|
||||
/** Zugang zur Ruecknahme einer Kuendigung: signierter Link ODER angemeldeter Besitzer. */
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
getCurrentCustomer: vi.fn(),
|
||||
db: { sponsorshipSubscription: { findFirst: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: m.getCurrentCustomer }));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
|
||||
vi.mock("@/lib/stripe", () => ({ getStripeClient: () => ({}) }));
|
||||
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
|
||||
|
||||
process.env.SESSION_SECRET = "test-secret-mit-genug-laenge-fuer-hmac-0123456789";
|
||||
|
||||
const { signCancellationUndo } = await import("@/lib/sponsorship-subscription/cancellation-undo");
|
||||
const { parseUndoParams, resolveUndoAccess } = await import("./undo-access");
|
||||
|
||||
const NOW = new Date("2026-10-16T12:00:00.000Z");
|
||||
const CANCEL_AT = new Date("2026-11-16T10:00:00.000Z");
|
||||
const SECONDS = CANCEL_AT.getTime() / 1000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m.getCurrentCustomer.mockResolvedValue(null);
|
||||
m.db.sponsorshipSubscription.findFirst.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("parseUndoParams", () => {
|
||||
test("liest Abo, Termin und Signatur und weist Unsinn ab", () => {
|
||||
expect(parseUndoParams({ abo: "cm1abcdefghijk", bis: String(SECONDS), sig: "abc" })).toEqual({
|
||||
subscriptionId: "cm1abcdefghijk",
|
||||
cancelAtSeconds: SECONDS,
|
||||
signature: "abc",
|
||||
});
|
||||
expect(parseUndoParams({ abo: "../x", bis: "1", sig: "abc" })).toBeNull();
|
||||
expect(parseUndoParams({ abo: "cm1abcdefghijk", bis: "nope" })).toEqual({ subscriptionId: "cm1abcdefghijk", cancelAtSeconds: null, signature: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUndoAccess", () => {
|
||||
test("gueltiger Link genuegt, ohne Anmeldung", async () => {
|
||||
const signature = signCancellationUndo("cm1abcdefghijk", CANCEL_AT);
|
||||
|
||||
const access = await resolveUndoAccess({ subscriptionId: "cm1abcdefghijk", cancelAtSeconds: SECONDS, signature }, NOW);
|
||||
|
||||
expect(access).toEqual({ ok: true, subscriptionId: "cm1abcdefghijk", expectedCancelAt: CANCEL_AT });
|
||||
expect(m.getCurrentCustomer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falsche Signatur ohne Anmeldung: kein Zugang", async () => {
|
||||
expect(await resolveUndoAccess({ subscriptionId: "cm1abcdefghijk", cancelAtSeconds: SECONDS, signature: "falsch" }, NOW)).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
test("angemeldet: nur das eigene Abo", async () => {
|
||||
m.getCurrentCustomer.mockResolvedValue({ id: "cust_1" });
|
||||
m.db.sponsorshipSubscription.findFirst.mockResolvedValue({ id: "cm1abcdefghijk" });
|
||||
|
||||
expect(await resolveUndoAccess({ subscriptionId: "cm1abcdefghijk", cancelAtSeconds: null, signature: "" }, NOW)).toEqual({
|
||||
ok: true,
|
||||
subscriptionId: "cm1abcdefghijk",
|
||||
ownerId: "cust_1",
|
||||
});
|
||||
expect(m.db.sponsorshipSubscription.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "cm1abcdefghijk", customerId: "cust_1" } })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { verifyCancellationUndo } from "@/lib/sponsorship-subscription/cancellation-undo";
|
||||
|
||||
/**
|
||||
* Wer darf eine Kuendigung zuruecknehmen? Der angemeldete Besitzer des Abos
|
||||
* ODER wer den signierten Link aus der Bestaetigungsmail hat (HMAC ueber Abo
|
||||
* und Kuendigungstermin, gueltig bis zum Termin). Gemeinsam fuer Seite und
|
||||
* Server-Action, damit beide dieselbe Regel pruefen.
|
||||
*/
|
||||
|
||||
export interface UndoLinkParams {
|
||||
subscriptionId: string;
|
||||
cancelAtSeconds: number | null;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export type UndoAccess =
|
||||
| { ok: true; subscriptionId: string; expectedCancelAt?: Date; ownerId?: string }
|
||||
| { ok: false };
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9]{10,40}$/i;
|
||||
|
||||
export function parseUndoParams(raw: { abo?: unknown; bis?: unknown; sig?: unknown }): UndoLinkParams | null {
|
||||
const subscriptionId = typeof raw.abo === "string" ? raw.abo : "";
|
||||
if (!ID_PATTERN.test(subscriptionId)) return null;
|
||||
const bis = typeof raw.bis === "string" && /^\d{1,12}$/.test(raw.bis) ? Number(raw.bis) : null;
|
||||
const signature = typeof raw.sig === "string" ? raw.sig.slice(0, 200) : "";
|
||||
return { subscriptionId, cancelAtSeconds: bis, signature };
|
||||
}
|
||||
|
||||
export async function resolveUndoAccess(params: UndoLinkParams, now: Date): Promise<UndoAccess> {
|
||||
if (
|
||||
params.cancelAtSeconds !== null &&
|
||||
params.signature &&
|
||||
verifyCancellationUndo({ subscriptionId: params.subscriptionId, cancelAtSeconds: params.cancelAtSeconds, signature: params.signature, now })
|
||||
) {
|
||||
return { ok: true, subscriptionId: params.subscriptionId, expectedCancelAt: new Date(params.cancelAtSeconds * 1000) };
|
||||
}
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) return { ok: false };
|
||||
const owned = await prisma.sponsorshipSubscription.findFirst({
|
||||
where: { id: params.subscriptionId, customerId: customer.id },
|
||||
select: { id: true },
|
||||
});
|
||||
return owned ? { ok: true, subscriptionId: owned.id, ownerId: customer.id } : { ok: false };
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { TrustBadges } from "@/components/trust-badges";
|
||||
import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||
import { ProductCard } from "@/components/product-card";
|
||||
import { useCartStore, cartTotalCents, cartPhysicalQuantity } from "@/store/cart-store";
|
||||
import { useCartStore, cartTotalCents, cartPhysicalQuantity, cartDiscountBase } from "@/store/cart-store";
|
||||
import { SponsorshipLineDetails, SponsorshipThumb } from "@/components/sponsorship-cart-line";
|
||||
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
|
||||
import WarenkorbLoading from "./loading";
|
||||
@@ -52,11 +52,14 @@ export function WarenkorbContent({
|
||||
// Live-Vorschau des serverseitig in api/checkout/route.ts automatisch
|
||||
// angewendeten Mengenrabatts — Hoehe/Menge kommen aus den im Admin-Bereich
|
||||
// (Gutscheine) gepflegten DiscountCode-Zeilen, nicht mehr aus Konstanten.
|
||||
const activeBulkDiscount = !hasPhysical ? null : resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
|
||||
// Basis ohne Patenschaftspakete, wie im Server (discountBase).
|
||||
const discountable = cartDiscountBase(items);
|
||||
const hasDiscountable = discountable.quantity > 0;
|
||||
const activeBulkDiscount = !hasDiscountable ? null : resolveBulkDiscount(bulkDiscountTiers, discountable.quantity);
|
||||
const bulkDiscountCents = activeBulkDiscount
|
||||
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
|
||||
? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100)
|
||||
: 0;
|
||||
const nextBulkDiscount = !hasPhysical ? null : getNextBulkDiscountTier(bulkDiscountTiers, totalQuantity);
|
||||
const nextBulkDiscount = !hasDiscountable ? null : getNextBulkDiscountTier(bulkDiscountTiers, discountable.quantity);
|
||||
|
||||
const cartProductIds = new Set(items.map((item) => item.productId));
|
||||
const crossSellProducts = availableProducts
|
||||
|
||||
@@ -92,12 +92,26 @@ export interface AboStrings {
|
||||
submitting: string;
|
||||
successHeading: string;
|
||||
receivedAtLabel: string;
|
||||
successMailSent: string;
|
||||
successMailFailed: string;
|
||||
/** Fuer passende und unpassende Angaben gleich (kein Hinweis, ob eine Mail rausging). */
|
||||
successMailNote: string;
|
||||
errorMessage: string;
|
||||
rateLimited: string;
|
||||
invalid: string;
|
||||
};
|
||||
undoPage: {
|
||||
metaTitle: string;
|
||||
title: string;
|
||||
intro: (end: string) => string;
|
||||
button: string;
|
||||
successTitle: string;
|
||||
successText: string;
|
||||
invalidTitle: string;
|
||||
invalidText: string;
|
||||
notCancelledText: string;
|
||||
expiredText: string;
|
||||
errorText: string;
|
||||
toAccount: string;
|
||||
};
|
||||
}
|
||||
|
||||
const de: AboStrings = {
|
||||
@@ -193,13 +207,26 @@ const de: AboStrings = {
|
||||
submitting: "Wird gesendet …",
|
||||
successHeading: "Deine Kündigung ist eingegangen",
|
||||
receivedAtLabel: "Eingegangen am",
|
||||
successMailSent: "Wir haben dir eine Eingangsbestätigung per Mail geschickt.",
|
||||
successMailFailed:
|
||||
"Die Bestätigungsmail konnte gerade nicht versendet werden. Deine Kündigung ist trotzdem eingegangen; bitte bewahre diese Seite als Nachweis auf.",
|
||||
successMailNote:
|
||||
"Die Eingangsbestätigung schicken wir an die E-Mail-Adresse deines Kundenkontos. Bitte bewahre diese Seite als Nachweis auf (Ausdruck oder Bildschirmfoto).",
|
||||
errorMessage: "Deine Kündigung konnte nicht gesendet werden. Bitte versuche es erneut oder schreib uns eine Mail.",
|
||||
rateLimited: "Zu viele Anfragen. Bitte versuche es später erneut oder schreib uns eine Mail.",
|
||||
invalid: "Bitte überprüfe deine Angaben.",
|
||||
},
|
||||
undoPage: {
|
||||
metaTitle: "Kündigung zurücknehmen",
|
||||
title: "Kündigung zurücknehmen",
|
||||
intro: (end) => `Dein Abo ist zum ${end} gekündigt. Wenn du die Kündigung zurücknimmst, läuft es einfach weiter wie bisher.`,
|
||||
button: "Kündigung zurücknehmen",
|
||||
successTitle: "Dein Abo läuft weiter",
|
||||
successText: "Die Kündigung ist zurückgenommen. Es ändert sich nichts an deinem Abo.",
|
||||
invalidTitle: "Dieser Link gilt nicht (mehr)",
|
||||
invalidText: "Der Link ist abgelaufen, unvollständig oder die Kündigung wurde schon zurückgenommen. Im Kundenkonto siehst du den aktuellen Stand.",
|
||||
notCancelledText: "Für dieses Abo ist keine Kündigung (mehr) vorgemerkt.",
|
||||
expiredText: "Der Kündigungstermin ist schon erreicht; zurücknehmen lässt sich die Kündigung nicht mehr.",
|
||||
errorText: "Das hat gerade nicht geklappt. Bitte versuche es gleich noch einmal oder schreib uns eine Mail.",
|
||||
toAccount: "Zum Kundenkonto",
|
||||
},
|
||||
};
|
||||
|
||||
const en: AboStrings = {
|
||||
@@ -293,13 +320,26 @@ const en: AboStrings = {
|
||||
submitting: "Sending …",
|
||||
successHeading: "We have received your cancellation",
|
||||
receivedAtLabel: "Received on",
|
||||
successMailSent: "We have sent you a confirmation of receipt by email.",
|
||||
successMailFailed:
|
||||
"The confirmation email could not be sent right now. Your cancellation has still been received; please keep this page as proof.",
|
||||
successMailNote:
|
||||
"We send the confirmation of receipt to the email address of your customer account. Please keep this page as proof (print or screenshot).",
|
||||
errorMessage: "Your cancellation could not be sent. Please try again or email us.",
|
||||
rateLimited: "Too many requests. Please try again later or email us.",
|
||||
invalid: "Please check your details.",
|
||||
},
|
||||
undoPage: {
|
||||
metaTitle: "Withdraw cancellation",
|
||||
title: "Withdraw cancellation",
|
||||
intro: (end) => `Your subscription is cancelled as of ${end}. If you withdraw the cancellation, it simply continues as before.`,
|
||||
button: "Withdraw cancellation",
|
||||
successTitle: "Your subscription continues",
|
||||
successText: "The cancellation has been withdrawn. Nothing changes about your subscription.",
|
||||
invalidTitle: "This link is not (or no longer) valid",
|
||||
invalidText: "The link has expired, is incomplete or the cancellation has already been withdrawn. Your customer account shows the current status.",
|
||||
notCancelledText: "No cancellation is (any longer) scheduled for this subscription.",
|
||||
expiredText: "The cancellation date has already been reached; the cancellation can no longer be withdrawn.",
|
||||
errorText: "That did not work just now. Please try again shortly or email us.",
|
||||
toAccount: "To your account",
|
||||
},
|
||||
};
|
||||
|
||||
export function getAboStrings(locale: Locale): AboStrings {
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface AccountSponsorshipStrings {
|
||||
};
|
||||
pendingTitle: string;
|
||||
pendingText: string;
|
||||
revokedTitle: string;
|
||||
revokedText: string;
|
||||
detailsHeading: string;
|
||||
fields: {
|
||||
number: string;
|
||||
@@ -192,6 +194,7 @@ const de: AccountSponsorshipStrings = {
|
||||
EXPIRED: "Abgelaufen",
|
||||
CANCELLING: "Gekündigt",
|
||||
ENDED: "Beendet",
|
||||
REVOKED: "Widerrufen",
|
||||
},
|
||||
term: {
|
||||
range: (start, end) => `${start} bis ${end}`,
|
||||
@@ -231,6 +234,9 @@ const de: AccountSponsorshipStrings = {
|
||||
},
|
||||
pendingTitle: "Deine Urkunde wird gerade erstellt",
|
||||
pendingText: "Sobald sie fertig ist, erscheinen hier Urkunde, Medaille und Downloads. Diese Seite aktualisiert sich von selbst.",
|
||||
revokedTitle: "Diese Patenschaft wurde widerrufen",
|
||||
revokedText:
|
||||
"Die Bestellung wurde storniert oder erstattet. Urkunde, Medaille und Downloads stehen deshalb nicht mehr zur Verfügung. Bei Fragen schreib uns gern.",
|
||||
detailsHeading: "Angaben",
|
||||
fields: {
|
||||
number: "Patenschaftsnummer",
|
||||
@@ -365,6 +371,7 @@ const en: AccountSponsorshipStrings = {
|
||||
EXPIRED: "Expired",
|
||||
CANCELLING: "Cancelled",
|
||||
ENDED: "Ended",
|
||||
REVOKED: "Revoked",
|
||||
},
|
||||
term: {
|
||||
range: (start, end) => `${start} to ${end}`,
|
||||
@@ -402,6 +409,9 @@ const en: AccountSponsorshipStrings = {
|
||||
},
|
||||
pendingTitle: "Your certificate is being created",
|
||||
pendingText: "As soon as it is ready, the certificate, medal and downloads appear here. This page refreshes on its own.",
|
||||
revokedTitle: "This sponsorship has been revoked",
|
||||
revokedText:
|
||||
"The order was cancelled or refunded, so the certificate, medal and downloads are no longer available. Feel free to contact us with any questions.",
|
||||
detailsHeading: "Details",
|
||||
fields: {
|
||||
number: "Sponsorship number",
|
||||
|
||||
@@ -34,7 +34,7 @@ const de = {
|
||||
kinds: { EINMALIG: "Einmalig", ABO: "Abo" },
|
||||
cycles: { VIERTELJAEHRLICH: "Vierteljährlich", ERNTE: "Nach der Ernte" },
|
||||
medals: { BRONZE: "Bronze", SILBER: "Silber", GOLD: "Gold", ABO: "Abo-Prägung" },
|
||||
statuses: { AUSSTEHEND: "Ausstehend", FEHLER: "Fehler", BEREIT: "Bereit", GEDRUCKT: "Gedruckt" },
|
||||
statuses: { AUSSTEHEND: "Ausstehend", FEHLER: "Fehler", BEREIT: "Bereit", GEDRUCKT: "Gedruckt", WIDERRUFEN: "Widerrufen" },
|
||||
subscriptionStatuses: {
|
||||
AUSSTEHEND: "Checkout offen",
|
||||
AKTIV: "Aktiv",
|
||||
@@ -110,6 +110,8 @@ const de = {
|
||||
notePrintPhysical: "Nur physische Pakete werden gedruckt.",
|
||||
notePrintReady: "Druckbar, sobald die Urkunde bereit ist.",
|
||||
noteMailReady: "Erneuter Versand erst nach der ersten Zustellung.",
|
||||
noteRevoked:
|
||||
"Widerrufen: die Bestellung wurde storniert oder erstattet. Die Kundschaft sieht keine Dateien mehr, Übertragung und Ausstellung sind gesperrt.",
|
||||
revisionsHeading: "Revisionen",
|
||||
colRevision: "Rev.",
|
||||
colCreatedAt: "Erstellt",
|
||||
@@ -324,6 +326,10 @@ const de = {
|
||||
coverage: (n: number) => `${n} ${plural(n, "Honigprodukt deckt", "Honigprodukte decken")} keine volle Abo-Lieferung mehr`,
|
||||
delinquent: (n: number) => `${n} ${plural(n, "Abo ist", "Abos sind")} im Verzug`,
|
||||
},
|
||||
order: {
|
||||
revokedNote: (numbers: string) =>
|
||||
`Patenschaft widerrufen (${numbers}): die Bestellung wurde storniert oder erstattet. Ein Wiederöffnen stellt die Patenschaft NICHT automatisch wieder her.`,
|
||||
},
|
||||
};
|
||||
|
||||
export type SponsorshipAdminStrings = typeof de;
|
||||
@@ -352,7 +358,7 @@ const en: SponsorshipAdminStrings = {
|
||||
kinds: { EINMALIG: "One-time", ABO: "Subscription" },
|
||||
cycles: { VIERTELJAEHRLICH: "Quarterly", ERNTE: "After the harvest" },
|
||||
medals: { BRONZE: "Bronze", SILBER: "Silver", GOLD: "Gold", ABO: "Subscription stamp" },
|
||||
statuses: { AUSSTEHEND: "Pending", FEHLER: "Error", BEREIT: "Ready", GEDRUCKT: "Printed" },
|
||||
statuses: { AUSSTEHEND: "Pending", FEHLER: "Error", BEREIT: "Ready", GEDRUCKT: "Printed", WIDERRUFEN: "Revoked" },
|
||||
subscriptionStatuses: {
|
||||
AUSSTEHEND: "Checkout open",
|
||||
AKTIV: "Active",
|
||||
@@ -428,6 +434,8 @@ const en: SponsorshipAdminStrings = {
|
||||
notePrintPhysical: "Only physical packages are printed.",
|
||||
notePrintReady: "Printable once the certificate is ready.",
|
||||
noteMailReady: "Resending is possible after the first delivery.",
|
||||
noteRevoked:
|
||||
"Revoked: the order was cancelled or refunded. The customer no longer sees any files; transfer and issuance are blocked.",
|
||||
revisionsHeading: "Revisions",
|
||||
colRevision: "Rev.",
|
||||
colCreatedAt: "Created",
|
||||
@@ -640,6 +648,10 @@ const en: SponsorshipAdminStrings = {
|
||||
coverage: (n) => `${n} ${plural(n, "honey product no longer covers", "honey products no longer cover")} a full subscription delivery`,
|
||||
delinquent: (n) => `${n} ${plural(n, "subscription is", "subscriptions are")} delinquent`,
|
||||
},
|
||||
order: {
|
||||
revokedNote: (numbers) =>
|
||||
`Sponsorship revoked (${numbers}): the order was cancelled or refunded. Reopening the order does NOT restore the sponsorship automatically.`,
|
||||
},
|
||||
};
|
||||
|
||||
const STRINGS: Record<Locale, SponsorshipAdminStrings> = { de, en };
|
||||
|
||||
@@ -6,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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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[]> {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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() },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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)]++;
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
migrateCartState,
|
||||
cartPhysicalQuantity,
|
||||
cartIsDigitalOnly,
|
||||
cartDiscountBase,
|
||||
type CartSponsorship,
|
||||
type NewCartItem,
|
||||
} from "./cart-store";
|
||||
@@ -125,6 +126,14 @@ describe("cart store", () => {
|
||||
expect(cartPhysicalQuantity(useCartStore.getState().items)).toBe(5);
|
||||
});
|
||||
|
||||
test("discount base excludes sponsorship packages including their honey (like the checkout server)", () => {
|
||||
const { addSponsorship, addItem } = useCartStore.getState();
|
||||
addItem(honey, 2);
|
||||
addSponsorship(packageItem, sponsorship("Lotte", { honeyJars: 3 }));
|
||||
|
||||
expect(cartDiscountBase(useCartStore.getState().items)).toEqual({ subtotalCents: 1980, quantity: 2 });
|
||||
});
|
||||
|
||||
test("a cart with only digital sponsorships is digital-only", () => {
|
||||
const { addSponsorship } = useCartStore.getState();
|
||||
addSponsorship(packageItem, sponsorship("Mila", { isPhysical: false, honeyJars: 0 }));
|
||||
|
||||
@@ -174,6 +174,20 @@ export function cartPhysicalQuantity(items: CartItem[]): number {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rabattbasis wie im Checkout-Server (discountBase): nur Ware ausserhalb von
|
||||
* Patenschaftspaketen — weder die Urkunde noch der Honig eines Pakets zaehlen
|
||||
* fuer Mengenrabatt-Schwelle und Rabattbetrag.
|
||||
*/
|
||||
export function cartDiscountBase(items: CartItem[]): { subtotalCents: number; quantity: number } {
|
||||
return items
|
||||
.filter((item) => !item.sponsorship)
|
||||
.reduce(
|
||||
(base, item) => ({ subtotalCents: base.subtotalCents + item.priceCents * item.quantity, quantity: base.quantity + item.quantity }),
|
||||
{ subtotalCents: 0, quantity: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* true, wenn nichts versendet werden muss: nur Patenschaften ohne Honig.
|
||||
* Dieselbe Regel wie hasPhysical im Server (physicalQuantity > 0), damit die
|
||||
|
||||
Reference in new issue
Block a user