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
+2955
-230
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
|
BEREIT
|
||||||
/// Nur physische Pakete: Urkunde gedruckt.
|
/// Nur physische Pakete: Urkunde gedruckt.
|
||||||
GEDRUCKT
|
GEDRUCKT
|
||||||
|
/// Einmalkauf storniert oder voll erstattet (Stornorechnung): keine
|
||||||
|
/// Downloads, keine Uebertragung, keine Ausstellung mehr.
|
||||||
|
WIDERRUFEN
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubscriptionStatus {
|
enum SubscriptionStatus {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { logAdminAction } from "@/lib/audit-log";
|
|||||||
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
||||||
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
||||||
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
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> {
|
export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
@@ -176,8 +178,19 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
|||||||
if (await isLastAdmin(id)) {
|
if (await isLastAdmin(id)) {
|
||||||
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
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 } });
|
const deleted = await prisma.customer.delete({ where: { id } });
|
||||||
|
await removeCertificateDirs(certificateDirs);
|
||||||
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
|
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
|
||||||
|
|||||||
@@ -66,14 +66,15 @@ async function settleCancelledOrder(order: {
|
|||||||
orderNumber: string;
|
orderNumber: string;
|
||||||
previousStatus: OrderStatus;
|
previousStatus: OrderStatus;
|
||||||
stripeCheckoutSessionId: string | null;
|
stripeCheckoutSessionId: string | null;
|
||||||
payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null;
|
payment: { status: PaymentStatus; stripePaymentIntentId: string | null; amountCents: number } | null;
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
|
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
|
||||||
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
|
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
|
||||||
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
|
// 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;
|
const paymentIntentId = order.payment.stripePaymentIntentId;
|
||||||
if (!paymentIntentId) {
|
if (!paymentIntentId) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
@@ -149,8 +150,8 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
stripeCheckoutSessionId: true,
|
stripeCheckoutSessionId: true,
|
||||||
payment: { select: { status: true, stripePaymentIntentId: true } },
|
payment: { select: { status: true, stripePaymentIntentId: true, amountCents: true } },
|
||||||
items: { select: { productId: true, quantity: true, productName: true } },
|
items: { select: { productId: true, quantity: true, productName: true, product: { select: { category: true } } } },
|
||||||
returnRequest: { select: { status: true } },
|
returnRequest: { select: { status: true } },
|
||||||
invoice: { select: { cancellation: { select: { number: 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.",
|
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.",
|
failed: "Der Versuch ist fehlgeschlagen. Alle automatischen Versuche sind aufgebraucht — Fehler siehe unten.",
|
||||||
missing: "Die Patenschaft wurde nicht gefunden.",
|
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.";
|
return "Die Patenschaft wurde nicht gefunden.";
|
||||||
case "no_revision":
|
case "no_revision":
|
||||||
return "Die Urkunde wurde noch nicht erzeugt — korrigieren lässt sich erst eine bestehende Urkunde.";
|
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":
|
case "reason":
|
||||||
return `Bitte einen Änderungsgrund angeben (${error.min}–${error.max} Zeichen).`;
|
return `Bitte einen Änderungsgrund angeben (${error.min}–${error.max} Zeichen).`;
|
||||||
case "unknown_template":
|
case "unknown_template":
|
||||||
|
|||||||
@@ -17,15 +17,23 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
import { isDhlConfigured } from "@/lib/dhl";
|
import { isDhlConfigured } from "@/lib/dhl";
|
||||||
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||||
|
|
||||||
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
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 statusLabels = t.orderStatusBadge;
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const order = await prisma.order.findUnique({
|
const order = await prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
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,
|
payment: true,
|
||||||
shipment: true,
|
shipment: true,
|
||||||
pickup: true,
|
pickup: true,
|
||||||
@@ -36,6 +44,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
});
|
});
|
||||||
if (!order) notFound();
|
if (!order) notFound();
|
||||||
const dhlConfigured = isDhlConfigured();
|
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
|
// Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt
|
||||||
// ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht
|
// ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht
|
||||||
// auseinanderlaufen koennen.
|
// auseinanderlaufen koennen.
|
||||||
@@ -88,6 +99,12 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{order.giftMessage ? (
|
||||||
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
|
<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>
|
<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">
|
<Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
|
||||||
{item.productName}
|
{item.productName}
|
||||||
</Link>{" "}
|
</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>
|
||||||
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
||||||
<td className="py-2 text-right">{item.quantity}</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 themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
|
||||||
const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([
|
const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([
|
||||||
prisma.order.count({
|
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.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
|
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 { prisma } from "@/lib/prisma";
|
||||||
import { pendingReviewWhere } from "@/lib/review-status";
|
import { pendingReviewWhere } from "@/lib/review-status";
|
||||||
import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard";
|
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 { formatPrice } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
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 },
|
where: { createdAt: { gte: startOfMonth() }, ...COUNTED_SALE_WHERE },
|
||||||
}),
|
}),
|
||||||
prisma.order.count({
|
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.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({
|
prisma.order.aggregate({
|
||||||
_avg: { totalCents: true },
|
_avg: { totalCents: true },
|
||||||
_count: true,
|
_count: true,
|
||||||
where: COUNTED_SALE_WHERE,
|
where: SHOP_SALE_WHERE,
|
||||||
}),
|
}),
|
||||||
// Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
|
// Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
|
||||||
// JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher,
|
// 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.
|
// machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann.
|
||||||
prisma.order.groupBy({
|
prisma.order.groupBy({
|
||||||
by: ["customerId"],
|
by: ["customerId"],
|
||||||
where: { customerId: { not: null }, ...COUNTED_SALE_WHERE },
|
where: { customerId: { not: null }, ...SHOP_SALE_WHERE },
|
||||||
having: { customerId: { _count: { gt: 1 } } },
|
having: { customerId: { _count: { gt: 1 } } },
|
||||||
}),
|
}),
|
||||||
prisma.discountCode.findMany({
|
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([
|
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries, sponsorshipItems] = await Promise.all([
|
||||||
getCurrentAdmin(),
|
getCurrentAdmin(),
|
||||||
prisma.order.count({
|
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.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
prisma.review.count({ where: pendingReviewWhere() }),
|
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) {
|
export function SponsorshipActions({ id, status, currentRevision, isPhysical, printed, ownerEmail, t }: SponsorshipActionsProps) {
|
||||||
const issued = currentRevision > 0;
|
const issued = currentRevision > 0;
|
||||||
const delivered = status === "BEREIT" || status === "GEDRUCKT";
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const SPONSORSHIP_TONES: Record<SponsorshipStatus, AdminBadgeTone> = {
|
|||||||
FEHLER: "danger",
|
FEHLER: "danger",
|
||||||
BEREIT: "positive",
|
BEREIT: "positive",
|
||||||
GEDRUCKT: "neutral",
|
GEDRUCKT: "neutral",
|
||||||
|
WIDERRUFEN: "danger",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUBSCRIPTION_TONES: Record<SubscriptionStatus, AdminBadgeTone> = {
|
const SUBSCRIPTION_TONES: Record<SubscriptionStatus, AdminBadgeTone> = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
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
|
* 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 },
|
select: { productId: true, quantity: true },
|
||||||
});
|
});
|
||||||
const stockById = new Map(inventories.map((row) => [row.productId, row.quantity]));
|
const stockById = new Map(inventories.map((row) => [row.productId, row.quantity]));
|
||||||
const entries = await Promise.all(
|
const reservedById = await reservedJarsByProduct(ids, now);
|
||||||
ids.map(async (id) => {
|
return new Map(
|
||||||
|
ids.map((id) => {
|
||||||
const stock = stockById.get(id) ?? 0;
|
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 [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. */
|
/** 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
|
* sondern die Arbeitsliste des Druckablaufs: physisches Paket, Urkunde
|
||||||
* bereit, noch nicht gedruckt.
|
* 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 type ListStatus = (typeof LIST_STATUSES)[number];
|
||||||
|
|
||||||
export const TO_PRINT_FILTER = {
|
export const TO_PRINT_FILTER = {
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ vi.mock("@/lib/stripe", () => ({
|
|||||||
const getProductsByIds = vi.fn();
|
const getProductsByIds = vi.fn();
|
||||||
vi.mock("@/lib/data", () => ({ getProductsByIds: (...args: unknown[]) => getProductsByIds(...args) }));
|
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);
|
const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true);
|
||||||
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
|
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
|
||||||
@@ -67,10 +68,12 @@ const db = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||||
// Patenschaften spielen in diesen Faellen keine Rolle: keine Angebote zu den
|
// Patenschaften spielen in den meisten Faellen keine Rolle: keine Angebote zu
|
||||||
// Produkten, keine Abo-Reservierung (eigene Tests in lib/sponsorship).
|
// den Produkten, keine Abo-Reservierung (eigene Tests in lib/sponsorship).
|
||||||
vi.mock("@/lib/sponsorship/offers", () => ({ getOffersByProductIds: vi.fn(async () => new Map()) }));
|
// Die Rabatt-Tests unten legen ein Paket-Angebot an.
|
||||||
vi.mock("@/lib/sponsorship-subscription/reservation", () => ({ reservedJarsForProduct: vi.fn(async () => 0) }));
|
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");
|
const { POST } = await import("./route");
|
||||||
|
|
||||||
@@ -116,6 +119,8 @@ beforeEach(() => {
|
|||||||
rateLimit.mockReturnValue(true);
|
rateLimit.mockReturnValue(true);
|
||||||
verifyTurnstileToken.mockResolvedValue(true);
|
verifyTurnstileToken.mockResolvedValue(true);
|
||||||
getProductsByIds.mockResolvedValue([honey]);
|
getProductsByIds.mockResolvedValue([honey]);
|
||||||
|
getCurrentCustomer.mockResolvedValue(null);
|
||||||
|
getOffersByProductIds.mockResolvedValue(new Map());
|
||||||
getShippingRegion.mockResolvedValue("DE");
|
getShippingRegion.mockResolvedValue("DE");
|
||||||
db.discountCode.findMany.mockResolvedValue([]);
|
db.discountCode.findMany.mockResolvedValue([]);
|
||||||
db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
||||||
@@ -418,4 +423,119 @@ describe("Checkout-Route", () => {
|
|||||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
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 { verifyTurnstileToken } from "@/lib/turnstile";
|
||||||
import { getShippingCostCents, getShippingRegion } from "@/lib/shipping";
|
import { getShippingCostCents, getShippingRegion } from "@/lib/shipping";
|
||||||
import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region";
|
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 { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +24,7 @@ import { vatRateForProduct } from "@/lib/vat";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
|
import { expandCheckoutItems, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand";
|
||||||
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
|
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
|
||||||
import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation";
|
import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||||
|
|
||||||
class OutOfStockError extends Error {}
|
class OutOfStockError extends Error {}
|
||||||
// Der fruehe pendingUses-Check weiter unten liest ausserhalb jeder Transaktion
|
// 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
|
// Transaktion gelesen: die Reservierung aendert sich nur mit Abos, nicht
|
||||||
// durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung
|
// durch gleichzeitige Bestellungen — die verbindliche Bestandspruefung
|
||||||
// bleibt das bedingte Update in der Transaktion.
|
// bleibt das bedingte Update in der Transaktion.
|
||||||
const reservedByProduct = new Map<string, number>();
|
const reservedByProduct = await reservedJarsByProduct(expanded.reserve.map((line) => line.productId));
|
||||||
for (const line of expanded.reserve) {
|
|
||||||
reservedByProduct.set(line.productId, await reservedJarsForProduct(line.productId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
// 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 discountCents = 0;
|
||||||
let discountCode: { id: string; code: string } | null = null;
|
let discountCode: { id: string; code: string } | null = null;
|
||||||
if (input.discountCode) {
|
if (input.discountCode) {
|
||||||
@@ -347,7 +347,9 @@ export async function POST(request: Request) {
|
|||||||
where: { code: input.discountCode.trim().toUpperCase() },
|
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(
|
return NextResponse.json(
|
||||||
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -372,7 +374,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
discountCents = calculateDiscountCents(code, base.subtotalCents);
|
||||||
discountCode = code;
|
discountCode = code;
|
||||||
} else {
|
} else {
|
||||||
// Kein manuell eingegebener Code — unter allen im Admin-Bereich
|
// 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 candidates = await prisma.discountCode.findMany({ where: { minQuantity: { not: null } } });
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const code = candidates
|
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];
|
.sort((a, b) => (b.minQuantity ?? 0) - (a.minQuantity ?? 0))[0];
|
||||||
if (code) {
|
if (code) {
|
||||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
discountCents = calculateDiscountCents(code, base.subtotalCents);
|
||||||
discountCode = code;
|
discountCode = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,7 +586,8 @@ export async function POST(request: Request) {
|
|||||||
price_data: {
|
price_data: {
|
||||||
currency: "eur",
|
currency: "eur",
|
||||||
unit_amount: item.unitPriceCents,
|
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
|
...(shippingCents > 0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { sponsorshipExportData } from "@/lib/sponsorship/data-export";
|
||||||
|
|
||||||
const EXPORT_ATTEMPTS_PER_WINDOW = 5;
|
const EXPORT_ATTEMPTS_PER_WINDOW = 5;
|
||||||
const EXPORT_WINDOW_MS = 60 * 60 * 1000;
|
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 });
|
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.address.findMany({ where: { customerId: customer.id } }),
|
||||||
prisma.order.findMany({
|
prisma.order.findMany({
|
||||||
where: { customerId: customer.id },
|
where: { customerId: customer.id },
|
||||||
@@ -33,6 +34,8 @@ export async function GET() {
|
|||||||
include: { product: { select: { name: true } } },
|
include: { product: { select: { name: true } } },
|
||||||
}),
|
}),
|
||||||
prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }),
|
prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }),
|
||||||
|
// Patenschaften, Abos samt Zahlungen und Uebertragungen (ohne Stripe-Kennungen).
|
||||||
|
sponsorshipExportData(customer.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const exportData = {
|
const exportData = {
|
||||||
@@ -82,6 +85,9 @@ export async function GET() {
|
|||||||
newsletter: newsletter
|
newsletter: newsletter
|
||||||
? { subscribed: newsletter.unsubscribedAt === null, confirmed: newsletter.confirmed }
|
? { subscribed: newsletter.unsubscribedAt === null, confirmed: newsletter.confirmed }
|
||||||
: { subscribed: false, confirmed: false },
|
: { subscribed: false, confirmed: false },
|
||||||
|
sponsorships: sponsorship.sponsorships,
|
||||||
|
sponsorshipSubscriptions: sponsorship.subscriptions,
|
||||||
|
sponsorshipTransfers: sponsorship.transfers,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const m = vi.hoisted(() => ({
|
|||||||
getCurrentCustomer: vi.fn(),
|
getCurrentCustomer: vi.fn(),
|
||||||
getOfferBySlug: vi.fn(),
|
getOfferBySlug: vi.fn(),
|
||||||
createSubscriptionCheckout: vi.fn(),
|
createSubscriptionCheckout: vi.fn(),
|
||||||
|
preparePendingCheckout: vi.fn(),
|
||||||
isTrustedOrigin: 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("@/i18n/locale", () => ({ getLocale: async () => "de" }));
|
||||||
vi.mock("@/lib/sponsorship/offers", () => ({ getOfferBySlug: m.getOfferBySlug }));
|
vi.mock("@/lib/sponsorship/offers", () => ({ getOfferBySlug: m.getOfferBySlug }));
|
||||||
vi.mock("@/lib/sponsorship-subscription/checkout", () => ({ createSubscriptionCheckout: m.createSubscriptionCheckout }));
|
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 }) }));
|
vi.mock("@/lib/certificate/layout", () => ({ checkCertificateFits: () => ({ ok: true }) }));
|
||||||
|
|
||||||
const { POST } = await import("./route");
|
const { POST } = await import("./route");
|
||||||
@@ -38,6 +40,7 @@ beforeEach(() => {
|
|||||||
m.getCurrentCustomer.mockResolvedValue(CUSTOMER);
|
m.getCurrentCustomer.mockResolvedValue(CUSTOMER);
|
||||||
m.getOfferBySlug.mockResolvedValue({ id: "offer_1", slug: "vierteljaehrlich", kind: "ABO", isAvailable: true });
|
m.getOfferBySlug.mockResolvedValue({ id: "offer_1", slug: "vierteljaehrlich", kind: "ABO", isAvailable: true });
|
||||||
m.createSubscriptionCheckout.mockResolvedValue({ clientSecret: "cs_secret", subscriptionId: "sub_local" });
|
m.createSubscriptionCheckout.mockResolvedValue({ clientSecret: "cs_secret", subscriptionId: "sub_local" });
|
||||||
|
m.preparePendingCheckout.mockResolvedValue({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/patenschaft/abo", () => {
|
describe("POST /api/patenschaft/abo", () => {
|
||||||
@@ -87,4 +90,21 @@ describe("POST /api/patenschaft/abo", () => {
|
|||||||
expect(response.status).toBe(502);
|
expect(response.status).toBe(502);
|
||||||
expect(JSON.stringify(await response.json())).not.toContain("sk_test");
|
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 { checkCertificateFits } from "@/lib/certificate/layout";
|
||||||
import { getOfferBySlug } from "@/lib/sponsorship/offers";
|
import { getOfferBySlug } from "@/lib/sponsorship/offers";
|
||||||
import { createSubscriptionCheckout } from "@/lib/sponsorship-subscription/checkout";
|
import { createSubscriptionCheckout } from "@/lib/sponsorship-subscription/checkout";
|
||||||
|
import { preparePendingCheckout } from "@/lib/sponsorship-subscription/pending";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abschluss eines Patenschafts-Abos: prueft Konto, Personalisierung und
|
* Abschluss eines Patenschafts-Abos: prueft Konto, Personalisierung und
|
||||||
@@ -81,6 +82,17 @@ export async function POST(request: Request) {
|
|||||||
// "Honigbestand und Kontingent").
|
// "Honigbestand und Kontingent").
|
||||||
if (!offer.isAvailable) return error("Dieses Abo ist derzeit ausgebucht.", 409);
|
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 {
|
try {
|
||||||
const { clientSecret } = await createSubscriptionCheckout({
|
const { clientSecret } = await createSubscriptionCheckout({
|
||||||
customer,
|
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 { getLocale } from "@/i18n/locale";
|
||||||
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
|
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
|
||||||
import { validatePreviewRequest, type PreviewFailure } from "./validate";
|
import { validatePreviewRequest, type PreviewFailure } from "./validate";
|
||||||
|
import { createLruCache, createRenderGate, previewCacheKey } from "./render-guard";
|
||||||
import {
|
import {
|
||||||
PREVIEW_NUMBER,
|
PREVIEW_NUMBER,
|
||||||
previewWatermark,
|
previewWatermark,
|
||||||
@@ -28,9 +29,30 @@ const PREVIEW_WIDTH_PX = 900;
|
|||||||
// Grosszuegig ueber dem, was die Feldlimits je erreichen koennen; alles
|
// Grosszuegig ueber dem, was die Feldlimits je erreichen koennen; alles
|
||||||
// darueber ist kein Formular mehr, sondern ein Versuch, den Parser zu beschaeftigen.
|
// darueber ist kein Formular mehr, sondern ein Versuch, den Parser zu beschaeftigen.
|
||||||
const MAX_BODY_BYTES = 16 * 1024;
|
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 {
|
const gate = createRenderGate({ maxConcurrent: MAX_CONCURRENT_RENDERS, maxPerMinute: MAX_RENDERS_PER_MINUTE });
|
||||||
return NextResponse.json(payload, { status, headers: { "Cache-Control": "no-store" } });
|
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> {
|
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) {
|
if (declaredLength > MAX_BODY_BYTES) {
|
||||||
return failure({ status: 413, error: "generic", field: null });
|
return failure({ status: 413, error: "generic", field: null });
|
||||||
}
|
}
|
||||||
const raw = await request.text().catch(() => "");
|
// Content-Length ist eine Angabe des Absenders, keine Grenze: bei
|
||||||
if (raw.length > MAX_BODY_BYTES) {
|
// "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 });
|
return failure({ status: 413, error: "generic", field: null });
|
||||||
}
|
}
|
||||||
let body: unknown = 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());
|
const input = validatePreviewRequest(body, new Date(), await getLocale());
|
||||||
if (!input.ok) return failure(input.failure);
|
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 {
|
try {
|
||||||
const image = await renderCertificateDocumentImage(
|
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" }
|
{ widthPx: PREVIEW_WIDTH_PX, watermark: previewWatermark(input.locale), format: "webp" }
|
||||||
);
|
);
|
||||||
return new Response(new Uint8Array(image), {
|
imageCache.set(cacheKey, image);
|
||||||
status: 200,
|
return imageResponse(image);
|
||||||
headers: {
|
|
||||||
"Content-Type": "image/webp",
|
|
||||||
// Personalisierte Bilder gehoeren in keinen Zwischenspeicher.
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("[patenschaft/vorschau] Vorschau konnte nicht erzeugt werden:", error);
|
console.error("[patenschaft/vorschau] Vorschau konnte nicht erzeugt werden:", error);
|
||||||
return failure({ status: 500, error: "generic", field: null });
|
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";
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
const getProductsByIds = vi.fn();
|
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();
|
const getOffersByProductIds = vi.fn();
|
||||||
vi.mock("@/lib/sponsorship/offers", () => ({
|
vi.mock("@/lib/sponsorship/offers", () => ({
|
||||||
@@ -38,6 +48,20 @@ describe("GET /api/shop/cart-stock", () => {
|
|||||||
expect(getOffersByProductIds).toHaveBeenCalledWith(["pkg"], "de");
|
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 () => {
|
test("marks a sponsorship as sold out when its offer is not available", async () => {
|
||||||
getProductsByIds.mockResolvedValue([{ id: "pkg", category: "PATENSCHAFT", isActive: true, inventory: null }]);
|
getProductsByIds.mockResolvedValue([{ id: "pkg", category: "PATENSCHAFT", isActive: true, inventory: null }]);
|
||||||
getOffersByProductIds.mockResolvedValue(new Map([["pkg", { isAvailable: false }]]));
|
getOffersByProductIds.mockResolvedValue(new Map([["pkg", { isAvailable: false }]]));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getProductsByIds } from "@/lib/data";
|
import { getProductsByIds, withFreeStock } from "@/lib/data";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
|
import { getOffersByProductIds } from "@/lib/sponsorship/offers";
|
||||||
import { defaultLocale } from "@/i18n/config";
|
import { defaultLocale } from "@/i18n/config";
|
||||||
@@ -41,7 +41,9 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ stock: {} });
|
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
|
// Patenschaften haben am Traegerprodukt keinen eigenen Bestand. Ob sie
|
||||||
// bestellbar sind, entscheidet das Angebot (bei Paketen der freie
|
// bestellbar sind, entscheidet das Angebot (bei Paketen der freie
|
||||||
// Honigbestand abzueglich Abo-Reservierung, siehe lib/sponsorship/offers.ts) —
|
// 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 { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { TrustBadges } from "@/components/trust-badges";
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
import Link from "next/link";
|
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 { SponsorshipLineDetails } from "@/components/sponsorship-cart-line";
|
||||||
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
|
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
|
||||||
import { loginPathWithNext } from "@/lib/safe-next-path";
|
import { loginPathWithNext } from "@/lib/safe-next-path";
|
||||||
@@ -124,7 +124,7 @@ export function CheckoutForm({
|
|||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
||||||
const subtotal = cartTotalCents(items);
|
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).
|
// Paket wiegt nichts (wie physicalQuantity im Checkout-Server).
|
||||||
const totalQuantity = cartPhysicalQuantity(items);
|
const totalQuantity = cartPhysicalQuantity(items);
|
||||||
// Nur Patenschaften ohne Honig: nichts zu versenden oder abzuholen. Dann
|
// 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.
|
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
||||||
const shipping =
|
const shipping =
|
||||||
digitalOnly || fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
|
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
|
const bulkDiscountCents = activeBulkDiscount
|
||||||
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
|
? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100)
|
||||||
: 0;
|
: 0;
|
||||||
const total = subtotal - bulkDiscountCents + shipping;
|
const total = subtotal - bulkDiscountCents + shipping;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { securityAlertEmail } from "@/lib/email-templates";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
||||||
import { succeed } from "@/lib/flash";
|
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
|
* 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
|
* Rechnung auch ohne verknuepftes Kundenkonto vollstaendig bleibt. Alle
|
||||||
* anderen persoenlichen Daten (Adressen, Wunschliste, Bewertungen,
|
* anderen persoenlichen Daten (Adressen, Wunschliste, Bewertungen,
|
||||||
* Newsletter, Backup-Codes) werden per Cascade mitgeloescht.
|
* 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> {
|
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
@@ -42,10 +48,20 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
|||||||
return fail("/konto/einstellungen", "Passwort ist falsch.");
|
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");
|
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
||||||
await sendMail({ to: customer.email, subject, html, text });
|
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 prisma.customer.delete({ where: { id: customer.id } });
|
||||||
|
await removeCertificateDirs(certificateDirs);
|
||||||
await destroySession();
|
await destroySession();
|
||||||
return succeed("/konto", "Dein Konto wurde endgültig gelöscht.");
|
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 }) {
|
function PendingPanel({ t }: { t: AccountSponsorshipStrings }) {
|
||||||
return (
|
return (
|
||||||
<div role="status" className="mx-auto flex max-w-xl flex-col items-center px-6 py-16 text-center">
|
<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();
|
if (!loaded) notFound();
|
||||||
const { sponsorship, locale } = loaded;
|
const { sponsorship, locale } = loaded;
|
||||||
const t = getAccountSponsorshipStrings(locale);
|
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 canTransfer = isTransferable(sponsorship);
|
||||||
const openTransfer = canTransfer ? await getOpenTransfer(sponsorship.id) : null;
|
const openTransfer = canTransfer ? await getOpenTransfer(sponsorship.id) : null;
|
||||||
const name = sponsorship.beeName || sponsorship.number;
|
const name = sponsorship.beeName || sponsorship.number;
|
||||||
@@ -134,8 +146,8 @@ export default async function SponsorshipDetailPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<LifecycleBadge
|
<LifecycleBadge
|
||||||
state={isReady ? sponsorship.lifecycle : "PENDING"}
|
state={isReady || isRevoked ? sponsorship.lifecycle : "PENDING"}
|
||||||
label={isReady ? t.lifecycle[sponsorship.lifecycle] : t.overview.pendingBadge}
|
label={isReady || isRevoked ? t.lifecycle[sponsorship.lifecycle] : t.overview.pendingBadge}
|
||||||
/>
|
/>
|
||||||
<SponsorshipActions
|
<SponsorshipActions
|
||||||
sponsorshipId={sponsorship.id}
|
sponsorshipId={sponsorship.id}
|
||||||
@@ -165,7 +177,9 @@ export default async function SponsorshipDetailPage({ params }: Props) {
|
|||||||
<h2 id="stage-heading" className="sr-only">
|
<h2 id="stage-heading" className="sr-only">
|
||||||
{t.detail.stageHeading}
|
{t.detail.stageHeading}
|
||||||
</h2>
|
</h2>
|
||||||
{isReady ? (
|
{isRevoked ? (
|
||||||
|
<RevokedPanel t={t} />
|
||||||
|
) : isReady ? (
|
||||||
<>
|
<>
|
||||||
<Stage sponsorship={sponsorship} t={t} />
|
<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">
|
<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>
|
<p className="mt-16 max-w-[70ch] text-xs leading-relaxed text-ink/60">{t.detail.symbolicNote}</p>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{!isReady ? <PendingRefresher /> : null}
|
{!isReady && !isRevoked ? <PendingRefresher /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ const STYLES: Record<SponsorshipLifecycle | "PENDING", string> = {
|
|||||||
CANCELLING: "border-ink/25 text-ink/75",
|
CANCELLING: "border-ink/25 text-ink/75",
|
||||||
EXPIRED: "border-ink/15 text-ink/65",
|
EXPIRED: "border-ink/15 text-ink/65",
|
||||||
ENDED: "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 }) {
|
export function LifecycleBadge({ state, label }: { state: SponsorshipLifecycle | "PENDING"; label: string }) {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ function collectMedals(
|
|||||||
t: AccountSponsorshipStrings
|
t: AccountSponsorshipStrings
|
||||||
): ShelfMedal[] {
|
): ShelfMedal[] {
|
||||||
const medals: ShelfMedal[] = sponsorships
|
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) => ({
|
.map((sponsorship) => ({
|
||||||
key: sponsorship.id,
|
key: sponsorship.id,
|
||||||
imageUrl: `${sponsorshipFilePath(sponsorship.id, "medaille.png")}?groesse=256`,
|
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
|
* Eine Patenschaft in der Uebersicht. Die ganze Karte ist der Link zur
|
||||||
* Detailseite; "Abo verwalten" steht darunter ausserhalb des Links, damit
|
* Detailseite; "Abo verwalten" steht darunter ausserhalb des Links, damit
|
||||||
@@ -37,7 +47,8 @@ export function SponsorshipRow({
|
|||||||
t: AccountSponsorshipStrings;
|
t: AccountSponsorshipStrings;
|
||||||
index: number;
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
const isPending = sponsorship.revision === null;
|
const isRevoked = sponsorship.lifecycle === "REVOKED";
|
||||||
|
const isPending = !isRevoked && sponsorship.revision === null;
|
||||||
const name = sponsorship.beeName || sponsorship.number;
|
const name = sponsorship.beeName || sponsorship.number;
|
||||||
return (
|
return (
|
||||||
<li
|
<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"
|
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]">
|
<span className="relative block h-[4.5rem] w-[4.5rem] sm:h-[5.5rem] sm:w-[5.5rem]">
|
||||||
{isPending ? (
|
{isRevoked ? (
|
||||||
|
<RevokedSeal />
|
||||||
|
) : isPending ? (
|
||||||
<PendingSeal />
|
<PendingSeal />
|
||||||
) : (
|
) : (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- geschuetzte Besitzer-Route, kein Bildoptimierer
|
// 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 { 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 { rateLimit } from "@/lib/rate-limit";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import type { Locale } from "@/i18n/config";
|
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 —
|
* Anmeldung und Besitz in einem Schritt. null heisst fuer jede Route: 404 —
|
||||||
* auch ohne Anmeldung, damit eine Route nicht verraet, ob es die Patenschaft
|
* 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> {
|
export async function loadOwnedSponsorship(id: string): Promise<OwnedContext | null> {
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) return null;
|
if (!customer) return null;
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const sponsorship = await getOwnedSponsorship(customer.id, id, locale);
|
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
|
// Rendern (Textur, Medaille, ZIP) kostet spuerbar CPU. Zwischengespeicherte
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export default async function KontoPatenschaftPage() {
|
|||||||
listOwnedSponsorships(customer.id, locale),
|
listOwnedSponsorships(customer.id, locale),
|
||||||
getCustomerLoyalty(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 (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Container, FormPanel, LinkButton, Button } from "@/components/ui";
|
import { Container, FormPanel, LinkButton, Button } from "@/components/ui";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
import { noIndexPrivate } from "@/lib/seo";
|
import { noIndexPrivate } from "@/lib/seo";
|
||||||
import { formatCertificateDate } from "@/lib/certificate/text";
|
import { formatCertificateDate } from "@/lib/certificate/text";
|
||||||
import { previewClaim, transferClaimPath, isWellFormedTransferToken } from "@/lib/sponsorship/transfer";
|
import { previewClaim, transferClaimPath, isWellFormedTransferToken } from "@/lib/sponsorship/transfer";
|
||||||
@@ -13,6 +14,12 @@ import { claimSponsorshipAction } from "./actions";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 }> };
|
type Props = { params: Promise<{ token: string }> };
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
@@ -59,6 +66,7 @@ export default async function ClaimSponsorshipPage({ params }: Props) {
|
|||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
if (!isWellFormedTransferToken(token)) return invalid;
|
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
|
// Ohne Konto zaehlt nur "gueltig oder nicht"; "eigene Patenschaft" kann es
|
||||||
// hier noch nicht geben.
|
// hier noch nicht geben.
|
||||||
|
|||||||
@@ -14,6 +14,38 @@ import type { PreviewErrorCode } from "@/i18n/sponsorship/shop";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const DEBOUNCE_MS = 400;
|
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 {
|
export interface PreviewServerError {
|
||||||
code: PreviewErrorCode;
|
code: PreviewErrorCode;
|
||||||
@@ -52,12 +84,7 @@ export function useCertificatePreview(request: PreviewRequest | null): Certifica
|
|||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
setState((current) => ({ ...current, loading: true }));
|
setState((current) => ({ ...current, loading: true }));
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/patenschaft/vorschau", {
|
const response = await fetchPreview(key, controller.signal);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: key,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body: unknown = await response.json().catch(() => null);
|
const body: unknown = await response.json().catch(() => null);
|
||||||
const error: PreviewServerError = isPreviewErrorBody(body)
|
const error: PreviewServerError = isPreviewErrorBody(body)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"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
|
* 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[]> {
|
export async function getRecentlyViewedProducts(ids: string[]): Promise<ProductWithRelations[]> {
|
||||||
if (ids.length === 0) return [];
|
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]));
|
const byId = new Map(products.map((product) => [product.id, product]));
|
||||||
|
|
||||||
return ids
|
return ids
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function CancellationForm({ s, turnstileSiteKey }: { s: AboStrings["publi
|
|||||||
<br />
|
<br />
|
||||||
<span className="tabular-nums text-heading">{result.receivedAt}</span>
|
<span className="tabular-nums text-heading">{result.receivedAt}</span>
|
||||||
</p>
|
</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>
|
</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 { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
||||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||||
import { ProductCard } from "@/components/product-card";
|
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 { SponsorshipLineDetails, SponsorshipThumb } from "@/components/sponsorship-cart-line";
|
||||||
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
|
import { getSponsorshipShopStrings } from "@/i18n/sponsorship/shop";
|
||||||
import WarenkorbLoading from "./loading";
|
import WarenkorbLoading from "./loading";
|
||||||
@@ -52,11 +52,14 @@ export function WarenkorbContent({
|
|||||||
// Live-Vorschau des serverseitig in api/checkout/route.ts automatisch
|
// Live-Vorschau des serverseitig in api/checkout/route.ts automatisch
|
||||||
// angewendeten Mengenrabatts — Hoehe/Menge kommen aus den im Admin-Bereich
|
// angewendeten Mengenrabatts — Hoehe/Menge kommen aus den im Admin-Bereich
|
||||||
// (Gutscheine) gepflegten DiscountCode-Zeilen, nicht mehr aus Konstanten.
|
// (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
|
const bulkDiscountCents = activeBulkDiscount
|
||||||
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
|
? Math.round((discountable.subtotalCents * activeBulkDiscount.percent) / 100)
|
||||||
: 0;
|
: 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 cartProductIds = new Set(items.map((item) => item.productId));
|
||||||
const crossSellProducts = availableProducts
|
const crossSellProducts = availableProducts
|
||||||
|
|||||||
@@ -92,12 +92,26 @@ export interface AboStrings {
|
|||||||
submitting: string;
|
submitting: string;
|
||||||
successHeading: string;
|
successHeading: string;
|
||||||
receivedAtLabel: string;
|
receivedAtLabel: string;
|
||||||
successMailSent: string;
|
/** Fuer passende und unpassende Angaben gleich (kein Hinweis, ob eine Mail rausging). */
|
||||||
successMailFailed: string;
|
successMailNote: string;
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
rateLimited: string;
|
rateLimited: string;
|
||||||
invalid: 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 = {
|
const de: AboStrings = {
|
||||||
@@ -193,13 +207,26 @@ const de: AboStrings = {
|
|||||||
submitting: "Wird gesendet …",
|
submitting: "Wird gesendet …",
|
||||||
successHeading: "Deine Kündigung ist eingegangen",
|
successHeading: "Deine Kündigung ist eingegangen",
|
||||||
receivedAtLabel: "Eingegangen am",
|
receivedAtLabel: "Eingegangen am",
|
||||||
successMailSent: "Wir haben dir eine Eingangsbestätigung per Mail geschickt.",
|
successMailNote:
|
||||||
successMailFailed:
|
"Die Eingangsbestätigung schicken wir an die E-Mail-Adresse deines Kundenkontos. Bitte bewahre diese Seite als Nachweis auf (Ausdruck oder Bildschirmfoto).",
|
||||||
"Die Bestätigungsmail konnte gerade nicht versendet werden. Deine Kündigung ist trotzdem eingegangen; bitte bewahre diese Seite als Nachweis auf.",
|
|
||||||
errorMessage: "Deine Kündigung konnte nicht gesendet werden. Bitte versuche es erneut oder schreib uns eine Mail.",
|
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.",
|
rateLimited: "Zu viele Anfragen. Bitte versuche es später erneut oder schreib uns eine Mail.",
|
||||||
invalid: "Bitte überprüfe deine Angaben.",
|
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 = {
|
const en: AboStrings = {
|
||||||
@@ -293,13 +320,26 @@ const en: AboStrings = {
|
|||||||
submitting: "Sending …",
|
submitting: "Sending …",
|
||||||
successHeading: "We have received your cancellation",
|
successHeading: "We have received your cancellation",
|
||||||
receivedAtLabel: "Received on",
|
receivedAtLabel: "Received on",
|
||||||
successMailSent: "We have sent you a confirmation of receipt by email.",
|
successMailNote:
|
||||||
successMailFailed:
|
"We send the confirmation of receipt to the email address of your customer account. Please keep this page as proof (print or screenshot).",
|
||||||
"The confirmation email could not be sent right now. Your cancellation has still been received; please keep this page as proof.",
|
|
||||||
errorMessage: "Your cancellation could not be sent. Please try again or email us.",
|
errorMessage: "Your cancellation could not be sent. Please try again or email us.",
|
||||||
rateLimited: "Too many requests. Please try again later or email us.",
|
rateLimited: "Too many requests. Please try again later or email us.",
|
||||||
invalid: "Please check your details.",
|
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 {
|
export function getAboStrings(locale: Locale): AboStrings {
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface AccountSponsorshipStrings {
|
|||||||
};
|
};
|
||||||
pendingTitle: string;
|
pendingTitle: string;
|
||||||
pendingText: string;
|
pendingText: string;
|
||||||
|
revokedTitle: string;
|
||||||
|
revokedText: string;
|
||||||
detailsHeading: string;
|
detailsHeading: string;
|
||||||
fields: {
|
fields: {
|
||||||
number: string;
|
number: string;
|
||||||
@@ -192,6 +194,7 @@ const de: AccountSponsorshipStrings = {
|
|||||||
EXPIRED: "Abgelaufen",
|
EXPIRED: "Abgelaufen",
|
||||||
CANCELLING: "Gekündigt",
|
CANCELLING: "Gekündigt",
|
||||||
ENDED: "Beendet",
|
ENDED: "Beendet",
|
||||||
|
REVOKED: "Widerrufen",
|
||||||
},
|
},
|
||||||
term: {
|
term: {
|
||||||
range: (start, end) => `${start} bis ${end}`,
|
range: (start, end) => `${start} bis ${end}`,
|
||||||
@@ -231,6 +234,9 @@ const de: AccountSponsorshipStrings = {
|
|||||||
},
|
},
|
||||||
pendingTitle: "Deine Urkunde wird gerade erstellt",
|
pendingTitle: "Deine Urkunde wird gerade erstellt",
|
||||||
pendingText: "Sobald sie fertig ist, erscheinen hier Urkunde, Medaille und Downloads. Diese Seite aktualisiert sich von selbst.",
|
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",
|
detailsHeading: "Angaben",
|
||||||
fields: {
|
fields: {
|
||||||
number: "Patenschaftsnummer",
|
number: "Patenschaftsnummer",
|
||||||
@@ -365,6 +371,7 @@ const en: AccountSponsorshipStrings = {
|
|||||||
EXPIRED: "Expired",
|
EXPIRED: "Expired",
|
||||||
CANCELLING: "Cancelled",
|
CANCELLING: "Cancelled",
|
||||||
ENDED: "Ended",
|
ENDED: "Ended",
|
||||||
|
REVOKED: "Revoked",
|
||||||
},
|
},
|
||||||
term: {
|
term: {
|
||||||
range: (start, end) => `${start} to ${end}`,
|
range: (start, end) => `${start} to ${end}`,
|
||||||
@@ -402,6 +409,9 @@ const en: AccountSponsorshipStrings = {
|
|||||||
},
|
},
|
||||||
pendingTitle: "Your certificate is being created",
|
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.",
|
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",
|
detailsHeading: "Details",
|
||||||
fields: {
|
fields: {
|
||||||
number: "Sponsorship number",
|
number: "Sponsorship number",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const de = {
|
|||||||
kinds: { EINMALIG: "Einmalig", ABO: "Abo" },
|
kinds: { EINMALIG: "Einmalig", ABO: "Abo" },
|
||||||
cycles: { VIERTELJAEHRLICH: "Vierteljährlich", ERNTE: "Nach der Ernte" },
|
cycles: { VIERTELJAEHRLICH: "Vierteljährlich", ERNTE: "Nach der Ernte" },
|
||||||
medals: { BRONZE: "Bronze", SILBER: "Silber", GOLD: "Gold", ABO: "Abo-Prägung" },
|
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: {
|
subscriptionStatuses: {
|
||||||
AUSSTEHEND: "Checkout offen",
|
AUSSTEHEND: "Checkout offen",
|
||||||
AKTIV: "Aktiv",
|
AKTIV: "Aktiv",
|
||||||
@@ -110,6 +110,8 @@ const de = {
|
|||||||
notePrintPhysical: "Nur physische Pakete werden gedruckt.",
|
notePrintPhysical: "Nur physische Pakete werden gedruckt.",
|
||||||
notePrintReady: "Druckbar, sobald die Urkunde bereit ist.",
|
notePrintReady: "Druckbar, sobald die Urkunde bereit ist.",
|
||||||
noteMailReady: "Erneuter Versand erst nach der ersten Zustellung.",
|
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",
|
revisionsHeading: "Revisionen",
|
||||||
colRevision: "Rev.",
|
colRevision: "Rev.",
|
||||||
colCreatedAt: "Erstellt",
|
colCreatedAt: "Erstellt",
|
||||||
@@ -324,6 +326,10 @@ const de = {
|
|||||||
coverage: (n: number) => `${n} ${plural(n, "Honigprodukt deckt", "Honigprodukte decken")} keine volle Abo-Lieferung mehr`,
|
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`,
|
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;
|
export type SponsorshipAdminStrings = typeof de;
|
||||||
@@ -352,7 +358,7 @@ const en: SponsorshipAdminStrings = {
|
|||||||
kinds: { EINMALIG: "One-time", ABO: "Subscription" },
|
kinds: { EINMALIG: "One-time", ABO: "Subscription" },
|
||||||
cycles: { VIERTELJAEHRLICH: "Quarterly", ERNTE: "After the harvest" },
|
cycles: { VIERTELJAEHRLICH: "Quarterly", ERNTE: "After the harvest" },
|
||||||
medals: { BRONZE: "Bronze", SILBER: "Silver", GOLD: "Gold", ABO: "Subscription stamp" },
|
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: {
|
subscriptionStatuses: {
|
||||||
AUSSTEHEND: "Checkout open",
|
AUSSTEHEND: "Checkout open",
|
||||||
AKTIV: "Active",
|
AKTIV: "Active",
|
||||||
@@ -428,6 +434,8 @@ const en: SponsorshipAdminStrings = {
|
|||||||
notePrintPhysical: "Only physical packages are printed.",
|
notePrintPhysical: "Only physical packages are printed.",
|
||||||
notePrintReady: "Printable once the certificate is ready.",
|
notePrintReady: "Printable once the certificate is ready.",
|
||||||
noteMailReady: "Resending is possible after the first delivery.",
|
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",
|
revisionsHeading: "Revisions",
|
||||||
colRevision: "Rev.",
|
colRevision: "Rev.",
|
||||||
colCreatedAt: "Created",
|
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`,
|
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`,
|
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 };
|
const STRINGS: Record<Locale, SponsorshipAdminStrings> = { de, en };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface ApiCoreStockIssues {
|
|||||||
overdue: number;
|
overdue: number;
|
||||||
/** Von ApiCore abgelehnt — braucht einen Menschen. */
|
/** Von ApiCore abgelehnt — braucht einen Menschen. */
|
||||||
rejected: number;
|
rejected: number;
|
||||||
/** Aktive Produkte, deren Bestand noch von Hand gepflegt wird. */
|
/** Aktive Produkte (ohne Patenschaften), deren Bestand noch von Hand gepflegt wird. */
|
||||||
unlinked: number;
|
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) } },
|
where: { sentAt: null, rejectedAt: null, createdAt: { lt: new Date(now.getTime() - OVERDUE_AFTER_MS) } },
|
||||||
}),
|
}),
|
||||||
prisma.apiCoreStockEvent.count({ where: { rejectedAt: { not: null } } }),
|
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 };
|
return { overdue, rejected, unlinked };
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,13 @@ export async function reconcileApiCoreStock(filter: ReconcileFilter = {}): Promi
|
|||||||
if (byTarget.length === 0) return result;
|
if (byTarget.length === 0) return result;
|
||||||
|
|
||||||
const products = await prisma.product.findMany({
|
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 },
|
select: { id: true, apicoreBottlingId: true, apicoreProductId: true },
|
||||||
});
|
});
|
||||||
if (products.length === 0) return result;
|
if (products.length === 0) return result;
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ describe("archiveCutoff", () => {
|
|||||||
describe("orderArchiveWhere", () => {
|
describe("orderArchiveWhere", () => {
|
||||||
const where = orderArchiveWhere(archiveCutoff(NOW));
|
const where = orderArchiveWhere(archiveCutoff(NOW));
|
||||||
|
|
||||||
it("archiviert nur versendete, abgeholte und stornierte Bestellungen", () => {
|
it("archiviert versendete, abgeholte und stornierte Bestellungen sowie bezahlte digitale (nichts zu versenden)", () => {
|
||||||
expect(where.status).toEqual({ in: ["VERSENDET", "ABGEHOLT", "STORNIERT"] });
|
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", () => {
|
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", () => {
|
it("laesst Bestellungen mit offener oder frisch bearbeiteter Retoure aktiv", () => {
|
||||||
expect(where.OR).toEqual([
|
expect(where.AND[1]).toEqual({
|
||||||
|
OR: [
|
||||||
{ returnRequest: { is: null } },
|
{ returnRequest: { is: null } },
|
||||||
{
|
{
|
||||||
returnRequest: {
|
returnRequest: {
|
||||||
is: { status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, updatedAt: { lte: archiveCutoff(NOW) } },
|
is: { status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, updatedAt: { lte: archiveCutoff(NOW) } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,7 +65,7 @@ describe("orderUnarchiveWhere", () => {
|
|||||||
archivedAt: { not: null },
|
archivedAt: { not: null },
|
||||||
archivedManually: false,
|
archivedManually: false,
|
||||||
OR: [
|
OR: [
|
||||||
{ status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
|
{ status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] }, NOT: { fulfillmentMethod: "DIGITAL", status: "BEZAHLT" } },
|
||||||
{ returnRequest: { is: { status: "ANGEFRAGT" } } },
|
{ returnRequest: { is: { status: "ANGEFRAGT" } } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-3
@@ -3,7 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Abgeschlossene Eintraege wandern nach einem Monat ohne Bearbeitung in den
|
* Abgeschlossene Eintraege wandern nach einem Monat ohne Bearbeitung in den
|
||||||
* Tab "Archiv", damit die aktiven Listen nur zeigen, was noch zu tun ist:
|
* 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
|
* - Retouren: Status ANGENOMMEN/ABGELEHNT/ERSTATTET
|
||||||
* - Produkte: Bestand 0 — Inventory.updatedAt ist dabei der Zeitpunkt der
|
* - Produkte: Bestand 0 — Inventory.updatedAt ist dabei der Zeitpunkt der
|
||||||
* letzten Bestandsaenderung, also der Moment, seit dem der Bestand 0 ist.
|
* letzten Bestandsaenderung, also der Moment, seit dem der Bestand 0 ist.
|
||||||
@@ -42,17 +44,24 @@ export function archiveCutoff(now: Date): Date {
|
|||||||
return new Date(now.getTime() - ARCHIVE_AFTER_DAYS * DAY_MS);
|
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) {
|
export function orderArchiveWhere(cutoff: Date) {
|
||||||
return {
|
return {
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
status: { in: [...ORDER_DONE_STATUSES] },
|
|
||||||
updatedAt: { lte: cutoff },
|
updatedAt: { lte: cutoff },
|
||||||
|
AND: [
|
||||||
|
{ OR: [{ status: { in: [...ORDER_DONE_STATUSES] } }, DIGITAL_DONE] },
|
||||||
// Eine Retoure haengt in eigener Tabelle — ihre Bearbeitung aendert
|
// Eine Retoure haengt in eigener Tabelle — ihre Bearbeitung aendert
|
||||||
// Order.updatedAt nicht und muss deshalb eigens abgewartet werden.
|
// Order.updatedAt nicht und muss deshalb eigens abgewartet werden.
|
||||||
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ returnRequest: { is: null } },
|
{ returnRequest: { is: null } },
|
||||||
{ returnRequest: { is: { status: { in: [...RETURN_DONE_STATUSES] }, updatedAt: { lte: cutoff } } } },
|
{ returnRequest: { is: { status: { in: [...RETURN_DONE_STATUSES] }, updatedAt: { lte: cutoff } } } },
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
} satisfies Prisma.OrderWhereInput;
|
} satisfies Prisma.OrderWhereInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +70,8 @@ export function orderUnarchiveWhere() {
|
|||||||
archivedAt: { not: null },
|
archivedAt: { not: null },
|
||||||
archivedManually: false,
|
archivedManually: false,
|
||||||
OR: [
|
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" } } },
|
{ returnRequest: { is: { status: "ANGEFRAGT" } } },
|
||||||
],
|
],
|
||||||
} satisfies Prisma.OrderWhereInput;
|
} 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);
|
const sendInvoiceCopy = vi.fn(async () => true);
|
||||||
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) }));
|
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 { issueCancellationInvoice, isCancellationNumber } = await import("./cancellation-invoice");
|
||||||
|
|
||||||
const order = {
|
const order = {
|
||||||
@@ -63,6 +66,32 @@ describe("issueCancellationInvoice", () => {
|
|||||||
|
|
||||||
expect(await issueCancellationInvoice("order1", "STORNIERUNG")).toBeNull();
|
expect(await issueCancellationInvoice("order1", "STORNIERUNG")).toBeNull();
|
||||||
expect(db.$transaction).not.toHaveBeenCalled();
|
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 () => {
|
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 { cancellationInvoiceEmail } from "@/lib/email-templates";
|
||||||
import { sendInvoiceCopy } from "@/lib/invoice-mail";
|
import { sendInvoiceCopy } from "@/lib/invoice-mail";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
|
import { revokeOrderSponsorships } from "@/lib/sponsorship/revoke";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stornorechnung: der Gegenbeleg, wenn eine Bestellung mit Rechnung
|
* 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 } } },
|
select: { id: true, totalCents: true, cancellation: { select: { number: true } } },
|
||||||
});
|
});
|
||||||
if (!invoice) return null;
|
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;
|
let number: string;
|
||||||
try {
|
try {
|
||||||
@@ -81,13 +87,29 @@ export async function issueCancellationInvoice(orderId: string, reason: Cancella
|
|||||||
if (!isUniqueConstraintError(error)) throw error;
|
if (!isUniqueConstraintError(error)) throw error;
|
||||||
const existing = await prisma.cancellationInvoice.findUnique({ where: { invoiceId: invoice.id }, select: { number: true } });
|
const existing = await prisma.cancellationInvoice.findUnique({ where: { invoiceId: invoice.id }, select: { number: true } });
|
||||||
if (!existing) throw error;
|
if (!existing) throw error;
|
||||||
|
await revokeSponsorships(orderId);
|
||||||
return { number: existing.number, created: false };
|
return { number: existing.number, created: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await revokeSponsorships(orderId);
|
||||||
await deliverCancellationInvoice(number);
|
await deliverCancellationInvoice(number);
|
||||||
return { number, created: true };
|
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) {
|
async function loadCancellation(number: string) {
|
||||||
return prisma.cancellationInvoice.findUnique({
|
return prisma.cancellationInvoice.findUnique({
|
||||||
where: { number },
|
where: { number },
|
||||||
|
|||||||
@@ -143,10 +143,22 @@ describe("Daten", () => {
|
|||||||
expect(fixedTermFor("2026-01-01")).toEqual({ kind: "FIXED", start: "2026-01-01", end: "2026-12-31" });
|
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", () => {
|
test("Startdatum: frühestens heute, spätestens sechs Monate danach", () => {
|
||||||
expect(startDateRange("2026-10-16")).toEqual({ min: "2026-10-16", max: "2027-04-16" });
|
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", () => {
|
test("isIsoDate erkennt echte Kalendertage", () => {
|
||||||
expect(isIsoDate("2028-02-29")).toBe(true);
|
expect(isIsoDate("2028-02-29")).toBe(true);
|
||||||
expect(isIsoDate("2027-02-29")).toBe(false);
|
expect(isIsoDate("2027-02-29")).toBe(false);
|
||||||
|
|||||||
@@ -147,10 +147,17 @@ export function toIsoDate(date: Date): string {
|
|||||||
return date.toISOString().slice(0, 10);
|
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);
|
const [year, month, day] = isoDate.split("-").map(Number);
|
||||||
// Date.UTC rollt Ueberlaeufe selbst (Tag 0 = letzter Tag des Vormonats).
|
// Tag 0 des Folgemonats = letzter Tag des Zielmonats.
|
||||||
return toIsoDate(new Date(Date.UTC(year, month - 1 + months, day + dayOffset)));
|
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 { prisma } from "@/lib/prisma";
|
||||||
import { asStringArray } from "@/lib/json-array";
|
import { asStringArray } from "@/lib/json-array";
|
||||||
import { matchesSearchQuery } from "@/lib/search-match";
|
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";
|
import type { Product, Harvest, Batch, ProductCategory, Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
// tasteTags/images sind im Schema als Json modelliert (MySQL-Kompatibilitaet,
|
// 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[] };
|
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 type ProductSort = "preis-auf" | "preis-ab" | "verfuegbarkeit";
|
||||||
|
|
||||||
export interface GetActiveProductsOptions {
|
export interface GetActiveProductsOptions {
|
||||||
query?: string;
|
query?: string;
|
||||||
category?: ProductCategory;
|
category?: ProductCategory;
|
||||||
sort?: ProductSort;
|
sort?: ProductSort;
|
||||||
/** Nur Produkte mit Lagerbestand > 0 anzeigen. */
|
/** Nur Produkte mit freiem Bestand > 0 anzeigen (Lager minus Abo-Reservierung). */
|
||||||
onlyAvailable?: boolean;
|
onlyAvailable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +87,16 @@ export async function getActiveProducts(
|
|||||||
orderBy,
|
orderBy,
|
||||||
})
|
})
|
||||||
).map(normalizeProduct);
|
).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
|
// Teilstring-Suche auf einzelnen tasteTags-Elementen unterstuetzt Prisma
|
||||||
// fuer Postgres-Arrays nicht (nur exakte Gleichheit via "has") — bei der
|
// 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
|
// matchesSearchQuery gleicht zusaetzlich Umlaute/ASCII-Transliteration ab
|
||||||
// (z.B. "Bluete" <-> "Blüte"), da Produktnamen/-tags durchgaengig echte
|
// (z.B. "Bluete" <-> "Blüte"), da Produktnamen/-tags durchgaengig echte
|
||||||
// Umlaute verwenden, aber nicht jede Kundin/jeder Kunde sie eintippt.
|
// 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(" ");
|
const haystack = [product.name, product.description, ...product.tasteTags].join(" ");
|
||||||
return matchesSearchQuery(haystack, trimmed);
|
return matchesSearchQuery(haystack, trimmed);
|
||||||
});
|
});
|
||||||
@@ -94,7 +124,7 @@ export async function getRelatedProducts(
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
return products.map(normalizeProduct);
|
return withFreeStock(products.map(normalizeProduct));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductBySlug(
|
export async function getProductBySlug(
|
||||||
@@ -106,9 +136,11 @@ export async function getProductBySlug(
|
|||||||
});
|
});
|
||||||
// Eine Patenschaft hat keine Produktseite (sie braucht eine Personalisierung).
|
// Eine Patenschaft hat keine Produktseite (sie braucht eine Personalisierung).
|
||||||
if (!product || product.category === "PATENSCHAFT") return null;
|
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(
|
export async function getProductsByIds(
|
||||||
ids: string[]
|
ids: string[]
|
||||||
): Promise<ProductWithRelations[]> {
|
): Promise<ProductWithRelations[]> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
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");
|
const now = new Date("2026-08-12T12:00:00Z");
|
||||||
|
|
||||||
@@ -88,3 +88,19 @@ describe("calculateDiscountCents", () => {
|
|||||||
expect(calculateDiscountCents({ type: "PERCENT", value: 100 }, 5450)).toBe(5450);
|
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 {
|
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);
|
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 { describe, test, expect, vi } from "vitest";
|
||||||
import { needsConfirmToChangeStatus, needsReserveOnReopen, needsRestockOnCancel, planInventoryEdit } from "./inventory";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import {
|
||||||
|
InsufficientStockError,
|
||||||
|
needsConfirmToChangeStatus,
|
||||||
|
needsReserveOnReopen,
|
||||||
|
needsRestockOnCancel,
|
||||||
|
planInventoryEdit,
|
||||||
|
reserveOrderItems,
|
||||||
|
} from "./inventory";
|
||||||
|
|
||||||
describe("planInventoryEdit", () => {
|
describe("planInventoryEdit", () => {
|
||||||
test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => {
|
test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => {
|
||||||
@@ -70,3 +78,33 @@ describe("needsReserveOnReopen", () => {
|
|||||||
expect(needsReserveOnReopen(null)).toBe(true);
|
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(
|
export async function reserveOrderItems(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
items: { productId: string; quantity: number; productName: string }[]
|
items: { productId: string; quantity: number; productName: string; product?: { category: string } | null }[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const item of items) {
|
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({
|
const reserved = await tx.inventory.updateMany({
|
||||||
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
||||||
data: { quantity: { decrement: item.quantity } },
|
data: { quantity: { decrement: item.quantity } },
|
||||||
|
|||||||
@@ -16,3 +16,17 @@ export const COUNTED_SALE_WHERE = {
|
|||||||
status: { notIn: ["NEU", "STORNIERT"] },
|
status: { notIn: ["NEU", "STORNIERT"] },
|
||||||
NOT: { returnRequest: { is: { status: "ERSTATTET" } } },
|
NOT: { returnRequest: { is: { status: "ERSTATTET" } } },
|
||||||
} satisfies Prisma.OrderWhereInput;
|
} 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", () => {
|
test("ohne gueltige Zahlung ist es der Starttag", () => {
|
||||||
expect(lastPaidDay([], start)).toEqual(start);
|
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", () => {
|
describe("isDisputeDelinquencyExpired", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SubscriptionStatus } from "@/generated/prisma/client";
|
import type { SubscriptionStatus } from "@/generated/prisma/client";
|
||||||
import type { Locale } from "@/i18n/config";
|
import type { Locale } from "@/i18n/config";
|
||||||
|
import { berlinIsoDate } from "@/lib/berlin-date";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reine Regeln der Abo-Abrechnung — ohne Datenbank und ohne Stripe, damit sie
|
* 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 …":
|
* Letzter bezahlter Tag eines beendeten Abos fuer die Urkunde "vom … bis …":
|
||||||
* der Tag vor dem Ende des letzten nicht zurueckgebuchten Zeitraums,
|
* 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 {
|
export function lastPaidDay(payments: readonly RulePayment[], startDate: Date): Date {
|
||||||
const ends = payments.filter((p) => p.reversedAt === null).map((p) => p.periodEnd.getTime());
|
const ends = payments.filter((p) => p.reversedAt === null).map((p) => p.periodEnd.getTime());
|
||||||
if (ends.length === 0) return utcDay(startDate);
|
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;
|
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 {
|
return {
|
||||||
ui_mode: "embedded_page",
|
ui_mode: "embedded_page",
|
||||||
mode: "subscription",
|
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,
|
customer: input.stripeCustomerId,
|
||||||
line_items: [{ price: input.stripePriceId, quantity: 1 }],
|
line_items: [{ price: input.stripePriceId, quantity: 1 }],
|
||||||
metadata,
|
metadata,
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ describe("createDueDeliveries", () => {
|
|||||||
const order = m.db.order.create.mock.calls[0][0].data;
|
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).toMatchObject({ status: "BEZAHLT", fulfillmentMethod: "VERSAND", totalCents: 0, street: "Wiesenweg 1" });
|
||||||
expect(order.items.create).toMatchObject({ productId: "honey_1", quantity: 2, unitPriceCents: 0, totalCents: 0 });
|
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({
|
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
|
||||||
where: { id: "del_1", orderId: null },
|
where: { id: "del_1", orderId: null },
|
||||||
data: { orderId: "order_9", status: "ANGELEGT", note: null },
|
data: { orderId: "order_9", status: "ANGELEGT", note: null },
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ export async function fulfillDelivery(deliveryId: string): Promise<FulfillResult
|
|||||||
sponsorshipRole: "ABO_DELIVERY",
|
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 },
|
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>.`,
|
`Wir haben dein Abo gefunden. Die Kündigung wird zum Ende des bezahlten Monats wirksam, also zum <strong>${end}</strong>.`,
|
||||||
publicUnmatched:
|
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.",
|
"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.`,
|
declaration: (reference: string) => `Hiermit kündige ich meinen Vertrag ${reference} zum nächstmöglichen Zeitpunkt.`,
|
||||||
name: "Name",
|
name: "Name",
|
||||||
email: "E-Mail",
|
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>.`,
|
`We have found your subscription. The cancellation takes effect at the end of the paid month, on <strong>${end}</strong>.`,
|
||||||
publicUnmatched:
|
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.",
|
"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.`,
|
declaration: (reference: string) => `I hereby cancel my contract ${reference} at the earliest possible date.`,
|
||||||
name: "Name",
|
name: "Name",
|
||||||
email: "Email",
|
email: "Email",
|
||||||
@@ -241,11 +247,14 @@ export async function publicCancellationReceiptEmail(input: {
|
|||||||
declaration: PublicCancellationDeclaration;
|
declaration: PublicCancellationDeclaration;
|
||||||
receivedAt: Date;
|
receivedAt: Date;
|
||||||
effectiveEnd: Date | null;
|
effectiveEnd: Date | null;
|
||||||
|
/** Link "Kündigung zurücknehmen" — nur bei automatisch vorgemerkter Kuendigung, nur an die Kontoadresse. */
|
||||||
|
undoUrl?: string | null;
|
||||||
}): Promise<EmailContent> {
|
}): Promise<EmailContent> {
|
||||||
const s = strings[input.locale];
|
const s = strings[input.locale];
|
||||||
const lines = cancellationDeclarationLines(input.declaration, input.locale);
|
const lines = cancellationDeclarationLines(input.declaration, input.locale);
|
||||||
const when = formatReceivedAt(input.receivedAt, input.locale);
|
const when = formatReceivedAt(input.receivedAt, input.locale);
|
||||||
const outcome = input.effectiveEnd ? s.publicMatched(formatDate(input.effectiveEnd, input.locale)) : s.publicUnmatched;
|
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(
|
const html = await emailLayout(
|
||||||
input.locale,
|
input.locale,
|
||||||
`${s.receivedAt}: ${when}`,
|
`${s.receivedAt}: ${when}`,
|
||||||
@@ -254,9 +263,21 @@ export async function publicCancellationReceiptEmail(input: {
|
|||||||
<p ${P}>${s.publicIntro}</p>
|
<p ${P}>${s.publicIntro}</p>
|
||||||
<div ${BOX}>${lines.map((line) => escapeHtml(line)).join("<br>")}</div>
|
<div ${BOX}>${lines.map((line) => escapeHtml(line)).join("<br>")}</div>
|
||||||
<p ${P}><strong>${s.receivedAt}:</strong> ${escapeHtml(when)}</p>
|
<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 };
|
return { subject: s.publicSubject, html, text };
|
||||||
}
|
}
|
||||||
@@ -13,9 +13,13 @@ vi.mock("@/lib/sponsorship-subscription/ending", () => ({
|
|||||||
endSubscription: m.endSubscription,
|
endSubscription: m.endSubscription,
|
||||||
ensureEndedCertificate: vi.fn(),
|
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 NOW = new Date("2026-10-30T12:00:00.000Z");
|
||||||
const DAY = 24 * 60 * 60 * 1000;
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
@@ -84,3 +88,14 @@ describe("endExpiredDisputes", () => {
|
|||||||
expect(await endExpiredDisputes(NOW)).toBe(0);
|
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 { DISPUTE_GRACE_DAYS, isDisputeDelinquencyExpired } from "@/lib/sponsorship-subscription/billing-rules";
|
||||||
import { createDueDeliveries } from "@/lib/sponsorship-subscription/deliveries";
|
import { createDueDeliveries } from "@/lib/sponsorship-subscription/deliveries";
|
||||||
import { endSubscription, ensureEndedCertificate } from "@/lib/sponsorship-subscription/ending";
|
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):
|
* Abo-Aufgaben im Hintergrundtakt (lib/background-jobs.ts, alle zehn Minuten):
|
||||||
*
|
*
|
||||||
* 1. faellige Honiglieferungen anlegen (deliveries.ts),
|
* 1. faellige Honiglieferungen anlegen (deliveries.ts),
|
||||||
* 2. Abos nach einer Rueckbuchung ohne Ausgleich nach 14 Tagen beenden,
|
* 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
|
* Jede Aufgabe ist idempotent und fuer sich abgesichert: ein Fehler in einer
|
||||||
* haelt die anderen nicht auf.
|
* haelt die anderen nicht auf.
|
||||||
@@ -78,4 +80,8 @@ export async function runSubscriptionJobs(now: Date): Promise<void> {
|
|||||||
await endExpiredDisputes(now);
|
await endExpiredDisputes(now);
|
||||||
});
|
});
|
||||||
await runStep("Urkunden beendeter Abos", repairEndedCertificates);
|
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
|
* Oeffentliche Kuendigung (/vertraege-kuendigen): Die Erklaerung wird IMMER
|
||||||
* gespeichert und bestaetigt; bei Stripe vorgemerkt wird sie nur, wenn Name,
|
* gespeichert. Bei Stripe vorgemerkt wird sie nur, wenn Vertragsnummer,
|
||||||
* E-Mail und Vertragsnummer zum Abo passen.
|
* 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";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
@@ -13,52 +15,64 @@ const m = vi.hoisted(() => ({
|
|||||||
},
|
},
|
||||||
cancelSubscription: vi.fn(),
|
cancelSubscription: vi.fn(),
|
||||||
sendMail: vi.fn(),
|
sendMail: vi.fn(),
|
||||||
|
rateLimit: vi.fn(),
|
||||||
|
receiptEmail: vi.fn(),
|
||||||
|
undoUrl: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
|
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
|
||||||
vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail }));
|
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", () => ({ cancelSubscription: m.cancelSubscription }));
|
||||||
|
vi.mock("@/lib/sponsorship-subscription/cancellation-undo", () => ({ cancellationUndoUrl: m.undoUrl }));
|
||||||
vi.mock("@/lib/sponsorship-subscription/emails", async (importOriginal) => {
|
vi.mock("@/lib/sponsorship-subscription/emails", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("./emails")>();
|
const actual = await importOriginal<typeof import("./emails")>();
|
||||||
return {
|
return { cancellationDeclarationLines: actual.cancellationDeclarationLines, publicCancellationReceiptEmail: m.receiptEmail };
|
||||||
cancellationDeclarationLines: actual.cancellationDeclarationLines,
|
|
||||||
publicCancellationReceiptEmail: vi.fn(async () => ({ subject: "Eingang", html: "<p></p>", text: "" })),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 NOW = new Date("2026-10-16T12:00:00.000Z");
|
||||||
|
const END = new Date("2026-11-16T10:00:00.000Z");
|
||||||
const SUBSCRIPTION = {
|
const SUBSCRIPTION = {
|
||||||
id: "cm1abcdefghijklmnop",
|
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 };
|
const INPUT = { name: "Erika Müller", email: "[email protected]", reference: "WH-P-2026-00042", message: null };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
m.db.sponsorship.findUnique.mockResolvedValue({ subscription: SUBSCRIPTION });
|
m.db.sponsorship.findUnique.mockResolvedValue({ subscription: SUBSCRIPTION });
|
||||||
m.db.sponsorshipSubscription.findUnique.mockResolvedValue(null);
|
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.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;
|
delete process.env.ADMIN_EMAIL;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("matchesCustomer", () => {
|
describe("normalizeName / matchesCustomer", () => {
|
||||||
test("passt bei gleicher E-Mail und enthaltenem Nachnamen, unabhaengig von Gross-/Kleinschreibung und Akzenten", () => {
|
test("normalisiert NFC, Gross-/Kleinschreibung und Leerraum", () => {
|
||||||
expect(matchesCustomer({ name: "erika MUELLER", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(true);
|
expect(normalizeName(" ERIKA Müller ")).toBe("erika müller");
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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: "Erika Müller", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(false);
|
||||||
expect(matchesCustomer({ name: "Max Mustermann", email: "[email protected]" }, SUBSCRIPTION.customer)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("submitPublicCancellation", () => {
|
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 });
|
const result = await submitPublicCancellation(INPUT, { locale: "de", verified: true, now: NOW });
|
||||||
|
|
||||||
expect(m.cancelSubscription).toHaveBeenCalledWith({ subscriptionId: SUBSCRIPTION.id, receivedAt: NOW, sendConfirmation: false });
|
expect(m.cancelSubscription).toHaveBeenCalledWith({ subscriptionId: SUBSCRIPTION.id, receivedAt: NOW, sendConfirmation: false });
|
||||||
@@ -67,26 +81,63 @@ describe("submitPublicCancellation", () => {
|
|||||||
entityType: "SponsorshipSubscription",
|
entityType: "SponsorshipSubscription",
|
||||||
entityId: SUBSCRIPTION.id,
|
entityId: SUBSCRIPTION.id,
|
||||||
});
|
});
|
||||||
expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "[email protected]" }));
|
expect(m.undoUrl).toHaveBeenCalledWith(SUBSCRIPTION.id, END);
|
||||||
expect(result.confirmationSent).toBe(true);
|
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");
|
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 });
|
const result = await submitPublicCancellation({ ...INPUT, email: "[email protected]" }, { locale: "de", verified: true, now: NOW });
|
||||||
|
|
||||||
expect(m.cancelSubscription).not.toHaveBeenCalled();
|
expect(m.cancelSubscription).not.toHaveBeenCalled();
|
||||||
expect(m.db.auditLog.create.mock.calls[0][0].data.entityId).toBeNull();
|
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(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 });
|
await submitPublicCancellation(INPUT, { locale: "de", verified: false, now: NOW });
|
||||||
|
|
||||||
expect(m.cancelSubscription).not.toHaveBeenCalled();
|
expect(m.cancelSubscription).not.toHaveBeenCalled();
|
||||||
expect(m.db.auditLog.create).toHaveBeenCalledOnce();
|
expect(m.receiptEmail).toHaveBeenCalledWith(expect.objectContaining({ effectiveEnd: null, undoUrl: null }));
|
||||||
expect(m.sendMail).toHaveBeenCalledOnce();
|
expect(m.sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: "[email protected]" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("findet das Abo auch ueber die Vertragsnummer aus dem Konto (Abo-ID)", async () => {
|
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 () => {
|
test("ein Fehler bei Stripe verhindert weder Speicherung noch Eingangsbestaetigung", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
m.cancelSubscription.mockRejectedValue(new Error("Stripe nicht erreichbar"));
|
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(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 { sendMail } from "@/lib/mail";
|
||||||
import { escapeHtml } from "@/lib/escape-html";
|
import { escapeHtml } from "@/lib/escape-html";
|
||||||
import { formatReceivedAt } from "@/lib/withdrawal";
|
import { formatReceivedAt } from "@/lib/withdrawal";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import type { Locale } from "@/i18n/config";
|
import type { Locale } from "@/i18n/config";
|
||||||
import { cancelSubscription } from "@/lib/sponsorship-subscription/cancellation";
|
import { cancelSubscription } from "@/lib/sponsorship-subscription/cancellation";
|
||||||
|
import { cancellationUndoUrl } from "@/lib/sponsorship-subscription/cancellation-undo";
|
||||||
import {
|
import {
|
||||||
cancellationDeclarationLines,
|
cancellationDeclarationLines,
|
||||||
publicCancellationReceiptEmail,
|
publicCancellationReceiptEmail,
|
||||||
@@ -15,14 +17,25 @@ import {
|
|||||||
* Kuendigung ohne Anmeldung (§ 312k BGB, Seite /vertraege-kuendigen), nach
|
* Kuendigung ohne Anmeldung (§ 312k BGB, Seite /vertraege-kuendigen), nach
|
||||||
* dem Muster der Widerrufsfunktion (api/widerruf):
|
* dem Muster der Widerrufsfunktion (api/widerruf):
|
||||||
*
|
*
|
||||||
* - Die Erklaerung wird IMMER gespeichert und ihr Eingang per Mail
|
* - Die Erklaerung wird IMMER gespeichert (Audit-Log) und dem Admin
|
||||||
* bestaetigt: sie ist mit dem Zugang abgegeben, die Zuordnung zum Vertrag
|
* gemeldet: sie ist mit dem Zugang abgegeben, die Zuordnung zum Vertrag
|
||||||
* ist unsere Aufgabe.
|
* ist unsere Aufgabe.
|
||||||
* - Automatisch bei Stripe vorgemerkt wird die Kuendigung nur, wenn E-Mail,
|
* - Automatisch bei Stripe vorgemerkt (zum Ende des bezahlten Monats) wird
|
||||||
* Name und Vertragsnummer zum Abo passen und die Sicherheitspruefung
|
* sie nur, wenn Vertragsnummer, E-Mail (ohne Gross-/Kleinschreibung) und
|
||||||
* bestanden ist. Sonst bearbeitet der Admin sie von Hand.
|
* Vor- plus Nachname EXAKT zum Konto passen (nach Normalisierung, kein
|
||||||
* - Die Antwort ist fuer passende und unpassende Angaben gleich — sonst
|
* Teilstring) und die Sicherheitspruefung bestanden ist.
|
||||||
* liessen sich Vertragsnummern samt E-Mail-Adresse durchprobieren.
|
* - 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
|
* Gespeichert wird im Audit-Log (adminId leer, Aktion
|
||||||
* "subscription.cancellation_request"): eine eigene Tabelle wie
|
* "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";
|
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 {
|
/** Name fuer den Vergleich: Unicode NFC, Kleinschreibung, Leerraum zusammengefasst. */
|
||||||
return value.normalize("NFKD").replace(/\p{M}/gu, "");
|
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"). */
|
/** Passt die Angabe zum Konto? Gleiche E-Mail und exakt Vor- plus Nachname des Kontos (nach Normalisierung). */
|
||||||
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. */
|
|
||||||
export function matchesCustomer(
|
export function matchesCustomer(
|
||||||
input: { name: string; email: string },
|
input: { name: string; email: string },
|
||||||
customer: { email: string; firstName: string; lastName: string }
|
customer: { email: string; firstName: string; lastName: string }
|
||||||
): boolean {
|
): boolean {
|
||||||
if (input.email.trim().toLowerCase() !== customer.email.trim().toLowerCase()) return false;
|
if (input.email.trim().toLowerCase() !== customer.email.trim().toLowerCase()) return false;
|
||||||
const given = nameForms(input.name);
|
const expected = normalizeName(`${customer.firstName} ${customer.lastName}`);
|
||||||
return nameForms(customer.lastName).some((last) => last.length > 0 && given.some((form) => form.includes(last)));
|
return expected.length > 0 && normalizeName(input.name) === expected;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionSelect = {
|
const subscriptionSelect = {
|
||||||
@@ -87,14 +96,30 @@ async function findReferencedSubscription(reference: string) {
|
|||||||
export interface PublicCancellationResult {
|
export interface PublicCancellationResult {
|
||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
lines: 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(
|
export async function submitPublicCancellation(
|
||||||
input: PublicCancellationDeclaration,
|
input: PublicCancellationDeclaration,
|
||||||
context: { locale: Locale; verified: boolean; now: Date }
|
context: { locale: Locale; verified: boolean; now: Date }
|
||||||
): Promise<PublicCancellationResult> {
|
): 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;
|
const matched = subscription && matchesCustomer(input, subscription.customer) ? subscription : null;
|
||||||
|
|
||||||
let effectiveEnd: Date | null = null;
|
let effectiveEnd: Date | null = null;
|
||||||
@@ -108,11 +133,7 @@ export async function submitPublicCancellation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lines = cancellationDeclarationLines(input, context.locale);
|
const lines = cancellationDeclarationLines(input, context.locale);
|
||||||
const outcome = effectiveEnd
|
const outcome = outcomeText({ effectiveEnd, matched: matched !== null, limited: !referenceAllowed || !emailAllowed });
|
||||||
? `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.";
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
adminId: null,
|
adminId: null,
|
||||||
@@ -124,19 +145,27 @@ export async function submitPublicCancellation(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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({
|
const receipt = await publicCancellationReceiptEmail({
|
||||||
locale: context.locale,
|
locale: context.locale,
|
||||||
declaration: input,
|
declaration: input,
|
||||||
receivedAt: context.now,
|
receivedAt: context.now,
|
||||||
effectiveEnd,
|
effectiveEnd,
|
||||||
|
undoUrl: matched && effectiveEnd ? cancellationUndoUrl(matched.id, effectiveEnd) : null,
|
||||||
});
|
});
|
||||||
const confirmationSent = await sendMail({ to: input.email, ...receipt });
|
confirmationSent = await sendMail({ to: recipient, ...receipt });
|
||||||
|
}
|
||||||
|
|
||||||
// Interne Benachrichtigung wie beim Widerruf: eine nicht automatisch
|
// Interne Benachrichtigung wie beim Widerruf: eine nicht automatisch
|
||||||
// vorgemerkte Kuendigung muss rechtzeitig von Hand erledigt werden.
|
// vorgemerkte Kuendigung muss rechtzeitig von Hand erledigt werden.
|
||||||
const adminEmail = process.env.ADMIN_EMAIL;
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
if (adminEmail) {
|
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({
|
await sendMail({
|
||||||
to: adminEmail,
|
to: adminEmail,
|
||||||
subject: `Neue Kündigung zu ${input.reference.replace(/[\r\n]+/g, " ")}`,
|
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">;
|
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({
|
const quarterly = await db.sponsorshipSubscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: [...RESERVING_STATUSES] },
|
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
|
// Verzug wegen einer fehlgeschlagenen Zahlung haelt sie dagegen: Stripe
|
||||||
// versucht es erneut, und meist klappt es.
|
// versucht es erneut, und meist klappt es.
|
||||||
NOT: { status: "IM_VERZUG", payments: { some: { reversedAt: { not: null } } } },
|
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 today = startOfUtcDay(now);
|
||||||
const windows = await db.harvestWindow.findMany({
|
const windows = await db.harvestWindow.findMany({
|
||||||
where: { endsOn: { gte: today }, offer: { honeyProductId: productId, cycle: "ERNTE" } },
|
where: { endsOn: { gte: today }, offer: { honeyProductId: { in: ids }, cycle: "ERNTE" } },
|
||||||
select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true },
|
select: { offerId: true, startsOn: true, endsOn: true, plannedJars: true, offer: { select: { honeyProductId: true } } },
|
||||||
});
|
});
|
||||||
let harvestJars = 0;
|
if (windows.length === 0) return result;
|
||||||
for (const window of windows) {
|
|
||||||
const delivered = await db.subscriptionDelivery.aggregate({
|
// 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: {
|
where: {
|
||||||
status: "ANGELEGT",
|
status: "ANGELEGT",
|
||||||
subscription: { offerId: window.offerId },
|
subscription: { offerId: { in: [...new Set(windows.map((window) => window.offerId))] } },
|
||||||
dueAt: { gte: window.startsOn, lte: endOfUtcDay(window.endsOn) },
|
dueAt: { gte: earliest },
|
||||||
},
|
},
|
||||||
_sum: { jars: true },
|
select: { jars: true, dueAt: true, subscription: { select: { offerId: true } } },
|
||||||
});
|
});
|
||||||
harvestJars += Math.max(0, window.plannedJars - (delivered._sum.jars ?? 0));
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return quarterlyJars + harvestJars;
|
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). */
|
/** 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> {
|
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 } });
|
const inventory = await db.inventory.findUnique({ where: { productId }, select: { quantity: true } });
|
||||||
if (!inventory) return 0;
|
if (!inventory) return 0;
|
||||||
const reserved = await reservedJarsForProduct(productId, now, db);
|
return freeStock(inventory.quantity, await reservedJarsForProduct(productId, now, db));
|
||||||
return Math.max(0, inventory.quantity - reserved);
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
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 {
|
import {
|
||||||
fromIsoDate,
|
fromIsoDate,
|
||||||
parseStoredPersonalization,
|
parseStoredPersonalization,
|
||||||
toIsoDate,
|
|
||||||
toStoredPersonalization,
|
toStoredPersonalization,
|
||||||
} from "@/lib/sponsorship/certificate-document";
|
} from "@/lib/sponsorship/certificate-document";
|
||||||
|
import { berlinIsoDate } from "@/lib/berlin-date";
|
||||||
import { isUniqueViolation } from "@/lib/sponsorship-subscription/lookup";
|
import { isUniqueViolation } from "@/lib/sponsorship-subscription/lookup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Patenschaft eines Abos entsteht mit der ersten bezahlten Rechnung
|
* Die Patenschaft eines Abos entsteht mit der ersten bezahlten Rechnung
|
||||||
* (Spec "Ablauf nach der Zahlung": "Beim Abo entsteht sie mit der ersten
|
* (Spec "Ablauf nach der Zahlung": "Beim Abo entsteht sie mit der ersten
|
||||||
* bezahlten Rechnung"). Startdatum ist der Tag dieser Zahlung — das beim
|
* bezahlten Rechnung"). Startdatum ist der Berliner Kalendertag dieser
|
||||||
* Abschluss gewaehlte Datum wird dabei ersetzt.
|
* Zahlung (wie der Leistungszeitraum der Rechnung) — das beim Abschluss
|
||||||
|
* gewaehlte Datum wird dabei ersetzt.
|
||||||
*
|
*
|
||||||
* Idempotent ueber das eindeutige subscriptionId an der Patenschaft: ein
|
* Idempotent ueber das eindeutige subscriptionId an der Patenschaft: ein
|
||||||
* paralleler oder wiederholter Lauf scheitert an der Datenbank und uebernimmt
|
* 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];
|
const first = subscription.payments[0];
|
||||||
if (!first) return null;
|
if (!first) return null;
|
||||||
const stored = parseStoredPersonalization(subscription.personalization);
|
const stored = parseStoredPersonalization(subscription.personalization);
|
||||||
const startDate = toIsoDate(first.paidAt);
|
const startDate = berlinIsoDate(first.paidAt);
|
||||||
try {
|
try {
|
||||||
sponsorshipId = await prisma.$transaction(async (tx) => {
|
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({
|
const created = await tx.sponsorship.create({
|
||||||
data: {
|
data: {
|
||||||
number,
|
number,
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ describe("checkout.session.completed (Abo)", () => {
|
|||||||
id: "cs_1",
|
id: "cs_1",
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
subscription: "sub_stripe",
|
subscription: "sub_stripe",
|
||||||
|
payment_status: "paid",
|
||||||
metadata: { sponsorshipSubscriptionId: "sub_local" },
|
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 () => {
|
test("laesst Einmal-Checkouts unberuehrt", async () => {
|
||||||
await handleSubscriptionEvent(event("checkout.session.completed", { id: "cs_1", mode: "payment", metadata: { orderId: "o1" } }));
|
await handleSubscriptionEvent(event("checkout.session.completed", { id: "cs_1", mode: "payment", metadata: { orderId: "o1" } }));
|
||||||
expect(m.db.sponsorshipSubscription.findUnique).not.toHaveBeenCalled();
|
expect(m.db.sponsorshipSubscription.findUnique).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export async function handleSubscriptionCheckoutCompleted(session: Stripe.Checko
|
|||||||
const stripeSubscriptionId = stripeIdOf(session.subscription);
|
const stripeSubscriptionId = stripeIdOf(session.subscription);
|
||||||
if (stripeSubscriptionId) await findLocalSubscription(stripeSubscriptionId, local.id);
|
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({
|
await prisma.sponsorshipSubscription.updateMany({
|
||||||
where: { id: local.id, status: "AUSSTEHEND" },
|
where: { id: local.id, status: "AUSSTEHEND" },
|
||||||
data: { status: "AKTIV", startedAt: new Date() },
|
data: { status: "AKTIV", startedAt: new Date() },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
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`);
|
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();
|
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. */
|
/** Abo gekuendigt, laeuft bis zum Ende des bezahlten Monats. */
|
||||||
| "CANCELLING"
|
| "CANCELLING"
|
||||||
/** Abo beendet. */
|
/** Abo beendet. */
|
||||||
| "ENDED";
|
| "ENDED"
|
||||||
|
/** Einmalkauf storniert oder erstattet (Status WIDERRUFEN): keine Downloads, keine Uebertragung. */
|
||||||
|
| "REVOKED";
|
||||||
|
|
||||||
export interface LifecycleInput {
|
export interface LifecycleInput {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date | null;
|
endDate: Date | null;
|
||||||
subscription: { status: SubscriptionStatus } | null;
|
subscription: { status: SubscriptionStatus } | null;
|
||||||
|
/** Gespeicherter Ausstellungsstatus; nur WIDERRUFEN wirkt hier. */
|
||||||
|
status?: SponsorshipStatus;
|
||||||
now: Date;
|
now: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +52,8 @@ function dayNumber(date: Date): number {
|
|||||||
return Math.floor(date.getTime() / 86_400_000);
|
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);
|
const today = dayNumber(now);
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
if (subscription.status === "BEENDET") return "ENDED";
|
if (subscription.status === "BEENDET") return "ENDED";
|
||||||
@@ -194,6 +199,7 @@ function toOwnedSponsorship(row: SponsorshipRow, locale: Locale, now: Date): Own
|
|||||||
startDate: row.startDate,
|
startDate: row.startDate,
|
||||||
endDate: row.endDate,
|
endDate: row.endDate,
|
||||||
subscription: row.subscription,
|
subscription: row.subscription,
|
||||||
|
status: row.status,
|
||||||
now,
|
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 {
|
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 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 { checkCertificateFits } from "@/lib/certificate/layout";
|
||||||
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
|
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
|
||||||
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
|
import type { SponsorshipOfferView } from "@/lib/sponsorship/offers";
|
||||||
@@ -81,14 +81,13 @@ export interface ExpandInput {
|
|||||||
now: Date;
|
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 } {
|
export function startDateWindow(now: Date): { min: string; max: string } {
|
||||||
const min = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
return startDateRange(toIsoDate(now));
|
||||||
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) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expandCheckoutItems(input: ExpandInput): ExpandResult {
|
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", () => {
|
it("verlangt ein Startdatum zwischen heute und in sechs Monaten", () => {
|
||||||
const window = startDateWindow(now);
|
const window = startDateWindow(now);
|
||||||
expect(window).toEqual({ min: "2026-11-02", max: "2027-05-02" });
|
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(
|
expect(
|
||||||
expandCheckoutItems(input([{ productId: "carrier", quantity: 1, personalization: { ...personalization, startDate: "2026-11-01" } }])).ok
|
expandCheckoutItems(input([{ productId: "carrier", quantity: 1, personalization: { ...personalization, startDate: "2026-11-01" } }])).ok
|
||||||
).toBe(false);
|
).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;
|
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
|
* 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);
|
const sponsorship = await loadForIssue(id);
|
||||||
if (!sponsorship) return "missing";
|
if (!sponsorship) return "missing";
|
||||||
if (sponsorship.status === "BEREIT" || sponsorship.status === "GEDRUCKT") return "ready";
|
if (sponsorship.status === "BEREIT" || sponsorship.status === "GEDRUCKT") return "ready";
|
||||||
|
// Storniert/erstattet: keine Urkunde und keine Mail mehr.
|
||||||
|
if (sponsorship.status === "WIDERRUFEN") return "revoked";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureFirstRevision(sponsorship);
|
await ensureFirstRevision(sponsorship);
|
||||||
@@ -216,8 +218,10 @@ export async function processSponsorship(id: string, now: Date = new Date()): Pr
|
|||||||
const attempts = sponsorship.pdfAttempts + 1;
|
const attempts = sponsorship.pdfAttempts + 1;
|
||||||
const exhausted = attempts >= MAX_ISSUE_ATTEMPTS;
|
const exhausted = attempts >= MAX_ISSUE_ATTEMPTS;
|
||||||
console.error(`[patenschaft] ${sponsorship.number}: Versuch ${attempts} fehlgeschlagen: ${message}`);
|
console.error(`[patenschaft] ${sponsorship.number}: Versuch ${attempts} fehlgeschlagen: ${message}`);
|
||||||
await prisma.sponsorship.update({
|
// updateMany mit Statusbedingung: ein gleichzeitiger Widerruf darf hier
|
||||||
where: { id },
|
// nicht auf AUSSTEHEND/FEHLER zurueckgesetzt werden.
|
||||||
|
await prisma.sponsorship.updateMany({
|
||||||
|
where: { id, status: { in: ["AUSSTEHEND", "FEHLER"] } },
|
||||||
data: {
|
data: {
|
||||||
pdfAttempts: attempts,
|
pdfAttempts: attempts,
|
||||||
lastError: message.slice(0, 2000),
|
lastError: message.slice(0, 2000),
|
||||||
@@ -243,7 +247,7 @@ export async function processPendingSponsorships(options: { orderId?: string; li
|
|||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
take: options.limit ?? 50,
|
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) {
|
for (const { id } of pending) {
|
||||||
counts[await processSponsorship(id, now)]++;
|
counts[await processSponsorship(id, now)]++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { asStringArray } from "@/lib/json-array";
|
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 { Locale } from "@/i18n/config";
|
||||||
import type {
|
import type {
|
||||||
MedalTier,
|
MedalTier,
|
||||||
@@ -82,8 +83,7 @@ function localize(row: OfferRow, locale: Locale, isAvailable: boolean): Sponsors
|
|||||||
|
|
||||||
async function freeJarsByProduct(rows: OfferRow[]): Promise<Map<string, number>> {
|
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 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 availableForSaleByProduct(ids);
|
||||||
return new Map(entries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activeSubscriptionCounts(rows: OfferRow[]): Promise<Map<string, number>> {
|
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();
|
if (aboIds.length === 0) return new Map();
|
||||||
const groups = await prisma.sponsorshipSubscription.groupBy({
|
const groups = await prisma.sponsorshipSubscription.groupBy({
|
||||||
by: ["offerId"],
|
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 },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
return new Map(groups.map((group) => [group.offerId, group._count._all]));
|
return new Map(groups.map((group) => [group.offerId, group._count._all]));
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export type ReviseSponsorshipError =
|
|||||||
| RevisionInputError
|
| RevisionInputError
|
||||||
| { code: "not_found" }
|
| { code: "not_found" }
|
||||||
| { code: "no_revision" }
|
| { code: "no_revision" }
|
||||||
|
| { code: "revoked" }
|
||||||
| { code: "glyphs"; field: CertificateField; characters: string[] }
|
| { code: "glyphs"; field: CertificateField; characters: string[] }
|
||||||
| { code: "fit"; field: CertificateField }
|
| { code: "fit"; field: CertificateField }
|
||||||
| { code: "conflict" };
|
| { code: "conflict" };
|
||||||
@@ -224,6 +225,8 @@ export async function reviseSponsorship(input: ReviseSponsorshipInput): Promise<
|
|||||||
const sponsorship = await loadForRevision(input.sponsorshipId);
|
const sponsorship = await loadForRevision(input.sponsorshipId);
|
||||||
if (!sponsorship) return { ok: false, error: { code: "not_found" } };
|
if (!sponsorship) return { ok: false, error: { code: "not_found" } };
|
||||||
if (sponsorship.currentRevision === 0) return { ok: false, error: { code: "no_revision" } };
|
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({
|
const currentRow = await prisma.sponsorshipRevision.findUnique({
|
||||||
where: { sponsorshipId_revision: { sponsorshipId: sponsorship.id, revision: sponsorship.currentRevision } },
|
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;
|
ownerId: string;
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
|
status: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
revisions: { personalization: unknown; term: unknown }[];
|
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);
|
const sponsorship = db.sponsorships.find((s) => s.id === row.sponsorshipId);
|
||||||
return sponsorship ? matches(sponsorship as unknown as Record<string, unknown>, expected as Where) : false;
|
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)) {
|
if (expected !== null && typeof expected === "object" && "gt" in (expected as object)) {
|
||||||
return (row[key] as Date).getTime() > ((expected as { gt: Date }).gt).getTime();
|
return (row[key] as Date).getTime() > ((expected as { gt: Date }).gt).getTime();
|
||||||
}
|
}
|
||||||
@@ -116,6 +120,7 @@ function seed(overrides: Partial<SponsorshipRow> = {}): SponsorshipRow {
|
|||||||
ownerId: "owner",
|
ownerId: "owner",
|
||||||
subscriptionId: null,
|
subscriptionId: null,
|
||||||
currentRevision: 1,
|
currentRevision: 1,
|
||||||
|
status: "BEREIT",
|
||||||
locale: "de",
|
locale: "de",
|
||||||
revisions: [{ personalization, term }],
|
revisions: [{ personalization, term }],
|
||||||
...overrides,
|
...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", () => {
|
describe("evaluateClaim", () => {
|
||||||
const transfer = { expiresAt: new Date(NOW.getTime() + HOUR), claimedAt: null, revokedAt: null, createdById: "owner" };
|
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", () => {
|
it("ist am Ablaufzeitpunkt selbst schon ungueltig", () => {
|
||||||
expect(evaluateClaim({ transfer, sponsorship, claimantId: "friend", now: NOW })).toBe("OK");
|
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 { personalizationSchema, type CertificateTerm } from "@/lib/certificate/schema";
|
||||||
import { parseStoredTerm } from "@/lib/sponsorship/access";
|
import { parseStoredTerm } from "@/lib/sponsorship/access";
|
||||||
import type { TransferValidity } from "@/lib/sponsorship/transfer-validity";
|
import type { TransferValidity } from "@/lib/sponsorship/transfer-validity";
|
||||||
|
import type { SponsorshipStatus } from "@/generated/prisma/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uebertragung einer Einmal-Patenschaft an ein anderes Konto (Spec
|
* Uebertragung einer Einmal-Patenschaft an ein anderes Konto (Spec
|
||||||
@@ -60,7 +61,7 @@ export function transferClaimPath(token: string): string {
|
|||||||
|
|
||||||
export interface ClaimCheckInput {
|
export interface ClaimCheckInput {
|
||||||
transfer: { expiresAt: Date; claimedAt: Date | null; revokedAt: Date | null; createdById: string } | null;
|
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;
|
claimantId: string;
|
||||||
now: Date;
|
now: Date;
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,7 @@ export type ClaimDecision = "OK" | "INVALID" | "OWN";
|
|||||||
/**
|
/**
|
||||||
* Reine Entscheidung, ob ein Link eingeloest werden darf. "INVALID" deckt
|
* Reine Entscheidung, ob ein Link eingeloest werden darf. "INVALID" deckt
|
||||||
* bewusst alle Gruende ab (abgelaufen, widerrufen, eingeloest, unbekannt,
|
* 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.
|
* Meldung und verraet nichts ueber die Patenschaft.
|
||||||
*/
|
*/
|
||||||
export function evaluateClaim({ transfer, sponsorship, claimantId, now }: ClaimCheckInput): ClaimDecision {
|
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 (transfer.expiresAt.getTime() <= now.getTime()) return "INVALID";
|
||||||
if (sponsorship.subscriptionId !== null) return "INVALID";
|
if (sponsorship.subscriptionId !== null) return "INVALID";
|
||||||
if (sponsorship.currentRevision === 0) 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
|
// Der Link gilt nur, solange die Patenschaft noch dem Konto gehoert, das
|
||||||
// ihn erstellt hat.
|
// ihn erstellt hat.
|
||||||
if (sponsorship.ownerId !== transfer.createdById) return "INVALID";
|
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 result = await prisma.$transaction(async (tx): Promise<CreateTransferResult> => {
|
||||||
const sponsorship = await tx.sponsorship.findFirst({
|
const sponsorship = await tx.sponsorship.findFirst({
|
||||||
where: { id: input.sponsorshipId, ownerId: input.ownerId },
|
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) 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" };
|
return { ok: false, reason: "NOT_TRANSFERABLE" };
|
||||||
}
|
}
|
||||||
await tx.sponsorshipTransfer.updateMany({
|
await tx.sponsorshipTransfer.updateMany({
|
||||||
@@ -178,6 +180,7 @@ const claimSelect = {
|
|||||||
ownerId: true,
|
ownerId: true,
|
||||||
subscriptionId: true,
|
subscriptionId: true,
|
||||||
currentRevision: true,
|
currentRevision: true,
|
||||||
|
status: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
revisions: { orderBy: { revision: "desc" }, take: 1, select: { personalization: true, term: 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" };
|
if (claimed.count === 0) return { ok: false, reason: "INVALID" };
|
||||||
|
|
||||||
const moved = await tx.sponsorship.updateMany({
|
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 },
|
data: { ownerId: input.claimantId },
|
||||||
});
|
});
|
||||||
if (moved.count === 0) {
|
if (moved.count === 0) {
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,6 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { asStringArray } from "@/lib/json-array";
|
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> {
|
export async function isProductWishlisted(productId: string, customerId: string): Promise<boolean> {
|
||||||
const item = await prisma.wishlistItem.findUnique({
|
const item = await prisma.wishlistItem.findUnique({
|
||||||
@@ -21,9 +21,12 @@ export async function getWishlistProducts(customerId: string): Promise<ProductWi
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return items.map((item) => ({
|
// Freier Bestand wie im Shop (Lager minus Abo-Reservierung).
|
||||||
|
return withFreeStock(
|
||||||
|
items.map((item) => ({
|
||||||
...item.product,
|
...item.product,
|
||||||
tasteTags: asStringArray(item.product.tasteTags),
|
tasteTags: asStringArray(item.product.tasteTags),
|
||||||
images: asStringArray(item.product.images),
|
images: asStringArray(item.product.images),
|
||||||
}));
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
migrateCartState,
|
migrateCartState,
|
||||||
cartPhysicalQuantity,
|
cartPhysicalQuantity,
|
||||||
cartIsDigitalOnly,
|
cartIsDigitalOnly,
|
||||||
|
cartDiscountBase,
|
||||||
type CartSponsorship,
|
type CartSponsorship,
|
||||||
type NewCartItem,
|
type NewCartItem,
|
||||||
} from "./cart-store";
|
} from "./cart-store";
|
||||||
@@ -125,6 +126,14 @@ describe("cart store", () => {
|
|||||||
expect(cartPhysicalQuantity(useCartStore.getState().items)).toBe(5);
|
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", () => {
|
test("a cart with only digital sponsorships is digital-only", () => {
|
||||||
const { addSponsorship } = useCartStore.getState();
|
const { addSponsorship } = useCartStore.getState();
|
||||||
addSponsorship(packageItem, sponsorship("Mila", { isPhysical: false, honeyJars: 0 }));
|
addSponsorship(packageItem, sponsorship("Mila", { isPhysical: false, honeyJars: 0 }));
|
||||||
|
|||||||
@@ -174,6 +174,20 @@ export function cartPhysicalQuantity(items: CartItem[]): number {
|
|||||||
}, 0);
|
}, 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.
|
* true, wenn nichts versendet werden muss: nur Patenschaften ohne Honig.
|
||||||
* Dieselbe Regel wie hasPhysical im Server (physicalQuantity > 0), damit die
|
* Dieselbe Regel wie hasPhysical im Server (physicalQuantity > 0), damit die
|
||||||
|
|||||||
Reference in new issue
Block a user