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

@@ -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();
});
});