Bug/transaction date corruption (#668)
* fix(transaction): enforce valid date range for transactions and add database constraint * fix(transaction): implement server-side validation for transaction dates and enhance error handling
This commit is contained in:
+33
-1
@@ -1,11 +1,20 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { format as formatDateFns, parseISO } from "date-fns"
|
||||
import { format as formatDateFns, parseISO, isValid } from "date-fns"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown by the date formatters when handed an Invalid Date. We fail closed —
|
||||
* render a neutral placeholder rather than the raw malformed string — so a
|
||||
* corrupted value is never surfaced to the UI, and never throws either. After
|
||||
* the server validation + DB CHECK landed, a bad date shouldn't reach here at
|
||||
* all; this is the last-resort guard.
|
||||
*/
|
||||
const INVALID_DATE_PLACEHOLDER = '—'
|
||||
|
||||
export function formatCurrency(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
@@ -21,9 +30,30 @@ export function formatDate(date: Date | string): string {
|
||||
// UTC for bare date strings — that's an off-by-one we don't want for
|
||||
// accounting data.
|
||||
const d = typeof date === 'string' ? parseISO(date) : date
|
||||
// A malformed value (e.g. a 6-digit year fat-fingered into a native
|
||||
// <input type="date">, stored by Postgres as year 202403) yields an Invalid
|
||||
// Date, and date-fns `format` THROWS a RangeError on that. One bad row must
|
||||
// never crash an entire route via the error boundary — degrade to the raw
|
||||
// input instead.
|
||||
if (!isValid(d)) return INVALID_DATE_PLACEHOLDER
|
||||
return formatDateFns(d, 'yyyy-MM-dd')
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `s` is a real, in-range calendar date in `YYYY-MM-DD` form.
|
||||
*
|
||||
* The shape check (4-digit year) is what stops the native <input type="date">
|
||||
* 6-digit-year corruption ('202403-02-05'); the parse + range check also
|
||||
* rejects impossible dates (2024-13-40) and absurd years. Exported as the ONE
|
||||
* authoritative date rule shared by the client form and the server-side
|
||||
* CreateTransactionSchema, so the two validation layers can never drift.
|
||||
*/
|
||||
export function isSaneDateString(s: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false
|
||||
const d = parseISO(s)
|
||||
return isValid(d) && d.getFullYear() >= 1900 && d.getFullYear() <= 2100
|
||||
}
|
||||
|
||||
/**
|
||||
* Date + time for audit / metadata displays: `2026-05-11 14:30`. ISO-ordered
|
||||
* and locale-independent (sortable, unambiguous), matching `formatDate`'s
|
||||
@@ -33,6 +63,7 @@ export function formatDate(date: Date | string): string {
|
||||
*/
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date
|
||||
if (!isValid(d)) return INVALID_DATE_PLACEHOLDER
|
||||
return formatDateFns(d, 'yyyy-MM-dd HH:mm')
|
||||
}
|
||||
|
||||
@@ -76,6 +107,7 @@ export function formatWholeKr(amount: number): string {
|
||||
*/
|
||||
export function formatDateLong(date: Date | string, locale: string = 'sv'): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date
|
||||
if (!isValid(d)) return INVALID_DATE_PLACEHOLDER
|
||||
const intlLocale = locale === 'en' ? 'en-US' : 'sv-SE'
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
day: 'numeric',
|
||||
|
||||
Reference in New Issue
Block a user