// Shared date parsing for Stampli M/D/YY and MM/DD/YYYY formats. // Stampli exports switched from MM/DD/YYYY to M/D/YY in 2026-06/07; both // formats must parse, and anything else must be rejected (null), never // passed through as a malformed string (payments-dashboard#65). // Parse "M/D/YY" or "MM/DD/YYYY" into { year, month, day }, or null. // Two-digit years are 2000-based. Validates the date is real (no 2/30). export function parseMDY(value) { const match = /^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/.exec(String(value ?? "").trim()); if (!match) return null; const month = parseInt(match[1], 10); const day = parseInt(match[2], 10); let year = parseInt(match[3], 10); if (match[3].length === 2) year += 2000; const dt = new Date(year, month - 1, day); if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) { return null; } return { year, month, day }; } // "YYYY-MM-DD" or null. export function toISODate(value) { const p = parseMDY(value); if (!p) return null; return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`; } // Canonical "MM/DD/YYYY" or null. Stored form for send_payment_on. export function toCanonicalMDY(value) { const p = parseMDY(value); if (!p) return null; return `${String(p.month).padStart(2, "0")}/${String(p.day).padStart(2, "0")}/${p.year}`; } // Local-midnight Date or null. Replaces slackAppHome's parseMDYLocal, which // built year-1926 dates from 2-digit years via new Date(26, ...). export function parseMDYLocal(value) { const p = parseMDY(value); if (!p) return null; return new Date(p.year, p.month - 1, p.day); } // Local-midnight Date from a stored ISO "YYYY-MM-DD" (returned_date, // paid_date), or null. new Date("YYYY-MM-DD") parses as UTC midnight and // renders a day early in US-local display; this stays local like // parseMDYLocal. Validates the date is real (no 2026-02-30). export function parseISOLocal(value) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? "").trim()); if (!match) return null; const year = parseInt(match[1], 10); const month = parseInt(match[2], 10); const day = parseInt(match[3], 10); const dt = new Date(year, month - 1, day); if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) { return null; } return dt; } // Plausibility window for ingested send dates. Anything outside is treated // as parser garbage and the row is rejected loudly rather than stored. // Relative to the current year so it never expires (7 years back covers // historical re-ingests; 2 years forward covers future-scheduled checks). export function isPlausibleSendYear(value) { const p = parseMDY(value); if (p === null) return false; const now = new Date().getFullYear(); return p.year >= now - 7 && p.year <= now + 2; }