From 4a544675993598f523a6d6e29fc4c84a3b6766b1 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:16:27 +0200 Subject: [PATCH] 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 --- app/(dashboard)/transactions/page.tsx | 56 ++++++++++--------- app/api/transactions/route.ts | 52 +++++++++++++++++ components/transactions/TransactionForm.tsx | 12 +++- lib/__tests__/format.test.ts | 39 ++++++++++++- lib/api/schemas.ts | 29 ++++++++++ .../_generated/sector-definitions.ts | 2 +- lib/utils.ts | 34 ++++++++++- messages/en.json | 1 + messages/sv.json | 1 + ...616120000_transactions_date_sane_range.sql | 32 +++++++++++ 10 files changed, 226 insertions(+), 32 deletions(-) create mode 100644 supabase/migrations/20260616120000_transactions_date_sane_range.sql diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index c5cb0eb5..2350a0ec 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -1208,37 +1208,39 @@ export default function TransactionsPage() { async function handleCreateTransaction(data: CreateTransactionInput) { setIsCreating(true) - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - toast({ title: t('login_required_title'), description: t('login_required_description'), variant: 'destructive' }) - setIsCreating(false) - return - } - - const { data: transaction, error } = await supabase - .from('transactions') - .insert({ - company_id: company!.id, - user_id: user.id, - date: data.date, - description: data.description, - amount: data.amount, - currency: data.currency, - category: data.category || 'uncategorized', - is_business: null, - notes: data.notes, + try { + // Create through the server route so the payload is validated server-side + // (shared CreateTransactionSchema) and the DB CHECK applies — the browser + // client must never be the only guard on a mutation. + const response = await fetch('/api/transactions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + date: data.date, + description: data.description, + amount: data.amount, + currency: data.currency, + category: data.category, + notes: data.notes, + }), }) - .select() - .single() - - if (error) { - toast({ title: 'Kunde inte skapa transaktion', description: error.message, variant: 'destructive' }) - } else { + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Kunde inte skapa transaktion', + description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }), + variant: 'destructive', + }) + return + } toast({ title: 'Transaktion tillagd', description: `${data.description} har lagts till` }) - setTransactions([transaction, ...transactions]) + setTransactions([result.data, ...transactions]) setIsDialogOpen(false) + } catch { + toast({ title: 'Kunde inte skapa transaktion', description: t('booking_failed_description'), variant: 'destructive' }) + } finally { + setIsCreating(false) } - setIsCreating(false) } async function handleDeleteTransaction(id: string) { diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index 858ae2e2..ef78605c 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -2,6 +2,9 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { requireCompanyId } from '@/lib/company/context' import { scopeTransactionsToAccount } from '@/lib/reconciliation/bank-reconciliation' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { CreateTransactionSchema } from '@/lib/api/schemas' const MAX_ROWS = 500 @@ -104,3 +107,52 @@ export async function GET(request: Request) { return NextResponse.json({ data: truncated, has_more: hasMore, limit: MAX_ROWS }) } + +// Manual bank-transaction creation. This is the server-side boundary the form +// now goes through (it used to insert straight into Supabase from the browser). +// withRouteContext enforces auth/MFA + resolves companyId; validateBody runs +// the shared CreateTransactionSchema so the date rule etc. are validated +// server-side, not just client-side. +export const POST = withRouteContext( + 'transaction.create', + async (request, { supabase, companyId, user, log }) => { + // Pass the request-scoped logger so a rejected payload (e.g. a malformed + // date) is recorded server-side — that's where anomaly detection belongs, + // not in the render-path formatter. + const validation = await validateBody(request, CreateTransactionSchema, { + log, + operation: 'transaction.create', + }) + if (!validation.success) return validation.response + const { date, description, amount, currency, category, notes } = validation.data + + const { data: transaction, error } = await supabase + .from('transactions') + .insert({ + company_id: companyId, + user_id: user.id, + date, + description, + amount, + currency, + category: category ?? 'uncategorized', + is_business: null, + notes: notes ?? '', + }) + .select() + .single() + + if (error) { + // A DB-level rejection here (e.g. the transactions_date_sane_range CHECK) + // is invalid input, not a server fault — surface it as 400 with the PG + // code so the client maps it to a friendly message. + return NextResponse.json( + { error: error.message, code: error.code, type: 'database_error' }, + { status: 400 }, + ) + } + + return NextResponse.json({ data: transaction }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/components/transactions/TransactionForm.tsx b/components/transactions/TransactionForm.tsx index ac243c67..b916729e 100644 --- a/components/transactions/TransactionForm.tsx +++ b/components/transactions/TransactionForm.tsx @@ -5,6 +5,7 @@ import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { format } from 'date-fns' +import { isSaneDateString } from '@/lib/utils' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -26,7 +27,14 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm const schema = useMemo( () => z.object({ - date: z.string().min(1, t('date_required')), + date: z + .string() + .min(1, t('date_required')) + // Single source of truth for the date rule, shared with the server + // CreateTransactionSchema: rejects the 6-digit-year corruption a + // native date input can emit ('202403-02-05') plus impossible / + // out-of-range dates. + .refine((s) => !s || isSaneDateString(s), t('date_invalid')), description: z.string().min(1, t('description_required')), amount: z.number().refine((n) => n !== 0, t('amount_required')), currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']), @@ -72,7 +80,7 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
- + {errors.date && (

{errors.date.message}

)} diff --git a/lib/__tests__/format.test.ts b/lib/__tests__/format.test.ts index 260e50f8..da394752 100644 --- a/lib/__tests__/format.test.ts +++ b/lib/__tests__/format.test.ts @@ -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) + }) +}) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 746b77b9..89790dc7 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index 4a86a6c1..ab32b519 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -76,7 +76,7 @@ export const EXTENSION_DEFINITIONS: Record = { "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." }, diff --git a/lib/utils.ts b/lib/utils.ts index 7caae269..1ce4e2ef 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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 + // , 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 + * 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', diff --git a/messages/en.json b/messages/en.json index 37d5dcdc..796f80f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1943,6 +1943,7 @@ }, "tx_form": { "date_required": "Date is required", + "date_invalid": "Enter a valid date (YYYY-MM-DD)", "description_required": "Description is required", "amount_required": "Amount must be entered", "date_label": "Date *", diff --git a/messages/sv.json b/messages/sv.json index fdb6e6dd..655bd0ed 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1943,6 +1943,7 @@ }, "tx_form": { "date_required": "Datum krävs", + "date_invalid": "Ange ett giltigt datum (ÅÅÅÅ-MM-DD)", "description_required": "Beskrivning krävs", "amount_required": "Belopp måste anges", "date_label": "Datum *", diff --git a/supabase/migrations/20260616120000_transactions_date_sane_range.sql b/supabase/migrations/20260616120000_transactions_date_sane_range.sql new file mode 100644 index 00000000..8466ba46 --- /dev/null +++ b/supabase/migrations/20260616120000_transactions_date_sane_range.sql @@ -0,0 +1,32 @@ +-- Backstop against malformed transaction dates. +-- +-- The manual "add transaction" form historically inserted straight into +-- `transactions` via the browser Supabase client (no server route, no +-- server-side validation). A native whose year subfield is +-- over-typed can emit a 6-digit year (e.g. '202403-02-05' = year 202403); +-- Postgres' `date` type accepts it, the row saves, and every date-fns formatter +-- then throws RangeError on render — taking down the whole dashboard route via +-- the error boundary. (Real incident, 2026-06: company 4e4e41e7 locked out of +-- the transactions page.) +-- +-- This CHECK is the database-level guard: an out-of-range date is rejected at +-- INSERT/UPDATE instead of silently corrupting a row. The window is wide on +-- purpose — it only rejects garbage, never a legitimate accounting date. +-- CHECK expressions must be immutable, so we cannot bound against CURRENT_DATE. +-- +-- Idempotent: safe to re-run / replay on preview branches. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'transactions_date_sane_range' + AND conrelid = 'public.transactions'::regclass + ) THEN + ALTER TABLE public.transactions + ADD CONSTRAINT transactions_date_sane_range + CHECK (date >= DATE '1900-01-01' AND date <= DATE '2100-12-31'); + END IF; +END $$; + +NOTIFY pgrst, 'reload schema';