feat(transactions): show all library templates in picker; fix PSD2 seed-row collision (#596)

* feat(transactions): show all library templates in picker; fix PSD2 seed-row collision

- Booking template picker: surface every active library template, not
  only the convertible 2-account shapes. Multi-leg/complex templates
  route to the manual journal editor pre-filled via applyTemplate
  instead of being hidden. Drop direction filtering for user
  templates (inferred direction is unreliable); the curated static
  catalog still respects it. Add an "Aktivera och bokfor" recovery
  toast for TX_CATEGORIZE_INVALID_ACCOUNT mirroring the existing
  ACCOUNTS_NOT_IN_CHART flow. CreateTemplateForm reflows to one card
  per line so trash buttons stop colliding on narrow screens.

- cash_accounts.upsertFromPsd2: the seed_default_cash_account
  migration plants a manual (bank_connection_id IS NULL) row on the
  same ledger_account, so the first PSD2 sync's upsert on
  (company_id, bank_connection_id, external_uid) cannot match it
  (NULL != NULL) and falls through to INSERT, tripping the
  (company_id, ledger_account) UNIQUE constraint. Look up and
  promote the seed row in place first.

- Tests: pin the TX_CATEGORIZE_INVALID_ACCOUNT error shape the
  recovery toast parses; cover applyTemplate on shapes the
  converter rejects (split-expense and all-'business'-typed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): PR review — currency metadata, MRU ordering, observable promote, BAS validation

- TransactionBookingDialog.buildInitialLinesFromTemplate: attach
  buildCurrencyMetadata to settlement lines for foreign-currency
  transactions so the journal entry retains the original currency,
  amount, and exchange_rate. Without this, non-SEK transactions
  routed through a non-convertible template were recorded in SEK
  only with no foreign-currency annotation. (Greptile #2)

- TemplatePicker.handleSelectLibraryRaw: only bump the MRU after
  confirming the click will actually do something (i.e. converted
  OR a callback is wired). Future consumers that omit
  onPickLibraryTemplate would otherwise corrupt MRU ordering for
  templates the user never successfully applied. (Greptile #1)

- cash_accounts.upsertFromPsd2 promote-seed: add .select('id') so
  a zero-row UPDATE is observable. If the seed row vanishes
  between the SELECT and UPDATE (concurrent ops), fall through to
  the normal upsert instead of silently returning success without
  persisting anything. (Greptile #3, compliance A.8.9)

- transactions/page.tsx TX_CATEGORIZE_INVALID_ACCOUNT toast:
  validate accountNumber against /^\d{4}$/ before embedding in any
  fetch URL/body. Defense-in-depth against a malformed server
  error envelope. (compliance V8.2.1)

- categorize route test: switch the not-in-chart fixture from
  '4535' (Inköp av varor från annat EU-land — reverse-charge) to
  '5420' (Programvaror) so the example doesn't imply a domestic
  override against an EU-reverse-charge account would be valid
  without its paired moms legs. (Swedish compliance review #3)

Other review items deliberately not addressed in this PR:
- Validate-on-save that every VAT-rated template has a 'vat'
  line — overrides existing CreateTemplateForm UX, separate PR.
- vat_rate enum guard in applyTemplate — defensive; the editor
  dropdown only surfaces legal rates and is the only write path
  in production today.
- Imbalance UI warning — already handled: JournalEntryForm
  computes isBalanced and gates submission; DB trigger
  check_journal_entry_balance enforces BFL 5 kap server-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-28 22:57:59 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ccdfed5fea
commit a586cc8a58
9 changed files with 503 additions and 107 deletions
+93 -2
View File
@@ -50,7 +50,7 @@ import { findBankSkvCounterparts } from '@/lib/skatteverket/bank-counterpart'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry } from '@/types' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary } from '@/types'
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
type InvoiceWithCustomer = Invoice & { customer?: Customer } type InvoiceWithCustomer = Invoice & { customer?: Customer }
@@ -109,6 +109,7 @@ export default function TransactionsPage() {
// Booking dialog (journal entry form) // Booking dialog (journal entry form)
const [bookingDialogOpen, setBookingDialogOpen] = useState(false) const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null) const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null)
const [bookingDialogTemplate, setBookingDialogTemplate] = useState<BookingTemplateLibrary | null>(null)
// Template picker dialog // Template picker dialog
const [templatePickerOpen, setTemplatePickerOpen] = useState(false) const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
@@ -541,6 +542,78 @@ export default function TransactionsPage() {
setProcessingId(null) setProcessingId(null)
return null return null
} }
if (result?.error?.code === 'TX_CATEGORIZE_INVALID_ACCOUNT') {
// The user picked a library template (or typed an account
// override) whose account isn't in this company's kontoplan.
// Mirror the ACCOUNTS_NOT_IN_CHART flow with a one-click
// "Aktivera och bokför" — pull the BAS name if known so the
// toast carries real context.
// Validate the BAS account number is a plain 4-digit string before
// embedding it in any fetch URL/body — the value comes from the
// server error envelope but defense-in-depth.
const rawAccountNumber: unknown = result.error.details?.accountNumber
const accountNumber: string | undefined =
typeof rawAccountNumber === 'string' && /^\d{4}$/.test(rawAccountNumber)
? rawAccountNumber
: undefined
let displayName = accountNumber ?? ''
if (accountNumber) {
try {
const lookupRes = await fetch(`/api/bookkeeping/accounts/bas-lookup?numbers=${encodeURIComponent(accountNumber)}`)
if (lookupRes.ok) {
const lookup = await lookupRes.json() as { data?: Array<{ account_number: string; account_name: string | null; known?: boolean }> }
const hit = lookup.data?.find((r) => r.account_number === accountNumber)
if (hit?.account_name) displayName = `${accountNumber} — ${hit.account_name}`
}
} catch { /* fall through to the plain number */ }
}
let invalidAccountActivateInFlight = false
toast({
title: 'Kontot finns inte i din kontoplan',
description: accountNumber
? `Kontot ${displayName} är inte aktiverat.`
: 'Kontot är inte aktiverat.',
variant: 'destructive',
action: accountNumber ? (
<ToastAction altText="Aktivera och bokför" onClick={async () => {
if (invalidAccountActivateInFlight) return
invalidAccountActivateInFlight = true
try {
const activateRes = await fetch('/api/bookkeeping/accounts/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: [accountNumber] }),
})
if (!activateRes.ok) {
const errBody = await activateRes.json().catch(() => null)
toast({
title: 'Kunde inte aktivera kontot',
description: getErrorMessage(errBody, { statusCode: activateRes.status }),
variant: 'destructive',
})
return
}
const activateBody = await activateRes.json()
if (Array.isArray(activateBody.unknown) && activateBody.unknown.length > 0) {
toast({
title: 'Kontot finns inte i BAS-planen',
description: `Lägg till ${accountNumber} manuellt under Inställningar → Kontoplan.`,
variant: 'destructive',
})
return
}
await runCategorize(args)
} finally {
invalidAccountActivateInFlight = false
}
}}>
Aktivera och bokför
</ToastAction>
) : undefined,
})
setProcessingId(null)
return null
}
if (result?.error?.code === 'ACCOUNTS_NOT_IN_CHART') { if (result?.error?.code === 'ACCOUNTS_NOT_IN_CHART') {
// The mapped template/category references one or more accounts // The mapped template/category references one or more accounts
// that aren't active in this company's kontoplan. Without an // that aren't active in this company's kontoplan. Without an
@@ -1240,6 +1313,7 @@ export default function TransactionsPage() {
}, 350) }, 350)
setBookingDialogOpen(false) setBookingDialogOpen(false)
setBookingDialogTransaction(null) setBookingDialogTransaction(null)
setBookingDialogTemplate(null)
toast({ title: 'Bokförd' }) toast({ title: 'Bokförd' })
} }
@@ -1392,10 +1466,22 @@ export default function TransactionsPage() {
setTemplatePickerOpen(false) setTemplatePickerOpen(false)
if (templatePickerTransaction) { if (templatePickerTransaction) {
setBookingDialogTransaction(templatePickerTransaction) setBookingDialogTransaction(templatePickerTransaction)
setBookingDialogTemplate(null)
setBookingDialogOpen(true) setBookingDialogOpen(true)
} }
} }
// Complex (multi-leg or otherwise non-convertible) library template picked
// from the transaction modal — route into the manual booking dialog with
// the template pre-applied against the transaction's amount.
function handlePickLibraryTemplate(raw: BookingTemplateLibrary) {
if (!templatePickerTransaction) return
setBookingDialogTransaction(templatePickerTransaction)
setBookingDialogTemplate(raw)
setTemplatePickerOpen(false)
setBookingDialogOpen(true)
}
async function handleQuickReviewConfirm( async function handleQuickReviewConfirm(
id: string, id: string,
category: TransactionCategory, category: TransactionCategory,
@@ -1714,8 +1800,12 @@ export default function TransactionsPage() {
<TransactionBookingDialog <TransactionBookingDialog
open={bookingDialogOpen} open={bookingDialogOpen}
onOpenChange={setBookingDialogOpen} onOpenChange={(o) => {
setBookingDialogOpen(o)
if (!o) setBookingDialogTemplate(null)
}}
transaction={bookingDialogTransaction} transaction={bookingDialogTransaction}
preselectedTemplate={bookingDialogTemplate}
onBooked={handleTransactionBooked} onBooked={handleTransactionBooked}
/> />
@@ -1767,6 +1857,7 @@ export default function TransactionsPage() {
setTemplatePickerOpen(false) setTemplatePickerOpen(false)
handleOpenTemplateReview(templatePickerTransaction, templateId) handleOpenTemplateReview(templatePickerTransaction, templateId)
}} }}
onPickLibraryTemplate={handlePickLibraryTemplate}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -714,4 +714,40 @@ describe('POST /api/transactions/[id]/categorize', () => {
// had to enqueue a response for it. The absence of an enqueue here plus // had to enqueue a response for it. The absence of an enqueue here plus
// the 400 status is the assertion that the route did not fall through. // the 400 status is the assertion that the route did not fall through.
}) })
// The transactions page surfaces TX_CATEGORIZE_INVALID_ACCOUNT with an
// inline "Aktivera och bokför" toast and reads details.accountNumber to
// call POST /accounts/activate. This test pins the error shape that flow
// depends on — if the field name changes the recovery UI silently breaks.
it('returns 400 TX_CATEGORIZE_INVALID_ACCOUNT with details.accountNumber when account_override is not in the chart', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -869.25,
merchant_name: 'Paddle',
journal_entry_id: null,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
// chart_of_accounts lookup for '5420' — not in the company's chart.
// Using a plain expense account (Programvaror) avoids the implication
// that 4535 (Inköp av varor från annat EU-land, reverse-charge) would
// be a valid override on a domestic transaction without its paired
// moms legs (2614/2645) — see the Swedish compliance review note.
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: true, category: 'expense_software', account_override: '5420' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { accountNumber?: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('TX_CATEGORIZE_INVALID_ACCOUNT')
expect(body.error.details.accountNumber).toBe('5420')
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
})
}) })
+53 -49
View File
@@ -490,59 +490,63 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
<Label>{t('lines_label')}</Label> <Label>{t('lines_label')}</Label>
<div className="space-y-2 mt-1"> <div className="space-y-2 mt-1">
{lines.map((line, i) => ( {lines.map((line, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="rounded-md border border-border p-2 space-y-1.5">
<Input <div className="flex items-center gap-2">
value={line.account} <Input
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))} value={line.account}
placeholder={t('account_placeholder')} onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
className="w-20 font-mono" placeholder={t('account_placeholder')}
maxLength={4} className="w-20 font-mono"
/> maxLength={4}
<Input />
value={line.label} <Input
onChange={(e) => updateLine(i, 'label', e.target.value)} value={line.label}
placeholder={t('description_short_placeholder')} onChange={(e) => updateLine(i, 'label', e.target.value)}
className="flex-1" placeholder={t('description_short_placeholder')}
/> className="flex-1 min-w-0"
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}> />
<SelectTrigger className="w-20"><SelectValue /></SelectTrigger> <Button
<SelectContent> type="button"
<SelectItem value="debit">{t('debit_label')}</SelectItem> variant="ghost"
<SelectItem value="credit">{t('credit_label')}</SelectItem> size="sm"
</SelectContent> onClick={() => removeLine(i)}
</Select> disabled={lines.length <= 2}
<Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}> className="h-8 w-8 p-0 shrink-0"
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">{t('type_cost')}</SelectItem>
<SelectItem value="vat">{t('type_vat')}</SelectItem>
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
</SelectContent>
</Select>
{line.type === 'vat' && (
<Select
value={String(line.vat_rate ?? 0.25)}
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
> >
<SelectTrigger className="w-20" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger> <Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
<SelectTrigger className="w-24"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem> <SelectItem value="debit">{t('debit_label')}</SelectItem>
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem> <SelectItem value="credit">{t('credit_label')}</SelectItem>
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} <Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}>
<Button <SelectTrigger className="flex-1"><SelectValue /></SelectTrigger>
type="button" <SelectContent>
variant="ghost" <SelectItem value="business">{t('type_cost')}</SelectItem>
size="sm" <SelectItem value="vat">{t('type_vat')}</SelectItem>
onClick={() => removeLine(i)} <SelectItem value="settlement">{t('type_settlement')}</SelectItem>
disabled={lines.length <= 2} </SelectContent>
className="h-8 w-8 p-0 shrink-0" </Select>
> {line.type === 'vat' && (
<Trash2 className="h-3.5 w-3.5" /> <Select
</Button> value={String(line.vat_rate ?? 0.25)}
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
>
<SelectTrigger className="w-24" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem>
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem>
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
</SelectContent>
</Select>
)}
</div>
</div> </div>
))} ))}
<Button type="button" variant="outline" size="sm" onClick={addLine}> <Button type="button" variant="outline" size="sm" onClick={addLine}>
+183 -52
View File
@@ -77,6 +77,73 @@ interface TemplateCardProps {
compact?: boolean compact?: boolean
} }
interface LibraryTemplateCardProps {
raw: BookingTemplateLibrary
converted: BookingTemplate | null
selected: boolean
onClick: () => void
}
function LibraryTemplateCard({ raw, converted, selected, onClick }: LibraryTemplateCardProps) {
const t = useTranslations('tx_template_picker')
// Convertible templates render the familiar two-account summary; complex
// ones list the business legs (the cost/revenue accounts) so the user can
// recognise the template at a glance, and carry an "opens editor" badge.
const businessLines = raw.lines.filter((l) => l.type === 'business')
const vatLabelKey = converted ? getVatLabelKey(converted) : null
return (
<button
type="button"
onClick={onClick}
className={`w-full text-left rounded-lg border px-3 py-2.5 transition-colors hover:bg-muted/50 ${
selected
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm leading-tight">{raw.name}</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{converted ? (
<span className="text-xs font-mono text-muted-foreground">
D: {formatAccountWithName(converted.debit_account)} &middot; K: {formatAccountWithName(converted.credit_account)}
</span>
) : (
<span className="text-xs font-mono text-muted-foreground">
{businessLines.slice(0, 2).map((l) => formatAccountWithName(l.account)).join(' · ') || raw.lines.map((l) => l.account).slice(0, 2).join(' · ')}
</span>
)}
{vatLabelKey && (
<Badge
variant="secondary"
className={`text-[10px] px-1.5 py-0 ${
converted?.vat_treatment === 'reverse_charge'
? 'bg-warning/10 text-warning-foreground'
: ''
}`}
>
{t(vatLabelKey)}
</Badge>
)}
{!converted && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{t('opens_editor_badge')}
</Badge>
)}
</div>
</div>
</div>
{raw.description && (
<p className="text-[11px] text-muted-foreground mt-1.5 leading-snug">
{raw.description}
</p>
)}
</button>
)
}
function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) { function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) {
const t = useTranslations('tx_template_picker') const t = useTranslations('tx_template_picker')
const vatLabelKey = getVatLabelKey(template) const vatLabelKey = getVatLabelKey(template)
@@ -140,6 +207,7 @@ interface TemplatePickerProps {
recentTemplateIds?: string[] recentTemplateIds?: string[]
onSelect: (template: BookingTemplate) => void onSelect: (template: BookingTemplate) => void
onSelectCounterparty?: (templateId: string) => void onSelectCounterparty?: (templateId: string) => void
onPickLibraryTemplate?: (raw: BookingTemplateLibrary) => void
selectedTemplateId?: string selectedTemplateId?: string
} }
@@ -149,19 +217,25 @@ export default function TemplatePicker({
suggestedTemplates, suggestedTemplates,
onSelect, onSelect,
onSelectCounterparty, onSelectCounterparty,
onPickLibraryTemplate,
selectedTemplateId, selectedTemplateId,
}: TemplatePickerProps) { }: TemplatePickerProps) {
const t = useTranslations('tx_template_picker') const t = useTranslations('tx_template_picker')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false)
const [libraryTemplates, setLibraryTemplates] = useState<BookingTemplate[]>([]) const [libraryRaw, setLibraryRaw] = useState<BookingTemplateLibrary[]>([])
// Map direction to template direction filter (transfers show in both) // Map direction to template direction filter (transfers show in both).
// Direction filtering applies only to the static "Vanliga mallar" list —
// user-created library templates ignore it (inferred direction is unreliable
// and users know what they made).
const templateDirection = direction === 'income' ? 'income' : 'expense' const templateDirection = direction === 'income' ? 'income' : 'expense'
// Fetch user-created booking templates (company + team scope) and map // Fetch the user's library templates (company + team scope). We keep them
// convertible ones into BookingTemplate shape. System templates are // in their raw shape so we can render every template, even ones that don't
// already covered by the static list below, so we exclude them here. // fit convertLibraryToBookingTemplate's simple 2-account contract — those
// get routed through the manual booking dialog instead of the QuickReview
// single-account path.
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
;(async () => { ;(async () => {
@@ -170,11 +244,7 @@ export default function TemplatePicker({
if (!res.ok) return if (!res.ok) return
const { data } = await res.json() as { data?: BookingTemplateLibrary[] } const { data } = await res.json() as { data?: BookingTemplateLibrary[] }
if (!data) return if (!data) return
const mapped = data setLibraryRaw(data.filter((tt) => !tt.is_system && tt.is_active))
.filter((t) => !t.is_system && t.is_active)
.map(convertLibraryToBookingTemplate)
.filter((t): t is BookingTemplate => t !== null)
setLibraryTemplates(mapped)
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof DOMException && err.name === 'AbortError') return
} }
@@ -182,6 +252,16 @@ export default function TemplatePicker({
return () => { controller.abort() } return () => { controller.abort() }
}, []) }, [])
// Lazy convertibility map. A template is "convertible" if it fits the
// simple debit/credit pair shape the QuickReview booking path expects.
// Non-convertible templates are still shown — they just route to the
// full journal-entry editor on click.
const convertedById = useMemo(() => {
const m = new Map<string, BookingTemplate | null>()
for (const raw of libraryRaw) m.set(raw.id, convertLibraryToBookingTemplate(raw))
return m
}, [libraryRaw])
const commonTemplates = useMemo( const commonTemplates = useMemo(
() => getCommonTemplates(entityType, templateDirection), () => getCommonTemplates(entityType, templateDirection),
[entityType, templateDirection] [entityType, templateDirection]
@@ -211,43 +291,78 @@ export default function TemplatePicker({
[advancedTemplates, advancedTransfers] [advancedTemplates, advancedTransfers]
) )
// User-created library templates filtered by direction + entity // Library templates filtered by entity_type only. Direction is NOT applied
const relevantLibraryTemplates = useMemo(() => { // here — see the comment on convertedById above.
return libraryTemplates.filter((t) => { const relevantLibraryRaw = useMemo(() => {
if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) { return libraryRaw.filter((tt) => {
if (entityType && tt.entity_type && tt.entity_type !== 'all' && tt.entity_type !== entityType) {
return false return false
} }
return t.direction === templateDirection || t.direction === 'transfer' return true
}) })
}, [libraryTemplates, entityType, templateDirection]) }, [libraryRaw, entityType])
// Search results (static + library) // Convertible templates surface first; within each group, sort by name.
const searchResults = useMemo(() => { const sortedLibraryRaw = useMemo(() => {
return [...relevantLibraryRaw].sort((a, b) => {
const ac = convertedById.get(a.id) ? 0 : 1
const bc = convertedById.get(b.id) ? 0 : 1
if (ac !== bc) return ac - bc
return a.name.localeCompare(b.name, 'sv')
})
}, [relevantLibraryRaw, convertedById])
// Search results (static + library). Library search ignores direction; the
// static catalog still respects it because it's curated content.
const searchResults = useMemo<
| { library: BookingTemplateLibrary[]; staticTemplates: BookingTemplate[] }
| null
>(() => {
if (!searchQuery.trim()) return null if (!searchQuery.trim()) return null
const q = searchQuery.toLowerCase() const q = searchQuery.toLowerCase()
const libraryMatches = relevantLibraryTemplates.filter((t) => const libraryMatches = sortedLibraryRaw.filter((tt) =>
t.name_sv.toLowerCase().includes(q) || t.description_sv.toLowerCase().includes(q) tt.name.toLowerCase().includes(q) ||
(tt.description ?? '').toLowerCase().includes(q)
) )
const staticMatches = searchTemplates(searchQuery, entityType).filter((t) => { const staticMatches = searchTemplates(searchQuery, entityType).filter((tt) => {
if (t.direction === templateDirection || t.direction === 'transfer') return true return tt.direction === templateDirection || tt.direction === 'transfer'
return false
}) })
return [...libraryMatches, ...staticMatches] return { library: libraryMatches, staticTemplates: staticMatches }
}, [searchQuery, entityType, templateDirection, relevantLibraryTemplates]) }, [searchQuery, entityType, templateDirection, sortedLibraryRaw])
// Group templates by group for display // Group templates by group for display
const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon]) const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon])
const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced]) const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced])
const bumpLibraryMru = (libraryId: string) => {
fetch(`/api/settings/booking-templates/${libraryId}/touch`, { method: 'POST' }).catch(() => {})
}
const handleSelect = (template: BookingTemplate) => { const handleSelect = (template: BookingTemplate) => {
// For library-backed templates, bump MRU so they surface at the top next time.
if (isLibraryTemplateId(template.id)) { if (isLibraryTemplateId(template.id)) {
const libraryId = template.id.slice(LIBRARY_TEMPLATE_PREFIX.length) bumpLibraryMru(template.id.slice(LIBRARY_TEMPLATE_PREFIX.length))
fetch(`/api/settings/booking-templates/${libraryId}/touch`, { method: 'POST' }).catch(() => {})
} }
onSelect(template) onSelect(template)
} }
// Click a raw library card. Convertible → fast QuickReview path via onSelect.
// Non-convertible → route to manual booking dialog pre-filled via the new
// onPickLibraryTemplate callback. MRU is only bumped after we confirm the
// click will actually do something — otherwise consumers that omit the
// callback would corrupt MRU ordering for templates the user never applied.
const handleSelectLibraryRaw = (raw: BookingTemplateLibrary) => {
const converted = convertedById.get(raw.id) ?? null
if (converted) {
bumpLibraryMru(raw.id)
onSelect(converted)
return
}
if (onPickLibraryTemplate) {
bumpLibraryMru(raw.id)
onPickLibraryTemplate(raw)
}
}
// Split suggestions: counterparty templates vs regular booking templates // Split suggestions: counterparty templates vs regular booking templates
const counterpartySuggestions = useMemo(() => { const counterpartySuggestions = useMemo(() => {
if (!suggestedTemplates) return [] if (!suggestedTemplates) return []
@@ -277,38 +392,54 @@ export default function TemplatePicker({
<div className="flex-1 overflow-auto px-4 pb-4 space-y-4"> <div className="flex-1 overflow-auto px-4 pb-4 space-y-4">
{/* Search results */} {/* Search results */}
{searchResults !== null ? ( {searchResults !== null ? (
<div> (() => {
<p className="text-xs font-medium text-muted-foreground mb-2"> const totalResults = searchResults.library.length + searchResults.staticTemplates.length
{searchResults.length === 0 ? t('no_results') : t('n_results', { count: searchResults.length })} return (
</p> <div>
<div className="space-y-1.5"> <p className="text-xs font-medium text-muted-foreground mb-2">
{searchResults.map((t) => ( {totalResults === 0 ? t('no_results') : t('n_results', { count: totalResults })}
<TemplateCard </p>
key={t.id} <div className="space-y-1.5">
template={t} {searchResults.library.map((raw) => (
selected={selectedTemplateId === t.id} <LibraryTemplateCard
onClick={() => handleSelect(t)} key={raw.id}
/> raw={raw}
))} converted={convertedById.get(raw.id) ?? null}
</div> selected={selectedTemplateId === (convertedById.get(raw.id)?.id ?? raw.id)}
</div> onClick={() => handleSelectLibraryRaw(raw)}
/>
))}
{searchResults.staticTemplates.map((tt) => (
<TemplateCard
key={tt.id}
template={tt}
selected={selectedTemplateId === tt.id}
onClick={() => handleSelect(tt)}
/>
))}
</div>
</div>
)
})()
) : ( ) : (
<> <>
{/* User-created library templates (company + team scope) */} {/* User-created library templates (company + team scope).
{relevantLibraryTemplates.length > 0 && ( Direction is intentionally NOT applied here — all the user's
own templates are shown regardless of expense/income context. */}
{sortedLibraryRaw.length > 0 && (
<div> <div>
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1.5"> <p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
<Building2 className="h-3 w-3" /> <Building2 className="h-3 w-3" />
{t('my_templates')} {t('my_templates')}
</p> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{relevantLibraryTemplates.map((t) => ( {sortedLibraryRaw.map((raw) => (
<TemplateCard <LibraryTemplateCard
key={t.id} key={raw.id}
template={t} raw={raw}
selected={selectedTemplateId === t.id} converted={convertedById.get(raw.id) ?? null}
onClick={() => handleSelect(t)} selected={selectedTemplateId === (convertedById.get(raw.id)?.id ?? raw.id)}
compact onClick={() => handleSelectLibraryRaw(raw)}
/> />
))} ))}
</div> </div>
@@ -13,6 +13,8 @@ import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils' import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
import type { BookingTemplateLibrary } from '@/types'
import type { TransactionWithInvoice } from './transaction-types' import type { TransactionWithInvoice } from './transaction-types'
interface TransactionBookingDialogProps { interface TransactionBookingDialogProps {
@@ -20,6 +22,7 @@ interface TransactionBookingDialogProps {
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null transaction: TransactionWithInvoice | null
onBooked: (transactionId: string, journalEntryId: string) => void onBooked: (transactionId: string, journalEntryId: string) => void
preselectedTemplate?: BookingTemplateLibrary | null
} }
function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescription: string): FormLine[] { function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescription: string): FormLine[] {
@@ -59,11 +62,41 @@ function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescript
return isExpense ? [bankLine, counterLine] : [bankLine, counterLine] return isExpense ? [bankLine, counterLine] : [bankLine, counterLine]
} }
function buildInitialLinesFromTemplate(
transaction: TransactionWithInvoice,
template: BookingTemplateLibrary,
): FormLine[] {
const sekAmount = Math.round(Math.abs(resolveSekAmount(
transaction.amount,
transaction.amount_sek,
transaction.currency,
transaction.exchange_rate
)) * 100) / 100
const lines = applyTemplate(template.lines, sekAmount)
// Match buildInitialLines's foreign-currency handling: attach original
// currency/amount/exchange_rate metadata to the settlement (bank/cash) legs
// so the journal entry retains the foreign-currency annotation. Without
// this the entry is silently recorded in SEK only.
const isForeign = !!transaction.currency && transaction.currency !== 'SEK'
if (!isForeign) return lines
const currencyMeta = buildCurrencyMetadata(
transaction.currency,
Math.abs(transaction.amount),
transaction.exchange_rate
)
return lines.map((line, i) => {
const raw = template.lines[i]
return raw?.type === 'settlement' ? { ...line, ...currencyMeta } : line
})
}
export default function TransactionBookingDialog({ export default function TransactionBookingDialog({
open, open,
onOpenChange, onOpenChange,
transaction, transaction,
onBooked, onBooked,
preselectedTemplate,
}: TransactionBookingDialogProps) { }: TransactionBookingDialogProps) {
const t = useTranslations('tx_booking_dialog') const t = useTranslations('tx_booking_dialog')
const { toast } = useToast() const { toast } = useToast()
@@ -179,9 +212,13 @@ export default function TransactionBookingDialog({
</div> </div>
<JournalEntryForm <JournalEntryForm
key={transaction.id} key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}`}
embedded embedded
initialLines={buildInitialLines(transaction, t('bank_line_description'))} initialLines={
preselectedTemplate
? buildInitialLinesFromTemplate(transaction, preselectedTemplate)
: buildInitialLines(transaction, t('bank_line_description'))
}
initialDate={transaction.date} initialDate={transaction.date}
initialDescription={transaction.description} initialDescription={transaction.description}
submitUrl={`/api/transactions/${transaction.id}/book`} submitUrl={`/api/transactions/${transaction.id}/book`}
@@ -200,4 +200,48 @@ describe('convertLibraryToBookingTemplate', () => {
const tpl = makeLibraryTemplate([], { lines: null as unknown as BookingTemplateLibraryLine[] }) const tpl = makeLibraryTemplate([], { lines: null as unknown as BookingTemplateLibraryLine[] })
expect(convertLibraryToBookingTemplate(tpl)).toBeNull() expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
}) })
// Real-world shape from before the editor defaulted new lines to 'vat': users
// would tap "add line" twice and end up with three lines all typed 'business'
// (the dropdown default at the time). The converter rightly rejects this;
// the transaction picker now still surfaces these templates and routes the
// click to the manual booking editor instead of hiding them.
it('returns null when every line is typed "business" (pre-#589 default)', () => {
const tpl = makeLibraryTemplate([
{ account: '5420', label: 'Programvara', side: 'debit', type: 'business', ratio: 1 },
{ account: '2640', label: 'Ingående moms', side: 'debit', type: 'business', ratio: 0.25 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'business', ratio: 1 },
])
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
})
describe('applyTemplate on shapes the converter rejects', () => {
// The transaction picker's fallback for unconvertible templates is to open
// the manual booking dialog with initialLines = applyTemplate(raw.lines, |amount|).
// These tests pin that path: even when the shape is too rich for the simple
// debit/credit summary, applyTemplate still produces a usable FormLine[].
it('still produces lines for a split-expense template (two business legs)', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '5420', label: 'Programvara', side: 'debit', type: 'business', ratio: 0.7 },
{ account: '6991', label: 'Övrigt', side: 'debit', type: 'business', ratio: 0.3 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1 },
]
const result = applyTemplate(lines, 1000)
expect(result).toHaveLength(3)
expect(result[0].debit_amount).toBe('700.00')
expect(result[1].debit_amount).toBe('300.00')
expect(result[2].credit_amount).toBe('1000.00')
})
it('still produces lines when every leg is typed "business"', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '5420', label: 'Programvara', side: 'debit', type: 'business', ratio: 1 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'business', ratio: 1 },
]
const result = applyTemplate(lines, 250)
expect(result).toHaveLength(2)
expect(result[0].debit_amount).toBe('250.00')
expect(result[1].credit_amount).toBe('250.00')
})
}) })
+51
View File
@@ -142,6 +142,57 @@ export async function upsertFromPsd2(
source: 'enable_banking' as CashAccountSource, source: 'enable_banking' as CashAccountSource,
} }
// create_company_with_owner and the seed_default_cash_account migration plant
// a manual (bank_connection_id IS NULL) row on the same ledger_account so
// reconciliation routes work before any PSD2 connection exists. The first
// PSD2 sync for that BAS slot has to promote that row in place — a plain
// upsert on (company_id, bank_connection_id, external_uid) wouldn't match it
// (NULL ≠ NULL) and the INSERT path then trips the (company_id,
// ledger_account) UNIQUE constraint.
const { data: seedRow, error: seedLookupError } = await supabase
.from('cash_accounts')
.select('id')
.eq('company_id', companyId)
.eq('ledger_account', input.ledger_account)
.is('bank_connection_id', null)
.maybeSingle()
if (seedLookupError) {
log.error('upsertFromPsd2 seed lookup failed', {
companyId,
bankConnectionId: input.bank_connection_id,
externalUid: input.external_uid,
error: seedLookupError.message,
})
throw new Error(`cash_accounts upsert failed: ${seedLookupError.message}`)
}
if (seedRow) {
// .select() so we can detect a 0-row UPDATE — Supabase's update().eq() returns
// { error: null, data: [] } if the row was deleted between the SELECT above
// and this UPDATE (rare but theoretically possible under concurrent ops).
// If that happens, fall through to the normal upsert path instead of
// silently returning success without persisting anything.
const { data: promoted, error: promoteError } = await supabase
.from('cash_accounts')
.update(payload)
.eq('id', seedRow.id)
.select('id')
if (promoteError) {
log.error('upsertFromPsd2 promote-seed failed', {
companyId,
bankConnectionId: input.bank_connection_id,
externalUid: input.external_uid,
error: promoteError.message,
})
throw new Error(`cash_accounts upsert failed: ${promoteError.message}`)
}
if (promoted && promoted.length > 0) {
return
}
// Seed row vanished between SELECT and UPDATE — fall through to upsert.
}
const { error } = await supabase const { error } = await supabase
.from('cash_accounts') .from('cash_accounts')
.upsert(payload, { onConflict: 'company_id,bank_connection_id,external_uid' }) .upsert(payload, { onConflict: 'company_id,bank_connection_id,external_uid' })
+2 -1
View File
@@ -1769,7 +1769,8 @@
"previous_counterparties": "Previous counterparties", "previous_counterparties": "Previous counterparties",
"suggested": "Suggested", "suggested": "Suggested",
"common_templates": "Common templates", "common_templates": "Common templates",
"more_templates": "More templates ({count})" "more_templates": "More templates ({count})",
"opens_editor_badge": "Opens journal editor"
}, },
"tx_invoice_match": { "tx_invoice_match": {
"title_supplier": "Confirm supplier invoice match", "title_supplier": "Confirm supplier invoice match",
+2 -1
View File
@@ -1769,7 +1769,8 @@
"previous_counterparties": "Tidigare motparter", "previous_counterparties": "Tidigare motparter",
"suggested": "Föreslagna", "suggested": "Föreslagna",
"common_templates": "Vanliga mallar", "common_templates": "Vanliga mallar",
"more_templates": "Fler mallar ({count})" "more_templates": "Fler mallar ({count})",
"opens_editor_badge": "Öppnar bokföringsformulär"
}, },
"tx_invoice_match": { "tx_invoice_match": {
"title_supplier": "Bekräfta leverantörsfakturamatchning", "title_supplier": "Bekräfta leverantörsfakturamatchning",