diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 94d80760..e003f007 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -418,7 +418,7 @@ export default function NewInvoicePage() { )} -
+
{/* Main content */}
@@ -771,10 +771,10 @@ export default function NewInvoicePage() { - {/* Actions — desktop only */} + {/* Actions — desktop/tablet only */} - {(!description || !selectedPeriod || isUploading || periodMismatch) && ( + {(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
{!description &&

Ange en beskrivning

} {!selectedPeriod &&

Välj en räkenskapsperiod

} {periodMismatch === 'no_period' &&

Skapa ett räkenskapsår som matchar datumet

} {isUploading &&

Vänta tills filerna laddats upp

} + {incompleteLineCount > 0 && ( +

Alla rader med belopp måste ha ett konto (och tvärtom)

+ )} + {submittableLines.length < 2 && incompleteLineCount === 0 && ( +

Minst två rader med konto och belopp krävs

+ )}
)}
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index d98bbcc5..b37691f2 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -309,6 +309,15 @@ export default function JournalEntryList({ periodId }: Props) { {entry.entry_date} + {entry.out_of_period && ( + + Efterföljande + + )} {(entry.status === 'reversed' || entry.source_type === 'storno' || entry.source_type === 'correction') && ( )} @@ -344,6 +353,15 @@ export default function JournalEntryList({ periodId }: Props) { {entry.entry_date} + {entry.out_of_period && ( + + Efterföljande + + )} {attachmentCounts[entry.id] ? ( diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index 9dc1ea5f..ee1e454e 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -148,7 +148,21 @@ function tryParseZodErrors(error: unknown): string | null { if (messages.length > 0) return messages.join('. ') } - // Check for { errors: { field: ["msg"] } } shape from validateBody + // Check for { errors: [{ field, message, code }] } shape from validateBody + if (Array.isArray(obj.errors)) { + const items = obj.errors as Array<{ field?: string; message?: string }> + const messages = items + .slice(0, 3) + .map((it) => { + const field = it.field || '' + const msg = it.message || 'ogiltigt värde' + return field ? `${field}: ${msg}` : msg + }) + .filter(Boolean) + if (messages.length > 0) return messages.join('. ') + } + + // Check for { errors: { field: ["msg"] } } shape (legacy) if (typeof obj.errors === 'object' && obj.errors !== null) { const fieldErrors = obj.errors as Record const messages: string[] = [] diff --git a/supabase/migrations/20260420120000_journal_entry_commit_metadata.sql b/supabase/migrations/20260420120001_journal_entry_commit_metadata.sql similarity index 96% rename from supabase/migrations/20260420120000_journal_entry_commit_metadata.sql rename to supabase/migrations/20260420120001_journal_entry_commit_metadata.sql index 638998e1..4d25477b 100644 --- a/supabase/migrations/20260420120000_journal_entry_commit_metadata.sql +++ b/supabase/migrations/20260420120001_journal_entry_commit_metadata.sql @@ -8,10 +8,10 @@ -- 1. Add columns (nullable — existing rows get NULL) ALTER TABLE public.journal_entries - ADD COLUMN commit_method TEXT CHECK (commit_method IS NULL OR commit_method IN ( + ADD COLUMN IF NOT EXISTS commit_method TEXT CHECK (commit_method IS NULL OR commit_method IN ( 'user_accept', 'bulk_accept', 'timing_ceiling', 'migration', 'legacy' )), - ADD COLUMN rubric_version TEXT; + ADD COLUMN IF NOT EXISTS rubric_version TEXT; -- 2. Update commit RPC to accept and set the new columns atomically CREATE OR REPLACE FUNCTION public.commit_journal_entry( diff --git a/supabase/migrations/20260421120000_journal_entries_with_related_rpc.sql b/supabase/migrations/20260421120000_journal_entries_with_related_rpc.sql new file mode 100644 index 00000000..587b2bce --- /dev/null +++ b/supabase/migrations/20260421120000_journal_entries_with_related_rpc.sql @@ -0,0 +1,120 @@ +-- RPC: list journal entries for a fiscal period, optionally including +-- follow-up entries booked in a later period that relate to aggregates +-- that originated in the selected period. +-- +-- Why this exists: journal_entries.fiscal_period_id is strictly bound to +-- entry_date (validated in lib/bookkeeping/engine.ts). So a customer +-- invoice created in FY2025 and paid in FY2026 produces two entries in +-- two different periods, and filtering the /bookkeeping view by +-- fiscal_period_id hides the tail of the story. Users reviewing a past +-- fiscal year expect to see the full processing history for that year's +-- aggregates (behandlingshistorik per BFL/BFNAR). +-- +-- Expansion rules (when p_include_related = true): +-- - Entries with source_type in invoice follow-ups whose invoice was +-- dated inside the selected period. +-- - Same for supplier invoice follow-ups. +-- +-- Storno and correction entries inherit fiscal_period_id from their +-- original (see lib/bookkeeping/engine.ts reverseEntry and +-- lib/core/bookkeeping/storno-service.ts), so they are already captured +-- by the primary fiscal_period_id filter — no extra rule needed. +-- +-- Currency revaluation is booked within the period it revalues, also +-- captured by the primary filter. +-- +-- Returns jsonb rows shaped like the PostgREST response used by +-- GET /api/bookkeeping/journal-entries (entry + nested lines), plus an +-- out_of_period boolean the UI uses to badge tail entries. + +CREATE OR REPLACE FUNCTION public.list_fiscal_period_entries_with_related( + p_company_id uuid, + p_period_id uuid, + p_include_related boolean DEFAULT true, + p_status text DEFAULT NULL, + p_date_from date DEFAULT NULL, + p_date_to date DEFAULT NULL, + p_sort_date text DEFAULT 'desc', + p_limit int DEFAULT 50, + p_offset int DEFAULT 0 +) +RETURNS TABLE ( + entry jsonb, + total_count bigint +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = public, pg_temp +AS $$ + WITH period AS ( + SELECT period_start, period_end + FROM public.fiscal_periods + WHERE id = p_period_id AND company_id = p_company_id + ), + matching AS ( + SELECT je.* + FROM public.journal_entries je + CROSS JOIN period p + WHERE je.company_id = p_company_id + AND ( + je.fiscal_period_id = p_period_id + OR ( + p_include_related + AND je.source_type IN ('invoice_paid','invoice_cash_payment','credit_note') + AND EXISTS ( + SELECT 1 FROM public.invoices i + WHERE i.id = je.source_id + AND i.company_id = p_company_id + AND i.invoice_date BETWEEN p.period_start AND p.period_end + ) + ) + OR ( + p_include_related + AND je.source_type IN ('supplier_invoice_paid','supplier_invoice_cash_payment','supplier_credit_note') + AND EXISTS ( + SELECT 1 FROM public.supplier_invoices si + WHERE si.id = je.source_id + AND si.company_id = p_company_id + AND si.invoice_date BETWEEN p.period_start AND p.period_end + ) + ) + ) + AND (p_status IS NULL OR je.status = p_status) + AND (p_date_from IS NULL OR je.entry_date >= p_date_from) + AND (p_date_to IS NULL OR je.entry_date <= p_date_to) + ), + matching_with_total AS ( + SELECT m.*, COUNT(*) OVER () AS total + FROM matching m + ), + paged AS ( + SELECT * + FROM matching_with_total + ORDER BY + CASE WHEN p_sort_date = 'asc' THEN entry_date END ASC NULLS LAST, + CASE WHEN p_sort_date = 'desc' THEN entry_date END DESC NULLS LAST, + voucher_series, + voucher_number + LIMIT p_limit OFFSET p_offset + ) + SELECT + (to_jsonb(p.*) - 'total') + || jsonb_build_object( + 'lines', COALESCE( + (SELECT jsonb_agg(to_jsonb(l.*) ORDER BY l.sort_order) + FROM public.journal_entry_lines l + WHERE l.journal_entry_id = p.id), + '[]'::jsonb + ), + 'out_of_period', (p.fiscal_period_id IS DISTINCT FROM p_period_id) + ) AS entry, + p.total AS total_count + FROM paged p; +$$; + +GRANT EXECUTE ON FUNCTION public.list_fiscal_period_entries_with_related( + uuid, uuid, boolean, text, date, date, text, int, int +) TO authenticated; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260421130000_drop_legacy_supplier_invoice_user_id_uniqueness.sql b/supabase/migrations/20260421130000_drop_legacy_supplier_invoice_user_id_uniqueness.sql new file mode 100644 index 00000000..98f91837 --- /dev/null +++ b/supabase/migrations/20260421130000_drop_legacy_supplier_invoice_user_id_uniqueness.sql @@ -0,0 +1,28 @@ +-- Drop the legacy user_id-scoped unique constraints on supplier_invoices that +-- the multi-tenant refactor (20260330130000) missed. +-- +-- That refactor tried to drop constraints named +-- supplier_invoices_user_id_arrival_number_key +-- supplier_invoices_user_id_supplier_id_supplier_invoice_numbe_key +-- (the auto-generated names Postgres would have picked had the original +-- CREATE TABLE used inline UNIQUE constraints). But the 20240101000025 +-- migration named them explicitly — uq_supplier_invoices_arrival and +-- uq_supplier_invoices_ref — so the IF EXISTS drops were no-ops and the +-- user_id-scoped uniqueness remained in place. +-- +-- Meanwhile get_next_arrival_number() was rewritten to scope by company_id. +-- The mismatch blows up the moment a single user has supplier invoices in +-- two companies: the second company's arrival_number restarts at 1 and +-- collides with the first company's row under the user_id-scoped constraint. +-- +-- The correct composite unique indexes — (company_id, arrival_number) and +-- (company_id, supplier_id, supplier_invoice_number) — were added as +-- CREATE UNIQUE INDEX IF NOT EXISTS in the 2026-03-30 refactor and are +-- already in place, so dropping the legacy constraints is all that's +-- needed. + +ALTER TABLE public.supplier_invoices + DROP CONSTRAINT IF EXISTS uq_supplier_invoices_arrival, + DROP CONSTRAINT IF EXISTS uq_supplier_invoices_ref; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 29525979..82d0260a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -982,6 +982,10 @@ export interface JournalEntry { updated_at: string // Relations lines?: JournalEntryLine[] + // Set by list_fiscal_period_entries_with_related when the entry was + // returned as a follow-up from a different fiscal period than the one + // being viewed. Absent from plain PostgREST responses. + out_of_period?: boolean } // Journal Entry Line