166 lines
6.6 KiB
TypeScript
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;
|
|
}
|