9ed0b9515a
* feat(invoices): add Plusgiro input to bank details settings Plusgiro was already persisted, validated by the API schema, rendered on the invoice PDF and toggleable via "Visa plusgiro" — but the settings UI had no field to enter the number, so plusgiro-only users could not fill it in. Add the input next to Bankgiro with Luhn validation and hyphen formatting, include it in the save payload (normalised on save so raw digits still match the dashed schema format), and add sv/en strings. Adds validatePlusgiroNumber/formatPlusgiroNumber helpers + tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoices): respect non-VAT-registered seller in PDF preview + portal tooltips Two user-reported bugs: - PDF preview (/api/invoices/preview-pdf) ignored company.vat_registered and fell back to the customer-driven 25% rate, so a non-momsregistrerad seller saw VAT in the review step even though the created invoice books none. Mirror the server-side write gate (build-invoice-write.ts): force 0% when vat_registered is false (delivery notes excepted). - InfoTooltip rendered TooltipContent without a Portal, so tooltips were clipped by the scrollable DialogContent (overflow-y-auto) in the send-invoice journal-entry review. Wrap in TooltipPrimitive.Portal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(transactions): book library mall from its literal lines, not a lossy fallback Booking a bank transaction with a user-created booking-template (mall) via the convertible "QuickReview" fast path reduced the template to a single category + one account_override, silently discarding the chosen debit/credit. A kundinbetalning mall (D 1930 / K 1510) booked as a generic cost (D 6991 / K 1930), or with a VAT line as D 1930 / K 1930 / K 2611 — and the result flipped with the direction inferred from the business/settlement line tags, so visually-identical templates produced different verifikationer. Route every library template through the journal-entry editor (applyTemplate -> /book), which posts the literal lines, regardless of convertibility. Add regression tests locking the contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): make the booking-time duplicate guard bypassable TRANSACTION_BOOK_POSSIBLE_DUPLICATE told users they could "book anyway" but the UI dead-ended on a toast with no way to do so. Add a shared DuplicateBookingDialog that surfaces the already-booked sibling and lets the user review it or book anyway (force bound to the reviewed candidate, which the server re-detects so a stale id cannot wave the guard away). - Wire the dialog into the /transactions categorize flow and the manual booking dialog (JournalEntryForm -> /api/transactions/[id]/book) - Bind the override to expected_duplicate_transaction_id OR expected_duplicate_journal_entry_id so ledger-only vouchers (paid invoice, salary run) can be confirmed too - Extend the guard to the pending-operations commit path and the MCP server - Tests for book/categorize routes, detection, and the commit guard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): log duplicate-guard bypass to behandlingshistorik in the agent commit path The web /book and /categorize routes append a durable BankTransactionDuplicateDismissed event when a user books over a detected possible double-booking. The agent commit path (commitCategorizeTransaction, commitMarkInvoicePaid) skipped the guard silently on allow_duplicate=true, leaving no behandlingshistorik — an auditor could not reconstruct why the duplicate was allowed (BFNAR 2013:2 kap 8). When allow_duplicate=true, re-detect the candidate and append the dismissal event (BankTransactionDuplicateDismissed for the bank-line path, InvoiceDuplicatePaymentDismissed for mark-paid). Best-effort — a logging failure never blocks a legitimate booking. Payloads stay PII-safe (ids, amounts, dates only — no customer or merchant name). Also fix the misleading DuplicateBookingDialog JSDoc: the retry binds expected_duplicate_journal_entry_id, not candidate.transaction_id, so the systemdokumentation matches the actual control (BFL 7 kap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp-server): stub booking-duplicate guard in receipt-matcher categorize tests The gnubok_categorize_transaction tool runs the booking-time duplicate guard before staging; its detection queries consumed the queued supabase mock results, so the staging assertions saw a thrown duplicate error instead of a staged op. Mock detectBookingDuplicate to "no duplicate" since these tests don't exercise that path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(transactions): use roundOre for duplicate-guard öre rounding Replace naive Math.round(x*100)/100 with roundOre() from @/lib/money in the booking-time duplicate guard (detection lib, commit executor, MCP categorize tool), satisfying the no-new-antipatterns ratchet guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
297 lines
14 KiB
TypeScript
297 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { applyTemplate, convertLibraryToBookingTemplate, getTemplateScope, LIBRARY_TEMPLATE_PREFIX, TEMPLATE_CATEGORY_LABELS } from '../template-library'
|
|
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
|
|
|
|
function makeLibraryTemplate(lines: BookingTemplateLibraryLine[], overrides: Partial<BookingTemplateLibrary> = {}): BookingTemplateLibrary {
|
|
return {
|
|
id: 'tpl-1',
|
|
company_id: 'co-1',
|
|
team_id: null,
|
|
created_by: 'user-1',
|
|
name: 'Test template',
|
|
description: '',
|
|
category: 'other',
|
|
entity_type: 'all',
|
|
lines,
|
|
is_system: false,
|
|
is_active: true,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('applyTemplate', () => {
|
|
it('creates simple two-line debit/credit entries', () => {
|
|
const lines: BookingTemplateLibraryLine[] = [
|
|
{ account: '1630', label: 'Skattekonto', side: 'debit', type: 'business', ratio: 1.0 },
|
|
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
|
|
]
|
|
const result = applyTemplate(lines, 10000)
|
|
expect(result).toHaveLength(2)
|
|
expect(result[0]).toEqual({
|
|
account_number: '1630',
|
|
debit_amount: '10000.00',
|
|
credit_amount: '',
|
|
line_description: 'Skattekonto',
|
|
})
|
|
expect(result[1]).toEqual({
|
|
account_number: '1930',
|
|
debit_amount: '',
|
|
credit_amount: '10000.00',
|
|
line_description: 'Företagskonto',
|
|
})
|
|
})
|
|
|
|
it('calculates VAT correctly for reverse charge (EU purchase)', () => {
|
|
const lines: BookingTemplateLibraryLine[] = [
|
|
{ account: '4010', label: 'Varuinköp', side: 'debit', type: 'business', ratio: 1.0 },
|
|
{ account: '2614', label: 'Utgående moms', side: 'credit', type: 'vat', vat_rate: 0.25 },
|
|
{ account: '2645', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
|
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
|
|
]
|
|
// Total payment is 10000 SEK (no VAT on the payment itself for reverse charge)
|
|
const result = applyTemplate(lines, 10000)
|
|
expect(result).toHaveLength(4)
|
|
// Business line = 10000 * 1.0
|
|
expect(result[0].debit_amount).toBe('10000.00')
|
|
// VAT = 10000 * 0.25 / (1 + 0.25) = 2000
|
|
expect(result[1].credit_amount).toBe('2000.00')
|
|
expect(result[2].debit_amount).toBe('2000.00')
|
|
// Settlement = 10000
|
|
expect(result[3].credit_amount).toBe('10000.00')
|
|
})
|
|
|
|
it('handles representation with 25% input VAT', () => {
|
|
const lines: BookingTemplateLibraryLine[] = [
|
|
{ account: '6072', label: 'Representation', side: 'debit', type: 'business', ratio: 1.0 },
|
|
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
|
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
|
|
]
|
|
// Total paid = 1250 (1000 + 250 VAT)
|
|
const result = applyTemplate(lines, 1250)
|
|
expect(result).toHaveLength(3)
|
|
// Business = 1250 (the representation cost at full ratio)
|
|
expect(result[0].debit_amount).toBe('1250.00')
|
|
// VAT = 1250 * 0.25 / 1.25 = 250
|
|
expect(result[1].debit_amount).toBe('250.00')
|
|
// Settlement = 1250
|
|
expect(result[2].credit_amount).toBe('1250.00')
|
|
})
|
|
|
|
it('rounds monetary values to 2 decimal places', () => {
|
|
const lines: BookingTemplateLibraryLine[] = [
|
|
{ account: '4010', label: 'Varuinköp', side: 'debit', type: 'business', ratio: 1.0 },
|
|
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
|
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1.0 },
|
|
]
|
|
// 333.33 should produce clean rounding
|
|
const result = applyTemplate(lines, 333.33)
|
|
expect(result[0].debit_amount).toBe('333.33')
|
|
// 333.33 * 0.25 / 1.25 = 66.666 → 66.67
|
|
expect(result[1].debit_amount).toBe('66.67')
|
|
expect(result[2].credit_amount).toBe('333.33')
|
|
})
|
|
})
|
|
|
|
describe('getTemplateScope', () => {
|
|
it('identifies system templates', () => {
|
|
expect(getTemplateScope({ is_system: true, team_id: null, company_id: null })).toBe('system')
|
|
})
|
|
|
|
it('identifies team templates', () => {
|
|
expect(getTemplateScope({ is_system: false, team_id: 'team-1', company_id: null })).toBe('team')
|
|
})
|
|
|
|
it('identifies company templates', () => {
|
|
expect(getTemplateScope({ is_system: false, team_id: null, company_id: 'comp-1' })).toBe('company')
|
|
})
|
|
})
|
|
|
|
describe('TEMPLATE_CATEGORY_LABELS', () => {
|
|
it('has labels for all categories', () => {
|
|
expect(Object.keys(TEMPLATE_CATEGORY_LABELS)).toHaveLength(9)
|
|
expect(TEMPLATE_CATEGORY_LABELS.eu_trade).toBe('EU-handel')
|
|
expect(TEMPLATE_CATEGORY_LABELS.tax_account).toBe('Skattekonto')
|
|
})
|
|
})
|
|
|
|
describe('convertLibraryToBookingTemplate', () => {
|
|
it('converts a simple 2-line business + settlement template', () => {
|
|
const tpl = makeLibraryTemplate([
|
|
{ account: '6072', label: 'Representation', side: 'debit', type: 'business', ratio: 1 },
|
|
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1 },
|
|
])
|
|
const result = convertLibraryToBookingTemplate(tpl)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.id).toBe(`${LIBRARY_TEMPLATE_PREFIX}tpl-1`)
|
|
expect(result!.direction).toBe('expense')
|
|
expect(result!.debit_account).toBe('6072')
|
|
expect(result!.credit_account).toBe('1930')
|
|
expect(result!.vat_treatment).toBeNull()
|
|
})
|
|
|
|
it('identifies direction "income" when business line is on credit', () => {
|
|
const tpl = makeLibraryTemplate([
|
|
{ account: '3001', label: 'Försäljning', side: 'credit', type: 'business', ratio: 1 },
|
|
{ account: '1930', label: 'Företagskonto', side: 'debit', type: 'settlement', ratio: 1 },
|
|
])
|
|
const result = convertLibraryToBookingTemplate(tpl)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.direction).toBe('income')
|
|
expect(result!.debit_account).toBe('1930')
|
|
expect(result!.credit_account).toBe('3001')
|
|
})
|
|
|
|
it.each([
|
|
[0.25, 'standard_25'],
|
|
[0.12, 'reduced_12'],
|
|
[0.06, 'reduced_6'],
|
|
] as const)('extracts VAT treatment for rate %f', (rate, treatment) => {
|
|
const tpl = makeLibraryTemplate([
|
|
{ account: '4010', label: 'Varor', side: 'debit', type: 'business', ratio: 1 },
|
|
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: rate },
|
|
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
|
|
])
|
|
const result = convertLibraryToBookingTemplate(tpl)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.vat_treatment).toBe(treatment)
|
|
expect(result!.vat_rate).toBe(rate)
|
|
})
|
|
|
|
it('detects reverse charge via 2614 fictitious output VAT', () => {
|
|
const tpl = makeLibraryTemplate([
|
|
{ account: '4056', label: 'EU-varor', side: 'debit', type: 'business', ratio: 1 },
|
|
{ account: '2614', label: 'Utg. moms omv.', side: 'credit', type: 'vat', vat_rate: 0.25 },
|
|
{ account: '2645', label: 'Ing. moms omv.', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
|
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
|
|
])
|
|
const result = convertLibraryToBookingTemplate(tpl)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.vat_treatment).toBe('reverse_charge')
|
|
})
|
|
|
|
it('returns null when there are 2 business lines', () => {
|
|
const tpl = makeLibraryTemplate([
|
|
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 0.5 },
|
|
{ account: '6073', label: 'B', side: 'debit', type: 'business', ratio: 0.5 },
|
|
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
|
|
])
|
|
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
|
|
})
|
|
|
|
it('returns null when there is no settlement line', () => {
|
|
const tpl = makeLibraryTemplate([
|
|
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 1 },
|
|
{ account: '2641', label: 'Moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
|
|
])
|
|
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
|
|
})
|
|
|
|
it('returns null when business and settlement are on the same side', () => {
|
|
const tpl = makeLibraryTemplate([
|
|
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 1 },
|
|
{ account: '1930', label: 'Bank', side: 'debit', type: 'settlement', ratio: 1 },
|
|
])
|
|
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
|
|
})
|
|
|
|
it('returns null when lines is not an array', () => {
|
|
const tpl = makeLibraryTemplate([], { lines: null as unknown as BookingTemplateLibraryLine[] })
|
|
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')
|
|
})
|
|
})
|
|
|
|
describe('library mall books its literal accounts (regression)', () => {
|
|
// A user's "Inbetalning från kund" mall is D 1930 (bank) / K 1510
|
|
// (kundfordran). The QuickReview fast path used to reduce a library template
|
|
// to a category + one account_override and book D 6991 / K 1930 — or, with a
|
|
// VAT line, D 1930 / K 1930 / K 2611 — silently dropping the chosen accounts.
|
|
// The transaction picker now routes EVERY library template through the
|
|
// journal-entry editor, whose lines come from applyTemplate. These tests pin
|
|
// the guarantee the editor path relies on: applyTemplate books exactly the
|
|
// accounts/sides the user defined, and never re-derives a counter account.
|
|
const AMOUNT = 5000
|
|
|
|
const customerPaymentLines: BookingTemplateLibraryLine[] = [
|
|
{ account: '1930', label: 'Inbetalning', side: 'debit', type: 'settlement', ratio: 1 },
|
|
{ account: '1510', label: 'Kundfordran', side: 'credit', type: 'business', ratio: 1 },
|
|
]
|
|
|
|
it('books exactly D 1930 / K 1510 with no re-derived accounts', () => {
|
|
const result = applyTemplate(customerPaymentLines, AMOUNT)
|
|
expect(result).toEqual([
|
|
{ account_number: '1930', debit_amount: '5000.00', credit_amount: '', line_description: 'Inbetalning' },
|
|
{ account_number: '1510', debit_amount: '', credit_amount: '5000.00', line_description: 'Kundfordran' },
|
|
])
|
|
// The accounts the lossy fast path used to inject must never appear.
|
|
const accounts = result.map((l) => l.account_number)
|
|
expect(accounts).not.toContain('6991')
|
|
expect(accounts).not.toContain('3001')
|
|
expect(accounts).not.toContain('2611')
|
|
})
|
|
|
|
it('is blind to business/settlement tagging — same accounts either way', () => {
|
|
// The old converter keyed "direction" (and thus the whole booking) off which
|
|
// leg was tagged business vs settlement. applyTemplate must not: swapping the
|
|
// tags leaves the same accounts on the same sides.
|
|
const swappedTags: BookingTemplateLibraryLine[] = [
|
|
{ account: '1930', label: 'Inbetalning', side: 'debit', type: 'business', ratio: 1 },
|
|
{ account: '1510', label: 'Kundfordran', side: 'credit', type: 'settlement', ratio: 1 },
|
|
]
|
|
expect(applyTemplate(swappedTags, AMOUNT)).toEqual(applyTemplate(customerPaymentLines, AMOUNT))
|
|
})
|
|
|
|
it('stays balanced (sum debit === sum credit)', () => {
|
|
const result = applyTemplate(customerPaymentLines, 4999.99)
|
|
const sumDebit = result.reduce((s, l) => s + (parseFloat(l.debit_amount || '0') || 0), 0)
|
|
const sumCredit = result.reduce((s, l) => s + (parseFloat(l.credit_amount || '0') || 0), 0)
|
|
expect(sumDebit).toBeCloseTo(sumCredit, 2)
|
|
expect(sumDebit).toBeCloseTo(4999.99, 2)
|
|
})
|
|
})
|