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:
co-authored by
Claude Fable 5
parent
816b1769c8
commit
755e0f7e47
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user