From 198d3092c7fe72a9ff285e529e2cc7c41d598c45 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:20:25 +0200 Subject: [PATCH] fix: counterparty template pick crashes the page (#1291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a suggestion under "Tidigare motparter" in Bokför transaktion replaced the page with "Något gick fel". handleOpenTemplateReview built the review state from `{ id, name_sv } as BookingTemplate`, so `template.debit_account` was undefined, reached QuickReviewDialog's required `defaultAccount: string`, and threw on `accountOverride.startsWith('2')` during the first render. Typed the dialog's template prop as a narrow ReviewTemplate whose optional fields are actually optional, so the cast disappears and the compiler owns this class of bug. Also carries the counterparty's learned accounts and VAT (the preview showed the category fallback, not what the server books) and decides "is this a counterparty booking" from the template id rather than the presence of a line_pattern (single-line templates got an account/VAT editor the categorize route discards). Five more page-crashes of the same shape, adversarially verified: - suppliers/[id] and supplier-invoices/[id] passed the error envelope OBJECT as a toast description. The Toaster is a sibling of {children} in the ROOT layout, so that throw escapes both segment error boundaries onto global-error. - components/reports/views wrote the same object into a useState at 13 sites and rendered it bare. - components/ui/toaster.tsx now coerces non-renderable values as a choke point. - skattekonto read data.informationstext.length off Skatteverket's raw JSON, where the field is not required. - TicWorkspace read profile.statuses.length off a persisted jsonb blob. 17 of 17 prod rows predate the TIC v2 upgrade (#584) and lack the key, so that workspace was in the error boundary for every company that had opened it. Plus hardening: formatCurrency coerces a null currency to SEK (prod has 0 NULL across 28 416 transactions, so defense not a live bug) and cleanSignatory returns [] for a missing description. Verified by rendering the real dialog against a throwaway /sandbox route: the pre-fix prop shape reproduces the exact error boundary, the fixed one renders D: 6570 Bankavgifter / K: 1930 Företagskonto and the matching verifikat. No migrations. --- DECISIONS.md | 14 +++ app/(dashboard)/skattekonto/page.tsx | 8 +- .../supplier-invoices/[id]/page.tsx | 37 ++++++-- app/(dashboard)/suppliers/[id]/page.tsx | 35 +++++-- app/(dashboard)/transactions/page.tsx | 54 +++++++---- .../transactions/suggest-categories/route.ts | 23 +---- .../extensions/general/TicWorkspace.tsx | 54 ++++++++++- components/reports/views/index.tsx | 42 +++++++-- components/settings/CompanyProfileView.tsx | 6 +- components/transactions/QuickReviewDialog.tsx | 60 +++++++++--- .../ui/__tests__/toaster-coerce.test.ts | 64 +++++++++++++ components/ui/toaster.tsx | 33 ++++++- lib/__tests__/utils.test.ts | 23 ++++- .../__tests__/counterparty-suggestion.test.ts | 93 +++++++++++++++++++ .../__tests__/quick-review-defaults.test.ts | 72 ++++++++++++++ lib/transactions/category-suggestions.ts | 58 +++++++++++- lib/transactions/quick-review-defaults.ts | 72 ++++++++++++++ lib/utils.ts | 10 +- messages/en.json | 2 + messages/sv.json | 2 + 20 files changed, 669 insertions(+), 93 deletions(-) create mode 100644 components/ui/__tests__/toaster-coerce.test.ts create mode 100644 lib/transactions/__tests__/counterparty-suggestion.test.ts create mode 100644 lib/transactions/__tests__/quick-review-defaults.test.ts create mode 100644 lib/transactions/quick-review-defaults.ts diff --git a/DECISIONS.md b/DECISIONS.md index e0f66b8d..1cad3e3e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -664,3 +664,17 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-29] Approval-queue MCP App widget (render_ui on list_pending_operations): high-risk confirmed=true now comes from a human click in-widget instead of agent-asserted; payload ceiling 58K->58.5K per the in-test trim-first convention. [2026-07-29] OAuth error popup stays open instead of auto-closing: the postMessage is dropped on any popup/opener origin mismatch, and closing anyway made every such failure invisible (Fortnox silent-connect incident). [2026-07-29] FORTNOX_REDIRECT_URI on prod deliberately left on app.gnubok.se for now: flipping it to app.accounted.se before that callback URL is registered in the Fortnox Developer Portal would break connect earlier, at the authorize step. + +[2026-07-29] Counterparty quick-review crash: typed the review dialog's `template` prop as a narrow `ReviewTemplate` instead of `BookingTemplate`, rather than filling out a full synthetic BookingTemplate. The crash existed because `{ id, name_sv } as BookingTemplate` made 20 absent fields look present to the compiler; `debit_account` came back undefined, reached the dialog's required `defaultAccount: string`, and threw on `accountOverride.startsWith('2')`. Making the optional fields optional turns that whole class into a compile error. A fabricated full template (picking an arbitrary TemplateGroup, risk_level, fallback_category for something that has no catalog entry) would have kept the type honest-looking and the data dishonest. + +[2026-07-29] Counterparty verifikation preview passes `templateVatRate` but deliberately NOT `templateVatTreatment`: for reverse charge, JournalEntryPreview emits the basbelopp pair (44xx/4598, momsdeklaration rutor 20-24), while buildMappingResultFromCounterpartyTemplate's legacy path emits only the 2645/2614 fiktiv-moms pair. That is not an engine gap: a counterparty template is learned from the user's own past vouchers, and a voucher that HAD basbelopp lines would have been learned as a multi-line line_pattern instead. So the preview mirrors the legacy path exactly and does not invent lines the booking will not create. + +[2026-07-29] Hardened components/ui/toaster.tsx to coerce non-renderable title/description values instead of only fixing the call sites that passed the error envelope object. The Toaster is a sibling of {children} in the ROOT layout, so "Objects are not valid as a React child" there escapes app/error.tsx AND app/(dashboard)/error.tsx and lands on global-error, which blanks the app and (via its one-shot reload) pins the user on the fallback for that path. Call sites still route through getErrorMessage; the coercion is the choke point for the one that forgets. + +[2026-07-29] Not fixed in the counterparty crash PR, logged instead: a successful counterparty-template booking sets exitingIds (the row animates out) but shows no "Bokförd" toast and no "Ångra" undo, unlike every other booking path in app/(dashboard)/transactions/page.tsx. Adversarial verification classified it non-crash UX parity; bundling a booking-flow behaviour change into a crash fix would have widened the diff past the report. + +[2026-07-29] formatCurrency now coerces a null/empty currency to SEK. Checked prod before writing this: 0 of 28 416 `transactions` rows have a NULL currency, so this is hardening, not a fixed live crash, and the adversarial verifiers were right to refute the "transactions list blanks" claim on reachability. It is still worth having: the column is nullable with no NOT NULL, `Transaction.currency` declares it required, the browser Supabase client carries no Database generic so rows arrive as `any`, and a `= 'SEK'` default parameter only fires on undefined (`currency: null` throws RangeError out of Intl). It also covers enable-banking's accounts_data JSONB, where currency genuinely is optional. + +[2026-07-29] TicWorkspace ignores a cached profile blob with no `statuses` key rather than normalising it and rendering. Prod check: 17 of 17 `extension_data` rows for general/tic + company_profile predate the TIC v2 upgrade (#584) and have no statuses/signatory/board/representatives/payrolls, i.e. the workspace is in the error boundary for every company that ever opened it. Normalising alone would fix the crash but leave those sections permanently blank, because the success render path has no refresh button, and the auto-fetch effect is gated on `!profile`. Dropping the stale blob lets that effect refetch the current shape and re-save it, so the 17 rows self-heal on next open. + +[2026-07-29] Center Node AB support deletion (Anders Orback) run manually service-side, bypassing the product's AAL2/consent gate on anonymize_user_account: his written support request is the consent (documented in the audit_log row 8501e9e0), and support had already replied "du behöver inte göra något mer", so the fix-the-button-and-let-him-click alternative would have contradicted a sent mail. Manual run mirrored the delete route + RPC body byte-for-byte; GoTrue admin logout endpoint 404s on our GoTrue version, so global signout was done by deleting auth.sessions rows (equivalent effect, ban blocks refresh regardless). diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index 38c65bc8..879050ba 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -506,12 +506,16 @@ export default function SkattekontoPage() { ) : null} - {data.informationstext.length > 0 && ( + {/* Optional chain on purpose: `data` is Skatteverket's raw saldo + JSON cast to our interface, and informationstext is not a + required field in SKV's own v2.1.0 schema. A response without + it blanked the whole Skattekonto page. */} + {(data.informationstext?.length ?? 0) > 0 && (

Information från Skatteverket

- {data.informationstext.map((info, i) => ( + {(data.informationstext ?? []).map((info, i) => (

{info}

diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index bff93c5e..4295bba4 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -122,16 +122,35 @@ export default function SupplierInvoiceDetailPage() { async function fetchInvoice() { setIsLoading(true) - const res = await fetch(`/api/supplier-invoices/${params.id}`) - const { data, error } = await res.json() - if (error) { - toast({ title: t('load_failed_title'), description: error, variant: 'destructive' }) - } else { - setInvoice(data) - setPayAmount(String(data.remaining_amount)) - setPaymentDate(new Date().toISOString().split('T')[0]) + // try/finally: a dropped connection or a non-JSON error page makes + // res.json() throw, and this runs from an effect, so the rejection is + // unhandled and isLoading would stay true: a spinner that never resolves. + try { + const res = await fetch(`/api/supplier-invoices/${params.id}`) + const body = await res.json().catch(() => null) + // See the identical fix in suppliers/[id]: `body.error` is the canonical + // envelope object, and rendering an object as a toast description throws + // out of the root layout into global-error. + if (!res.ok || body?.error || !body?.data) { + toast({ + title: t('load_failed_title'), + description: getErrorMessage(body, { statusCode: res.status, context: 'supplier_invoice' }), + variant: 'destructive', + }) + } else { + setInvoice(body.data) + setPayAmount(String(body.data.remaining_amount)) + setPaymentDate(new Date().toISOString().split('T')[0]) + } + } catch (err) { + toast({ + title: t('load_failed_title'), + description: getErrorMessage(err, { context: 'supplier_invoice' }), + variant: 'destructive', + }) + } finally { + setIsLoading(false) } - setIsLoading(false) } useEffect(() => { diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index 4823e491..9e18183b 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -60,14 +60,35 @@ export default function SupplierDetailPage() { async function fetchSupplier() { setIsLoading(true) - const res = await fetch(`/api/suppliers/${params.id}`) - const { data, error } = await res.json() - if (error) { - toast({ title: t('load_failed_title'), description: error, variant: 'destructive' }) - } else { - setSupplier(data) + // try/finally: this runs from an effect, so a throw out of fetch/res.json() + // (dropped connection, non-JSON error page) would be an unhandled rejection + // and leave isLoading stuck true on a spinner that never resolves. + try { + const res = await fetch(`/api/suppliers/${params.id}`) + const body = await res.json().catch(() => null) + // `body.error` is the canonical envelope OBJECT, not a string: handing it + // straight to the toast made the root render an object as a + // React child, which throws past every segment error boundary and lands + // the whole app on global-error. Route it through getErrorMessage, same + // as every other call site in this file. + if (!res.ok || body?.error) { + toast({ + title: t('load_failed_title'), + description: getErrorMessage(body, { statusCode: res.status, context: 'supplier' }), + variant: 'destructive', + }) + } else { + setSupplier(body.data) + } + } catch (err) { + toast({ + title: t('load_failed_title'), + description: getErrorMessage(err, { context: 'supplier' }), + variant: 'destructive', + }) + } finally { + setIsLoading(false) } - setIsLoading(false) } async function fetchInvoices() { diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 5c492b0e..0212b00a 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -29,8 +29,8 @@ import SkattekontoInboxCard from '@/components/transactions/SkattekontoInboxCard import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton' -import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates' +import { resolveQuickReviewDefaults, type ReviewTemplate } from '@/lib/transactions/quick-review-defaults' import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates' import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library' import type { @@ -202,7 +202,9 @@ interface QuickReviewState { transaction: TransactionWithInvoice category: TransactionCategory label: string - template: BookingTemplate | null + // ReviewTemplate, not BookingTemplate: a learned counterparty template has + // no catalog entry, so most BookingTemplate fields are genuinely absent. + template: ReviewTemplate | null templateId: string | undefined linePattern: LinePatternEntry[] | null // Learned counterparty bag; prefills the review dialog's dimension picker. @@ -2158,12 +2160,32 @@ export default function TransactionsPage() { function handleOpenTemplateReview(transaction: TransactionWithInvoice, templateId: string) { if (isCounterpartyTemplateId(templateId)) { const cpSuggestion = templateSuggestions[transaction.id]?.find(ts => ts.template_id === templateId) - if (!cpSuggestion) return + if (!cpSuggestion) { + // The suggestion list went stale under the open modal (refetch, or the + // template was deleted in another tab). Say so instead of swallowing + // the click. + toast({ + title: t('counterparty_suggestion_gone_title'), + description: t('counterparty_suggestion_gone_description'), + variant: 'destructive', + }) + return + } setQuickReview({ transaction, category: transaction.amount < 0 ? 'expense_other' : 'income_services', label: cpSuggestion.name_sv, - template: { id: templateId, name_sv: cpSuggestion.name_sv } as BookingTemplate, + // Carry the learned accounts and VAT: they are what the server books + // (buildMappingResultFromCounterpartyTemplate) and what the dialog + // previews. A counterparty template has no catalog entry, so the rest + // of BookingTemplate genuinely does not exist here. + template: { + id: templateId, + name_sv: cpSuggestion.name_sv, + debit_account: cpSuggestion.debit_account, + credit_account: cpSuggestion.credit_account, + vat_treatment: cpSuggestion.vat_treatment ?? null, + }, templateId: undefined, linePattern: cpSuggestion.line_pattern ?? null, defaultDimensions: cpSuggestion.default_dimensions ?? null, @@ -2324,6 +2346,15 @@ export default function TransactionsPage() { return journalEntryId } + // Library and counterparty templates (no templateId) seed the review form + // from their own accounts; everything else falls back to the category + // defaults. Never undefined: see resolveQuickReviewDefaults. + const quickReviewDefaults = resolveQuickReviewDefaults( + quickReview?.template, + quickReview?.templateId, + quickReview?.category, + ) + return (
{/* Page header (concept scene 10): title + Importera split button */} @@ -2786,19 +2817,8 @@ export default function TransactionsPage() { transaction={quickReview?.transaction ?? null} category={quickReview?.category ?? null} categoryLabel={quickReview?.label ?? ''} - defaultAccount={ - // For library templates (no templateId but a template object), use the - // template's debit account as the default; otherwise fall back to the - // category's default account. - !quickReview?.templateId && quickReview?.template - ? quickReview.template.debit_account - : quickReview?.category ? getDefaultAccountForCategory(quickReview.category) : '' - } - defaultVat={ - !quickReview?.templateId && quickReview?.template - ? (quickReview.template.vat_treatment ?? 'none') - : quickReview?.category ? (getDefaultVatTreatmentForCategory(quickReview.category) ?? 'none') : 'none' - } + defaultAccount={quickReviewDefaults.account} + defaultVat={quickReviewDefaults.vat} entityType={entityType as EntityType} template={quickReview?.template ?? null} templateId={quickReview?.templateId} diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index 5daafed4..8d767003 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' -import { getSuggestedCategories, getSuggestedTemplates, buildMerchantHistory, merchantHistoryFor, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' -import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' +import { getSuggestedCategories, getSuggestedTemplates, buildMerchantHistory, merchantHistoryFor, buildCounterpartySuggestion, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' +import { findCounterpartyTemplatesBatch } from '@/lib/bookkeeping/counterparty-templates' import type { Transaction, EntityType } from '@/types' /** @@ -87,24 +87,7 @@ export const POST = withRouteContext( const cpMatch = counterpartyMatches.get(tx.id) if (!cpMatch) continue - const tmpl = cpMatch.template - const cpSuggestion: SuggestedTemplate = { - template_id: toCounterpartyTemplateId(tmpl.id), - name_sv: formatCounterpartyName(tmpl.counterparty_name), - name_en: formatCounterpartyName(tmpl.counterparty_name), - group: 'counterparty', - debit_account: tmpl.debit_account, - credit_account: tmpl.credit_account, - confidence: cpMatch.confidence, - description_sv: `${tmpl.occurrence_count} tidigare bokföringar`, - risk_level: 'NONE', - requires_review: false, - line_pattern: tmpl.line_pattern ?? null, - default_dimensions: - tmpl.default_dimensions && Object.keys(tmpl.default_dimensions).length > 0 - ? tmpl.default_dimensions - : null, - } + const cpSuggestion = buildCounterpartySuggestion(cpMatch.template, cpMatch.confidence) const existing = template_suggestions[tx.id] || [] template_suggestions[tx.id] = [cpSuggestion, ...existing] diff --git a/components/extensions/general/TicWorkspace.tsx b/components/extensions/general/TicWorkspace.tsx index 4f3dc51a..69993f4a 100644 --- a/components/extensions/general/TicWorkspace.tsx +++ b/components/extensions/general/TicWorkspace.tsx @@ -32,8 +32,42 @@ import { import { Badge } from '@/components/ui/badge' import Link from 'next/link' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import { getErrorMessage } from '@/lib/errors/get-error-message' import type { TICCompanyProfile } from '@/extensions/general/tic/lib/tic-types' +/** + * The profile is hydrated from a persisted `extension_data` jsonb blob, so its + * shape is whatever the TIC schema looked like when it was cached, not what + * TICCompanyProfile promises today. A blob written before the v2 upgrade (#584) + * has no `statuses` key at all, and `profile.statuses.length` on a rendered + * blob like that dropped the whole workspace into the error boundary. + * + * Normalising once at the hydration boundary keeps every list read below + * honest, including the ones a future schema change would otherwise break. + */ +function normalizeProfile(raw: unknown): TICCompanyProfile { + const p = (raw ?? {}) as Partial + const list = (value: T[] | undefined | null): T[] => (Array.isArray(value) ? value : []) + return { + ...(p as TICCompanyProfile), + registration: p.registration ?? { fTax: false, vat: false, payroll: false }, + sniCodes: list(p.sniCodes), + bankAccounts: list(p.bankAccounts), + beneficialOwners: list(p.beneficialOwners), + financialReports: list(p.financialReports), + fiscalYearHistory: list(p.fiscalYearHistory), + signatory: list(p.signatory), + representatives: list(p.representatives), + payrolls: list(p.payrolls), + statuses: list(p.statuses), + // Declared nullable, so undefined would already read as falsy at the + // render guards; normalised anyway so the object matches its own type. + fiscalYear: p.fiscalYear ?? null, + board: p.board ?? null, + financials: p.financials ?? null, + } +} + function formatKSEK(value: number | null): string { if (value === null) return '-' return `${(value * 1000).toLocaleString('sv-SE')} kr` @@ -165,8 +199,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) { useEffect(() => { if (isDataLoading) return const cached = getByKey('company_profile') - if (cached?.value) { - setProfile(cached.value as unknown as TICCompanyProfile) + // Blobs written before the TIC v2 upgrade (#584) predate the statuses / + // board / payroll sections entirely. Rendering one normalized would show + // a permanently section-less profile, because the success path has no + // refresh button to repair it: leaving `profile` null instead lets the + // auto-fetch effect below pull the current shape and re-save it. + if (cached?.value && 'statuses' in cached.value) { + setProfile(normalizeProfile(cached.value)) } setInitialLoad(false) }, [isDataLoading, getByKey]) @@ -196,14 +235,19 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) { ) if (!res.ok) { - const { error } = await res.json() - toast({ title: error ?? t('toast_profile_failed'), variant: 'destructive' }) + // `error` is the canonical envelope object, not a string: as a bare + // toast title it would render an object as a React child. + const body = await res.json().catch(() => null) + toast({ + title: body?.error ? getErrorMessage(body, { statusCode: res.status }) : t('toast_profile_failed'), + variant: 'destructive', + }) setFetchFailed(true) return } const { data } = await res.json() - setProfile(data) + setProfile(normalizeProfile(data)) await save('company_profile', data) } catch { toast({ title: t('toast_unexpected_error'), variant: 'destructive' }) diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index f2ee3b07..6604ad5d 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -17,6 +17,7 @@ import { EmptyState } from '@/components/ui/empty-state' import { FyPicker } from '@/components/common/FyPicker' import { ContextPicker } from '@/components/common/ContextPicker' import { cn, formatDate } from '@/lib/utils' +import { getErrorMessage } from '@/lib/errors/get-error-message' import { roundOre } from '@/lib/money' import { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from '@/lib/reports/latest-vouchers-format' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' @@ -113,7 +114,10 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: .then((res) => res.json()) .then((result) => { if (result.error) { - setError(result.error) + // `result.error` is the canonical envelope OBJECT; assigning it to a + // string state and rendering it bare threw "Objects are not valid as + // a React child" and blanked the report page. + setError(getErrorMessage(result)) } else { setData(result.data) } @@ -433,7 +437,10 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul .then((res) => res.json()) .then((result) => { if (result.error) { - setError(result.error) + // `result.error` is the canonical envelope OBJECT; assigning it to a + // string state and rendering it bare threw "Objects are not valid as + // a React child" and blanked the report page. + setError(getErrorMessage(result)) } else { setData(result.data) } @@ -591,7 +598,10 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: { .then((res) => res.json()) .then((result) => { if (result.error) { - setError(result.error) + // `result.error` is the canonical envelope OBJECT; assigning it to a + // string state and rendering it bare threw "Objects are not valid as + // a React child" and blanked the report page. + setError(getErrorMessage(result)) } else { setData(result.data) } @@ -717,7 +727,10 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul .then((res) => res.json()) .then((result) => { if (result.error) { - setError(result.error) + // `result.error` is the canonical envelope OBJECT; assigning it to a + // string state and rendering it bare threw "Objects are not valid as + // a React child" and blanked the report page. + setError(getErrorMessage(result)) } else { setData(result.data) } @@ -872,7 +885,10 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }: .then((res) => res.json()) .then((result) => { if (result.error) { - setError(result.error) + // `result.error` is the canonical envelope OBJECT; assigning it to a + // string state and rendering it bare threw "Objects are not valid as + // a React child" and blanked the report page. + setError(getErrorMessage(result)) } else { setData(result.data) } @@ -2351,7 +2367,9 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) { const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}&as_of_date=${asOfDate}`) const result = await res.json() if (result.error) { - setError(result.error) + // Envelope object, not a string: see the note on the other report + // fetches. Rendering it bare blanks the page. + setError(getErrorMessage(result)) } else { setData(result.data) } @@ -2628,7 +2646,9 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil const res = await fetch(`/api/reports/general-ledger?${params}`) const result = await res.json() if (result.error) { - setError(result.error) + // Envelope object, not a string: see the note on the other report + // fetches. Rendering it bare blanks the page. + setError(getErrorMessage(result)) } else { setData(result.data) } @@ -2833,7 +2853,9 @@ export function JournalRegisterView({ periodId }: { periodId: string }) { const res = await fetch(`/api/reports/journal-register?period_id=${periodId}`) const result = await res.json() if (result.error) { - setError(result.error) + // Envelope object, not a string: see the note on the other report + // fetches. Rendering it bare blanks the page. + setError(getErrorMessage(result)) } else { setData(result.data) } @@ -3133,7 +3155,9 @@ export function ARLedgerView({ periodId }: { periodId: string }) { const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}&as_of_date=${asOfDate}`) const result = await res.json() if (result.error) { - setError(result.error) + // Envelope object, not a string: see the note on the other report + // fetches. Rendering it bare blanks the page. + setError(getErrorMessage(result)) } else { setData(result.data) } diff --git a/components/settings/CompanyProfileView.tsx b/components/settings/CompanyProfileView.tsx index 3ba3bd40..ba8ecf25 100644 --- a/components/settings/CompanyProfileView.tsx +++ b/components/settings/CompanyProfileView.tsx @@ -53,7 +53,11 @@ interface SnapshotShape { // and collapses several rules onto one line. Strip the markers, normalise // whitespace, and split run-on "Firman tecknas …" clauses onto their own // lines so each rule reads as a sentence. -function cleanSignatory(raw: string): string[] { +function cleanSignatory(raw: string | null | undefined): string[] { + // The snapshot is unvalidated registry JSON: `description` is declared + // required inside an interface whose every other field is optional, so a + // signatory row without one would throw here and blank the settings panel. + if (!raw) return [] const normalised = raw .replace(/>/g, ' ') .replace(/\s+/g, ' ') diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index 9a9daef4..0e0e3fd0 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -12,7 +12,9 @@ import { formatCurrency, formatDate } from '@/lib/utils' import { linkDocuments, formatFailedDocumentNames } from '@/lib/documents/link-documents' import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react' import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping' -import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates' +import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' +import { getVatRate } from '@/lib/bookkeeping/vat-entries' +import type { ReviewTemplate } from '@/lib/transactions/quick-review-defaults' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import JournalEntryPreview from './JournalEntryPreview' @@ -33,10 +35,11 @@ interface QuickReviewDialogProps { transaction: TransactionWithInvoice | null category: TransactionCategory | null categoryLabel: string + /** Empty string when there is no sensible default: never undefined. */ defaultAccount: string defaultVat: VatTreatment | 'none' entityType?: EntityType - template?: BookingTemplate | null + template?: ReviewTemplate | null templateId?: string counterpartyLinePattern?: LinePatternEntry[] | null /** @@ -76,7 +79,12 @@ export default function QuickReviewDialog({ const tCat = useTranslations('tx_categories') const { toast } = useToast() const router = useRouter() - const [accountOverride, setAccountOverride] = useState(defaultAccount) + // `?? ''` is deliberate belt-and-braces: the prop is a required string, but + // a caller that hands over a template-shaped object missing debit_account + // used to make this undefined and take the whole page down on the + // .startsWith() below. An empty account disables the confirm button; it + // never throws. + const [accountOverride, setAccountOverride] = useState(defaultAccount ?? '') const [vatTreatment, setVatTreatment] = useState(defaultVat) const [accounts, setAccounts] = useState([]) const [isProcessing, setIsProcessing] = useState(false) @@ -103,8 +111,8 @@ export default function QuickReviewDialog({ // Handle account changes: clear VAT for liability/equity accounts (class 2) const handleAccountChange = useCallback((account: string) => { - setAccountOverride(account) - if (account.startsWith('2')) { + setAccountOverride(account ?? '') + if (account?.startsWith('2')) { setVatTreatment('none') } }, []) @@ -200,9 +208,17 @@ export default function QuickReviewDialog({ const tx = enrichedTx ?? transaction const isIncome = tx.amount > 0 - const isCounterpartyTemplate = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0) + // Keyed off the template ID, not off the presence of a line pattern: a + // *learned* counterparty (one business line, no pattern) is still booked + // server-side from counterparty_template_id, so its accounts and VAT come + // from the stored template. Deciding this from counterparties that happen + // to have a multi-line pattern made single-line ones fall through to the + // category branch, which previewed the wrong accounts and offered an + // account/VAT editor whose values the categorize route discards. + const isCounterpartyTemplate = !!template?.id && isCounterpartyTemplateId(template.id) + const hasCounterpartyPattern = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0) const isTemplateBooking = !!templateId || isCounterpartyTemplate - const isLiabilityAccount = accountOverride.startsWith('2') + const isLiabilityAccount = accountOverride?.startsWith('2') ?? false // For non-SEK transactions, the verifikation and the headline must show // the SEK-converted total: the mall/category booking always posts in SEK. const sekAmount = resolveSekAmount( @@ -399,7 +415,7 @@ export default function QuickReviewDialog({ {patternDimsLabel} )} - {onChangeTemplate && !isCounterpartyTemplate && ( + {onChangeTemplate && !hasCounterpartyPattern && (
- {template && !isCounterpartyTemplate && ( + {/* Only when there IS a single debit/credit pair to show: a + multi-line counterparty pattern has none, and a template that + never carried accounts would render "D: → K: ". */} + {!hasCounterpartyPattern && template?.debit_account && template?.credit_account && (

D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)}

@@ -452,15 +471,26 @@ export default function QuickReviewDialog({
diff --git a/components/ui/__tests__/toaster-coerce.test.ts b/components/ui/__tests__/toaster-coerce.test.ts new file mode 100644 index 00000000..0332c4b9 --- /dev/null +++ b/components/ui/__tests__/toaster-coerce.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { createElement, isValidElement } from 'react' +import { coerceToastNode } from '../toaster' + +/** + * The Toaster is a sibling of {children} in the ROOT layout, so a throw here + * escapes both app/error.tsx and app/(dashboard)/error.tsx and lands on + * global-error, blanking the whole app (and, because global-error reloads + * once, pinning the user on the fallback for that path). + * + * Call sites that forward an unchecked `await res.json()` field pass the + * canonical `{ code, message, … }` envelope OBJECT instead of a string, which + * React refuses to render. This coercion is the choke point. + */ +describe('coerceToastNode', () => { + it('passes strings and numbers through untouched', () => { + expect(coerceToastNode('Kunde inte spara')).toBe('Kunde inte spara') + expect(coerceToastNode(42)).toBe(42) + }) + + it('passes null/undefined through so the description simply does not render', () => { + expect(coerceToastNode(null)).toBeNull() + expect(coerceToastNode(undefined)).toBeUndefined() + }) + + it('keeps React elements renderable', () => { + const el = createElement('span', null, 'hej') + expect(coerceToastNode(el)).toBe(el) + expect(isValidElement(coerceToastNode(el))).toBe(true) + }) + + it('turns a canonical error envelope into a readable string instead of throwing', () => { + const result = coerceToastNode({ + code: 'NOT_FOUND', + message: 'Leverantören hittades inte', + } as unknown as React.ReactNode) + expect(typeof result).toBe('string') + expect(result).toBe('Leverantören hittades inte') + }) + + it('turns any other object into a string rather than a React child throw', () => { + const result = coerceToastNode({ requestId: 'abc' } as unknown as React.ReactNode) + expect(typeof result).toBe('string') + expect((result as string).length).toBeGreaterThan(0) + }) + + it('coerces objects nested inside an array child', () => { + const el = createElement('span', { key: 'a' }, 'hej') + const result = coerceToastNode([ + 'text', + { code: 'NOT_FOUND', message: 'Hittades inte' }, + el, + ] as unknown as React.ReactNode) as unknown[] + expect(result[0]).toBe('text') + expect(result[1]).toBe('Hittades inte') + // Elements pass through by identity so their keys survive. + expect(result[2]).toBe(el) + }) + + it('drops booleans, which React would render as nothing anyway', () => { + expect(coerceToastNode(true)).toBeNull() + expect(coerceToastNode(false)).toBeNull() + }) +}) diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx index e2233852..1bc828a5 100644 --- a/components/ui/toaster.tsx +++ b/components/ui/toaster.tsx @@ -9,6 +9,31 @@ import { ToastViewport, } from "@/components/ui/toast" import { useToast } from "@/components/ui/use-toast" +import { isValidElement, type ReactNode } from "react" +import { getErrorMessage } from "@/lib/errors/get-error-message" + +/** + * The Toaster lives in the ROOT layout, as a sibling of {children}. A throw in + * here escapes every segment error boundary and lands on global-error, which + * blanks the entire app. Callers that hand over an unchecked `await + * res.json()` field pass the canonical `{ code, message, … }` envelope object + * rather than a string, and React throws "Objects are not valid as a React + * child" on it. + * + * Individual call sites are still expected to run their errors through + * getErrorMessage. This is the choke point that keeps the one that forgets + * from taking the whole app down with it. + */ +export function coerceToastNode(value: ReactNode): ReactNode { + if (value == null || typeof value === "string" || typeof value === "number") return value + if (typeof value === "boolean") return null + if (isValidElement(value)) return value + // Arrays are legitimate React children, but an unchecked JSON array can hold + // objects, and one of those still throws. Coerce members too; elements pass + // through by identity so their keys survive. + if (Array.isArray(value)) return value.map(coerceToastNode) + return getErrorMessage(value) +} export function Toaster() { const { toasts } = useToast() @@ -16,12 +41,14 @@ export function Toaster() { return ( {toasts.map(function ({ id, title, description, action, ...props }) { + const safeTitle = coerceToastNode(title) + const safeDescription = coerceToastNode(description) return (
- {title && {title}} - {description && ( - {description} + {safeTitle && {safeTitle}} + {safeDescription && ( + {safeDescription} )}
{action} diff --git a/lib/__tests__/utils.test.ts b/lib/__tests__/utils.test.ts index e77178a4..452b2baa 100644 --- a/lib/__tests__/utils.test.ts +++ b/lib/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { swedishToday } from '../utils' +import { swedishToday, formatCurrency } from '../utils' describe('swedishToday', () => { it('formats the date as ISO yyyy-MM-dd with a Swedish weekday', () => { @@ -22,3 +22,24 @@ describe('swedishToday', () => { expect(morning).not.toMatch(/\d{2}:\d{2}/) }) }) + +describe('formatCurrency', () => { + it('falls back to SEK for a NULL currency instead of throwing', () => { + // transactions.currency is nullable and NULL is legacy for the 'SEK' + // column default (migration 20260726100000), but the Transaction type + // declares it required. Intl throws RangeError on `currency: null`, and + // one such row used to blank the whole transactions list. + expect(() => formatCurrency(1234.5, null)).not.toThrow() + expect(formatCurrency(1234.5, null)).toBe(formatCurrency(1234.5, 'SEK')) + }) + + it('falls back to SEK for undefined and for an empty string', () => { + expect(formatCurrency(10, undefined)).toBe(formatCurrency(10, 'SEK')) + expect(formatCurrency(10, '')).toBe(formatCurrency(10, 'SEK')) + expect(formatCurrency(10)).toBe(formatCurrency(10, 'SEK')) + }) + + it('still honours a real currency code', () => { + expect(formatCurrency(10, 'EUR')).toContain('€') + }) +}) diff --git a/lib/transactions/__tests__/counterparty-suggestion.test.ts b/lib/transactions/__tests__/counterparty-suggestion.test.ts new file mode 100644 index 00000000..2bdca36d --- /dev/null +++ b/lib/transactions/__tests__/counterparty-suggestion.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { buildCounterpartySuggestion } from '../category-suggestions' +import { resolveQuickReviewDefaults } from '../quick-review-defaults' +import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' +import type { CategorizationTemplate } from '@/types' + +/** + * The "Tidigare motparter" suggestion is the only input the review dialog has + * about a learned counterparty: there is no catalog template to look up by id. + * Anything missing here surfaces as an undefined field in the dialog, which is + * how picking a Bankavgift counterparty used to replace the page with the + * "Något gick fel" error boundary. + */ + +function makeTemplate(overrides: Partial = {}): CategorizationTemplate { + return { + id: '11111111-1111-1111-1111-111111111111', + user_id: null, + company_id: '22222222-2222-2222-2222-222222222222', + counterparty_name: 'fee', + counterparty_aliases: [], + debit_account: '6570', + credit_account: '1930', + vat_treatment: null, + vat_account: null, + category: null, + line_pattern: null, + occurrence_count: 4, + confidence: 0.8, + last_seen_date: '2026-07-01', + source: 'sie_import', + is_active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + ...overrides, + } +} + +describe('buildCounterpartySuggestion', () => { + it('carries the accounts the review dialog needs to seed its form', () => { + const s = buildCounterpartySuggestion(makeTemplate(), 0.9) + expect(s.debit_account).toBe('6570') + expect(s.credit_account).toBe('1930') + expect(isCounterpartyTemplateId(s.template_id)).toBe(true) + expect(s.name_sv).toBe('Fee') + expect(s.description_sv).toBe('4 tidigare bokföringar') + }) + + it('carries the learned VAT treatment so the preview matches the booking', () => { + const s = buildCounterpartySuggestion( + makeTemplate({ debit_account: '5420', vat_treatment: 'standard_25', vat_account: '2641' }), + 0.9, + ) + expect(s.vat_treatment).toBe('standard_25') + }) + + it('passes a multi-line pattern through untouched', () => { + const pattern = [ + { account: '5010', side: 'debit' as const, type: 'business' as const, ratio: 1 }, + { account: '2641', side: 'debit' as const, type: 'vat' as const, vat_rate: 0.25 }, + ] + const s = buildCounterpartySuggestion(makeTemplate({ line_pattern: pattern }), 0.9) + expect(s.line_pattern).toEqual(pattern) + }) + + it('normalises an empty dimension bag to null', () => { + expect(buildCounterpartySuggestion(makeTemplate({ default_dimensions: {} }), 0.9).default_dimensions).toBeNull() + expect( + buildCounterpartySuggestion(makeTemplate({ default_dimensions: { '1': 'KS01' } }), 0.9).default_dimensions, + ).toEqual({ '1': 'KS01' }) + }) + + it('feeds the review dialog a defined account, which is what the crash needed', () => { + // Exactly the reported case: a Bankavgift line, a "Fee" counterparty with + // four prior bookings. The dialog reads defaultAccount off this shape and + // immediately calls .startsWith() on it. + const s = buildCounterpartySuggestion(makeTemplate(), 0.9) + const { account, vat } = resolveQuickReviewDefaults( + { + id: s.template_id, + name_sv: s.name_sv, + debit_account: s.debit_account, + credit_account: s.credit_account, + vat_treatment: s.vat_treatment ?? null, + }, + undefined, + 'expense_other', + ) + expect(account).toBe('6570') + expect(() => account.startsWith('2')).not.toThrow() + expect(vat).toBe('none') + }) +}) diff --git a/lib/transactions/__tests__/quick-review-defaults.test.ts b/lib/transactions/__tests__/quick-review-defaults.test.ts new file mode 100644 index 00000000..73ec53d6 --- /dev/null +++ b/lib/transactions/__tests__/quick-review-defaults.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { resolveQuickReviewDefaults, type ReviewTemplate } from '../quick-review-defaults' +import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping' + +/** + * Regression cover for the "Tidigare motparter" crash: picking a learned + * counterparty template in the Bokför-transaktion modal replaced the whole + * page with the "Något gick fel" error boundary. + * + * The synthetic counterparty template carried only { id, name_sv }, so + * `template.debit_account` was undefined, the dialog's `defaultAccount` prop + * (declared `string`) received undefined, and the first render threw on + * `accountOverride.startsWith('2')`. + */ +describe('resolveQuickReviewDefaults', () => { + const counterparty: ReviewTemplate = { + id: 'counterparty:11111111-1111-1111-1111-111111111111', + name_sv: 'Fee', + debit_account: '6570', + credit_account: '1930', + vat_treatment: null, + } + + it('never returns undefined for the account, whatever the template omits', () => { + const bare: ReviewTemplate = { id: 'counterparty:abc', name_sv: 'Fee' } + const { account, vat } = resolveQuickReviewDefaults(bare, undefined, 'expense_other') + expect(account).toBe(getDefaultAccountForCategory('expense_other')) + expect(typeof account).toBe('string') + expect(vat).toBe('none') + }) + + it('returns an empty account rather than undefined when there is nothing at all', () => { + expect(resolveQuickReviewDefaults(null, undefined, null)).toEqual({ account: '', vat: 'none' }) + expect(resolveQuickReviewDefaults({ id: 'counterparty:abc', name_sv: 'Fee' }, undefined, null)) + .toEqual({ account: '', vat: 'none' }) + }) + + it('seeds from the counterparty template accounts, not the category fallback', () => { + const { account, vat } = resolveQuickReviewDefaults(counterparty, undefined, 'expense_other') + expect(account).toBe('6570') + expect(vat).toBe('none') + }) + + it('carries the learned VAT treatment of a counterparty template', () => { + const { vat } = resolveQuickReviewDefaults( + { ...counterparty, debit_account: '5420', vat_treatment: 'standard_25' }, + undefined, + 'expense_other', + ) + expect(vat).toBe('standard_25') + }) + + it('ignores the template and uses the category when a catalog templateId is present', () => { + const catalog: ReviewTemplate = { + id: 'bank_fees', + name_sv: 'Bankavgifter', + debit_account: '6570', + credit_account: '1930', + vat_treatment: null, + } + const { account } = resolveQuickReviewDefaults(catalog, 'bank_fees', 'expense_other') + // Catalog templates are validated server-side by id; the form's account + // field is not the source of truth for them. + expect(account).toBe(getDefaultAccountForCategory('expense_other')) + }) + + it('falls back to the category defaults when no template is involved', () => { + const { account, vat } = resolveQuickReviewDefaults(null, undefined, 'expense_other') + expect(account).toBe(getDefaultAccountForCategory('expense_other')) + expect(vat === 'none' || typeof vat === 'string').toBe(true) + }) +}) diff --git a/lib/transactions/category-suggestions.ts b/lib/transactions/category-suggestions.ts index 0bf37404..a9840a39 100644 --- a/lib/transactions/category-suggestions.ts +++ b/lib/transactions/category-suggestions.ts @@ -1,8 +1,20 @@ import { suggestCategory } from '@/lib/tax/expense-warnings' import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping' -import { normalizeCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' +import { + normalizeCounterpartyName, + formatCounterpartyName, + toCounterpartyTemplateId, +} from '@/lib/bookkeeping/counterparty-templates' import { findMatchingTemplates, getTemplateById, type TemplateMatch } from '@/lib/bookkeeping/booking-templates' -import type { Transaction, TransactionCategory, EntityType, MappingRule, LinePatternEntry } from '@/types' +import type { + Transaction, + TransactionCategory, + EntityType, + MappingRule, + LinePatternEntry, + VatTreatment, + CategorizationTemplate, +} from '@/types' export interface SuggestedCategory { category: TransactionCategory @@ -247,6 +259,11 @@ export interface SuggestedTemplate { risk_level: string requires_review: boolean line_pattern?: LinePatternEntry[] | null + // Learned VAT treatment on a single-line counterparty suggestion. Without + // it the review dialog previews the verifikation at gross with no moms leg, + // while the server books the expense net + 2641. Multi-line suggestions + // carry their VAT inside line_pattern instead. + vat_treatment?: VatTreatment | null // Learned {sie_dim_no: code} bag on counterparty suggestions: prefills the // review dialog's dimension picker (the server applies it at booking anyway; // surfacing it keeps the user in the loop). @@ -350,3 +367,40 @@ export async function getSuggestedTemplates( .sort((a, b) => b.confidence - a.confidence) .slice(0, 10) } + +/** + * Shape a learned counterparty template into the suggestion the transaction + * modal renders under "Tidigare motparter". + * + * Every field the review dialog later reads has to come across here: the + * dialog books through `counterparty_template_id`, so the accounts and VAT it + * previews must be the template's own, not the transaction category's + * fallbacks. A suggestion that omitted them previously left the dialog with an + * undefined default account, which crashed the page. + */ +export function buildCounterpartySuggestion( + template: CategorizationTemplate, + confidence: number, +): SuggestedTemplate { + return { + template_id: toCounterpartyTemplateId(template.id), + name_sv: formatCounterpartyName(template.counterparty_name), + name_en: formatCounterpartyName(template.counterparty_name), + group: 'counterparty', + debit_account: template.debit_account, + credit_account: template.credit_account, + confidence, + description_sv: `${template.occurrence_count} tidigare bokföringar`, + risk_level: 'NONE', + requires_review: false, + line_pattern: template.line_pattern ?? null, + // Single-line templates book net expense + input VAT from this treatment + // (buildMappingResultFromCounterpartyTemplate); the review dialog needs it + // to preview the same verifikation. + vat_treatment: template.vat_treatment ?? null, + default_dimensions: + template.default_dimensions && Object.keys(template.default_dimensions).length > 0 + ? template.default_dimensions + : null, + } +} diff --git a/lib/transactions/quick-review-defaults.ts b/lib/transactions/quick-review-defaults.ts new file mode 100644 index 00000000..d54081a5 --- /dev/null +++ b/lib/transactions/quick-review-defaults.ts @@ -0,0 +1,72 @@ +import { + getDefaultAccountForCategory, + getDefaultVatTreatmentForCategory, +} from '@/lib/bookkeeping/category-mapping' +import type { TransactionCategory, VatTreatment } from '@/types' + +/** + * The template shape the transaction review dialog actually reads. + * + * Deliberately NOT `BookingTemplate`: the review dialog is opened from three + * sources, and only one of them has a full catalog template behind it. + * + * - a static catalog template (`BookingTemplate`, structurally assignable), + * - a user library template converted to the same shape, + * - a learned counterparty template ("Tidigare motparter"), which has no + * catalog entry at all: it carries a name and a debit/credit pair and + * nothing else. + * + * The counterparty case used to be forced into `BookingTemplate` with an + * `as BookingTemplate` cast on a two-field object literal. The cast made + * every missing field look present to the compiler, so `template.debit_account` + * read `undefined` at runtime, flowed into the dialog's `defaultAccount` prop + * (typed `string`), and crashed the page on `accountOverride.startsWith('2')`. + * Typing the optional fields as optional is what makes that class of bug a + * compile error instead of an error boundary. + */ +export interface ReviewTemplate { + id: string + name_sv: string + debit_account?: string + credit_account?: string + vat_treatment?: VatTreatment | null + vat_rate?: number + special_rules_sv?: string + deductibility_note_sv?: string + requires_vat_registration_data?: boolean + reverse_charge_supplier_type?: 'eu_business' | 'non_eu_business' | 'swedish_business' +} + +export interface QuickReviewDefaults { + account: string + vat: VatTreatment | 'none' +} + +/** + * Resolve the account + VAT the review dialog starts on. + * + * A template without a `templateId` (library or counterparty template) is not + * validated server-side against the static catalog, so its own debit account + * and VAT treatment seed the form. Anything the template leaves unset falls + * back to the transaction category's defaults, and finally to an empty + * account / no VAT: the caller feeds a required `string` prop, and an + * `undefined` there is what took the page down. + */ +export function resolveQuickReviewDefaults( + template: ReviewTemplate | null | undefined, + templateId: string | undefined, + category: TransactionCategory | null | undefined, +): QuickReviewDefaults { + const useTemplateDefaults = !templateId && !!template + + const account = + (useTemplateDefaults ? template.debit_account : undefined) || + (category ? getDefaultAccountForCategory(category) : '') || + '' + + const vat: VatTreatment | 'none' = useTemplateDefaults + ? (template.vat_treatment ?? 'none') + : (category ? (getDefaultVatTreatmentForCategory(category) ?? 'none') : 'none') + + return { account, vat } +} diff --git a/lib/utils.ts b/lib/utils.ts index e2139bf6..34ec3e31 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -35,12 +35,18 @@ const INVALID_DATE_PLACEHOLDER = '-' */ export function formatCurrency( amount: number, - currency: string = 'SEK', + currency?: string | null, options?: { minimumFractionDigits?: number; maximumFractionDigits?: number }, ): string { + // A `= 'SEK'` default only covers undefined. `transactions.currency` is a + // nullable column whose NULL is legacy for the 'SEK' default (see migration + // 20260726100000), yet the Transaction type declares it required, so a NULL + // reached Intl unguarded: `currency: null` throws RangeError and a single + // legacy row blanked the whole transactions list into the error boundary. + const code = currency || 'SEK' return new Intl.NumberFormat('sv-SE', { style: 'currency', - currency, + currency: code, minimumFractionDigits: options?.minimumFractionDigits ?? 0, maximumFractionDigits: options?.maximumFractionDigits ?? 2, }).format(amount) diff --git a/messages/en.json b/messages/en.json index 5af4073e..608e869d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4696,6 +4696,8 @@ "no_results": "No matching companies" }, "transactions": { + "counterparty_suggestion_gone_title": "That counterparty is no longer available", + "counterparty_suggestion_gone_description": "The suggestion was refreshed. Close the dialog, open it again and pick the counterparty once more.", "page_title": "Transactions", "subtitle_to_post": "to post", "subtitle_matches": "{count} invoice matches", diff --git a/messages/sv.json b/messages/sv.json index 76a41dec..396309f8 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -4696,6 +4696,8 @@ "no_results": "Inga företag matchar" }, "transactions": { + "counterparty_suggestion_gone_title": "Motparten är inte längre tillgänglig", + "counterparty_suggestion_gone_description": "Förslaget hann uppdateras. Stäng rutan, öppna den igen och välj motparten på nytt.", "page_title": "Transaktioner", "subtitle_to_post": "att bokföra", "subtitle_matches": "{count} fakturamatchningar",