import { formatPrice } from "./format"; // Server-only — imported exclusively by app/api/discount/*/route.ts (Route // Handlers are never bundled for the client anyway, but this file also // touches DISCOUNT_SERVICE_SECRET, which must never end up reachable from // a "use client" import graph). Kept out of lib/payload.ts on purpose, // same reasoning as that file's own comment about staying free of // next/headers — a shared module used by both server and client code is // exactly where an accidental server-only dependency causes a build break. const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de"; const TENANT_SLUG = "einfach-produktiv"; const SERVICE_SECRET = process.env.DISCOUNT_SERVICE_SECRET || ""; type PayloadDiscountCode = { id: number; code: string; type: "percent" | "fixed"; value: number; validFrom: string | null; validUntil: string | null; minOrderValue: number | null; maxRedemptions: number | null; redemptionCount: number; active: boolean; }; async function fetchDiscountCode(code: string): Promise { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, "where[code][equals]": code.toUpperCase().trim(), limit: "1", }); const res = await fetch(`${PAYLOAD_URL}/api/discount-codes?${params}`, { headers: { "x-discount-service-secret": SERVICE_SECRET }, cache: "no-store", }); if (!res.ok) { console.error(`fetchDiscountCode: Payload returned ${res.status} ${res.statusText}`); return null; } const data: { docs?: PayloadDiscountCode[] } = await res.json(); return data.docs?.[0] ?? null; } // Whether it's worth showing the cart's manual "Rabattcode" input field at // all — no point offering an open text field for a shopper to type into // when there's nothing in Payload that could ever validate. Existence-only // check (active: true), not the fuller validFrom/validUntil/minOrderValue // window validateDiscountCode() does for an actual submitted code — this // just gates whether the field renders, the real validation still happens // at apply time regardless. export async function hasActiveDiscountCode(): Promise { const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, "where[active][equals]": "true", limit: "1", }); const res = await fetch(`${PAYLOAD_URL}/api/discount-codes?${params}`, { headers: { "x-discount-service-secret": SERVICE_SECRET }, next: { revalidate: 60 }, }); if (!res.ok) { console.error(`hasActiveDiscountCode: Payload returned ${res.status} ${res.statusText}`); return false; } const data: { docs?: unknown[] } = await res.json(); return (data.docs?.length ?? 0) > 0; } export type DiscountValidation = | { valid: true; doc: PayloadDiscountCode } | { valid: false; reason: string }; // Shared by both routes below — /validate calls this read-only when a // shopper applies a code in the cart; /redeem calls it again immediately // before incrementing the counter (the window/limit may have changed // between the two, however unlikely), so neither route duplicates these // rules independently. export async function validateDiscountCode(code: string, subtotal: number): Promise { const doc = await fetchDiscountCode(code); if (!doc) return { valid: false, reason: "Dieser Code existiert nicht." }; if (!doc.active) return { valid: false, reason: "Dieser Code ist nicht mehr gültig." }; const now = Date.now(); if (doc.validFrom && now < new Date(doc.validFrom).getTime()) { return { valid: false, reason: "Dieser Code ist noch nicht gültig." }; } if (doc.validUntil && now > new Date(doc.validUntil).getTime()) { return { valid: false, reason: "Dieser Code ist abgelaufen." }; } if (doc.minOrderValue != null && subtotal < doc.minOrderValue) { return { valid: false, reason: `Dieser Code gilt erst ab einem Bestellwert von ${formatPrice(doc.minOrderValue)}.` }; } if (doc.maxRedemptions != null && doc.redemptionCount >= doc.maxRedemptions) { return { valid: false, reason: "Dieser Code wurde bereits zu oft eingelöst." }; } return { valid: true, doc }; } // Read-then-write, not an atomic conditional update — a true concurrent // race on the very last redemption of a capped code has a narrow window // where two requests could both pass validateDiscountCode() before either // increments. Accepted, not worth custom atomic SQL for this shop's // traffic level. export async function redeemDiscountCode(doc: PayloadDiscountCode): Promise { const res = await fetch(`${PAYLOAD_URL}/api/discount-codes/${doc.id}`, { method: "PATCH", headers: { "x-discount-service-secret": SERVICE_SECRET, "Content-Type": "application/json", }, body: JSON.stringify({ redemptionCount: doc.redemptionCount + 1 }), }); if (!res.ok) { console.error(`redeemDiscountCode: Payload returned ${res.status} ${res.statusText}`); } return res.ok; }