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:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatAmount, formatWholeKr, formatDateTime, formatDate } from '@/lib/utils'
|
||||
import { formatAmount, formatWholeKr, formatDateTime, formatDate, isSaneDateString } from '@/lib/utils'
|
||||
|
||||
// Intl sv-SE groups thousands with a non-breaking / narrow space (U+00A0 or
|
||||
// U+202F depending on ICU version) and may render negatives with U+2212. Both
|
||||
@@ -41,3 +41,40 @@ describe('formatDateTime', () => {
|
||||
expect(formatDateTime(iso).startsWith(formatDate(iso))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats a valid ISO date', () => {
|
||||
expect(formatDate('2026-05-11')).toBe('2026-05-11')
|
||||
})
|
||||
|
||||
it('fails closed on a malformed date instead of throwing', () => {
|
||||
// Regression: a 6-digit year ('202403-02-05', the real incident) parses to
|
||||
// an Invalid Date; date-fns format() throws on that and used to crash the
|
||||
// whole transactions route via the error boundary. It must degrade, not throw.
|
||||
expect(() => formatDate('202403-02-05')).not.toThrow()
|
||||
expect(formatDate('202403-02-05')).toBe('—')
|
||||
expect(formatDate('not-a-date')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSaneDateString', () => {
|
||||
it('accepts real, in-range YYYY-MM-DD dates', () => {
|
||||
expect(isSaneDateString('2026-06-04')).toBe(true)
|
||||
expect(isSaneDateString('2024-03-02')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects the 6-digit-year corruption from native date inputs', () => {
|
||||
expect(isSaneDateString('202403-02-05')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects impossible and out-of-range dates', () => {
|
||||
expect(isSaneDateString('2024-13-40')).toBe(false) // month 13 / day 40
|
||||
expect(isSaneDateString('1899-12-31')).toBe(false) // below floor
|
||||
expect(isSaneDateString('2101-01-01')).toBe(false) // above ceiling
|
||||
})
|
||||
|
||||
it('rejects empty and non-date strings', () => {
|
||||
expect(isSaneDateString('')).toBe(false)
|
||||
expect(isSaneDateString('not-a-date')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
|
||||
import { isSaneDateString } from '@/lib/utils'
|
||||
|
||||
// ============================================================
|
||||
// Shared primitives
|
||||
@@ -11,6 +12,16 @@ const uuid = z.string().uuid()
|
||||
/** ISO date string (YYYY-MM-DD) */
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
|
||||
|
||||
/**
|
||||
* ISO date that must also be a real, in-range calendar date — not just the
|
||||
* right shape. Backed by the shared `isSaneDateString` rule (also used by the
|
||||
* transaction form) so a 6-digit year or impossible date can't slip through
|
||||
* for user-entered dates. Use this over `isoDate` for free-text date input.
|
||||
*/
|
||||
const saneIsoDate = z
|
||||
.string()
|
||||
.refine(isSaneDateString, 'Invalid or out-of-range date (expected YYYY-MM-DD, year 1900–2100)')
|
||||
|
||||
/** BAS account number — always a string of 4 digits */
|
||||
const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactly 4 digits')
|
||||
|
||||
@@ -509,6 +520,24 @@ export const RecordateJournalEntrySchema = z.object({
|
||||
// Transaction schemas
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Manual bank-transaction creation (the "Lägg till transaktion" form).
|
||||
*
|
||||
* The authoritative server-side boundary for that flow. Historically the form
|
||||
* inserted straight into Supabase from the browser with only
|
||||
* `z.string().min(1)` on the date, which let a corrupt 6-digit year through and
|
||||
* crashed the page on render. The form reuses `isSaneDateString` (via this
|
||||
* schema's `saneIsoDate`) so the date rule is single-sourced across layers.
|
||||
*/
|
||||
export const CreateTransactionSchema = z.object({
|
||||
date: saneIsoDate,
|
||||
description: z.string().min(1, 'Description is required').max(500),
|
||||
amount: z.number().refine((n) => n !== 0, 'Amount must not be zero'),
|
||||
currency: CurrencySchema,
|
||||
category: TransactionCategorySchema.optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export const CategorizeTransactionSchema = z.object({
|
||||
is_business: z.boolean(),
|
||||
category: TransactionCategorySchema.optional(),
|
||||
|
||||
@@ -76,7 +76,7 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"icon": "Cloud",
|
||||
"dataPattern": "manual",
|
||||
"description": "Synka säkerhetsbackup till din egen molnlagring",
|
||||
"longDescription": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Gnubok skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"longDescription": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"hasOwnData": true,
|
||||
"subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive — ingen data lagras hos tredje part utöver Google."
|
||||
},
|
||||
|
||||
+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