feat(dimensions): PR7 producers — auto-tagged documents (invoices, supplier invoices, bulk-book, templates, MCP) (#868)

* feat(dimensions): PR7 producers — invoices/supplier invoices carry dims, generators propagate, BulkBook + templates + MCP bags

Source documents now carry dimension tags and every entry generator
propagates them onto journal lines (dev_docs/dimensions_implementation_plan.md PR7):

- invoices/supplier_invoices.default_dimensions + per-item dimensions
  (migration 20260702200000; jsonb DEFAULT '{}' + object CHECK)
- invoice-entries: issuance/payment/cash/credit propagate — item bags merge
  over the invoice default per revenue line (account+bag aggregation
  identity), payment vouchers re-propagate the linked invoice's bag onto
  every leg incl. FX result lines; ROT/RUT 1513 carries the item bag
- supplier-invoice-entries: registration/payment/cash/privately-paid/credit
  propagate with the same merge rules (expense buckets keyed account+bag)
- bulk_book_transactions RPC persists per-line bags + derives
  cost_center/project mirrors in SQL (migration 20260702201000; malformed
  bags rejected with BULK_BOOK_INVALID_DIMENSIONS); route merges the header
  default into template/manual lines
- counterparty templates: LinePatternEntry.dimensions learned from SIE
  voucher history (kept only when every occurrence agrees), applied to
  business lines on booking; QuickReviewDialog shows a dims badge
- categorize: staged dimensions bag tags business lines only (bank/VAT
  untagged); credit/convert/inbox copy paths carry bags forward
- propose-payment/send-lines stamp the invoice default so the editable
  payment grid books what the preview shows; mark-paid override lines
  accept dimensions
- UI: InvoiceEditor + NewSupplierInvoiceForm header KS/Projekt pair with
  per-row override; BulkBookDialog header default pair (both tabs)
- MCP: default_dimensions/items[].dimensions on create_invoice +
  create_supplier_invoice_from_inbox, dimensions on categorize_transaction,
  per-line bags on bulk_book_transactions — resolve-don't-select via the
  shared registry helpers, resolutions echoed

32 new propagation unit tests + 4 pg-real tests for the RPC migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: use roundOre in new dims rounding assertions (ratchet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: copy dimension bag per payment line, document dimensionsBagKey normalization contract (review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 21:21:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 816b1769c8
commit 755e0f7e47
41 changed files with 3574 additions and 112 deletions
+24
View File
@@ -291,6 +291,9 @@ export const CreateInvoiceItemSchema = z
accrual_period_start: isoDate.nullable().optional(),
accrual_period_end: isoDate.nullable().optional(),
accrual_balance_account: deferredRevenueAccount.nullable().optional(),
// Dimensions PR7: per-item bag merged over the invoice's
// default_dimensions on the revenue line this item books to.
dimensions: DimensionsBagSchema.optional(),
})
.superRefine((item, ctx) => {
validateAccrualPeriod(item, ctx)
@@ -359,6 +362,9 @@ export const CreateInvoiceSchema = z.object({
// Per-invoice öresavrundning toggle (display-only). Omitted → stored as null,
// which inherits company_settings.ore_rounding when rendering totals.
ore_rounding: z.boolean().optional(),
// Dimensions PR7: invoice-level bag applied to every generated journal line;
// items[].dimensions merge over it per revenue line.
default_dimensions: DimensionsBagSchema.optional(),
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
})
@@ -491,6 +497,9 @@ export const MarkInvoicePaidSchema = z.object({
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
// Dimensions PR7: user-edited payment lines keep their tags (the
// no-override path re-propagates the invoice's default_dimensions).
dimensions: DimensionsBagSchema.optional(),
})).min(2).optional(),
// Bypass the duplicate-payment guard. Set after the user reviews the
// candidate list returned by INVOICE_PAID_LIKELY_DUPLICATE and confirms
@@ -591,6 +600,9 @@ export const CreateSupplierInvoiceItemSchema = z.object({
accrual_period_start: isoDate.nullable().optional(),
accrual_period_end: isoDate.nullable().optional(),
accrual_balance_account: prepaidExpenseAccount.nullable().optional(),
// Dimensions PR7: per-item bag merged over the invoice's
// default_dimensions on the expense line this item books to.
dimensions: DimensionsBagSchema.optional(),
}).refine(
(item) => {
if (item.vat_amount == null) return true
@@ -641,6 +653,9 @@ export const CreateSupplierInvoiceSchema = z.object({
// For paid_with_private_funds: the date the owner paid out-of-pocket.
// Defaults to invoice_date (common for kvitto where the two coincide).
payment_date: isoDate.optional(),
// Dimensions PR7: invoice-level bag applied to every generated journal line;
// items[].dimensions merge over it per expense line.
default_dimensions: DimensionsBagSchema.optional(),
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
})
@@ -662,6 +677,9 @@ export const MarkSupplierInvoicePaidSchema = z.object({
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
// Dimensions PR7: user-edited payment lines keep their tags (the
// no-override path re-propagates the invoice's default_dimensions).
dimensions: DimensionsBagSchema.optional(),
})).min(2).optional(),
})
@@ -1015,11 +1033,17 @@ export const BulkBookSchema = z
credit_amount: nonNegativeAmount.max(99_999_999, 'Line amount exceeds maximum'),
currency: z.string().min(3).max(3).default('SEK'),
line_description: z.string().max(200).optional(),
// Dimensions PR7: per-line bag, wins over default_dimensions.
dimensions: DimensionsBagSchema.optional(),
})
)
.min(2, 'A verifikat needs at least two lines')
.max(200)
.optional(),
// Dimensions PR7: header-level bag applied to every generated line in
// BOTH the template and manual paths (per-line bags win per key). The
// route merges before calling the RPC.
default_dimensions: DimensionsBagSchema.optional(),
})
.superRefine((data, ctx) => {
const hasExisting = !!data.existing_journal_entry_id
@@ -17,6 +17,8 @@ import {
populateTemplatesFromSieVouchers,
} from '../counterparty-templates'
import type { TemplateUpsertParams } from '../counterparty-templates'
import type { LinePatternEntry } from '@/types'
import type { SIETransactionLine } from '@/lib/import/types'
describe('counterparty-templates', () => {
// ── Normalization ──────────────────────────────────────────
@@ -747,3 +749,150 @@ describe('counterparty-templates', () => {
})
})
})
// ── dimensions propagation (PR7) ─────────────────────────────
describe('dimensions propagation (PR7)', () => {
describe('buildMappingResultFromCounterpartyTemplate — multi-line pattern', () => {
it('materialized business lines carry the pattern bag; VAT and tax lines stay untagged', () => {
const template = makeCategorizationTemplate({
debit_account: '5410',
credit_account: '1930',
line_pattern: [
{ account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 },
{ account: '5410', type: 'business', side: 'debit', ratio: 0.8, dimensions: { '1': 'KS01' } },
// Even a (bogus) bag on a tax entry must not materialize — only
// business lines are tagged.
{ account: '2710', type: 'tax', side: 'debit', ratio: 0.2, dimensions: { '1': 'KS01' } },
],
})
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
const tx = makeTransaction({ amount: -1250 })
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
expect(result.all_lines_complete).toBe(true)
const business = result.vat_lines.find((l) => l.account_number === '5410')
expect(business?.dimensions).toEqual({ '1': 'KS01' })
expect(result.vat_lines.find((l) => l.account_number === '2641')?.dimensions).toBeUndefined()
expect(result.vat_lines.find((l) => l.account_number === '2710')?.dimensions).toBeUndefined()
})
it('a pattern business line without a bag materializes untagged', () => {
const template = makeCategorizationTemplate({
debit_account: '5410',
credit_account: '1930',
line_pattern: [
{ account: '5410', type: 'business', side: 'debit', ratio: 1 },
],
})
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
expect(result.vat_lines.find((l) => l.account_number === '5410')?.dimensions).toBeUndefined()
})
})
describe('populateTemplatesFromSieVouchers — line-pattern dimension learning', () => {
/**
* Queue-based supabase mock that also records `.insert()` payloads per
* table, so assertions can inspect the stored line_pattern. Follows the
* capture pattern in lib/pending-operations/__tests__/create-invoice-executor.test.ts.
*/
function createCapturingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
const queue = [...results]
const inserts: Record<string, unknown[]> = {}
const from = vi.fn((_table: string) => {
const raw = queue.shift() ?? { data: null, error: null }
const result = { data: raw.data ?? null, error: raw.error ?? null }
const chain: object = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
if (prop === 'insert') {
return (payload: unknown) => {
;(inserts[_table] ??= []).push(payload)
return chain
}
}
return () => chain
},
},
)
return chain
})
return { supabase: { from }, inserts }
}
function capturedLinePattern(inserts: Record<string, unknown[]>): LinePatternEntry[] {
const rows = inserts['categorization_templates']
expect(rows).toHaveLength(1)
const row = rows[0] as { line_pattern: LinePatternEntry[] | null }
expect(row.line_pattern).not.toBeNull()
return row.line_pattern!
}
it('preserves a business line bag only when EVERY voucher occurrence has the identical bag', async () => {
// Two business accounts → not "simple", so the pattern is stored as
// line_pattern JSONB. All three vouchers tag both accounts identically.
const vouchers = Array.from({ length: 3 }, (_, i) =>
makeSIEVoucher({
description: 'Projektbygget AB',
number: i + 1,
lines: [
{ account: '1930', amount: -1100 },
{ account: '6212', amount: 600, dimensions: { '1': 'KS01' } },
{ account: '5410', amount: 500, dimensions: { '6': 'P001' } },
],
})
)
const { supabase, inserts } = createCapturingSupabase([
{ data: [] }, // pre-fetch existing templates
{ data: null }, // insert
])
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
expect(count).toBe(1)
const pattern = capturedLinePattern(inserts)
expect(pattern.find((e) => e.account === '6212')?.dimensions).toEqual({ '1': 'KS01' })
expect(pattern.find((e) => e.account === '5410')?.dimensions).toEqual({ '6': 'P001' })
})
it('drops the bag when a voucher disagrees — or leaves the line untagged', async () => {
// 6212 conflicts across vouchers (KS01 vs KS02); 5410 is untagged in one
// voucher. Both must lose the bag: a template must never invent a tag
// history does not consistently support.
const linesFor = (v: number): SIETransactionLine[] => [
{ account: '1930', amount: -1100 },
{ account: '6212', amount: 600, dimensions: v === 2 ? { '1': 'KS02' } : { '1': 'KS01' } },
v === 2
? { account: '5410', amount: 500 }
: { account: '5410', amount: 500, dimensions: { '6': 'P001' } },
]
const vouchers = [1, 2, 3].map((v) =>
makeSIEVoucher({ description: 'Projektbygget AB', number: v, lines: linesFor(v) })
)
const { supabase, inserts } = createCapturingSupabase([
{ data: [] }, // pre-fetch existing templates
{ data: null }, // insert
])
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
expect(count).toBe(1)
const pattern = capturedLinePattern(inserts)
expect(pattern.find((e) => e.account === '6212')?.dimensions).toBeUndefined()
expect(pattern.find((e) => e.account === '5410')?.dimensions).toBeUndefined()
})
})
})
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getRevenueAccount, getOutputVatAccount } from '../invoice-entries'
import { roundOre } from '@/lib/money'
import type { Invoice, InvoiceItem, CreateJournalEntryInput } from '@/types'
// Mock the engine so we can capture the input passed to createJournalEntry
@@ -974,6 +975,318 @@ describe('createInvoiceJournalEntry — ROT/RUT-avdrag', () => {
})
})
describe('dimensions propagation (PR7) — createInvoiceJournalEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('1510 + VAT lines carry the invoice default; revenue lines carry the merged item-over-default bag', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ description: 'A', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150, dimensions: { '6': 'P001' } }),
makeItem({ id: 'item-2', description: 'B', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100, dimensions: { '1': 'KS02' } }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.dimensions).toEqual({ '1': 'KS01' })
const vat2611 = input.lines.filter((l) => l.account_number === '2611')
expect(vat2611).toHaveLength(1)
expect(vat2611[0].dimensions).toEqual({ '1': 'KS01' })
// Same vat_rate + account, DIFFERENT bags → separate revenue lines
// (aggregation identity = account + bag).
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(2)
expect(rev3001[0].credit_amount).toBe(600)
expect(rev3001[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(rev3001[1].credit_amount).toBe(400)
// The item bag wins per key over the invoice default.
expect(rev3001[1].dimensions).toEqual({ '1': 'KS02' })
})
it('items with the identical merged bag still aggregate into one revenue line', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ description: 'A', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150, dimensions: { '6': 'P001' } }),
makeItem({ id: 'item-2', description: 'B', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100, dimensions: { '6': 'P001' } }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(1)
expect(rev3001[0].credit_amount).toBe(1000)
expect(rev3001[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('per-rate rounding remainder is absorbed by the last dimension bucket (balanced against 1510)', async () => {
// Same account, same 25% rate — split only by the dimensions bag. The
// rate-level total (20.01) is the balance anchor; independent per-bucket
// rounding would give 2 × 10.01 = 20.02, so the last bucket must absorb.
const invoice = makeInvoice({
subtotal: 20.01,
vat_amount: 5.0,
total: 25.01,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'A', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5 }),
makeItem({ id: 'item-2', description: 'B', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5, dimensions: { '6': 'P001' } }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(2)
expect(rev3001[0].dimensions).toBeUndefined()
expect(rev3001[1].dimensions).toEqual({ '6': 'P001' })
const revSum = rev3001.reduce((s, l) => s + l.credit_amount, 0)
expect(roundOre(revSum)).toBe(20.01)
const debit = roundOre(input.lines.reduce((s, l) => s + l.debit_amount, 0))
const credit = roundOre(input.lines.reduce((s, l) => s + l.credit_amount, 0))
expect(debit).toBe(credit)
expect(debit).toBe(25.01)
})
it('ROT 1513 line carries the item merged bag', async () => {
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({
quantity: 1,
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
dimensions: { '6': 'P001' },
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3000)
expect(debit1513?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// 1510 still carries the default only.
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.dimensions).toEqual({ '1': 'KS01' })
})
it('fallback path (no items) carries the invoice default on revenue + VAT lines', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01', '6': 'P001' },
items: [],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.find((l) => l.account_number === '3001')
expect(rev3001?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const vat2611 = input.lines.find((l) => l.account_number === '2611')
expect(vat2611?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('no default and no item bags → line.dimensions stays undefined', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
items: [
makeItem({ unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1510 = input.lines.find((l) => l.account_number === '1510')
// toEqual ignores undefined-valued keys — the line shape is unchanged.
expect(debit1510).toEqual({
account_number: '1510',
debit_amount: 1250,
credit_amount: 0,
line_description: 'Faktura 1001',
})
expect(debit1510?.dimensions).toBeUndefined()
expect(input.lines.find((l) => l.account_number === '3001')?.dimensions).toBeUndefined()
expect(input.lines.find((l) => l.account_number === '2611')?.dimensions).toBeUndefined()
})
})
describe('dimensions propagation (PR7) — createInvoicePaymentJournalEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('SEK payment: both 1930 and 1510 carry the invoice default', async () => {
const invoice = makeInvoice({ total: 1250, default_dimensions: { '1': 'KS01', '6': 'P001' } })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
for (const line of input.lines) {
expect(line.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
}
})
it('FX payment: the 3960 kursvinst line carries the default too', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
default_dimensions: { '6': 'P001' },
})
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', 200)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
for (const account of ['1930', '1510', '3960']) {
const line = input.lines.find((l) => l.account_number === account)
expect(line?.dimensions).toEqual({ '6': 'P001' })
}
})
it('FX loss payment: the 7960 kursförlust line carries the default too', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
default_dimensions: { '6': 'P001' },
})
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', -300)
const input = mockedCreateEntry.mock.calls[0][3]
const loss7960 = input.lines.find((l) => l.account_number === '7960')
expect(loss7960?.dimensions).toEqual({ '6': 'P001' })
})
it('no default bag → payment lines carry no dimensions', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.dimensions).toBeUndefined()
}
})
})
describe('dimensions propagation (PR7) — createInvoiceCashEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('1930 carries the default; revenue carries the merged item bag; VAT carries the default', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250, dimensions: { '6': 'P001' } }),
],
})
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
const input = mockedCreateEntry.mock.calls[0][3]
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.dimensions).toEqual({ '1': 'KS01' })
const rev3001 = input.lines.find((l) => l.account_number === '3001')
expect(rev3001?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const vat2611 = input.lines.find((l) => l.account_number === '2611')
expect(vat2611?.dimensions).toEqual({ '1': 'KS01' })
})
})
describe('dimensions propagation (PR7) — createCreditNoteJournalEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('swapped lines keep the item bags; 1510 carries the credit note default', async () => {
const creditNote = makeInvoice({
invoice_number: 'KR-1001',
subtotal: -1000,
vat_amount: -198,
total: -1198,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ quantity: -1, unit_price: 600, line_total: -600, vat_rate: 25, vat_amount: -150, dimensions: { '6': 'P001' } }),
makeItem({ id: 'item-2', quantity: -1, unit_price: 400, line_total: -400, vat_rate: 12, vat_amount: -48 }),
],
})
await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
const input = mockedCreateEntry.mock.calls[0][3]
// The reversed (debit) revenue lines keep the merged item bags.
const debit3001 = input.lines.find((l) => l.account_number === '3001')
expect(debit3001?.debit_amount).toBe(600)
expect(debit3001?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const debit3002 = input.lines.find((l) => l.account_number === '3002')
expect(debit3002?.dimensions).toEqual({ '1': 'KS01' })
// VAT lines carry the default only.
expect(input.lines.find((l) => l.account_number === '2611')?.dimensions).toEqual({ '1': 'KS01' })
expect(input.lines.find((l) => l.account_number === '2621')?.dimensions).toEqual({ '1': 'KS01' })
// 1510 carries the credit note's own default bag.
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(1198)
expect(credit1510?.dimensions).toEqual({ '1': 'KS01' })
})
})
describe('createInvoiceCashEntry — ROT/RUT-avdrag', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -210,3 +210,81 @@ describe('proposePaymentLines', () => {
})
})
})
describe('proposePaymentLines — dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' }
it('accrual: every proposed line carries a copy of the invoice default bag', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput(), default_dimensions: bag },
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(2)
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
// A copy, not the shared reference — editing one line must not mutate
// the invoice bag or a sibling line.
expect(line.dimensions).not.toBe(bag)
}
expect(lines[0].dimensions).not.toBe(lines[1].dimensions)
})
it('accrual with FX difference: the 3960 line carries the bag too', () => {
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({
total: 1000,
total_sek: 10000,
currency: 'EUR',
exchange_rate: 10,
}),
default_dimensions: bag,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
exchangeRateDifference: 500,
})
expect(lines).toHaveLength(3)
expect(lines[2].account_number).toBe('3960')
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
}
})
it('cash: payment, revenue and VAT lines all carry the bag', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput(), default_dimensions: bag },
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(3)
expect(lines.map((l) => l.account_number)).toEqual(['1930', '3001', '2611'])
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
}
})
it('absent or empty bag → no dimensions key on any line', () => {
const withoutBag = proposePaymentLines({
invoice: makeInvoiceInput(),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
for (const line of withoutBag) {
expect('dimensions' in line).toBe(false)
}
const withEmptyBag = proposePaymentLines({
invoice: { ...makeInvoiceInput(), default_dimensions: {} },
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
for (const line of withEmptyBag) {
expect('dimensions' in line).toBe(false)
}
})
})
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest'
import { proposeSendLines } from '../propose-send-lines'
import type { InvoiceItem, VatTreatment } from '@/types'
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: 'item-1',
invoice_id: 'inv-1',
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
sort_order: 0,
created_at: '2025-01-01',
...overrides,
}
}
function makeInvoiceInput(overrides: Partial<{
invoice_number: string
total: number
total_sek: number | null
subtotal: number
subtotal_sek: number | null
vat_amount: number
vat_amount_sek: number | null
currency: string
exchange_rate: number | null
vat_treatment: VatTreatment
items: InvoiceItem[]
default_dimensions: Record<string, string> | null
}> = {}) {
return {
invoice_number: '2025-001',
total: 12500,
total_sek: null,
subtotal: 10000,
subtotal_sek: null,
vat_amount: 2500,
vat_amount_sek: null,
currency: 'SEK',
exchange_rate: null,
vat_treatment: 'standard_25' as VatTreatment,
items: [makeItem()],
...overrides,
}
}
describe('proposeSendLines', () => {
it('single VAT rate → debit 1510, credit 3001, credit 2611', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput(),
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(3)
expect(lines[0]).toEqual({
account_number: '1510',
debit_amount: '12500',
credit_amount: '',
line_description: 'Försäljning faktura 2025-001',
})
expect(lines[1]).toEqual({
account_number: '3001',
debit_amount: '',
credit_amount: '10000',
line_description: 'Försäljning faktura 2025-001',
})
expect(lines[2]).toEqual({
account_number: '2611',
debit_amount: '',
credit_amount: '2500',
line_description: 'Utgående moms 25%',
})
})
describe('dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' }
it('every proposed line carries a copy of the invoice default bag', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({ default_dimensions: bag }),
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(3)
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
// A copy, not the shared reference — editing one line must not mutate
// the invoice bag or a sibling line.
expect(line.dimensions).not.toBe(bag)
}
expect(lines[0].dimensions).not.toBe(lines[1].dimensions)
})
it('mixed rates: 1510 + both revenue and both VAT lines carry the bag', () => {
const items = [
makeItem({ id: 'i1', vat_rate: 25, line_total: 8000, vat_amount: 2000, unit_price: 8000 }),
makeItem({ id: 'i2', vat_rate: 12, line_total: 2000, vat_amount: 240, unit_price: 2000 }),
]
const lines = proposeSendLines({
invoice: makeInvoiceInput({
total: 12240,
subtotal: 10000,
vat_amount: 2240,
items,
default_dimensions: bag,
}),
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(5)
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
}
})
it('absent or empty bag → no dimensions key on any line', () => {
const withoutBag = proposeSendLines({
invoice: makeInvoiceInput(),
entityType: 'enskild_firma',
})
for (const line of withoutBag) {
expect('dimensions' in line).toBe(false)
}
const withEmptyBag = proposeSendLines({
invoice: makeInvoiceInput({ default_dimensions: {} }),
entityType: 'enskild_firma',
})
for (const line of withEmptyBag) {
expect('dimensions' in line).toBe(false)
}
})
})
})
@@ -1713,3 +1713,281 @@ describe('createSupplierInvoicePrivatelyPaidEntry', () => {
expect(debit6110[0].debit_amount).toBe(600)
})
})
// ============================================================
// dimensions propagation (PR7)
// ============================================================
describe('dimensions propagation (PR7) — createSupplierInvoiceRegistrationEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense lines carry merged item-over-default bags; 2641 and 2440 carry the default', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000,
vat_amount: 2000,
total: 10000,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ id: 'si-item-1', line_total: 5000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
makeItem({ id: 'si-item-2', line_total: 3000, account_number: '6200', vat_rate: 0.25, dimensions: { '1': 'KS02' } }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
// Same account, DIFFERENT bags → two separate expense lines
// (aggregation identity = account + bag).
const debit6200 = findByAccount(input.lines, '6200')
expect(debit6200).toHaveLength(2)
expect(debit6200[0].debit_amount).toBe(5000)
expect(debit6200[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(debit6200[1].debit_amount).toBe(3000)
// The item bag wins per key over the invoice default.
expect(debit6200[1].dimensions).toEqual({ '1': 'KS02' })
// VAT and AP legs carry the default only.
expect(findByAccount(input.lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2440')[0].dimensions).toEqual({ '1': 'KS01' })
assertBalanced(input)
})
it('items with the identical merged bag still aggregate into one expense line', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000,
vat_amount: 2000,
total: 10000,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ id: 'si-item-1', line_total: 5000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
makeItem({ id: 'si-item-2', line_total: 3000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
const debit6200 = findByAccount(input.lines, '6200')
expect(debit6200).toHaveLength(1)
expect(debit6200[0].debit_amount).toBe(8000)
expect(debit6200[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('reverse charge: fiktiv moms + basbelopp lines carry the default; expense keeps the merged bag', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000,
vat_amount: 0,
total: 10000,
reverse_charge: true,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6540')[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// RC fiktiv moms pair + basis pair carry the default only.
expect(findByAccount(input.lines, '2645')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2614')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '4535')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '4598')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2440')[0].dimensions).toEqual({ '1': 'KS01' })
assertBalanced(input)
})
it('no default and no item bags → line.dimensions stays undefined', async () => {
const invoice = makeSupplierInvoice({ subtotal: 8000, vat_amount: 2000, total: 10000 })
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
const debit6200 = findByAccount(input.lines, '6200')[0]
// toEqual ignores undefined-valued keys — the line shape is unchanged.
expect(debit6200).toEqual({
account_number: '6200',
debit_amount: 8000,
credit_amount: 0,
line_description: 'Leverantörsfaktura LF-001 (ankomstnr 1)',
})
expect(debit6200.dimensions).toBeUndefined()
expect(findByAccount(input.lines, '2641')[0].dimensions).toBeUndefined()
expect(findByAccount(input.lines, '2440')[0].dimensions).toBeUndefined()
})
})
describe('dimensions propagation (PR7) — createSupplierInvoicePaymentEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('standard payment: both 2440 and payment-account lines carry the default', async () => {
const invoice = makeSupplierInvoice({ default_dimensions: { '1': 'KS01', '6': 'P001' } })
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
for (const line of input.lines) {
expect(line.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
}
})
it('FX payment: the 3960/7960 result lines carry the default too', async () => {
const invoice = makeSupplierInvoice({ default_dimensions: { '6': 'P001' } })
// Gain: paid less SEK than booked → credit 3960.
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01', 200
)
let input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '3960')[0].dimensions).toEqual({ '6': 'P001' })
expect(findByAccount(input.lines, '2440')[0].dimensions).toEqual({ '6': 'P001' })
expect(findByAccount(input.lines, '1930')[0].dimensions).toEqual({ '6': 'P001' })
// Loss: debit 7960.
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01', -300
)
input = mockedCreateEntry.mock.calls[1][3]
expect(findByAccount(input.lines, '7960')[0].dimensions).toEqual({ '6': 'P001' })
})
it('no default bag → payment lines carry no dimensions', async () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.dimensions).toBeUndefined()
}
})
})
describe('dimensions propagation (PR7) — createSupplierInvoiceCashEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense lines carry merged bags; 2641 and payment account carry the default', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000,
vat_amount: 2000,
total: 10000,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6200')[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(findByAccount(input.lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '1930')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
assertBalanced(input)
})
})
describe('dimensions propagation (PR7) — createSupplierInvoicePrivatelyPaidEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense lines carry merged bags; 2641 and the owner account carry the default', async () => {
const invoice = makeSupplierInvoice({
subtotal: 400,
vat_amount: 100,
total: 500,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6110')[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(findByAccount(input.lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2893')[0].dimensions).toEqual({ '1': 'KS01' })
assertBalanced(input)
})
})
describe('dimensions propagation (PR7) — createSupplierCreditNoteEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense credits keep the item bags; 2440 and 2641 carry the credit note default', async () => {
const creditNote = makeSupplierInvoice({
is_credit_note: true,
subtotal: -8000,
vat_amount: -2000,
total: -10000,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ id: 'si-item-1', line_total: -5000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
makeItem({ id: 'si-item-2', line_total: -3000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P002' } }),
]
await createSupplierCreditNoteEntry(
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
// Two credit lines on the same account, split by the item bags.
const credit6200 = findByAccount(input.lines, '6200')
expect(credit6200).toHaveLength(2)
expect(credit6200[0].credit_amount).toBe(5000)
expect(credit6200[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(credit6200[1].credit_amount).toBe(3000)
expect(credit6200[1].dimensions).toEqual({ '1': 'KS01', '6': 'P002' })
expect(findByAccount(input.lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2440')[0].dimensions).toEqual({ '1': 'KS01' })
assertBalanced(input)
})
})
@@ -553,3 +553,134 @@ describe('buildDomesticExpenseLines', () => {
}
})
})
describe('createTransactionJournalEntry — dimensions propagation (PR7)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense: tags ONLY the business debit line — bank and VAT lines stay untagged', async () => {
const tx = makeTransaction({ amount: -1250, description: 'Software license' })
const vatLines: VatJournalLine[] = [
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms 25%' },
]
const mapping = makeMappingResult({
debit_account: '5410',
credit_account: '1930',
vat_lines: vatLines,
dimensions: { '1': 'KS01', '6': 'P001' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
const debit5410 = input.lines.find(l => l.account_number === '5410')
expect(debit5410?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(input.lines.find(l => l.account_number === '2641')?.dimensions).toBeUndefined()
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
})
it('income: tags ONLY the revenue credit line — bank and output-VAT lines stay untagged', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Sales income' })
const vatLines: VatJournalLine[] = [
{ account_number: '2611', debit_amount: 0, credit_amount: 2500, description: 'Utgående moms 25%' },
]
const mapping = makeMappingResult({
debit_account: '1930',
credit_account: '3001',
vat_lines: vatLines,
dimensions: { '6': 'P001' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find(l => l.account_number === '3001')?.dimensions).toEqual({ '6': 'P001' })
expect(input.lines.find(l => l.account_number === '2611')?.dimensions).toBeUndefined()
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
})
it('all_lines_complete: each vat_lines[i].dimensions is used per line, with NO fallback to mappingResult.dimensions', async () => {
const tx = makeTransaction({ amount: -1250, description: 'Multi-line pattern' })
const vatLines: VatJournalLine[] = [
{ account_number: '5410', debit_amount: 1000, credit_amount: 0, description: 'Kostnad', dimensions: { '6': 'P001' } },
// No dimensions on the VAT line — must NOT inherit the categorize-level bag.
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms' },
]
const mapping = makeMappingResult({
debit_account: '5410',
credit_account: '1930',
all_lines_complete: true,
vat_lines: vatLines,
// Would mis-tag the VAT line if any fallback existed.
dimensions: { '1': 'LEAK' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
expect(input.lines.find(l => l.account_number === '5410')?.dimensions).toEqual({ '6': 'P001' })
expect(input.lines.find(l => l.account_number === '2641')?.dimensions).toBeUndefined()
// Settlement line stays untagged.
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
assertBalanced(input)
})
it('all_lines_complete income: per-line bags authoritative on the credit side too', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Multi-line income' })
const vatLines: VatJournalLine[] = [
{ account_number: '3001', debit_amount: 0, credit_amount: 10000, description: 'Försäljning', dimensions: { '1': 'KS01' } },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500, description: 'Utgående moms' },
]
const mapping = makeMappingResult({
debit_account: '1930',
credit_account: '3001',
all_lines_complete: true,
vat_lines: vatLines,
dimensions: { '1': 'LEAK' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find(l => l.account_number === '3001')?.dimensions).toEqual({ '1': 'KS01' })
expect(input.lines.find(l => l.account_number === '2611')?.dimensions).toBeUndefined()
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
})
it('default_private path never tags — even when a bag is set on the mapping', async () => {
const tx = makeTransaction({ amount: -500, description: 'Lunch privat' })
const mapping = makeMappingResult({
debit_account: '2013',
credit_account: '1930',
default_private: true,
dimensions: { '1': 'KS01' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.dimensions).toBeUndefined()
}
})
it('no bag on the mapping → business line stays untagged', async () => {
const tx = makeTransaction({ amount: -299 })
const mapping = makeMappingResult({ debit_account: '5410', credit_account: '1930' })
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.dimensions).toBeUndefined()
}
})
})
+40 -5
View File
@@ -8,6 +8,7 @@ import {
generateReverseChargeLines,
getVatRate,
} from './vat-entries'
import { dimensionsBagKey } from './dimension-resolver'
import type {
CategorizationTemplate,
CategorizationTemplateSource,
@@ -379,6 +380,11 @@ function buildMultiLineMappingResult(
debit_amount: entry.side === 'debit' ? amount : 0,
credit_amount: entry.side === 'credit' ? amount : 0,
description: '',
// Dimensions PR7: business lines carry the pattern's learned bag;
// VAT/tax/rounding lines stay untagged.
...(entry.type === 'business' && entry.dimensions
? { dimensions: entry.dimensions }
: {}),
})
}
}
@@ -653,11 +659,12 @@ interface VoucherLinePattern {
* Returns null if the voucher can't be represented as a pattern.
*/
function extractVoucherLinePattern(
lines: { account: string; amount: number }[]
lines: { account: string; amount: number; dimensions?: Record<string, string> }[]
): VoucherLinePattern | null {
const settlement: { account: string; amount: number }[] = []
const vat: { account: string; amount: number }[] = []
const business: { account: string; amount: number }[] = []
type PatternLine = { account: string; amount: number; dimensions?: Record<string, string> }
const settlement: PatternLine[] = []
const vat: PatternLine[] = []
const business: PatternLine[] = []
for (const line of lines) {
if (isSettlementAccount(line.account)) {
@@ -712,6 +719,11 @@ function extractVoucherLinePattern(
type: isTaxAccount(b.account) ? 'tax' : 'business',
side: b.amount >= 0 ? 'debit' : 'credit',
ratio: Math.round(ratio * 10000) / 10000,
// Dimensions PR7: carry the source line's bag so SIE-learned templates
// keep tagging like the history did (dropped in averaging on conflict).
...(b.dimensions && Object.keys(b.dimensions).length > 0
? { dimensions: b.dimensions }
: {}),
})
}
@@ -773,7 +785,18 @@ function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePattern
if (voucherPatterns.length === 1) return normalizeRatios(voucherPatterns[0].entries)
// Collect all accounts across all patterns
const accountMap = new Map<string, { type: LinePatternEntry['type']; side: LinePatternEntry['side']; ratios: number[]; vat_rate?: number }>()
const accountMap = new Map<string, {
type: LinePatternEntry['type']
side: LinePatternEntry['side']
ratios: number[]
vat_rate?: number
// Dimensions PR7: conservative — a bag survives averaging only when EVERY
// occurrence of the account carries the identical bag. A single
// disagreeing (or untagged) voucher drops it: a template must never
// invent a tag history doesn't consistently support.
dimensions?: Record<string, string>
dimensionsConsistent: boolean
}>()
for (const vp of voucherPatterns) {
// Normalize per-voucher ratios before averaging
@@ -786,11 +809,20 @@ function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePattern
side: entry.side,
ratios: entry.ratio !== undefined ? [entry.ratio] : [],
vat_rate: entry.vat_rate,
dimensions: entry.dimensions,
dimensionsConsistent: true,
})
} else {
if (entry.ratio !== undefined) {
existing.ratios.push(entry.ratio)
}
if (
existing.dimensionsConsistent &&
dimensionsBagKey(existing.dimensions) !== dimensionsBagKey(entry.dimensions)
) {
existing.dimensionsConsistent = false
existing.dimensions = undefined
}
}
}
}
@@ -804,6 +836,9 @@ function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePattern
if (data.ratios.length > 0) {
entry.ratio = Math.round((data.ratios.reduce((s, r) => s + r, 0) / data.ratios.length) * 10000) / 10000
}
if (data.dimensionsConsistent && data.dimensions && Object.keys(data.dimensions).length > 0) {
entry.dimensions = data.dimensions
}
entries.push(entry)
}
+37
View File
@@ -97,6 +97,43 @@ export function coerceDimensionsBag(raw: unknown): LineDimensions | undefined {
return Object.keys(dims).length > 0 ? dims : undefined
}
/**
* Merge a line/item-level dimensions bag over a document-level default
* (producers, PR7: invoice default_dimensions under item.dimensions). The
* override wins per key; an explicit empty-string override clears the key —
* the same clear-semantics as normalizeLineDimensions, which does the final
* cleanup. Returns undefined when the merged bag is empty so callers can
* assign it to an optional field without writing `{}` noise.
*/
export function mergeDimensionBags(
base?: LineDimensions | null,
override?: LineDimensions | null
): LineDimensions | undefined {
if (!base && !override) return undefined
const merged = normalizeLineDimensions({
dimensions: { ...(base ?? {}), ...(override ?? {}) },
})
return Object.keys(merged).length > 0 ? merged : undefined
}
/**
* Stable serialization of a dimensions bag for grouping keys, so generators
* that aggregate amounts per account can keep items with different dimension
* tags on separate journal lines (account + bag = the aggregation identity).
* Key order is canonicalized; '' means "no dimensions". Callers must pass a
* NORMALIZED bag (mergeDimensionBags/normalizeLineDimensions output) — an
* unnormalized bag ('01' vs '1', untrimmed values) would key differently
* from its normalized twin.
*/
export function dimensionsBagKey(dimensions?: LineDimensions): string {
if (!dimensions) return ''
return JSON.stringify(
Object.keys(dimensions)
.sort()
.map((key) => [key, dimensions[key]])
)
}
/**
* Derive the legacy mirror columns from the canonical map. Pure function —
* divergence between `dimensions` and cost_center/project is impossible as
+94 -25
View File
@@ -1,6 +1,12 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { resolveBookingAccount } from './accruals/account-suggestions'
import {
coerceDimensionsBag,
dimensionsBagKey,
mergeDimensionBags,
type LineDimensions,
} from './dimension-resolver'
import { generateSalesVatLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
@@ -55,6 +61,13 @@ function buildInvoiceDescription(
* schedules may pass true (invoice entry + credit note) — the cash-method
* entry books revenue directly even if a line carries stale accrual fields,
* since no schedule would ever dissolve the interim balance.
*
* options.defaultDimensions (dimensions PR7): the invoice-level bag. Revenue
* lines carry item.dimensions merged over it (item wins per key) — the merged
* bag is part of the aggregation identity, so two items on the same
* rate+account but different tags stay on separate lines. VAT lines carry
* the default only (the VAT account is a function of the treatment, never of
* a specific item).
*/
function generatePerRateLines(
items: InvoiceItem[],
@@ -63,7 +76,7 @@ function generatePerRateLines(
invoiceTagText: string,
currency?: string | null,
exchangeRate?: number | null,
options?: { deferAccruals?: boolean }
options?: { deferAccruals?: boolean; defaultDimensions?: LineDimensions }
): CreateJournalEntryLineInput[] {
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
@@ -85,7 +98,9 @@ function generatePerRateLines(
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (!hasPerLineVat) {
// Legacy fallback: single rate from invoice level
// Legacy fallback: single rate from invoice level. All items collapse
// into one revenue line, so only the invoice default can apply here —
// legacy rows predate per-item tagging anyway.
const revenueAccount = getRevenueAccount(invoiceVatTreatment, entityType)
const subtotal = items.reduce((sum, item) => sum + item.line_total, 0)
const subtotalSek = toSek(subtotal)
@@ -94,6 +109,7 @@ function generatePerRateLines(
debit_amount: 0,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${invoiceTagText}`,
dimensions: options?.defaultDimensions,
})
const totalVat = items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
@@ -107,6 +123,7 @@ function generatePerRateLines(
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${invoiceTagText}`,
dimensions: options?.defaultDimensions,
})
} else {
const vatLines = generateSalesVatLines({
@@ -114,20 +131,29 @@ function generatePerRateLines(
baseAmount: subtotal,
direction: 'sales',
})
lines.push(...vatLines)
lines.push(...vatLines.map((line) => ({
...line,
dimensions: options?.defaultDimensions,
})))
}
}
return lines
}
// Group items by vat_rate (preserve first-seen rate order). Within each rate,
// sub-group revenue by the resolved BAS account so a per-line/article account
// override produces its own credit line. VAT stays aggregated per rate (the
// VAT account is a function of the treatment, never of the revenue override).
// sub-group revenue by the resolved BAS account + merged dimensions bag so a
// per-line/article account override — or a per-item dimension tag — produces
// its own credit line. VAT stays aggregated per rate (the VAT account is a
// function of the treatment, never of the revenue override).
type RevenueBucket = {
account: string
dimensions?: LineDimensions
subtotal: number
}
type RateGroup = {
vatAmount: number
// resolved revenue account -> summed line_total (first-seen account order)
byAccount: Map<string, number>
// account + dims bag -> bucket (first-seen order)
buckets: Map<string, RevenueBucket>
}
const rateGroups = new Map<number, RateGroup>()
@@ -151,9 +177,14 @@ function generatePerRateLines(
? plAccount
: resolveBookingAccount('revenue', item, plAccount)
const group = rateGroups.get(rate) ?? { vatAmount: 0, byAccount: new Map<string, number>() }
const dimensions = mergeDimensionBags(options?.defaultDimensions, item.dimensions)
const bucketKey = `${account}\u0000${dimensionsBagKey(dimensions)}`
const group = rateGroups.get(rate) ?? { vatAmount: 0, buckets: new Map<string, RevenueBucket>() }
group.vatAmount += item.vat_amount || 0
group.byAccount.set(account, (group.byAccount.get(account) ?? 0) + item.line_total)
const bucket = group.buckets.get(bucketKey) ?? { account, dimensions, subtotal: 0 }
bucket.subtotal += item.line_total
group.buckets.set(bucketKey, bucket)
rateGroups.set(rate, group)
}
@@ -165,26 +196,28 @@ function generatePerRateLines(
// The rate-level rounded subtotal is the balance anchor — identical to the
// pre-override single-account behaviour. When a rate splits across multiple
// accounts, distribute that exact total so independent per-account rounding
// can never introduce a 1-öre imbalance against the 1510 debit: every
// account but the last rounds normally; the last absorbs the remainder.
// buckets (account and/or dimensions), distribute that exact total so
// independent per-bucket rounding can never introduce a 1-öre imbalance
// against the 1510 debit: every bucket but the last rounds normally; the
// last absorbs the remainder.
const rateSubtotalSek = Math.round(
toSek(Array.from(group.byAccount.values()).reduce((sum, v) => sum + v, 0)) * 100
toSek(Array.from(group.buckets.values()).reduce((sum, b) => sum + b.subtotal, 0)) * 100
) / 100
const accounts = Array.from(group.byAccount.entries())
const buckets = Array.from(group.buckets.values())
let allocated = 0
accounts.forEach(([account, subtotal], idx) => {
const isLast = idx === accounts.length - 1
buckets.forEach((bucket, idx) => {
const isLast = idx === buckets.length - 1
const credit = isLast
? Math.round((rateSubtotalSek - allocated) * 100) / 100
: Math.round(toSek(subtotal) * 100) / 100
: Math.round(toSek(bucket.subtotal) * 100) / 100
allocated = Math.round((allocated + credit) * 100) / 100
lines.push({
account_number: account,
account_number: bucket.account,
debit_amount: 0,
credit_amount: credit,
line_description: `Försäljning faktura ${invoiceTagText}`,
dimensions: bucket.dimensions,
})
})
@@ -196,6 +229,7 @@ function generatePerRateLines(
debit_amount: 0,
credit_amount: roundedVat,
line_description: `Utgående moms ${rate}% faktura ${invoiceTagText}`,
dimensions: options?.defaultDimensions,
})
}
}
@@ -223,6 +257,7 @@ function generateRotRutLines(
invoiceTagText: string,
currency?: string | null,
exchangeRate?: number | null,
defaultDimensions?: LineDimensions,
): { lines: CreateJournalEntryLineInput[]; totalSek: number } {
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
@@ -255,6 +290,8 @@ function generateRotRutLines(
debit_amount: amountSek,
credit_amount: 0,
line_description: `${kind}-avdrag faktura ${invoiceTagText}`,
// Per-item line — carries the item's merged bag like its revenue line.
dimensions: mergeDimensionBags(defaultDimensions, item.dimensions),
})
}
@@ -305,6 +342,9 @@ export async function createInvoiceJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
const tag = options?.numberOverride ?? invoiceTag(invoice)
// Dimensions PR7: the invoice default rides every generated line; item bags
// merge over it inside generatePerRateLines/generateRotRutLines.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
@@ -315,7 +355,7 @@ export async function createInvoiceJournalEntry(
invoice.currency, invoice.exchange_rate,
// Schedules are created right after this entry commits (send/mark-sent
// flows), so deferring to 29xx here is safe.
{ deferAccruals: true }
{ deferAccruals: true, defaultDimensions }
))
} else {
// Fallback: no items available, use invoice-level amounts
@@ -327,6 +367,7 @@ export async function createInvoiceJournalEntry(
debit_amount: 0,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${tag}`,
dimensions: defaultDimensions,
})
if (invoice.vat_amount > 0) {
@@ -338,6 +379,7 @@ export async function createInvoiceJournalEntry(
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${tag}`,
dimensions: defaultDimensions,
})
} else {
const vatLines = generateSalesVatLines({
@@ -345,7 +387,10 @@ export async function createInvoiceJournalEntry(
baseAmount: invoice.subtotal,
direction: 'sales',
})
creditLines.push(...vatLines)
creditLines.push(...vatLines.map((line) => ({
...line,
dimensions: defaultDimensions,
})))
}
}
}
@@ -355,7 +400,7 @@ export async function createInvoiceJournalEntry(
// balanced (debits 1510 + 1513 = credits revenue + VAT). The customer
// only owes the post-deduction amount; Skatteverket pays the rest.
const rotRut = invoice.items && invoice.items.length > 0
? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate)
? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate, defaultDimensions)
: { lines: [], totalSek: 0 }
// Debit: Kundfordringar — balance guarantee: debit = sum of all credit
@@ -371,6 +416,7 @@ export async function createInvoiceJournalEntry(
debit_amount: arAmount,
credit_amount: 0,
line_description: `Faktura ${tag}`,
dimensions: defaultDimensions,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
@@ -423,6 +469,10 @@ export async function createInvoicePaymentJournalEntry(
customerName,
invoice.id,
)
// Dimensions PR7: the payment voucher re-propagates the linked invoice's
// default bag onto every leg — incl. the FX result lines, so a project's
// kursvinst/kursförlust stays inside the project P&L.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// When paymentAmount is provided, use it for the 1930/1510 line amounts.
// Otherwise use the full invoice total (backward compatible).
@@ -489,6 +539,12 @@ export async function createInvoicePaymentJournalEntry(
)
}
if (defaultDimensions) {
// Copy per line — a shared bag object would let one line's mutation
// leak into every other line (same contract as proposal stamping).
for (const line of lines) line.dimensions = { ...defaultDimensions }
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
@@ -533,6 +589,9 @@ export async function createCreditNoteJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
const tag = invoiceTag(creditNote)
const lineSuffix = originalVoucherRef ? ` (avser ${originalVoucherRef})` : ''
// Dimensions PR7: the credit note's bag (copied from the original at credit
// time) so the reversal nets against the same dimension cells in reports.
const defaultDimensions = coerceDimensionsBag(creditNote.default_dimensions)
// Generate reversed revenue + VAT lines per rate group (debit side for credit notes)
const debitLines: CreateJournalEntryLineInput[] = []
@@ -545,7 +604,7 @@ export async function createCreditNoteJournalEntry(
// Credit-note items carry the original's accrual fields so the reversal
// hits the same 29xx interim account; the original's schedule is
// cancelled/stornoed by the credit flow.
{ deferAccruals: true }
{ deferAccruals: true, defaultDimensions }
)
for (const line of creditLines) {
debitLines.push({
@@ -566,6 +625,7 @@ export async function createCreditNoteJournalEntry(
debit_amount: absSubtotal,
credit_amount: 0,
line_description: `Kreditfaktura ${tag}`,
dimensions: defaultDimensions,
})
if (absVat > 0) {
@@ -575,6 +635,7 @@ export async function createCreditNoteJournalEntry(
debit_amount: absVat,
credit_amount: 0,
line_description: `Moms kreditfaktura ${tag}${lineSuffix}`,
dimensions: defaultDimensions,
})
}
}
@@ -588,6 +649,7 @@ export async function createCreditNoteJournalEntry(
debit_amount: 0,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: `Kreditfaktura ${tag}`,
dimensions: defaultDimensions,
})
const baseDescription = buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id)
@@ -631,6 +693,9 @@ export async function createInvoiceCashEntry(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
const tag = invoiceTag(invoice)
// Dimensions PR7: kontantmetoden books revenue at payment, so this IS the
// producer path for cash-method companies — same merge rules as issuance.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
@@ -638,7 +703,8 @@ export async function createInvoiceCashEntry(
if (invoice.items && invoice.items.length > 0) {
creditLines.push(...generatePerRateLines(
invoice.items, invoice.vat_treatment, entityType, tag,
invoice.currency, invoice.exchange_rate
invoice.currency, invoice.exchange_rate,
{ defaultDimensions }
))
} else {
// Fallback: invoice-level amounts
@@ -650,6 +716,7 @@ export async function createInvoiceCashEntry(
debit_amount: 0,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${tag}`,
dimensions: defaultDimensions,
})
if (invoice.vat_amount > 0) {
@@ -660,6 +727,7 @@ export async function createInvoiceCashEntry(
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${tag}`,
dimensions: defaultDimensions,
})
}
}
@@ -669,7 +737,7 @@ export async function createInvoiceCashEntry(
// life; the rest comes from Skatteverket later. We model that by
// splitting the debit: 1930 = total - deduction, 1513 = deduction.
const rotRut = invoice.items && invoice.items.length > 0
? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate)
? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate, defaultDimensions)
: { lines: [], totalSek: 0 }
// Debit: Företagskonto — balance guarantee: debit = sum of credit lines
@@ -684,6 +752,7 @@ export async function createInvoiceCashEntry(
debit_amount: bankAmount,
credit_amount: 0,
line_description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName, invoice.id),
dimensions: defaultDimensions,
})
lines.push(...rotRut.lines)
+18 -3
View File
@@ -23,6 +23,14 @@ export interface ProposePaymentLinesInput {
exchange_rate?: number | null
vat_treatment: VatTreatment
items?: InvoiceItem[]
/**
* Dimensions PR7: the invoice's default bag. Stamped on every proposed
* line — the payment dialog always submits its (editable) lines, so the
* preview IS the booked entry and must re-propagate the tag like the
* no-override generator path does. Per-item bags are not split out here
* (the preview groups per rate); users can retag lines in the grid.
*/
default_dimensions?: Record<string, string> | null
}
accountingMethod: 'accrual' | 'cash'
entityType: EntityType
@@ -65,10 +73,17 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
const paymentAccount = input.paymentAccount || '1930'
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
if (accountingMethod === 'accrual') {
return proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference)
const lines = accountingMethod === 'accrual'
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference)
: proposeCashLines(invoice, paymentAccount, desc, entityType)
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
const bag = invoice.default_dimensions
if (bag && Object.keys(bag).length > 0) {
return lines.map((line) => ({ ...line, dimensions: { ...bag } }))
}
return proposeCashLines(invoice, paymentAccount, desc, entityType)
return lines
}
function proposeAccrualLines(
+25
View File
@@ -23,6 +23,12 @@ export interface ProposeSendLinesInput {
exchange_rate?: number | null
vat_treatment: VatTreatment
items?: InvoiceItem[]
/**
* Dimensions PR7: the invoice's default bag, stamped on every proposed
* line so the preview matches what createInvoiceJournalEntry books
* (display-only — the send routes book via the generator).
*/
default_dimensions?: Record<string, string> | null
}
entityType: EntityType
}
@@ -41,6 +47,25 @@ function toFormAmount(n: number): string {
*/
export function proposeSendLines(input: ProposeSendLinesInput): FormLine[] {
const { invoice, entityType } = input
const lines: FormLine[] = stampProposalDimensions(
buildSendLines(invoice, entityType),
invoice.default_dimensions
)
return lines
}
function stampProposalDimensions(
lines: FormLine[],
bag?: Record<string, string> | null
): FormLine[] {
if (!bag || Object.keys(bag).length === 0) return lines
return lines.map((line) => ({ ...line, dimensions: { ...bag } }))
}
function buildSendLines(
invoice: ProposeSendLinesInput['invoice'],
entityType: EntityType
): FormLine[] {
const lines: FormLine[] = []
const isForeign = invoice.currency !== 'SEK'
const desc = invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'
+117 -45
View File
@@ -7,6 +7,12 @@ import {
isReverseChargeBasisAccount,
resolveReverseChargeRate,
} from './vat-entries'
import {
coerceDimensionsBag,
dimensionsBagKey,
mergeDimensionBags,
type LineDimensions,
} from './dimension-resolver'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -33,6 +39,37 @@ function buildSupplierDescription(
return suffix ? `${base} ${suffix}` : base
}
/**
* Aggregate item amounts per (booking account, merged dimensions bag) —
* dimensions PR7. The merged bag (item.dimensions over the invoice's
* default_dimensions) is part of the aggregation identity so two items on the
* same account but different tags stay on separate journal lines instead of
* collapsing. Insertion order is first-seen, matching the old per-account map.
*/
interface ExpenseBucket {
account: string
dimensions?: LineDimensions
amount: number
}
function groupExpenseBuckets(
items: SupplierInvoiceItem[],
resolveAccount: (item: SupplierInvoiceItem) => string,
toSek: (item: SupplierInvoiceItem) => number,
defaultDimensions?: LineDimensions
): ExpenseBucket[] {
const buckets = new Map<string, ExpenseBucket>()
for (const item of items) {
const account = resolveAccount(item)
const dimensions = mergeDimensionBags(defaultDimensions, item.dimensions)
const key = `${account}\u0000${dimensionsBagKey(dimensions)}`
const bucket = buckets.get(key) ?? { account, dimensions, amount: 0 }
bucket.amount += toSek(item)
buckets.set(key, bucket)
}
return [...buckets.values()]
}
/**
* Create journal entry when a supplier invoice is registered (accrual method)
*
@@ -68,27 +105,30 @@ export async function createSupplierInvoiceRegistrationEntry(
const lines: CreateJournalEntryLineInput[] = []
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const isForeign = invoice.currency !== 'SEK'
// Dimensions PR7: expense lines carry item bags merged over the invoice
// default; every other line (VAT, RC/basis, 2440) carries the default only.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Aggregate expense amounts by account number and convert to SEK.
// Aggregate expense amounts by (account, dimensions) and convert to SEK.
// Periodiserade lines book their net to the 17xx interim account instead
// of the cost account (resolveBookingAccount); VAT and 2440 are untouched —
// moms is never deferred (redovisas på fakturadatum).
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
const current = expenseByAccount.get(bookingAccount) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(bookingAccount, current + itemSek)
}
const expenseBuckets = groupExpenseBuckets(
items,
(item) => resolveBookingAccount('expense', item, item.account_number),
(item) => resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate),
defaultDimensions
)
// Debit: Expense accounts (in SEK)
const debitLines: CreateJournalEntryLineInput[] = []
for (const [accountNumber, amount] of expenseByAccount) {
for (const bucket of expenseBuckets) {
debitLines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: bucket.dimensions,
})
}
lines.push(...debitLines)
@@ -124,11 +164,11 @@ export async function createSupplierInvoiceRegistrationEntry(
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines)
lines.push(...rcLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
lines.push(...basisLines)
lines.push(...basisLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
}
}
}
@@ -142,6 +182,7 @@ export async function createSupplierInvoiceRegistrationEntry(
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
@@ -156,6 +197,7 @@ export async function createSupplierInvoiceRegistrationEntry(
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
@@ -202,6 +244,9 @@ export async function createSupplierInvoicePaymentEntry(
const desc = buildSupplierDescription('Utbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: the payment voucher re-propagates the linked invoice's
// default bag onto every leg (incl. FX result lines) — see the stamp below.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
if (exchangeRateDifference && exchangeRateDifference !== 0) {
// Foreign currency with exchange rate difference
@@ -259,6 +304,12 @@ export async function createSupplierInvoicePaymentEntry(
})
}
if (defaultDimensions) {
// Copy per line — a shared bag object would let one line's mutation
// leak into every other line (same contract as proposal stamping).
for (const line of lines) line.dimensions = { ...defaultDimensions }
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
@@ -320,25 +371,29 @@ export async function createSupplierInvoiceCashEntry(
const desc = buildSupplierDescription('Kontantbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: kontantmetoden books the expense at payment — same merge
// rules as the registration entry.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Expense debit lines tracked separately so a sub-öre translation residual
// can be folded into the largest one (öresavrundning step below).
const expenseLines: CreateJournalEntryLineInput[] = []
// Aggregate expense amounts by account number and convert to SEK
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, effectiveRate)
expenseByAccount.set(item.account_number, current + itemSek)
}
// Aggregate expense amounts by (account, dimensions) and convert to SEK
const expenseBuckets = groupExpenseBuckets(
items,
(item) => item.account_number,
(item) => resolveSekAmount(item.line_total, null, invoice.currency, effectiveRate),
defaultDimensions
)
// Debit: Expense accounts (in SEK)
for (const [accountNumber, amount] of expenseByAccount) {
for (const bucket of expenseBuckets) {
const line: CreateJournalEntryLineInput = {
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: bucket.dimensions,
}
lines.push(line)
expenseLines.push(line)
@@ -367,11 +422,11 @@ export async function createSupplierInvoiceCashEntry(
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines)
lines.push(...rcLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
lines.push(...basisLines)
lines.push(...basisLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
}
}
}
@@ -386,6 +441,7 @@ export async function createSupplierInvoiceCashEntry(
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
@@ -418,6 +474,7 @@ export async function createSupplierInvoiceCashEntry(
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
@@ -465,20 +522,24 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
const ownerAccount = entityType === 'aktiebolag' ? '2893' : '2018'
const desc = buildSupplierDescription('Eget utlägg', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: this IS the utlägg path — billable-expense-to-project
// tagging rides the same merge rules as the registration entry.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Debit: Expense accounts (in SEK), aggregated per account
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
}
for (const [accountNumber, amount] of expenseByAccount) {
// Debit: Expense accounts (in SEK), aggregated per (account, dimensions)
const expenseBuckets = groupExpenseBuckets(
items,
(item) => item.account_number,
(item) => resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate),
defaultDimensions
)
for (const bucket of expenseBuckets) {
lines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: bucket.dimensions,
})
}
@@ -492,6 +553,7 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
@@ -504,6 +566,7 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
debit_amount: 0,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
@@ -542,26 +605,30 @@ export async function createSupplierCreditNoteEntry(
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomstnr ${creditNote.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: items are the ORIGINAL invoice's (see below), so their
// bags reverse against the same dimension cells; the credit note's own
// default (copied from the original at credit time) rides the other legs.
const defaultDimensions = coerceDimensionsBag(creditNote.default_dimensions)
// Credit: Expense accounts (reverse, in SEK). The caller passes the
// ORIGINAL invoice's items so deferred lines reverse against the same 17xx
// interim account they were registered on (the schedule's posted
// dissolutions are stornoed separately by cancelSchedulesForSource).
const creditLines: CreateJournalEntryLineInput[] = []
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
const current = expenseByAccount.get(bookingAccount) || 0
const itemSek = Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate))
expenseByAccount.set(bookingAccount, current + itemSek)
}
const expenseBuckets = groupExpenseBuckets(
items,
(item) => resolveBookingAccount('expense', item, item.account_number),
(item) => Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate)),
defaultDimensions
)
for (const [accountNumber, amount] of expenseByAccount) {
for (const bucket of expenseBuckets) {
creditLines.push({
account_number: accountNumber,
account_number: bucket.account,
debit_amount: 0,
credit_amount: Math.round(amount * 100) / 100,
credit_amount: Math.round(bucket.amount * 100) / 100,
line_description: desc,
dimensions: bucket.dimensions,
})
}
@@ -595,12 +662,14 @@ export async function createSupplierCreditNoteEntry(
debit_amount: 0,
credit_amount: fiktivVat,
line_description: `Omvänd fiktiv ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
lines.push({
account_number: outputAccount,
debit_amount: fiktivVat,
credit_amount: 0,
line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
@@ -618,6 +687,7 @@ export async function createSupplierCreditNoteEntry(
debit_amount: line.credit_amount,
credit_amount: line.debit_amount,
line_description: line.line_description,
dimensions: defaultDimensions,
})
}
}
@@ -633,6 +703,7 @@ export async function createSupplierCreditNoteEntry(
debit_amount: 0,
credit_amount: amount,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
@@ -648,6 +719,7 @@ export async function createSupplierCreditNoteEntry(
debit_amount: Math.round((totalCredits - totalDebits) * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
+15 -2
View File
@@ -1,5 +1,6 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { coerceDimensionsBag } from './dimension-resolver'
import { extractNetAmount, extractVatAmount } from './vat-entries'
import { InvalidMappingResultError } from '@/lib/bookkeeping/errors'
import { createLogger } from '@/lib/logger'
@@ -74,6 +75,11 @@ export async function createTransactionJournalEntry(
transaction.exchange_rate
)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: the bag tags the business (expense/revenue) lines only —
// bank/settlement and VAT lines stay untagged. In the multi-line template
// path each pattern line carries its own bag instead (LinePatternEntry).
// The private path books to a balance account (2013/2893) — never tagged.
const businessDimensions = coerceDimensionsBag(mappingResult.dimensions)
if (mappingResult.default_private) {
// Private expense — use entity-specific account from mappingResult
@@ -99,13 +105,16 @@ export async function createTransactionJournalEntry(
: (mappingResult.debit_account || '1930')
if (isExpense) {
// All non-settlement lines (business, VAT, tax, rounding)
// All non-settlement lines (business, VAT, tax, rounding). Per-line bags
// are authoritative here — the pattern marks business lines only, so no
// fallback to the categorize-level bag (it would mis-tag VAT/tax lines).
for (const line of mappingResult.vat_lines) {
lines.push({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
line_description: line.description || transaction.description,
dimensions: coerceDimensionsBag(line.dimensions),
})
}
// Credit bank for full amount
@@ -125,13 +134,14 @@ export async function createTransactionJournalEntry(
line_description: transaction.description,
...(isForeign ? currencyMeta : {}),
})
// All non-settlement lines
// All non-settlement lines — per-line bags authoritative (see above).
for (const line of mappingResult.vat_lines) {
lines.push({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
line_description: line.description || transaction.description,
dimensions: coerceDimensionsBag(line.dimensions),
})
}
}
@@ -163,6 +173,7 @@ export async function createTransactionJournalEntry(
debit_amount: netAmount,
credit_amount: 0,
line_description: transaction.description,
dimensions: businessDimensions,
})
} else {
// No VAT handling - debit full amount to expense account
@@ -171,6 +182,7 @@ export async function createTransactionJournalEntry(
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
dimensions: businessDimensions,
})
}
@@ -208,6 +220,7 @@ export async function createTransactionJournalEntry(
debit_amount: 0,
credit_amount: netAmount,
line_description: transaction.description,
dimensions: businessDimensions,
})
// Credit output VAT
for (const vatLine of mappingResult.vat_lines) {
+10
View File
@@ -54,6 +54,8 @@ export interface InvoiceWriteItemInput {
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
/** Dimensions PR7: per-item bag merged over the invoice default at booking. */
dimensions?: Record<string, string>
}
export interface InvoiceWriteInput {
@@ -69,6 +71,8 @@ export interface InvoiceWriteInput {
ore_rounding?: boolean
deduction_personnummer?: string
deduction_housing_designation?: string
/** Dimensions PR7: invoice-level bag applied to every generated journal line. */
default_dimensions?: Record<string, string>
items: InvoiceWriteItemInput[]
}
@@ -102,6 +106,7 @@ export type InvoiceWriteFields = {
deduction_total: number
deduction_personnummer_encrypted: string | null
deduction_personnummer_last4: string | null
default_dimensions: Record<string, string>
}
export type InvoiceWriteItemRow = {
@@ -125,6 +130,7 @@ export type InvoiceWriteItemRow = {
accrual_period_start: string | null
accrual_period_end: string | null
accrual_balance_account: string | null
dimensions: Record<string, string>
}
export type BuildInvoiceWriteResult =
@@ -344,6 +350,8 @@ export async function buildInvoiceWriteData(params: {
deduction_total: deductionTotal,
deduction_personnummer_encrypted: deductionPersonnummerEncrypted,
deduction_personnummer_last4: deductionPersonnummerLast4,
// Dimensions PR7: stored as-is; the generators coerce + merge at booking.
default_dimensions: input.default_dimensions ?? {},
}
const itemRows: InvoiceWriteItemRow[] = items.map((item, index) => {
@@ -372,6 +380,7 @@ export async function buildInvoiceWriteData(params: {
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: {},
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
@@ -425,6 +434,7 @@ export async function buildInvoiceWriteData(params: {
documentType === 'invoice' && !deductionType && item.accrual_period_start && item.accrual_period_end
? (item.accrual_balance_account ?? DEFAULT_DEFERRED_REVENUE_ACCOUNT)
: null,
dimensions: item.dimensions ?? {},
}
})
@@ -182,3 +182,86 @@ describe('commitPendingOperation: create_invoice', () => {
})
})
})
describe('commitPendingOperation: create_invoice — dimensions propagation (PR7)', () => {
it('staged default_dimensions lands on the invoices row and item bags on invoice_items rows', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
default_dimensions: { '1': 'KS01' },
items: [
{
description: 'Konsulttimmar',
quantity: 1,
unit: 'tim',
unit_price: 1000,
vat_rate: 25,
dimensions: { '6': 'P001' },
},
{ line_type: 'text', description: 'Avser vecka 23', quantity: 0, unit: '', unit_price: 0, vat_rate: 0 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices'][0]).toMatchObject({ default_dimensions: { '1': 'KS01' } })
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows).toHaveLength(2)
expect(itemRows[0]).toMatchObject({ line_type: 'product', dimensions: { '6': 'P001' } })
// Text rows never carry a bag.
expect(itemRows[1]).toMatchObject({ line_type: 'text', dimensions: {} })
})
it('defaults to {} when no bags are staged', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices'][0]).toMatchObject({ default_dimensions: {} })
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows[0]).toMatchObject({ dimensions: {} })
})
it('coerces an INVALID staged bag away — the insert gets {} (drift/tamper gate)', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
// '0' is not a valid SIE dimension number — the whole bag is rejected.
default_dimensions: { '0': 'X' },
items: [
{
description: 'Konsulttimmar',
quantity: 1,
unit: 'tim',
unit_price: 1000,
vat_rate: 25,
// Empty code fails the schema — the whole bag is rejected.
dimensions: { '1': '' },
},
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices'][0]).toMatchObject({ default_dimensions: {} })
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows[0]).toMatchObject({ dimensions: {} })
})
})
@@ -576,3 +576,145 @@ describe('commitPendingOperation: create_supplier_invoice_from_inbox', () => {
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
})
describe('commitPendingOperation: create_supplier_invoice_from_inbox — dimensions propagation (PR7)', () => {
/**
* Capture both the supplier_invoices parent insert and the
* supplier_invoice_items rows (cash method — no JE, shortest queue).
*/
function withInsertCapture(supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']) {
const captured: {
invoice: Record<string, unknown> | null
items: Array<Record<string, unknown>> | null
} = { invoice: null, items: null }
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
if (table === 'supplier_invoices') {
return {
insert: (row: Record<string, unknown>) => {
captured.invoice = row
return {
select: () => ({
single: () =>
Promise.resolve({
data: makeSupplierInvoice({ id: 'inv-dims', supplier_invoice_number: 'INV-100' }),
error: null,
}),
}),
}
},
}
}
if (table === 'supplier_invoice_items') {
return {
insert: (rows: Array<Record<string, unknown>>) => {
captured.items = rows
return Promise.resolve({ data: null, error: null })
},
}
}
return (originalFrom as (t: string) => unknown)(table)
})
return captured
}
/** Queue for the cash-method path with both inserts intercepted above. */
function enqueueCashFlow(enqueue: ReturnType<typeof createQueuedMockSupabase>['enqueue']) {
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
}) // inbox fetch
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
}) // supplier fetch
enqueue({ data: 42, error: null }) // arrival number RPC
enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
}
it('staged default_dimensions lands on the supplier_invoices row and item bags on the item rows', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const captured = withInsertCapture(supabase)
enqueueCashFlow(enqueue)
const op = makePendingOp()
op.params = {
...(op.params as Record<string, unknown>),
default_dimensions: { '1': 'KS01' },
items: [
{
line_number: 1,
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '6530',
vat_rate: 0.25,
vat_amount: 250,
dimensions: { '6': 'P001' },
},
{
line_number: 2,
description: 'Frakt',
quantity: 1,
unit: 'st',
unit_price: 100,
line_total: 100,
account_number: '5710',
vat_rate: 0.25,
vat_amount: 25,
// No dims staged on this row.
},
],
}
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(captured.invoice).toMatchObject({ default_dimensions: { '1': 'KS01' } })
expect(captured.items).toHaveLength(2)
expect(captured.items![0]).toMatchObject({ account_number: '6530', dimensions: { '6': 'P001' } })
// Absent bag defaults to {} on the row.
expect(captured.items![1]).toMatchObject({ account_number: '5710', dimensions: {} })
})
it('defaults to {} when absent and coerces an INVALID staged bag away (drift/tamper gate)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const captured = withInsertCapture(supabase)
enqueueCashFlow(enqueue)
const op = makePendingOp()
op.params = {
...(op.params as Record<string, unknown>),
// '0' is not a valid SIE dimension number — the whole bag is rejected.
default_dimensions: { '0': 'X' },
items: [
{
line_number: 1,
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '6530',
vat_rate: 0.25,
vat_amount: 250,
// Empty code fails the schema — the whole bag is rejected.
dimensions: { '1': '' },
},
],
}
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(captured.invoice).toMatchObject({ default_dimensions: {} })
expect(captured.items![0]).toMatchObject({ dimensions: {} })
})
})
@@ -43,12 +43,24 @@ vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
}
})
vi.mock('@/lib/transactions/categorize-core', async () => {
const actual =
await vi.importActual<typeof import('@/lib/transactions/categorize-core')>(
'@/lib/transactions/categorize-core'
)
return {
...actual,
categorizeMatchedTransaction: vi.fn(),
}
})
import { commitPendingOperation } from '../commit'
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport } from '@/lib/import/sie-import'
import { commitAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
@@ -861,3 +873,83 @@ describe('commitPendingOperation: link_document_to_voucher', () => {
expect(result.http_status).toBe(409)
})
})
// ─── categorize_transaction — dimensions propagation (PR7) ──────────
describe('commitPendingOperation: categorize_transaction — dimensions propagation (PR7)', () => {
it('threads the staged dimensions bag into categorizeMatchedTransaction opts', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: {
transaction_id: 'tx-1',
category: 'office_supplies',
dimensions: { '1': 'KS01', '6': 'P001' },
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(categorizeMatchedTransaction).toHaveBeenCalledTimes(1)
const call = vi.mocked(categorizeMatchedTransaction).mock.calls[0]
expect(call[1]).toBe('user-1')
expect(call[2]).toBe('company-1')
expect(call[3]).toBe('tx-1')
expect(call[4].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('coerces an INVALID staged bag to undefined (drift/tamper gate)', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: {
transaction_id: 'tx-1',
category: 'office_supplies',
// '0' is not a valid SIE dimension number — the whole bag is rejected
// and booking proceeds without dimensions.
dimensions: { '0': 'X' },
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
expect(opts.dimensions).toBeUndefined()
})
it('passes no dimensions when nothing was staged', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'office_supplies' },
})
await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
expect(opts.dimensions).toBeUndefined()
})
})
+24
View File
@@ -222,6 +222,8 @@ async function commitCategorizeTransaction(
vatAmount,
notes,
allowDuplicate: params.allow_duplicate === true,
// Dimensions PR7: resolved at staging; coerce is the drift/tamper gate.
dimensions: coerceDimensionsBag(params.dimensions),
})
}
@@ -692,7 +694,11 @@ async function commitCreateInvoice(
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
article_id?: string | null; revenue_account?: string | null
line_type?: 'product' | 'text'
dimensions?: Record<string, string>
}>
// Dimensions PR7: bags were resolved against the registry at staging time
// (resolveDimensionBags in the MCP tool); coerce is the drift/tamper gate.
const defaultDimensions = coerceDimensionsBag(params.default_dimensions)
// Free-text rows carry no amounts and never book. The MCP staging tool does
// not accept line_type today, but the totals math must stay identical to
@@ -794,6 +800,7 @@ async function commitCreateInvoice(
our_reference: (params.our_reference as string) || null,
your_reference: (params.your_reference as string) || null,
notes: (params.notes as string) || null,
default_dimensions: defaultDimensions ?? {},
})
.select()
.single()
@@ -818,6 +825,7 @@ async function commitCreateInvoice(
vat_amount: 0,
article_id: null,
revenue_account: null,
dimensions: {},
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
@@ -838,6 +846,7 @@ async function commitCreateInvoice(
// account; null falls back to the VAT-treatment-derived account.
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
dimensions: coerceDimensionsBag(item.dimensions) ?? {},
}
})
@@ -2067,6 +2076,8 @@ async function commitCreateSupplierInvoiceFromInbox(
const vatTreatment = (params.vat_treatment as string) || 'standard_25'
const notes = (params.notes as string | null) ?? null
const rawItems = (params.items as Array<Record<string, unknown>> | undefined) ?? []
// Dimensions PR7: resolved at staging time; coerce is the drift/tamper gate.
const defaultDimensions = coerceDimensionsBag(params.default_dimensions)
if (!inboxItemId || !supplierId || !supplierInvoiceNumber || !invoiceDate || rawItems.length === 0) {
return {
@@ -2171,6 +2182,7 @@ async function commitCreateSupplierInvoiceFromInbox(
remaining_amount: totalRounded,
document_id: documentId,
notes,
default_dimensions: defaultDimensions ?? {},
})
.select()
.single()
@@ -2227,6 +2239,7 @@ async function commitCreateSupplierInvoiceFromInbox(
reverse_charge_rate: reverseCharge
? ([0.06, 0.12, 0.25].includes(Number(item.reverse_charge_rate)) ? Number(item.reverse_charge_rate) : null)
: null,
dimensions: coerceDimensionsBag(item.dimensions) ?? {},
}
})
@@ -2434,6 +2447,8 @@ async function commitCreditSupplierInvoice(
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: id,
// Dimensions PR7: copy so the reversal nets against the same cells.
default_dimensions: original.default_dimensions ?? {},
})
.select()
.single()
@@ -2452,6 +2467,7 @@ async function commitCreditSupplierInvoice(
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
dimensions: item.dimensions ?? {},
}))
await supabase.from('supplier_invoice_items').insert(creditItems)
@@ -2559,6 +2575,8 @@ async function commitCreditInvoice(
our_reference: original.our_reference,
notes: reason || `Krediterar faktura ${original.invoice_number}`,
credited_invoice_id: id,
// Dimensions PR7: copy so the reversal nets against the same cells.
default_dimensions: original.default_dimensions ?? {},
status: 'sent',
})
.select()
@@ -2580,6 +2598,7 @@ async function commitCreditInvoice(
vat_amount?: number
revenue_account?: string | null
article_id?: string | null
dimensions?: Record<string, string>
}) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
@@ -2595,6 +2614,8 @@ async function commitCreditInvoice(
// VAT-derived 3001) so the override account doesn't keep a dangling balance.
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
// Same reasoning for the per-item bag (dimensions PR7).
dimensions: item.dimensions ?? {},
}))
const { error: itemsError } = await supabase
@@ -2722,6 +2743,8 @@ async function commitConvertInvoice(
notes: proforma.notes,
document_type: 'invoice',
converted_from_id: id,
// Dimensions PR7: the converted invoice books with the proforma's bag.
default_dimensions: proforma.default_dimensions ?? {},
})
.select()
.single()
@@ -2751,6 +2774,7 @@ async function commitConvertInvoice(
vat_amount: item.vat_amount ?? 0,
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
dimensions: item.dimensions ?? {},
}))
if (items.length > 0) {
+11 -1
View File
@@ -59,6 +59,12 @@ export interface CategorizeMatchedTransactionOpts {
* account, and the caller surfaces the skip.
*/
allowDuplicate?: boolean
/**
* Dimensions PR7: bag applied to the business (expense/revenue) lines of the
* generated verifikat — bank/VAT lines stay untagged. Resolved against the
* registry at staging time (MCP) or picked in the UI.
*/
dimensions?: Record<string, string>
}
// ── Helper: ensure a fiscal period covers the date ──────────────────
@@ -151,7 +157,7 @@ export async function categorizeMatchedTransaction(
*/
exclude?: BookingDuplicateExclusions,
): Promise<CategorizeCoreResult> {
const { category, vatTreatment, vatAmount, notes, allowDuplicate } = opts
const { category, vatTreatment, vatAmount, notes, allowDuplicate, dimensions } = opts
const { data: transaction, error: fetchError } = await supabase
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
@@ -243,6 +249,10 @@ export async function categorizeMatchedTransaction(
const mappingResult = buildMappingResultFromCategory(
category, transaction as Transaction, isBusiness, entityType, vatTreatment, vatAmount
)
// Dimensions PR7: tag the business lines of the generated verifikat.
if (dimensions && Object.keys(dimensions).length > 0) {
mappingResult.dimensions = dimensions
}
if (!mappingResult.debit_account || !mappingResult.credit_account) {
return { error: `No account mapping for category "${category}" with entity type "${entityType}".`, status: 400 }