Files

166 lines
6.6 KiB
TypeScript

export type WertTyp = "euro" | "prozent";
export type VoucherInput = {
name: string;
anbieter: string;
wertTyp?: string;
wertCents: number;
restCents: number;
prozentWert?: number | null;
mindestbestellwertCents?: number | null;
ablaufdatum: string | null;
codeText?: string | null;
kategorie?: string | null;
notiz?: string | null;
};
export function validateVoucher(v: VoucherInput): string[] {
const errs: string[] = [];
if (!v.name?.trim()) errs.push("name");
if (!v.anbieter?.trim()) errs.push("anbieter");
const typ = v.wertTyp ?? "euro";
if (typ !== "euro" && typ !== "prozent") errs.push("wertTyp");
if (typ === "euro") {
if (!Number.isInteger(v.wertCents) || v.wertCents <= 0) errs.push("wertCents");
if (!Number.isInteger(v.restCents) || v.restCents < 0 || v.restCents > v.wertCents) errs.push("restCents");
} else if (typ === "prozent") {
if (!Number.isInteger(v.prozentWert) || (v.prozentWert as number) < 1 || (v.prozentWert as number) > 100) {
errs.push("prozentWert");
}
}
if (v.mindestbestellwertCents !== undefined && v.mindestbestellwertCents !== null) {
if (!Number.isInteger(v.mindestbestellwertCents) || v.mindestbestellwertCents < 0) {
errs.push("mindestbestellwertCents");
}
}
return errs;
}
export function formatEuro(cents: number): string {
return (cents / 100).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
}
/**
* Prüft einen optionalen Shop-Link. Leere Eingabe → { url: null, error: false }.
* Fehlendes Schema wird mit https:// ergänzt. Nur http/https ist erlaubt.
*/
export function parseShopUrl(raw: unknown): { url: string | null; error: boolean } {
if (raw === undefined || raw === null || String(raw).trim() === "") return { url: null, error: false };
const s = String(raw).trim();
if (s.length > 2000) return { url: null, error: true };
const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s) ? s : `https://${s}`;
try {
const u = new URL(withScheme);
if (u.protocol !== "http:" && u.protocol !== "https:") return { url: null, error: true };
if (!u.hostname.includes(".")) return { url: null, error: true };
return { url: u.toString(), error: false };
} catch {
return { url: null, error: true };
}
}
/**
* Wandelt eine Euro-Eingabe in Cent um. Akzeptiert deutsches ("25,50",
* "1.000,50") und englisches ("25.50") Dezimalformat.
* Leere Eingabe → null, ungültige Eingabe → NaN.
*/
import { parseReminderDays } from "./reminders";
import { normalizeDomain } from "./logo";
export type ParsedVoucherFields = {
name: string;
anbieter: string;
wertTyp: "euro" | "prozent";
wertCents: number;
restCents: number;
prozentWert: number | null;
mindestbestellwertCents: number | null;
ablaufdatum: string | null;
codeText: string | null;
kategorie: string | null;
notiz: string | null;
providerDomain: string | null;
shopUrl: string | null;
contactIds: number[];
reminderDays: number[];
explicitNone: boolean;
};
/** Parst einen Gutschein-Request-Body (POST und PATCH teilen sich das). API-Vertrag bleibt Cent-basiert. */
export function parseVoucherFields(body: any): { fields: ParsedVoucherFields; errors: string[] } {
const wertTyp = body?.wertTyp === "prozent" ? "prozent" : "euro";
const mindestRaw = body?.mindestbestellwertCents;
const fields: ParsedVoucherFields = {
name: String(body?.name ?? ""),
anbieter: String(body?.anbieter ?? ""),
wertTyp,
wertCents: wertTyp === "euro" ? Number(body?.wertCents) : 0,
restCents:
wertTyp === "euro"
? body?.restCents === undefined || body?.restCents === null || body?.restCents === ""
? Number(body?.wertCents)
: Number(body?.restCents)
: 0,
prozentWert: wertTyp === "prozent" ? Number(body?.prozentWert) : null,
mindestbestellwertCents:
mindestRaw === undefined || mindestRaw === null || String(mindestRaw).trim() === "" ? null : Number(mindestRaw),
ablaufdatum: body?.ablaufdatum ? String(body.ablaufdatum) : null,
codeText: body?.codeText ? String(body.codeText) : null,
kategorie: body?.kategorie ? String(body.kategorie) : null,
notiz: body?.notiz ? String(body.notiz) : null,
providerDomain: null,
shopUrl: null,
contactIds: Array.isArray(body?.contactIds) ? body.contactIds.map(Number).filter(Number.isInteger) : [],
reminderDays: [],
explicitNone: Array.isArray(body?.reminderDays) && body.reminderDays.length === 0,
};
const errs: string[] = [];
const rawDays =
typeof body?.reminderDays === "string"
? body.reminderDays
: Array.isArray(body?.reminderDays)
? body.reminderDays
: undefined;
fields.reminderDays = parseReminderDays(rawDays);
if (
!fields.explicitNone &&
rawDays !== undefined &&
(typeof rawDays !== "string" || rawDays.trim() !== "") &&
fields.reminderDays.length === 0
) {
errs.push("reminderDays");
}
const shop = parseShopUrl(body?.shopUrl);
if (shop.error) errs.push("shopUrl");
else fields.shopUrl = shop.url;
const pdRaw = body?.providerDomain === undefined || body?.providerDomain === null ? null : String(body.providerDomain);
if (pdRaw !== null && pdRaw.trim() !== "" && !normalizeDomain(pdRaw)) errs.push("providerDomain");
else fields.providerDomain = pdRaw && normalizeDomain(pdRaw) ? normalizeDomain(pdRaw) : null;
return { fields, errors: errs };
}
/**
* Wandelt eine Euro-Eingabe in Cent um. Akzeptiert deutsches ("25,50",
* "1.000,50") und englisches ("25.50") Dezimalformat.
* Leere Eingabe → null, ungültige Eingabe → NaN.
*/
export function euroToCents(s: string | undefined | null): number | null {
if (s === undefined || s === null || s.trim() === "") return null;
let t = s.trim();
if (t.includes(",")) t = t.replace(/\./g, "").replace(",", ".");
const n = Number(t);
if (!Number.isFinite(n)) return NaN;
return Math.round(n * 100);
}
export function formatWert(v: { wertTyp: string; wertCents: number; prozentWert?: number | null }): string {
if (v.wertTyp === "prozent") return `${v.prozentWert ?? 0} % Rabatt`;
return formatEuro(v.wertCents);
}
export function formatMindestbestellwert(cents: number | null | undefined): string | null {
if (cents === null || cents === undefined) return null;
return `ab ${formatEuro(cents)} Mindestbestellwert`;
}
// TODO(Task 6) erledigt: "bald"-Fenster = 30 Tage (Spec), Reminder-Regeln (Default 30/6/1) steuern den Versand.
export const VOUCHER_SOON_DAYS = 30;
export function isExpiringSoon(ablaufdatum: Date | string | null, now: number = Date.now()): boolean {
if (!ablaufdatum) return false;
const t = new Date(ablaufdatum).getTime();
return t >= now && t <= now + VOUCHER_SOON_DAYS * 24 * 3600 * 1000;
}