feat: Gutscheine gelten fuer Patenschaften, Layout der Patenschaftsseiten, "Suesses Abo"
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 4m36s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 5m4s
Docker Image bauen und veroeffentlichen / test (push) Successful in 3m32s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 11m51s

- Eingegebene Gutscheincodes wirken auf die ganze Zwischensumme inkl.
  Patenschaften; automatische Mengenrabatte zaehlen weiter nur Ware
  ausserhalb von Patenschaftspaketen; Vorschau und Server rechnen gleich
- Paketkarten erst ab lg dreispaltig, Umschalter bricht nicht mehr aus,
  Schrittleiste ohne Querscrollen bei 1024 px, Vorschau-Dialog passt in
  die Hoehe, Felder scrollen ueber die mobile Leiste, 16 px auf Touch
- Umschalter-Beschriftung "Suesses Abo" / "Sweet subscription"

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 13:26:26 +02:00
1 parent dd180563ca
commit e5b6736dcb
28 files changed
+332 -100

No files matched your search

+20 -1
View File
@@ -1,5 +1,6 @@
import { describe, test, expect } from "vitest";
import { resolveBulkDiscount, getNextBulkDiscountTier } from "./bulk-discount-calc";
import { resolveBulkDiscount, getNextBulkDiscountTier, computeBulkDiscount } from "./bulk-discount-calc";
import { calculateDiscountCents } from "./discount-calc";
const tiers = [
{ code: "MENGE10", minQuantity: 3, percent: 10 },
@@ -46,3 +47,21 @@ describe("getNextBulkDiscountTier", () => {
expect(getNextBulkDiscountTier(tiers, 3)).toEqual({ unitsUntilNext: 3, percent: 15 });
});
});
describe("computeBulkDiscount", () => {
test("ohne rabattfaehige Ware gibt es keinen Mengenrabatt", () => {
expect(computeBulkDiscount(tiers, { subtotalCents: 0, quantity: 0 })).toBeNull();
});
test("rechnet den Betrag wie der Checkout-Server (calculateDiscountCents)", () => {
const result = computeBulkDiscount(tiers, { subtotalCents: 4455, quantity: 5 });
expect(result?.tier.code).toBe("MENGE10");
expect(result?.discountCents).toBe(calculateDiscountCents({ type: "PERCENT", value: 10 }, 4455));
});
test("ueberspringt eine Stufe, deren Mindestbestellwert nicht erreicht ist, wie der Server", () => {
const withMinimum = [tiers[0], { ...tiers[1], minOrderCents: 10000 }];
expect(computeBulkDiscount(withMinimum, { subtotalCents: 5940, quantity: 6 })?.tier.code).toBe("MENGE10");
expect(computeBulkDiscount(withMinimum, { subtotalCents: 10000, quantity: 6 })?.tier.code).toBe("MENGE15");
});
});
+22
View File
@@ -1,7 +1,11 @@
import { calculateDiscountCents, type DiscountBase } from "./discount-calc";
export interface BulkDiscountTierLike {
code: string;
minQuantity: number;
percent: number;
/** Mindestbestellwert des Codes in Cent (wie DiscountCode.minOrderCents), fehlt = 0. */
minOrderCents?: number;
}
/**
@@ -31,3 +35,21 @@ export function getNextBulkDiscountTier(
if (!next) return null;
return { unitsUntilNext: next.minQuantity - totalQuantity, percent: next.percent };
}
/**
* Vorschau des Mengenrabatts fuer Warenkorb und Kasse — dieselbe Auswahl wie
* der Checkout-Server: unter allen Stufen, deren Mengenschwelle UND
* Mindestbestellwert die Basis erreicht, gewinnt die mit der hoechsten
* Schwelle; der Betrag kommt aus calculateDiscountCents. Die Basis ist
* bulkDiscountBase bzw. cartBulkDiscountBase (ohne Patenschaften).
*/
export function computeBulkDiscount(
tiers: BulkDiscountTierLike[],
base: DiscountBase
): { tier: BulkDiscountTierLike; discountCents: number } | null {
if (base.quantity <= 0 || base.subtotalCents <= 0) return null;
const reachable = tiers.filter((tier) => base.subtotalCents >= (tier.minOrderCents ?? 0));
const tier = resolveBulkDiscount(reachable, base.quantity);
if (!tier) return null;
return { tier, discountCents: calculateDiscountCents({ type: "PERCENT", value: tier.percent }, base.subtotalCents) };
}
+15 -2
View File
@@ -38,7 +38,20 @@ export async function getBulkDiscountTiers(): Promise<BulkDiscountTierLike[]> {
const codes = await prisma.discountCode.findMany({
where: { minQuantity: { not: null }, type: "PERCENT", isActive: true },
});
// Dieselben Bedingungen wie isDiscountCodeValid im Checkout-Server
// (Zeitraum, Nutzungslimit; Mindestbestellwert prueft computeBulkDiscount),
// damit die Vorschau keinen Rabatt verspricht, den die Kasse nicht gibt.
return codes
.filter((code) => (!code.validFrom || code.validFrom <= now) && (!code.validUntil || code.validUntil >= now))
.map((code) => ({ code: code.code, minQuantity: code.minQuantity as number, percent: code.value }));
.filter(
(code) =>
(!code.validFrom || code.validFrom <= now) &&
(!code.validUntil || code.validUntil >= now) &&
(code.maxUses === null || code.usedCount < code.maxUses)
)
.map((code) => ({
code: code.code,
minQuantity: code.minQuantity as number,
percent: code.value,
minOrderCents: code.minOrderCents,
}));
}
+25 -5
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import { isDiscountCodeValid, calculateDiscountCents, discountBase, type DiscountCodeLike } from "./discount-calc";
import { isDiscountCodeValid, calculateDiscountCents, bulkDiscountBase, discountBaseForCode, type DiscountCodeLike } from "./discount-calc";
const now = new Date("2026-08-12T12:00:00Z");
@@ -89,9 +89,9 @@ describe("calculateDiscountCents", () => {
});
});
describe("discountBase", () => {
describe("bulkDiscountBase", () => {
test("zaehlt nur Positionen ohne Patenschaftsrolle (MAIN und HONEY bleiben draussen)", () => {
const base = discountBase([
const base = bulkDiscountBase([
{ totalCents: 1980, quantity: 2 },
{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" },
{ totalCents: 2100, quantity: 3, sponsorshipRole: "HONEY" },
@@ -100,7 +100,27 @@ describe("discountBase", () => {
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 });
test("reiner Patenschafts-Warenkorb hat keine Mengenrabatt-Basis", () => {
expect(bulkDiscountBase([{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" }])).toEqual({ subtotalCents: 0, quantity: 0 });
});
});
describe("discountBaseForCode", () => {
const items = [
{ totalCents: 1980, quantity: 2 },
{ totalCents: 3800, quantity: 1, sponsorshipRole: "MAIN" },
{ totalCents: 2100, quantity: 3, sponsorshipRole: "HONEY" },
];
test("ein normaler Gutschein rechnet mit der ganzen Zwischensumme inklusive Patenschaft", () => {
expect(discountBaseForCode({ minQuantity: null }, items)).toEqual({ subtotalCents: 7880, quantity: 6 });
});
test("ein normaler Gutschein wirkt auch auf einen reinen Patenschafts-Warenkorb", () => {
expect(discountBaseForCode({ minQuantity: null }, items.slice(1))).toEqual({ subtotalCents: 5900, quantity: 4 });
});
test("ein Mengenrabatt-Code (minQuantity gesetzt) zaehlt weiterhin nur Ware ausserhalb von Patenschaften", () => {
expect(discountBaseForCode({ minQuantity: 3 }, items)).toEqual({ subtotalCents: 1980, quantity: 2 });
});
});
+30 -13
View File
@@ -44,20 +44,37 @@ export interface DiscountBase {
quantity: number;
}
type DiscountableItem = { totalCents: number; quantity: number; sponsorshipRole?: string | null };
function sumBase(items: ReadonlyArray<DiscountableItem>): DiscountBase {
return items.reduce<DiscountBase>(
(base, item) => ({ subtotalCents: base.subtotalCents + item.totalCents, quantity: base.quantity + item.quantity }),
{ subtotalCents: 0, quantity: 0 }
);
}
/**
* 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.
* Basis fuer Mengenrabatte (DiscountCode mit minQuantity): nur Ware
* ausserhalb von Patenschaften. Patenschaftspositionen (MAIN und der
* Honiganteil HONEY) zaehlen weder zur Mengenschwelle noch zum Rabattbetrag —
* sonst liesse sich mit einem Paket ein Mengenrabatt auf andere Ware
* freischalten.
*/
export function discountBase(
items: ReadonlyArray<{ totalCents: number; quantity: number; sponsorshipRole?: string | null }>
export function bulkDiscountBase(items: ReadonlyArray<DiscountableItem>): DiscountBase {
return sumBase(items.filter((item) => !item.sponsorshipRole));
}
/**
* Rabattbasis fuer einen konkreten Code. Ein eingegebener Gutschein (ohne
* minQuantity) wirkt auf die ganze Zwischensumme, Patenschaften
* eingeschlossen: Mindestbestellwert und Rabattbetrag rechnen mit allen
* Positionen. Ein Mengenrabatt-Code bleibt bei bulkDiscountBase — auch dann,
* wenn ihn jemand von Hand eintippt, damit sich die Schwelle nicht ueber
* Paketglaeser umgehen laesst.
*/
export function discountBaseForCode(
code: Pick<DiscountCodeLike, "minQuantity">,
items: ReadonlyArray<DiscountableItem>
): DiscountBase {
return items
.filter((item) => !item.sponsorshipRole)
.reduce((base, item) => ({ subtotalCents: base.subtotalCents + item.totalCents, quantity: base.quantity + item.quantity }), {
subtotalCents: 0,
quantity: 0,
});
return code.minQuantity === null ? sumBase(items) : bulkDiscountBase(items);
}
+13
View File
@@ -209,6 +209,19 @@ function distributeProportionally(amountCents: number, weights: readonly number[
* sich anteilig auf. Fuer den Rabatt gilt dasselbe, er mindert alle Saetze
* im selben Verhaeltnis.
*
* Ein eingegebener Gutschein wirkt auf die ganze Zwischensumme inklusive
* Patenschaften (lib/discount-calc.ts, discountBaseForCode) — dafuer ist die
* Verteilung ueber alle Positionen exakt richtig. Ein Mengenrabatt rechnet
* dagegen nur mit Ware ausserhalb von Patenschaften (bulkDiscountBase), wird
* hier aber trotzdem ueber alle Positionen verteilt: die Bestellung haelt
* nicht fest, auf welcher Basis ihr Rabatt berechnet wurde (der verknuepfte
* DiscountCode ist nachtraeglich editierbar und kann geloescht werden, taugt
* also nicht als Beleg). Liegen Patenschaft (Urkunde) und rabattierte Ware
* auf verschiedenen Saetzen, verschiebt sich dadurch ein kleiner Teil des
* Rabatts zwischen den Saetzen; der Rechnungsbetrag und die Summe aller
* Zeilen bleiben exakt. Genau waere es erst mit einem eigenen Feld an der
* Bestellung (Rabattbasis), das bewusst noch nicht angelegt ist.
*
* Zugesichert: die Summe aller grossCents ergibt exakt den Rechnungsbetrag,
* und je Zeile gilt netCents + vatCents === grossCents.
*/