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
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:
1 parent
dd180563ca
commit
e5b6736dcb
28 files changed
+332
-100
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user