fix: counterparty template pick crashes the page (#1291)

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<string | null>
  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.
This commit is contained in:
Jakob Wennberg
2026-07-29 19:20:25 +02:00
committed by GitHub
parent 16f34fb214
commit 198d3092c7
20 changed files with 669 additions and 93 deletions
+22 -1
View File
@@ -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('€')
})
})
@@ -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> = {}): 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')
})
})
@@ -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)
})
})
+56 -2
View File
@@ -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,
}
}
+72
View File
@@ -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 }
}
+8 -2
View File
@@ -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)