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

- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und
  Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC)
- Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache
- Rabatte gelten nicht fuer Patenschaftspositionen
- Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen
  zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten
- Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt
- Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich)
- Shop zeigt freien Bestand nach Abo-Reservierung
- Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN)
- Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %)
- Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin,
  Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 15:09:32 +02:00
1 parent f227aeb8f9
commit 515fca1c16
97 files changed
+2979 -254

No files matched your search

+13
View File
@@ -10,6 +10,8 @@ import { logAdminAction } from "@/lib/audit-log";
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
import { certificateDirsOfCustomer, hasBlockingSubscription, removeCertificateDirs } from "@/lib/sponsorship/customer-deletion";
import { discardPendingCheckouts } from "@/lib/sponsorship-subscription/pending";
export async function createCustomerAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
@@ -176,8 +178,19 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
if (await isLastAdmin(id)) {
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
}
// Laufendes Abo: erst kuendigen bzw. bei Stripe beenden — sonst zoege
// Stripe weiter ab, ohne dass es ein Konto gibt (lib/sponsorship/customer-deletion.ts).
if (await hasBlockingSubscription(id)) {
return fail(
"/admin/benutzer",
"Dieses Konto hat ein laufendes Patenschafts-Abo. Bitte das Abo zuerst kündigen und sein Ende abwarten (oder bei Stripe beenden) — erst danach lässt sich das Konto löschen."
);
}
const certificateDirs = await certificateDirsOfCustomer(id);
await discardPendingCheckouts(id);
const deleted = await prisma.customer.delete({ where: { id } });
await removeCertificateDirs(certificateDirs);
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
revalidatePath("/admin/benutzer");
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
+5 -4
View File
@@ -66,14 +66,15 @@ async function settleCancelledOrder(order: {
orderNumber: string;
previousStatus: OrderStatus;
stripeCheckoutSessionId: string | null;
payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null;
payment: { status: PaymentStatus; stripePaymentIntentId: string | null; amountCents: number } | null;
}): Promise<string[]> {
const warnings: string[] = [];
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
if (order.payment?.status === "SUCCEEDED") {
// 0-€-Zahlungen (Abo-Honiglieferung, Provider "abo") haben nichts zu erstatten.
if (order.payment?.status === "SUCCEEDED" && order.payment.amountCents > 0) {
const paymentIntentId = order.payment.stripePaymentIntentId;
if (!paymentIntentId) {
warnings.push(
@@ -149,8 +150,8 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
firstName: true,
locale: true,
stripeCheckoutSessionId: true,
payment: { select: { status: true, stripePaymentIntentId: true } },
items: { select: { productId: true, quantity: true, productName: true } },
payment: { select: { status: true, stripePaymentIntentId: true, amountCents: true } },
items: { select: { productId: true, quantity: true, productName: true, product: { select: { category: true } } } },
returnRequest: { select: { status: true } },
invoice: { select: { cancellation: { select: { number: true } } } },
},
+3
View File
@@ -47,6 +47,7 @@ const PROCESS_MESSAGES: Record<ProcessResult, string> = {
retry: "Der Versuch ist fehlgeschlagen. Der nächste automatische Versuch ist eingeplant — Fehler siehe unten.",
failed: "Der Versuch ist fehlgeschlagen. Alle automatischen Versuche sind aufgebraucht — Fehler siehe unten.",
missing: "Die Patenschaft wurde nicht gefunden.",
revoked: "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und wird nicht mehr ausgestellt.",
};
/**
@@ -152,6 +153,8 @@ function revisionErrorMessage(error: ReviseSponsorshipError): string {
return "Die Patenschaft wurde nicht gefunden.";
case "no_revision":
return "Die Urkunde wurde noch nicht erzeugt — korrigieren lässt sich erst eine bestehende Urkunde.";
case "revoked":
return "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und lässt sich nicht mehr korrigieren.";
case "reason":
return `Bitte einen Änderungsgrund angeben (${error.min}–${error.max} Zeichen).`;
case "unknown_template":
+20 -3
View File
@@ -17,15 +17,23 @@ import { getDictionary } from "@/i18n/get-dictionary";
import { isDhlConfigured } from "@/lib/dhl";
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
import { Button } from "@/components/ui";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
const t = getDictionary(await getLocale());
const locale = await getLocale();
const t = getDictionary(locale);
const statusLabels = t.orderStatusBadge;
const { id } = await props.params;
const order = await prisma.order.findUnique({
where: { id },
include: {
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } },
items: {
include: {
product: { select: { slug: true } },
batch: { select: { code: true } },
sponsorship: { select: { number: true, status: true } },
},
},
payment: true,
shipment: true,
pickup: true,
@@ -36,6 +44,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
});
if (!order) notFound();
const dhlConfigured = isDhlConfigured();
// Storno/Erstattung widerruft Patenschaften (lib/sponsorship/revoke.ts);
// ein Wiederoeffnen stellt sie bewusst nicht von selbst wieder her.
const revokedNumbers = order.items.flatMap((item) => (item.sponsorship?.status === "WIDERRUFEN" ? [item.sponsorship.number] : []));
// Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt
// ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht
// auseinanderlaufen koennen.
@@ -88,6 +99,12 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
/>
</div>
{revokedNumbers.length > 0 ? (
<p role="note" className="mb-8 border border-ink/20 bg-ink/[0.03] px-6 py-4 text-sm text-ink/80">
{getSponsorshipAdminStrings(locale).order.revokedNote(revokedNumbers.join(", "))}
</p>
) : null}
{order.giftMessage ? (
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
@@ -211,7 +228,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
{item.productName}
</Link>{" "}
<span className="text-ink/65">({item.weightGrams} g)</span>
{item.weightGrams > 0 ? <span className="text-ink/65">({item.weightGrams} g)</span> : null}
</td>
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
<td className="py-2 text-right">{item.quantity}</td>
+6 -1
View File
@@ -55,7 +55,12 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([
prisma.order.count({
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
// DIGITAL (nur Patenschaften, Abo-Monatsrechnungen): nichts zu packen.
where: {
status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] },
archivedAt: null,
fulfillmentMethod: { not: "DIGITAL" },
},
}),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
+17 -6
View File
@@ -3,7 +3,7 @@ import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-p
import { prisma } from "@/lib/prisma";
import { pendingReviewWhere } from "@/lib/review-status";
import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard";
import { COUNTED_SALE_WHERE } from "@/lib/sales";
import { COUNTED_SALE_WHERE, SHOP_SALE_WHERE } from "@/lib/sales";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -130,14 +130,20 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
where: { createdAt: { gte: startOfMonth() }, ...COUNTED_SALE_WHERE },
}),
prisma.order.count({
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] }, fulfillmentMethod: { not: "DIGITAL" } },
}),
prisma.inventory.aggregate({ _sum: { quantity: true } }),
prisma.orderItem.aggregate({ _sum: { quantity: true }, where: { order: COUNTED_SALE_WHERE } }),
// Glaeser: Patenschafts-Positionen (Urkunde, Abo-Monatszeilen) sind keine.
prisma.orderItem.aggregate({
_sum: { quantity: true },
where: { order: COUNTED_SALE_WHERE, product: { category: { not: "PATENSCHAFT" } } },
}),
// Warenkorbwert nur aus echten Shop-Bestellungen: Abo-Monatsrechnungen
// und 0-€-Abo-Lieferungen sind keine Warenkoerbe.
prisma.order.aggregate({
_avg: { totalCents: true },
_count: true,
where: COUNTED_SALE_WHERE,
where: SHOP_SALE_WHERE,
}),
// Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
// JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher,
@@ -168,7 +174,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
// machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann.
prisma.order.groupBy({
by: ["customerId"],
where: { customerId: { not: null }, ...COUNTED_SALE_WHERE },
where: { customerId: { not: null }, ...SHOP_SALE_WHERE },
having: { customerId: { _count: { gt: 1 } } },
}),
prisma.discountCode.findMany({
@@ -270,7 +276,12 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries, sponsorshipItems] = await Promise.all([
getCurrentAdmin(),
prisma.order.count({
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
// DIGITAL (nur Patenschaften, Abo-Monatsrechnungen): nichts zu packen.
where: {
status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] },
archivedAt: null,
fulfillmentMethod: { not: "DIGITAL" },
},
}),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.review.count({ where: pendingReviewWhere() }),
@@ -27,6 +27,27 @@ const buttonClass = "px-4 py-2 text-xs";
export function SponsorshipActions({ id, status, currentRevision, isPhysical, printed, ownerEmail, t }: SponsorshipActionsProps) {
const issued = currentRevision > 0;
const delivered = status === "BEREIT" || status === "GEDRUCKT";
// Widerrufen (Storno/Erstattung): nur noch das Druck-PDF zur Ansicht, keine
// Aktion, die der Kundschaft wieder etwas zustellen oder aendern wuerde.
if (status === "WIDERRUFEN") {
return (
<div className="flex flex-col gap-4">
{issued ? (
<div className="flex flex-wrap items-center gap-3">
<a
href={`/admin/patenschaften/${id}/revision/${currentRevision}.pdf?inline=1`}
target="_blank"
rel="noopener"
className="inline-flex items-center justify-center border border-ink/20 px-4 py-2 text-xs tracking-wide-caps text-ink transition-colors hover:border-ink"
>
{t.openPrintPdf}
</a>
</div>
) : null}
<AdminActionNote>{t.noteRevoked}</AdminActionNote>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
@@ -6,6 +6,7 @@ const SPONSORSHIP_TONES: Record<SponsorshipStatus, AdminBadgeTone> = {
FEHLER: "danger",
BEREIT: "positive",
GEDRUCKT: "neutral",
WIDERRUFEN: "danger",
};
const SUBSCRIPTION_TONES: Record<SubscriptionStatus, AdminBadgeTone> = {
+5 -5
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/prisma";
import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation";
import { reservedJarsByProduct } from "@/lib/sponsorship-subscription/reservation";
/**
* Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und
@@ -41,14 +41,14 @@ export async function getHoneyStock(productIds: string[], now: Date = new Date()
select: { productId: true, quantity: true },
});
const stockById = new Map(inventories.map((row) => [row.productId, row.quantity]));
const entries = await Promise.all(
ids.map(async (id) => {
const reservedById = await reservedJarsByProduct(ids, now);
return new Map(
ids.map((id) => {
const stock = stockById.get(id) ?? 0;
const reserved = await reservedJarsForProduct(id, now);
const reserved = reservedById.get(id) ?? 0;
return [id, { stock, reserved, free: Math.max(0, stock - reserved) }] as const;
})
);
return new Map(entries);
}
/** Deckung je Honigprodukt, das mit mindestens einem Abo-Angebot verknuepft ist. */
@@ -8,7 +8,7 @@ import { beeNameFromJson } from "./revision-data";
* sondern die Arbeitsliste des Druckablaufs: physisches Paket, Urkunde
* bereit, noch nicht gedruckt.
*/
export const LIST_STATUSES = ["AUSSTEHEND", "FEHLER", "BEREIT", "GEDRUCKT", "ZU_DRUCKEN"] as const;
export const LIST_STATUSES = ["AUSSTEHEND", "FEHLER", "BEREIT", "GEDRUCKT", "WIDERRUFEN", "ZU_DRUCKEN"] as const;
export type ListStatus = (typeof LIST_STATUSES)[number];
export const TO_PRINT_FILTER = {