feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto

Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

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 14:07:30 +02:00
1 parent dcb578f062
commit f227aeb8f9
269 files changed
+28054 -141

No files matched your search

+73
View File
@@ -39,6 +39,7 @@ const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCen
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
const item = (overrides: Partial<CartItem> = {}): CartItem => ({
key: "p1",
productId: "p1",
slug: "fruehtracht-2026",
name: "Frühtracht 2026",
@@ -62,6 +63,24 @@ function renderCheckout(items: CartItem[], stripePublishableKey: string | undefi
);
}
const sponsorshipItem = (key: string, beeName: string, honeyJars = 0): CartItem => ({
key,
productId: "pkg-bienenfreund",
slug: "bienenfreund",
name: "Bienenfreund",
weightGrams: 0,
priceCents: 1900,
image: null,
quantity: 1,
sponsorship: {
offerSlug: "bienenfreund",
offerName: "Bienenfreund",
isPhysical: honeyJars > 0,
honeyJars,
personalization: { beeName, recipientName: "Erika Muster", occasion: "GEBURTSTAG", startDate: "2026-11-01" },
},
});
async function fillAndSubmit(userEventInstance: ReturnType<typeof userEvent.setup>) {
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
@@ -179,4 +198,58 @@ describe("CheckoutForm", () => {
expect(await screen.findByText(/zahlung ist gerade nicht verfügbar/i)).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
test("a cart with only digital sponsorships needs no delivery and is sent as DIGITAL with personalization", async () => {
const userEventInstance = userEvent.setup();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }),
} as Response);
renderCheckout([sponsorshipItem("sp_1", "Frieda"), sponsorshipItem("sp_2", "Frieda")]);
expect(screen.queryByRole("button", { name: /abholung/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText(/straße|strasse/i)).not.toBeInTheDocument();
expect(screen.getAllByText("Biene „Frieda“")).toHaveLength(2);
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
await screen.findByTestId("embedded-payment");
const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)) as {
fulfillmentMethod: string;
street?: string;
items: { productId: string; quantity: number; personalization?: { beeName: string } }[];
};
expect(body.fulfillmentMethod).toBe("DIGITAL");
expect(body.street).toBeUndefined();
// Zwei gleiche Pakete bleiben zwei Positionen, jede mit eigener Personalisierung.
expect(body.items).toHaveLength(2);
expect(body.items.every((line) => line.quantity === 1 && line.personalization?.beeName === "Frieda")).toBe(true);
});
test("a package with honey keeps the delivery choice and counts its jars for shipping", () => {
renderCheckout([sponsorshipItem("sp_1", "Lotte", 1)]);
expect(screen.getByRole("button", { name: /abholung/i })).toBeInTheDocument();
expect(screen.getByText("7,00 €")).toBeInTheDocument();
});
test("shows the account message with a login link when the server answers 401", async () => {
const userEventInstance = userEvent.setup();
const message = "Für eine Bienenpatenschaft brauchst du ein Kundenkonto. Bitte melde dich an.";
fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: message }) } as Response);
renderCheckout([sponsorshipItem("sp_1", "Frieda")]);
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
expect(await screen.findByText(message, { exact: false })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Zur Anmeldung" })).toHaveAttribute("href", "/konto?next=%2Fcheckout");
});
});