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:
@@ -506,12 +506,16 @@ export default function SkattekontoPage() {
|
||||
</AttnLine>
|
||||
) : 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 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Information från Skatteverket
|
||||
</p>
|
||||
{data.informationstext.map((info, i) => (
|
||||
{(data.informationstext ?? []).map((info, i) => (
|
||||
<p key={i} className="text-xs leading-5 text-muted-foreground">
|
||||
{info}
|
||||
</p>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 <Toaster> 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() {
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
{/* 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}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user