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:
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date">{t('date_label')}</Label>
|
||||
<Input id="date" type="date" {...register('date')} />
|
||||
<Input id="date" type="date" min="1900-01-01" max="2100-12-31" {...register('date')} />
|
||||
{errors.date && (
|
||||
<p className="text-sm text-destructive">{errors.date.message}</p>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 *",
|
||||
|
||||
@@ -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 *",
|
||||
|
||||
@@ -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 <input type="date"> 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';
|
||||
Reference in New Issue
Block a user