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