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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 21:21:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 816b1769c8
commit 755e0f7e47
41 changed files with 3574 additions and 112 deletions
+4 -1
View File
@@ -90,6 +90,8 @@ export async function POST(
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()
@@ -98,7 +100,7 @@ export async function POST(
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
}
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; dimensions?: Record<string, string> }) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
@@ -107,6 +109,7 @@ export async function POST(
unit: item.unit,
unit_price: item.unit_price,
line_total: item.line_total,
dimensions: item.dimensions ?? {},
}))
if (items.length > 0) {
+7 -1
View File
@@ -315,6 +315,9 @@ async function createCreditNote(
our_reference: originalInvoice.our_reference,
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
credited_invoice_id: input.credited_invoice_id,
// Copy the original's dimension bag so the credit-note verifikat nets
// against the same dimension cells in reports (dimensions PR7).
default_dimensions: originalInvoice.default_dimensions ?? {},
status: 'sent',
})
.select()
@@ -328,7 +331,7 @@ async function createCreditNote(
})
}
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null }) => ({
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null; dimensions?: Record<string, string> }) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
@@ -353,6 +356,9 @@ async function createCreditNote(
accrual_period_start: item.accrual_period_start ?? null,
accrual_period_end: item.accrual_period_end ?? null,
accrual_balance_account: item.accrual_balance_account ?? null,
// Same reasoning as revenue_account: the reversal must carry the exact
// per-item bag the original booked with (dimensions PR7).
dimensions: item.dimensions ?? {},
}))
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
@@ -59,6 +59,9 @@ export const POST = withRouteContext(
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: id,
// Copy the original's dimension bag so the reversal nets against the
// same dimension cells in reports (dimensions PR7).
default_dimensions: original.default_dimensions ?? {},
})
.select()
.single()
@@ -86,6 +89,9 @@ export const POST = withRouteContext(
// Preserve the self-assessed RC rate so the credit-note verifikat
// reverses fiktiv moms at the same rate the original was booked at.
reverse_charge_rate: item.reverse_charge_rate,
// Dims copied for display parity; the journal reversal reads the
// ORIGINAL items below (dimensions PR7).
dimensions: item.dimensions ?? {},
}))
await supabase.from('supplier_invoice_items').insert(creditItems)
+5
View File
@@ -182,6 +182,9 @@ export const POST = withRouteContext(
? (item.accrual_balance_account ??
suggestBalanceAccount('expense', item.account_number))
: null,
// Dimensions PR7: per-item bag, merged over default_dimensions on the
// expense line at booking (supplier-invoice-entries.ts).
dimensions: item.dimensions ?? {},
}
})
@@ -250,6 +253,8 @@ export const POST = withRouteContext(
notes: body.notes || null,
// Display-only öresavrundning override; null = off (no retroactive rounding).
ore_rounding: body.ore_rounding ?? null,
// Dimensions PR7: invoice-level bag; generators apply it to every line.
default_dimensions: body.default_dimensions ?? {},
})
.select()
.single()
+10
View File
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
import { BulkBookSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
import { mergeDimensionBags } from '@/lib/bookkeeping/dimension-resolver'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { BookingTemplateLibraryLine, Transaction } from '@/types'
@@ -34,6 +35,9 @@ interface ComputedLine {
currency: string
line_description?: string
sort_order?: number
// Dimensions PR7: bag persisted by the RPC onto journal_entry_lines
// (with cost_center/project mirrors derived server-side).
dimensions?: Record<string, string>
}
function round2(n: number): number {
@@ -120,6 +124,8 @@ export const POST = withRouteContext(
currency: l.currency,
line_description: l.line_description,
sort_order: i,
// Dimensions PR7: per-line bag wins over the header default.
dimensions: mergeDimensionBags(body.default_dimensions, l.dimensions),
})),
}
} else if (body.template_id && body.mode && body.entry_description) {
@@ -199,6 +205,8 @@ export const POST = withRouteContext(
currency,
line_description: formLine.line_description || undefined,
sort_order: sortOrder++,
// Dimensions PR7: header default applies to all template lines.
dimensions: body.default_dimensions,
})
}
} else {
@@ -221,6 +229,8 @@ export const POST = withRouteContext(
? `${formLine.line_description ?? ''} – ${txTag}`.trim()
: formLine.line_description || undefined,
sort_order: sortOrder++,
// Dimensions PR7: header default applies to all template lines.
dimensions: body.default_dimensions,
})
}
}
+153 -8
View File
@@ -24,7 +24,7 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock } from 'lucide-react'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -54,6 +54,7 @@ import {
} from '@/lib/invoices/rot-rut-rules'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } from '@/types'
@@ -85,6 +86,20 @@ function RequiredMark() {
return <span className="text-destructive ml-0.5" aria-hidden="true">*</span>
}
// True when a dimensions bag ({sie_dim_no: code}) carries at least one value.
function hasDimensionValues(dims: Record<string, string> | null | undefined): boolean {
return !!dims && Object.keys(dims).length > 0
}
// Compact display of a dimensions bag, e.g. "KS01 · P001" (dim-number order).
function compactDims(dims: Record<string, string>): string {
return Object.entries(dims)
.filter(([, v]) => v)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v)
.join(' · ')
}
export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'create' }) {
// Edit mode pre-fills the form from an existing draft and saves via PATCH.
const isEditMode = props.mode === 'edit'
@@ -128,6 +143,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: z.string().nullable().optional(),
accrual_period_end: z.string().nullable().optional(),
accrual_balance_account: z.string().nullable().optional(),
// Per-item dimensions bag ({sie_dim_no: code}, dimensions PR7). Stored
// as-is; the server merges it over the invoice's default_dimensions on
// the item's revenue line at booking time.
dimensions: z.record(z.string(), z.string()).nullable().optional(),
}).superRefine((item, ctx) => {
if (item.accrual_balance_account != null) {
const start = item.accrual_period_start
@@ -221,6 +240,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// override, plus which rows currently show that picker.
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([])
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set())
// Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances
// render only when company_settings.dimensions_enabled — a UI-visibility
// gate; a draft that already carries bags still round-trips untouched when
// the toggle is off. defaultDims is the invoice-level default; per-item
// overrides live on the form items and open via the row ⋮ menu (same
// open/close bookkeeping as accountOverrideRows).
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [defaultDims, setDefaultDims] = useState<Record<string, string>>(
initial?.default_dimensions ?? {},
)
const [dimensionOverrideRows, setDimensionOverrideRows] = useState<Set<number>>(new Set())
// True only when the user had zero invoices when this page loaded. The
// post-create flow uses this to offer a one-shot "upload a logo?" prompt
// — issue #520. Self-limits: once count > 0 it stays false.
@@ -279,6 +309,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: item.accrual_period_start ?? null,
accrual_period_end: item.accrual_period_end ?? null,
accrual_balance_account: item.accrual_balance_account ?? null,
dimensions: hasDimensionValues(item.dimensions) ? item.dimensions ?? null : null,
})),
}
: {
@@ -306,6 +337,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: null,
}],
},
})
@@ -452,7 +484,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
if (!company?.id) return
const { data } = await supabase
.from('company_settings')
.select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered')
.select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered, dimensions_enabled')
.eq('company_id', company.id)
.single()
if (data?.invoice_default_notes) {
@@ -479,6 +511,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
if (typeof data?.vat_registered === 'boolean') {
setVatRegistered(data.vat_registered)
}
// Gates the dimension affordances (header default + per-row override).
setDimensionsEnabled(data?.dimensions_enabled === true)
}
// First-invoice detection (issue #520): captured at page load so the
@@ -711,6 +745,57 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
}
// Open/close the optional per-item dimensions override (⋮ menu). Closing
// clears the bag so the row falls back to the invoice's default_dimensions.
function toggleItemDimensions(index: number) {
const isOpen = dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)
if (isOpen) {
setValue(`items.${index}.dimensions`, null, { shouldDirty: true })
setDimensionOverrideRows((prev) => {
const next = new Set(prev)
next.delete(index)
return next
})
} else {
setDimensionOverrideRows((prev) => new Set(prev).add(index))
}
}
function updateItemDimension(index: number, dimNo: string, code: string | null) {
const current = { ...(watchItems[index]?.dimensions ?? {}) }
const trimmed = code?.trim()
if (trimmed) current[dimNo] = trimmed
else delete current[dimNo]
setValue(
`items.${index}.dimensions`,
Object.keys(current).length > 0 ? current : null,
{ shouldDirty: true },
)
// Keep the sub-row open after the user clears the last value — it closes
// only via the ⋮ menu (same lifecycle as the account override).
setDimensionOverrideRows((prev) => (prev.has(index) ? prev : new Set(prev).add(index)))
}
function setDefaultDimension(dimNo: string, code: string | null) {
setDefaultDims((prev) => {
const next = { ...prev }
const trimmed = code?.trim()
if (trimmed) next[dimNo] = trimmed
else delete next[dimNo]
return next
})
}
// Per-item bags ride the payload only when they carry values — the server
// treats an absent bag as "inherit the invoice's default_dimensions".
function pruneItemDimensions<T extends { dimensions?: Record<string, string> | null }>(
items: T[],
): T[] {
return items.map((item) =>
hasDimensionValues(item.dimensions) ? item : { ...item, dimensions: undefined },
)
}
// Self-billing path: no review dialog, no PDF, no send — it arrives already
// booked. POST straight to the dedicated endpoint and open the verifikat.
async function handleSelfBilledSubmit(data: FormData) {
@@ -834,7 +919,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// null in the form state, but null personal-data fields shouldn't ride
// along on every regular invoice.
const anyDeduction = pendingData.items.some((i) => i.deduction_type)
const sanitizedItems = pendingData.items.map((item) => {
const sanitizedItems = pruneItemDimensions(pendingData.items).map((item) => {
if (item.deduction_type) return item
const {
deduction_type: _dt,
@@ -846,9 +931,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
} = item
return rest
})
const sanitizedPayload: CreateInvoiceInput = {
const sanitizedPayload: CreateInvoiceInput & { default_dimensions: Record<string, string> } = {
...(pendingData as CreateInvoiceInput),
ore_rounding: oreRounding,
// Invoice-level default dims — always sent so an edited draft can clear
// them; {} means "no defaults".
default_dimensions: defaultDims,
items: sanitizedItems as CreateInvoiceInput['items'],
...(anyDeduction
? {}
@@ -907,7 +995,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
setIsSavingDraft(true)
const anyDeduction = data.items.some((i) => i.deduction_type)
const sanitizedItems = data.items.map((item) => {
const sanitizedItems = pruneItemDimensions(data.items).map((item) => {
if (item.deduction_type) return item
const {
deduction_type: _dt,
@@ -919,10 +1007,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
} = item
return rest
})
const payload: CreateInvoiceInput = {
const payload: CreateInvoiceInput & { default_dimensions: Record<string, string> } = {
...(data as CreateInvoiceInput),
save_as_draft: true,
ore_rounding: oreRounding,
default_dimensions: defaultDims,
items: sanitizedItems as CreateInvoiceInput['items'],
...(anyDeduction
? {}
@@ -964,7 +1053,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
setIsSubmitting(true)
const anyDeduction = data.items.some((i) => i.deduction_type)
const sanitizedItems = data.items.map((item) => {
const sanitizedItems = pruneItemDimensions(data.items).map((item) => {
if (item.deduction_type) return item
const {
deduction_type: _dt,
@@ -976,9 +1065,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
} = item
return rest
})
const payload: CreateInvoiceInput = {
const payload: CreateInvoiceInput & { default_dimensions: Record<string, string> } = {
...(data as CreateInvoiceInput),
ore_rounding: oreRounding,
default_dimensions: defaultDims,
items: sanitizedItems as CreateInvoiceInput['items'],
...(anyDeduction
? {}
@@ -1343,6 +1433,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</DropdownMenuItem>
</>
)}
{dimensionsEnabled && watchItems[index]?.line_type !== 'text' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => toggleItemDimensions(index)} className="py-2">
<Tags className="h-4 w-4" />
{(dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions))
? t('row_menu_remove_dimensions')
: t('row_menu_set_dimensions')}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="py-2 text-destructive focus:text-destructive"
@@ -1662,6 +1763,28 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</div>
)}
{/* Per-item dimensions override (dimensions PR7) — opened
via the row's ⋮ menu. The bag is stored as-is; the
server merges it over the invoice's default_dimensions
for this item's revenue line at booking time. */}
{dimensionsEnabled && isInvoiceDoc && watchItems[index]?.line_type !== 'text' &&
(dimensionOverrideRows.has(index) || hasDimensionValues(watchItems[index]?.dimensions)) && (
<div className="md:col-span-12 mt-2 md:mt-3">
<div className="max-w-md">
<LineDimensionFields
dimensions={watchItems[index]?.dimensions ?? undefined}
onChange={(dimNo, code) => updateItemDimension(index, dimNo, code)}
inputClassName="h-8"
/>
</div>
{hasDimensionValues(defaultDims) && (
<p className="mt-1 text-xs text-muted-foreground">
{t('row_dimensions_inherit_hint', { dims: compactDims(defaultDims) })}
</p>
)}
</div>
)}
{/* Mobile summary row */}
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
<span className="text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
@@ -1696,6 +1819,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: null,
})
}
>
@@ -1731,6 +1855,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: null,
})
}
>
@@ -1922,6 +2047,26 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
)}
/>
</div>
{/* Invoice-level default dims (kostnadsställe/projekt) —
written to every generated journal line; per-item bags
(row ⋮ menu) merge on top. Renders only when dimensions
are enabled for the company and the doc actually books. */}
{dimensionsEnabled && isInvoiceDoc && (
<>
<Separator />
<div className="space-y-1">
<LineDimensionFields
dimensions={defaultDims}
onChange={setDefaultDimension}
inputClassName="h-9"
/>
<p className="text-xs text-muted-foreground">
{t('dimensions_default_hint')}
</p>
</div>
</>
)}
</>
)}
</CardContent>
@@ -152,6 +152,7 @@ export default function PaymentBookingDialog({
exchange_rate: invoice.exchange_rate,
vat_treatment: invoice.vat_treatment,
items: invoice.items,
default_dimensions: invoice.default_dimensions,
},
accountingMethod,
entityType,
@@ -245,6 +246,12 @@ export default function PaymentBookingDialog({
debit_amount: parseFloat(l.debit_amount) || 0,
credit_amount: parseFloat(l.credit_amount) || 0,
line_description: l.line_description || undefined,
// Dimensions PR7: the proposal re-propagates the invoice default;
// whatever the grid holds is what gets booked.
dimensions:
l.dimensions && Object.keys(l.dimensions).length > 0
? l.dimensions
: undefined,
}))
const response = await fetch(`/api/invoices/${invoice.id}/mark-paid`, {
@@ -122,6 +122,7 @@ export default function SendInvoiceDialog({
exchange_rate: invoice.exchange_rate,
vat_treatment: invoice.vat_treatment,
items: invoice.items,
default_dimensions: invoice.default_dimensions,
},
entityType,
})
@@ -26,9 +26,10 @@ import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock } from 'lucide-react'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock, Tags } from 'lucide-react'
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types'
interface LineItem {
@@ -44,6 +45,10 @@ interface LineItem {
accrual_period_start?: string
accrual_period_end?: string
accrual_balance_account?: string
// Per-item dimensions bag ({sie_dim_no: code}, dimensions PR7). Defined
// (possibly empty) while the row's dimensions panel is open; the server
// merges it over the invoice's default_dimensions at booking time.
dimensions?: Record<string, string>
}
// The existing invoice surfaced on a duplicate-number conflict, used to drive
@@ -309,6 +314,12 @@ export default function NewSupplierInvoiceForm({
// Öresavrundning is display-only; defaults to the company-wide setting and is
// overridable per invoice via the toggle in the totals section.
const [oreRounding, setOreRounding] = useState<boolean>(true)
// Dimension tagging (kostnadsställe/projekt). Affordances render only when
// company_settings.dimensions_enabled — the same UI-visibility gate as
// JournalEntryForm. defaultDims is the invoice-level default bag; per-item
// bags live on the form's items and merge over it server-side.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [defaultDims, setDefaultDims] = useState<Record<string, string>>({})
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [periodsLoaded, setPeriodsLoaded] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -661,8 +672,9 @@ export default function NewSupplierInvoiceForm({
setAccountingMethod(data.accounting_method)
}
if (typeof data?.ore_rounding === 'boolean') setOreRounding(data.ore_rounding)
setDimensionsEnabled(data?.dimensions_enabled === true)
} catch {
// Default to enskild_firma / accrual
// Default to enskild_firma / accrual, dimension affordances hidden
}
}
@@ -735,6 +747,68 @@ export default function NewSupplierInvoiceForm({
}
}
// --- Dimension tagging (kostnadsställe/projekt, dimensions PR7) ---
// Mirrors the accrual pattern: a defined (possibly empty) bag on the item
// means the row's panel is open; closing clears the bag entirely so the
// payload never carries a stale override.
function setDefaultDimension(dimNo: string, code: string | null) {
setDefaultDims((prev) => {
const next = { ...prev }
const trimmed = code?.trim()
if (trimmed) next[dimNo] = trimmed
else delete next[dimNo]
return next
})
}
function isDimOpen(index: number): boolean {
return watchedItems?.[index]?.dimensions != null
}
function toggleDimensions(index: number) {
if (isDimOpen(index)) {
setValue(`items.${index}.dimensions`, undefined, { shouldDirty: true })
} else {
setValue(`items.${index}.dimensions`, {}, { shouldDirty: true })
}
}
function updateItemDimension(index: number, dimNo: string, code: string | null) {
const current = { ...(getValues(`items.${index}.dimensions`) ?? {}) }
const trimmed = code?.trim()
if (trimmed) current[dimNo] = trimmed
else delete current[dimNo]
setValue(`items.${index}.dimensions`, current, { shouldDirty: true })
}
// Compact display of the invoice default, e.g. "KS01 · P001" (dim-number order).
const defaultDimsSummary = Object.entries(defaultDims)
.filter(([, v]) => v)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v)
.join(' · ')
function renderDimensionsPanel(index: number) {
const item = watchedItems?.[index]
if (!item || item.dimensions == null) return null
return (
<div className="rounded-lg border bg-muted/30 p-3 space-y-1">
<div className="max-w-md">
<LineDimensionFields
dimensions={item.dimensions}
onChange={(dimNo, code) => updateItemDimension(index, dimNo, code)}
inputClassName="h-8"
/>
</div>
{defaultDimsSummary && (
<p className="text-xs text-muted-foreground">
{t('row_dimensions_inherit_hint', { dims: defaultDimsSummary })}
</p>
)}
</div>
)
}
function renderAccrualPanel(index: number, idPrefix: string) {
const item = watchedItems?.[index]
if (!item || item.accrual_balance_account == null) return null
@@ -861,6 +935,9 @@ export default function NewSupplierInvoiceForm({
notes: data.notes || undefined,
paid_with_private_funds: data.paid_with_private_funds,
ore_rounding: oreRounding,
// Invoice-level default dimensions (kostnadsställe/projekt) — only sent
// when the user actually picked something.
...(Object.keys(defaultDims).length > 0 ? { default_dimensions: defaultDims } : {}),
items: data.items.map((item) => ({
description: item.description,
amount: item.amount,
@@ -879,6 +956,11 @@ export default function NewSupplierInvoiceForm({
accrual_balance_account: item.accrual_balance_account || undefined,
}
: {}),
// Per-item dimensions override — only when the bag carries values
// (an open-but-empty panel means "inherit the invoice default").
...(item.dimensions && Object.keys(item.dimensions).length > 0
? { dimensions: item.dimensions }
: {}),
})),
}
}
@@ -1437,6 +1519,22 @@ export default function NewSupplierInvoiceForm({
)}
</div>
{/* Invoice-level default dims (kostnadsställe/projekt) — applied to
every generated journal line; per-row bags in Kontering merge on
top. Renders only when dimensions are enabled for the company. */}
{dimensionsEnabled && (
<div className="space-y-1">
<div className="max-w-md">
<LineDimensionFields
dimensions={defaultDims}
onChange={setDefaultDimension}
inputClassName="h-9"
/>
</div>
<p className="text-xs text-muted-foreground">{t('dimensions_default_hint')}</p>
</div>
)}
{showNoPeriodWarning && (
<div
role="alert"
@@ -1555,7 +1653,7 @@ export default function NewSupplierInvoiceForm({
<tbody>
{fields.map((field, index) => (
<Fragment key={field.id}>
<tr className={cn('align-top', canUseAccrual && isAccrualOpen(index) ? 'border-0' : 'border-b last:border-0')}>
<tr className={cn('align-top', (canUseAccrual && isAccrualOpen(index)) || (dimensionsEnabled && isDimOpen(index)) ? 'border-0' : 'border-b last:border-0')}>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.account_number`}
@@ -1625,6 +1723,24 @@ export default function NewSupplierInvoiceForm({
</td>
<td className="py-2 pt-3">
<div className="flex items-center">
{dimensionsEnabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => toggleDimensions(index)}
aria-label={t('row_dimensions_aria', { index: index + 1 })}
aria-pressed={isDimOpen(index)}
title={t('row_dimensions_title')}
>
<Tags
className={cn(
'h-4 w-4',
isDimOpen(index) ? 'text-foreground' : 'text-muted-foreground',
)}
/>
</Button>
)}
{canUseAccrual && (
<Button
type="button"
@@ -1652,12 +1768,19 @@ export default function NewSupplierInvoiceForm({
</td>
</tr>
{canUseAccrual && isAccrualOpen(index) && (
<tr className="border-b last:border-0">
<tr className={cn(dimensionsEnabled && isDimOpen(index) ? 'border-0' : 'border-b last:border-0')}>
<td colSpan={6} className="pb-3">
{renderAccrualPanel(index, `accrual-desktop-${index}`)}
</td>
</tr>
)}
{dimensionsEnabled && isDimOpen(index) && (
<tr className="border-b last:border-0">
<td colSpan={6} className="pb-3">
{renderDimensionsPanel(index)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
@@ -1671,6 +1794,24 @@ export default function NewSupplierInvoiceForm({
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
<div className="flex items-center">
{dimensionsEnabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => toggleDimensions(index)}
aria-label={t('row_dimensions_aria', { index: index + 1 })}
aria-pressed={isDimOpen(index)}
title={t('row_dimensions_title')}
>
<Tags
className={cn(
'h-4 w-4',
isDimOpen(index) ? 'text-foreground' : 'text-muted-foreground',
)}
/>
</Button>
)}
{canUseAccrual && (
<Button
type="button"
@@ -1770,6 +1911,7 @@ export default function NewSupplierInvoiceForm({
</div>
{canUseAccrual && isAccrualOpen(index) &&
renderAccrualPanel(index, `accrual-mobile-${index}`)}
{dimensionsEnabled && isDimOpen(index) && renderDimensionsPanel(index)}
</div>
))}
</div>
+73 -3
View File
@@ -14,6 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -23,6 +24,7 @@ import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { Loader2, FileText, AlertTriangle, Check, Plus, Trash2, Paperclip } from 'lucide-react'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
import type { TransactionWithInvoice } from './transaction-types'
@@ -88,6 +90,12 @@ export default function BulkBookDialog({
const [description, setDescription] = useState('')
const [manualLines, setManualLines] = useState<ManualLine[]>([])
const [submitting, setSubmitting] = useState(false)
// Dimension tagging (kostnadsställe/projekt) — the pair renders only when
// company_settings.dimensions_enabled, same gate as JournalEntryForm. One
// header-level default bag applies to both tabs; the server tags the
// generated voucher's lines with it.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [defaultDims, setDefaultDims] = useState<Record<string, string>>({})
// Documents that will inherit onto the new verifikat. Computed from
// transactions.document_id; the RPC reads these and updates each doc's
@@ -162,6 +170,22 @@ export default function BulkBookDialog({
return () => { cancelled = true }
}, [open])
// Company settings gate the dimension affordance (dimensions_enabled).
// Fetched once per open; on failure the pair simply stays hidden.
useEffect(() => {
if (!open) return
let cancelled = false
fetch('/api/settings')
.then((r) => r.json())
.then(({ data }) => {
if (!cancelled) setDimensionsEnabled(data?.dimensions_enabled === true)
})
.catch(() => {
if (!cancelled) setDimensionsEnabled(false)
})
return () => { cancelled = true }
}, [open])
// Reset state when dialog closes so the next open starts clean.
useEffect(() => {
if (!open) {
@@ -171,6 +195,7 @@ export default function BulkBookDialog({
setDescription('')
setManualLines([])
setCashAccounts(null)
setDefaultDims({})
} else if (sharedDate) {
// Pre-fill description with a sensible default the user can edit.
setDescription(t('default_description', { date: sharedDate }))
@@ -303,6 +328,23 @@ export default function BulkBookDialog({
bankMatches &&
allAccountsValid
function setDefaultDimension(dimNo: string, code: string | null) {
setDefaultDims((prev) => {
const next = { ...prev }
const trimmed = code?.trim()
if (trimmed) next[dimNo] = trimmed
else delete next[dimNo]
return next
})
}
// Compact display, e.g. "KS01 · P001" (dim-number order) for the preview badge.
const dimsSummary = Object.entries(defaultDims)
.filter(([, v]) => v)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v)
.join(' · ')
function updateManualLine(id: string, patch: Partial<Omit<ManualLine, 'id'>>) {
setManualLines((prev) => prev.map((l) => (l.id === id ? { ...l, ...patch } : l)))
}
@@ -331,6 +373,12 @@ export default function BulkBookDialog({
// Build the payload per the active tab. Template path uses the
// existing schema branch (template_id + mode). Manual path sends
// the user-edited lines directly.
// Header-level default dimensions ride as a top-level field on both
// branches; only sent when the user actually picked something.
const defaultDimensions =
dimensionsEnabled && Object.keys(defaultDims).length > 0
? { default_dimensions: defaultDims }
: {}
const payload =
tab === 'manual'
? {
@@ -343,12 +391,14 @@ export default function BulkBookDialog({
currency: sharedCurrency,
line_description: l.line_description ?? undefined,
})),
...defaultDimensions,
}
: {
tx_ids: transactions.map((tx) => tx.id),
template_id: selectedTemplateId,
mode,
entry_description: description.trim(),
...defaultDimensions,
}
const response = await fetch('/api/transactions/bulk-book', {
method: 'POST',
@@ -625,6 +675,19 @@ export default function BulkBookDialog({
</div>
)}
{/* Header default dims (kostnadsställe/projekt) — one bag applied to
the whole verifikat, shared by both tabs. */}
{dimensionsEnabled && tabReady && (
<div className="space-y-1">
<LineDimensionFields
dimensions={defaultDims}
onChange={setDefaultDimension}
inputClassName="h-8"
/>
<p className="text-xs text-muted-foreground">{t('dimensions_hint')}</p>
</div>
)}
{/* Document inheritance hint — informs the user which receipts
follow the txs onto the combined verifikat. Zero is fine
(txs without docs don't break anything); we only render
@@ -639,9 +702,16 @@ export default function BulkBookDialog({
{/* Live preview */}
{tabReady && previewLines.length > 0 && (
<div className="space-y-2">
<Label>
{t('preview_label', { count: previewLines.length })}
</Label>
<div className="flex items-center gap-2">
<Label>
{t('preview_label', { count: previewLines.length })}
</Label>
{dimensionsEnabled && dimsSummary && (
<Badge variant="secondary" className="font-mono tabular-nums">
{dimsSummary}
</Badge>
)}
</div>
<div className="rounded-lg border bg-muted/30 overflow-hidden">
<table className="w-full text-xs tabular-nums">
<thead>
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
@@ -171,6 +172,20 @@ export default function QuickReviewDialog({
const isForeign = !!(tx.currency && tx.currency !== 'SEK')
const sekConversionMissing = isForeign && (tx.amount_sek == null || tx.exchange_rate == null)
// Dimensions carried by the counterparty template's line pattern (dimensions
// PR7). Business lines may each carry a {sie_dim_no: code} bag — merge them
// into one compact display label ("KS01 · P001", dim-number order). This is
// display-only: booking applies the pattern's bags server-side.
const patternDims: Record<string, string> = {}
for (const line of counterpartyLinePattern ?? []) {
if (line.dimensions) Object.assign(patternDims, line.dimensions)
}
const patternDimsLabel = Object.entries(patternDims)
.filter(([, code]) => code)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, code]) => code)
.join(' · ')
async function handleConfirm() {
if (!category || !transaction) return
@@ -311,6 +326,11 @@ export default function QuickReviewDialog({
<span className="text-sm font-medium text-foreground">
{template ? template.name_sv : categoryLabel}
</span>
{patternDimsLabel && (
<Badge variant="secondary" className="font-mono tabular-nums">
{patternDimsLabel}
</Badge>
)}
{onChangeTemplate && !isCounterpartyTemplate && (
<button
type="button"
@@ -49,6 +49,14 @@ function makeMock(opts: {
supplierByOrg?: Record<string, unknown> | null
supplierByName?: Record<string, unknown> | null
pendingInsert?: Record<string, unknown>
/** When set, company_settings/dimensions/dimension_values serve this registry. */
dimensions?: {
enabled: boolean
registry?: Array<Record<string, unknown>>
values?: Array<Record<string, unknown>>
}
/** When provided, every pending_operations .insert(payload) is recorded here. */
inserts?: Array<Record<string, unknown>>
}) {
const inboxResult = { data: opts.inbox ?? null, error: opts.inbox ? null : { message: 'not found' } }
const supplierByOrgResult = { data: opts.supplierByOrg ?? null, error: null }
@@ -96,6 +104,12 @@ function makeMock(opts: {
{},
{
get(_t, prop) {
if (prop === 'insert') {
return (payload: Record<string, unknown>) => {
opts.inserts?.push(payload)
return pendingChain()
}
}
if (prop === 'single') return () => Promise.resolve(insertResult)
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(insertResult)
return () => pendingChain()
@@ -103,13 +117,36 @@ function makeMock(opts: {
},
)
// Static chains for the dims registry (resolveDimensionBags reads these).
const staticChain = (result: { data: unknown; error: unknown }): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'single' || prop === 'maybeSingle') return () => Promise.resolve(result)
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
return () => staticChain(result)
},
},
)
return {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'invoice_inbox_items') return inboxChain()
if (table === 'suppliers') return supplierChain()
if (table === 'pending_operations') return pendingChain()
if (table === 'company_settings' && opts.dimensions) {
return staticChain({ data: { dimensions_enabled: opts.dimensions.enabled }, error: null })
}
if (table === 'dimensions' && opts.dimensions) {
return staticChain({ data: opts.dimensions.registry ?? [], error: null })
}
if (table === 'dimension_values' && opts.dimensions) {
return staticChain({ data: opts.dimensions.values ?? [], error: null })
}
return inboxChain()
}),
rpc: vi.fn().mockResolvedValue({ data: null, error: null }),
} as never
}
@@ -267,6 +304,99 @@ describe('gnubok_create_supplier_invoice_from_inbox — execute', () => {
expect(items[1].account_number).toBe('6420') // override wins over extracted suggestion
})
it('stages resolved default_dimensions top-level and per-line bags via line_overrides', async () => {
const extractedTwoLines = {
...baseExtracted,
lineItems: [
{ description: 'Line A', quantity: 1, unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100 },
{ description: 'Line B', quantity: 1, unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 },
],
}
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-8',
status: 'received',
extracted_data: extractedTwoLines,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-8',
},
dimensions: {
enabled: true,
registry: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
values: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
],
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{
inbox_item_id: 'inbox-8',
default_dimensions: { '6': 'villa almgren tak' },
line_overrides: [{ line_number: 2, dimensions: { '1': 'KS01' } }],
},
'company-1', 'user-1', supabase,
)) as {
staged: boolean
preview: { dimension_resolutions?: Array<Record<string, unknown>> }
}
expect(result.staged).toBe(true)
// Contract: staged params carry `default_dimensions` top-level (resolved to
// codes) and each item its OWN resolved bag — the executor merges.
expect(inserts).toHaveLength(1)
const params = inserts[0].params as {
default_dimensions?: Record<string, string>
items: Array<{ dimensions?: Record<string, string> }>
}
expect(params.default_dimensions).toEqual({ '6': 'P001' })
expect(params.items[0].dimensions).toBeUndefined()
expect(params.items[1].dimensions).toEqual({ '1': 'KS01' })
// Non-exact name resolution is echoed in the preview.
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
})
it('stages no dims keys when nothing is tagged (backward compatible)', async () => {
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-9',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-9',
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-9' },
'company-1', 'user-1', supabase,
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.dimension_resolutions).toBeUndefined()
const params = inserts[0].params as { default_dimensions?: unknown; items: Array<Record<string, unknown>> }
expect('default_dimensions' in params).toBe(false)
expect('dimensions' in params.items[0]).toBe(false)
})
it('throws when extracted_data is missing', async () => {
const supabase = makeMock({
inbox: {
@@ -9,7 +9,7 @@
* idempotency) lives in lib/pending-operations/__tests__/.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { createQueuedMockSupabase, makeTransaction } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { tools } from '../server'
import {
@@ -25,11 +25,53 @@ const listDimensions = tools.find((t) => t.name === 'gnubok_list_dimensions')!
const listDimensionValues = tools.find((t) => t.name === 'gnubok_list_dimension_values')!
const createDimensionValue = tools.find((t) => t.name === 'gnubok_create_dimension_value')!
const createVoucher = tools.find((t) => t.name === 'gnubok_create_voucher')!
const createInvoice = tools.find((t) => t.name === 'gnubok_create_invoice')!
const categorizeTransaction = tools.find((t) => t.name === 'gnubok_categorize_transaction')!
const bulkBookTransactions = tools.find((t) => t.name === 'gnubok_bulk_book_transactions')!
beforeEach(() => {
vi.clearAllMocks()
})
/**
* Wrap a queued supabase mock so every `.insert(payload)` is recorded with its
* table name — lets tests assert the exact staged pending_operations params
* (the contract the parallel-built executors consume), not just the preview.
*/
function captureInserts(
supabase: ReturnType<typeof createQueuedMockSupabase>['supabase'],
): Array<{ table: string; payload: Record<string, unknown> }> {
const inserts: Array<{ table: string; payload: Record<string, unknown> }> = []
const fromMock = supabase.from as ReturnType<typeof vi.fn>
const original = fromMock.getMockImplementation() as (table: string) => object
fromMock.mockImplementation((table: string) => {
const chain = original(table)
return new Proxy(chain, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver)
if (prop === 'insert' && typeof value === 'function') {
return (...insertArgs: unknown[]) => {
inserts.push({ table, payload: insertArgs[0] as Record<string, unknown> })
return (value as (...a: unknown[]) => unknown)(...insertArgs)
}
}
return value
},
})
})
return inserts
}
/** Registry fixture shared by the producer-tool dims tests below. */
const REGISTRY_ROWS = [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
]
const VALUE_ROWS = [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
]
function makeDim(overrides: Partial<DimensionRegistryEntry> = {}): DimensionRegistryEntry {
return {
id: 'dim-6',
@@ -578,3 +620,268 @@ describe('gnubok_create_voucher — dimensions bag', () => {
expect(supabase.rpc).not.toHaveBeenCalled()
})
})
// ── Dims bags on the PR7 producer tools ──────────────────────────────────────
describe('gnubok_create_invoice — dimensions bag', () => {
it('stages resolved default_dimensions top-level and per-item bags (default NOT merged into items)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: REGISTRY_ROWS, error: null })
enqueue({ data: VALUE_ROWS, error: null })
// customers fetch
enqueue({
data: { id: 'cust-1', name: 'Acme AB', customer_type: 'swedish_business', vat_number_validated: false, default_payment_terms: 30 },
error: null,
})
// resolvePeriodStatusForDate (auto-extracted from invoice_date): 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
// pending_operations insert
enqueue({ data: { id: 'op-inv-dims' }, error: null })
const result = (await createInvoice.execute(
{
customer_id: 'cust-1',
invoice_date: '2026-05-12',
default_dimensions: { '6': 'villa almgren tak' },
items: [
{ description: 'Takarbete', quantity: 10, unit: 'tim', unit_price: 1000, dimensions: { '1': 'KS01' } },
{ description: 'Material', quantity: 1, unit: 'st', unit_price: 500 },
],
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
items: Array<{ dimensions?: Record<string, string> }>
dimension_resolutions?: Array<Record<string, unknown>>
}
}
expect(result.staged).toBe(true)
// Contract: staged params carry `default_dimensions` top-level (resolved to
// codes) and each item its OWN resolved bag — the executor merges.
const op = inserts.find((i) => i.table === 'pending_operations')!
expect(op).toBeDefined()
const params = op.payload.params as {
default_dimensions?: Record<string, string>
items: Array<{ dimensions?: Record<string, string> }>
}
expect(params.default_dimensions).toEqual({ '6': 'P001' })
expect(params.items[0].dimensions).toEqual({ '1': 'KS01' })
expect(params.items[1].dimensions).toBeUndefined()
// (d) Non-exact name resolution is echoed in the result preview.
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
// Preview items mirror the staged (resolved) bags.
expect(result.preview.items[0].dimensions).toEqual({ '1': 'KS01' })
})
it('stages no dims keys at all when nothing is tagged (zero dim queries)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
enqueue({
data: { id: 'cust-1', name: 'Acme AB', customer_type: 'swedish_business', vat_number_validated: false, default_payment_terms: 30 },
error: null,
})
enqueue({ data: null, error: null }) // period status layer 1
enqueue({ data: null, error: null }) // period status layer 2
enqueue({ data: { id: 'op-inv-plain' }, error: null })
const result = (await createInvoice.execute(
{
customer_id: 'cust-1',
invoice_date: '2026-05-12',
items: [{ description: 'Arbete', quantity: 1, unit: 'st', unit_price: 100 }],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { dimension_resolutions?: unknown } }
expect(result.staged).toBe(true)
expect(result.preview.dimension_resolutions).toBeUndefined()
expect(supabase.rpc).not.toHaveBeenCalled()
const params = inserts.find((i) => i.table === 'pending_operations')!.payload.params as Record<string, unknown>
expect(params.default_dimensions).toBeUndefined()
expect((params.items as Array<Record<string, unknown>>)[0].dimensions).toBeUndefined()
})
})
describe('gnubok_categorize_transaction — dimensions bag', () => {
it('resolves the bag and stages it as params.dimensions with the echo in the preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
const tx = makeTransaction({ id: 'tx-1', amount: -500 })
// categorizeTransactionCore: transaction fetch + company_settings
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
// transaction fetch for the title
enqueue({ data: tx, error: null })
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: REGISTRY_ROWS, error: null })
enqueue({ data: VALUE_ROWS, error: null })
// resolvePeriodStatusForDate: 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
// pending_operations insert
enqueue({ data: { id: 'op-cat-dims' }, error: null })
const result = (await categorizeTransaction.execute(
{
transaction_id: 'tx-1',
category: 'expense_office',
dimensions: { '1': 'KS01', '6': 'villa almgren tak' },
// Skip the booking-duplicate guard so its queries don't consume the queue.
allow_duplicate: true,
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
dimensions?: Record<string, string>
dimension_resolutions?: Array<Record<string, unknown>>
}
}
expect(result.staged).toBe(true)
// Contract: staged param name is `dimensions`, resolved to registry codes.
const params = inserts.find((i) => i.table === 'pending_operations')!.payload.params as {
dimensions?: Record<string, string>
}
expect(params.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// Resolved bag + non-exact echo surface in the approval preview.
expect(result.preview.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
})
})
it('omits the dimensions key entirely when untagged (zero dim queries)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
const tx = makeTransaction({ id: 'tx-1', amount: -500 })
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: tx, error: null })
enqueue({ data: null, error: null }) // period status layer 1
enqueue({ data: null, error: null }) // period status layer 2
enqueue({ data: { id: 'op-cat-plain' }, error: null })
const result = (await categorizeTransaction.execute(
{ transaction_id: 'tx-1', category: 'expense_office', allow_duplicate: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.dimension_resolutions).toBeUndefined()
expect(supabase.rpc).not.toHaveBeenCalled()
const params = inserts.find((i) => i.table === 'pending_operations')!.payload.params as Record<string, unknown>
expect('dimensions' in params).toBe(false)
})
})
describe('gnubok_bulk_book_transactions — dimensions bag', () => {
it('merges default_dimensions into per-line bags and drops the default from staged params', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({ data: REGISTRY_ROWS, error: null })
enqueue({ data: VALUE_ROWS, error: null })
// transactions fetch
enqueue({
data: [{ id: 'tx-1', amount: -400, currency: 'SEK', date: '2026-05-12', journal_entry_id: null }],
error: null,
})
// resolvePeriodStatusForDate: 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
// pending_operations insert
enqueue({ data: { id: 'op-bulk-dims' }, error: null })
const result = (await bulkBookTransactions.execute(
{
tx_ids: ['tx-1'],
default_dimensions: { '6': 'villa almgren tak' },
new_entry: {
description: 'Samlingsverifikation material',
lines: [
{ account_number: '4010', debit_amount: 400, credit_amount: 0, currency: 'SEK', dimensions: { '1': 'KS01' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 400, currency: 'SEK' },
],
},
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: { dimension_resolutions?: Array<Record<string, unknown>> }
}
expect(result.staged).toBe(true)
// Contract: per-line `new_entry.lines[].dimensions` carries the MERGED
// (line-over-default) resolved bags; the top-level default is dropped —
// the executor's RPC reads per-line dims only.
const params = inserts.find((i) => i.table === 'pending_operations')!.payload.params as {
default_dimensions?: Record<string, string>
new_entry: { lines: Array<{ dimensions?: Record<string, string> }> }
}
expect(params.default_dimensions).toBeUndefined()
expect(params.new_entry.lines[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(params.new_entry.lines[1].dimensions).toEqual({ '6': 'P001' })
// (d) Non-exact name resolution echoed once in the preview.
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
})
it('rejects default_dimensions in link-existing mode (posted verifikat is immutable)', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
bulkBookTransactions.execute(
{
tx_ids: ['tx-1'],
existing_journal_entry_id: 'je-1',
default_dimensions: { '6': 'P001' },
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/default_dimensions only applies/i)
expect(supabase.from).not.toHaveBeenCalled()
})
})
@@ -78,9 +78,16 @@ describe('tools/list payload size guard', () => {
// group_by/group_by_dimension + totals_scope + groups on
// gnubok_query_journal. Descriptions trimmed first (~100 tokens
// recovered); the ~55-token remainder is schema structure.
// * 43K → 44K with dimensions PR7 producers: default_dimensions + per-item/
// per-line dims bags on gnubok_create_invoice, gnubok_create_supplier_
// invoice_from_inbox, gnubok_categorize_transaction and
// gnubok_bulk_book_transactions (8 new object properties). Descriptions
// already use the compact "Dims bag" form (~90 tokens trimmed first);
// the remainder is schema structure the resolve-don't-select contract
// depends on, not trimmable prose.
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(43_000)
expect(approxTokens).toBeLessThan(44_000)
})
})
+161 -12
View File
@@ -2840,7 +2840,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_categorize_transaction',
title: 'Categorize Bank Transaction',
description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. vat_amount overrides computed moms; reverse_charge is rejected when the underlag shows the seller charged VAT.',
description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. vat_amount overrides computed moms; reverse_charge is rejected when the underlag shows the seller charged VAT. Optional dimensions tag the business lines.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -2850,6 +2850,11 @@ export const tools: McpTool[] = [
vat_treatment: { type: 'string', description: 'VAT treatment override. Defaults to standard_25 for business expenses. Set reverse_charge ONLY when the underlag confirms the seller did NOT charge VAT (omvänd skattskyldighet). An invoice with foreign VAT already debited is NOT reverse charge.', enum: [...VALID_VAT_TREATMENTS] },
vat_amount: { type: 'number', exclusiveMinimum: 0, description: 'The underlag\'s exact moms (> 0) when it differs from rate × belopp — e.g. dricks carries no VAT. Requires a rate-based vat_treatment. Swedish moms only — foreign VAT is never deductible. For a 0-moms document use vat_treatment="exempt".' },
notes: { type: 'string', description: 'Audit-trail context appended to the verifikation description. For category=representation use this to record deltagare + syfte ("Anna Andersson (Acme AB), kundmöte om Y"). For project work, include the project ref. Keep under 200 chars; pure metadata, not a re-description of the transaction.' },
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"1":"KS01","6":"P001"}. Tags the expense/business lines of the generated voucher — never the bank or VAT lines. Unknown values rejected, never auto-created.',
},
allow_duplicate: { type: 'boolean', description: 'Override the duplicate-booking guard (default false). Set true ONLY after the user confirms this bank line is a genuinely separate event — the guard blocks a second verifikat for an event already booked (e.g. a paid invoice or a salary payout).' },
},
required: ['transaction_id', 'category'],
@@ -2866,6 +2871,10 @@ export const tools: McpTool[] = [
? args.vat_amount
: undefined
// MCP boundary gate — reject a malformed dims bag before the DB-heavy
// categorization preview runs. Resolution happens right before staging.
const inputDimensions = parseDimensionsArg(args.dimensions, 'dimensions')
// Compute the preview (accounts, amounts, VAT lines)
const result = await categorizeTransactionCore(
args.transaction_id as string,
@@ -2919,6 +2928,18 @@ export const tools: McpTool[] = [
? `${tx.merchant_name || tx.description || 'Transaktion'} ${tx.amount} ${tx.currency}`
: String(args.transaction_id)
// Resolve-don't-select: codes AND natural-language names resolve against
// the registry in one pass (zero queries when untagged; free-text
// passthrough while dimensions_enabled is off). The resolved bag lands on
// the expense/business lines only — the executor never tags bank/VAT lines.
const { bags: dimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
supabase,
companyId,
[inputDimensions],
)
const resolvedDimensions = dimBags[0]
const hasDimensions = Boolean(resolvedDimensions && Object.keys(resolvedDimensions).length > 0)
// Stage for user approval
return stagePendingOperation(supabase, companyId, userId, 'categorize_transaction',
`Kategorisera: ${txDesc}`,
@@ -2930,6 +2951,7 @@ export const tools: McpTool[] = [
notes: typeof args.notes === 'string' && args.notes.trim().length > 0
? (args.notes as string).trim()
: null,
...(hasDimensions ? { dimensions: resolvedDimensions } : {}),
allow_duplicate: args.allow_duplicate === true,
},
{
@@ -2940,6 +2962,10 @@ export const tools: McpTool[] = [
vat_lines: result.vat_lines || [],
category: result.category,
underlag: result.underlag ?? null,
...(hasDimensions ? { dimensions: resolvedDimensions } : {}),
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
},
actor,
{
@@ -3366,7 +3392,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_invoice',
title: 'Create Customer Invoice',
description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Stages for user approval — invoice number assigned at approval.',
description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Items accept dims bags. Stages for user approval — invoice number assigned at approval.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
@@ -3383,11 +3409,21 @@ export const tools: McpTool[] = [
unit: { type: 'string', description: 'st, tim, dag, mån' },
unit_price: { type: 'number', description: 'Price per unit excl. VAT' },
vat_rate: { type: 'number', description: 'VAT rate 0–100 (optional override)' },
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.',
},
},
required: ['description', 'quantity', 'unit', 'unit_price'],
},
description: 'Invoice line items',
},
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag keyed by SIE dim no, value = code OR name, e.g. {"1":"KS01","6":"Villa Almgren"}. Applied to every item not setting the key. Unknown values rejected — never auto-created.',
},
invoice_date: { type: 'string', description: 'YYYY-MM-DD (default today)' },
due_date: { type: 'string', description: 'YYYY-MM-DD (default from payment terms)' },
currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] },
@@ -3411,6 +3447,7 @@ export const tools: McpTool[] = [
unit: string
unit_price: number
vat_rate?: number
dimensions?: unknown
}>
if (!customerId) throw new Error('customer_id is required. Use gnubok_list_customers to find IDs.')
@@ -3423,6 +3460,25 @@ export const tools: McpTool[] = [
if (item.unit_price == null) throw new Error(`Item ${i + 1}: unit_price is required`)
}
// Resolve-don't-select: parse the invoice-level default bag + each item's
// own bag, then resolve codes AND natural-language names against the
// registry in ONE pass (zero queries when nothing is tagged; free-text
// passthrough while dimensions_enabled is off). The resolved default is
// staged top-level; each item keeps only its own resolved bag — the
// executor merges item-over-default at commit time.
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
supabase,
companyId,
[defaultDimensions, ...items.map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`))],
)
const resolvedDefaultDimensions = resolvedDimBags[0]
const stagedItems = items.map((item, i) => {
const { dimensions: _rawDimensions, ...rest } = item
const bag = resolvedDimBags[i + 1]
return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest
})
const today = new Date().toISOString().split('T')[0]
const currency = ((args.currency as string) || 'SEK') as Currency
const invoiceDate = (args.invoice_date as string) || today
@@ -3473,7 +3529,10 @@ export const tools: McpTool[] = [
`Ny faktura: ${customer.name} ${Math.round(total * 100) / 100} ${currency}`,
{
customer_id: customerId,
items,
items: stagedItems,
...(resolvedDefaultDimensions && Object.keys(resolvedDefaultDimensions).length > 0
? { default_dimensions: resolvedDefaultDimensions }
: {}),
invoice_date: invoiceDate,
due_date: dueDate,
currency,
@@ -3484,7 +3543,7 @@ export const tools: McpTool[] = [
{
customer_name: customer.name,
customer_type: customer.customer_type,
items: items.map(item => ({
items: stagedItems.map(item => ({
...item,
line_total: item.quantity * item.unit_price,
vat_rate: item.vat_rate ?? vatRules.rate,
@@ -3496,6 +3555,9 @@ export const tools: McpTool[] = [
vat_treatment: vatRules.treatment,
invoice_date: invoiceDate,
due_date: dueDate,
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
},
actor,
{
@@ -6213,7 +6275,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_bulk_book_transactions',
title: 'Bulk-Book Transactions',
description: 'Bulk-book N bank txs on the same date into 1 samlingsverifikat (BFL 5 kap 6§). Either link N txs to an existing posted verifikat, or create a new verifikat from caller lines. All txs share date + direction. Stages.',
description: 'Bulk-book N bank txs on the same date into 1 samlingsverifikat (BFL 5 kap 6§). Either link N txs to an existing posted verifikat, or create a new verifikat from caller lines (accept dims bags). All txs share date + direction. Stages.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -6238,6 +6300,11 @@ export const tools: McpTool[] = [
credit_amount: { type: 'number', minimum: 0 },
currency: { type: 'string', minLength: 3, maxLength: 3 },
line_description: { type: 'string', maxLength: 200 },
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.',
},
},
required: ['account_number', 'debit_amount', 'credit_amount', 'currency'],
},
@@ -6245,6 +6312,11 @@ export const tools: McpTool[] = [
},
required: ['description', 'lines'],
},
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag keyed by SIE dim no, value = code OR name. Applied to every new_entry line not setting the key. Only valid with new_entry — a posted verifikat is immutable.',
},
},
required: ['tx_ids'],
},
@@ -6300,6 +6372,43 @@ export const tools: McpTool[] = [
}
}
// Resolve-don't-select: merge the batch-level default_dimensions under
// each caller line's own bag, then resolve codes AND natural-language
// names against the registry in ONE pass (zero queries when nothing is
// tagged; free-text passthrough while dimensions_enabled is off). The
// resolved bags are written back onto the staged new_entry lines — the
// executor's RPC reads per-line dims only, so the merged default is
// never staged top-level.
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
let dimensionResolutions: DimensionResolution[] = []
let stagedNewEntry = newEntry
if (newEntry) {
const rawLines = newEntry.lines as Array<Record<string, unknown>>
const { bags, resolutions } = await resolveDimensionBags(
supabase,
companyId,
rawLines.map((l, i) =>
mergeLineDimensions(
{ dimensions: parseDimensionsArg(l.dimensions, `new_entry.lines[${i}].dimensions`) },
defaultDimensions,
),
),
)
dimensionResolutions = resolutions
stagedNewEntry = {
...newEntry,
lines: rawLines.map((l, i) => {
const { dimensions: _rawDimensions, ...rest } = l
const bag = bags[i]
return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest
}),
}
} else if (defaultDimensions) {
throw new Error(
'default_dimensions only applies when creating a new verifikat (new_entry) — a posted verifikat is immutable, so link-existing mode cannot be tagged.'
)
}
const { data: txs, error: txError } = await supabase
.from('transactions')
.select('id, amount, currency, date, journal_entry_id')
@@ -6372,7 +6481,7 @@ export const tools: McpTool[] = [
{
tx_ids: txIds,
existing_journal_entry_id: existingJeId,
new_entry: newEntry,
new_entry: stagedNewEntry,
},
// GDPR Art.25: preview_data carries only aggregate counts + the
// shared date/direction — no per-tx descriptions, no per-line
@@ -6386,6 +6495,9 @@ export const tools: McpTool[] = [
tx_sum: txSum,
direction,
mode: existingJeId ? 'link_existing' : 'create_new',
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
},
actor,
{
@@ -7371,7 +7483,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_supplier_invoice_from_inbox',
title: 'Create Supplier Invoice from Inbox',
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX, attaches the document. Stages for human review; honors dry_run.",
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX + dimension tags, attaches the document. Stages for human review; honors dry_run.",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -7382,17 +7494,27 @@ export const tools: McpTool[] = [
due_date_override: { type: 'string', description: 'Override extracted due date (YYYY-MM-DD)' },
line_overrides: {
type: 'array',
description: 'Per-line account overrides (1-based line_number). Wins over accountSuggestion and supplier default.',
description: 'Per-line overrides (1-based line_number): account_number wins over accountSuggestion and supplier default; dimensions tags that line.',
items: {
type: 'object',
additionalProperties: false,
properties: {
line_number: { type: 'number', description: '1-based index matching items_preview' },
account_number: { type: 'string', description: 'BAS account number for this line (e.g. "6420")' },
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}, for this line. Wins per key over default_dimensions.',
},
},
required: ['line_number', 'account_number'],
required: ['line_number'],
},
},
default_dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Dims bag keyed by SIE dim no, value = code OR name, e.g. {"1":"KS01","6":"Villa Almgren"}. Applied to every line not setting the key. Unknown values rejected — never auto-created.',
},
notes: { type: 'string', description: 'Optional notes appended to the supplier invoice' },
dry_run: { type: 'boolean', description: 'If true, return the assembled payload without staging (default false)' },
idempotency_key: { type: 'string', description: 'UUID. Repeat calls with same key + payload return cached response.' },
@@ -7512,14 +7634,34 @@ export const tools: McpTool[] = [
}
}
// Build a lookup for per-line account overrides keyed by 1-based line number.
const rawLineOverrides = (args.line_overrides as Array<{ line_number: number; account_number: string }> | undefined) ?? []
const lineOverrideMap = new Map(rawLineOverrides.map((o) => [o.line_number, o.account_number]))
// Build lookups for per-line overrides keyed by 1-based line number.
const rawLineOverrides = (args.line_overrides as Array<{ line_number: number; account_number?: string; dimensions?: unknown }> | undefined) ?? []
const lineOverrideMap = new Map(
rawLineOverrides.filter((o) => o.account_number).map((o) => [o.line_number, o.account_number as string]),
)
const lineDimensionsMap = new Map(
rawLineOverrides.map((o, i) => [o.line_number, parseDimensionsArg(o.dimensions, `line_overrides[${i}].dimensions`)]),
)
// Resolve-don't-select: parse the invoice-level default bag + each line's
// own bag, then resolve codes AND natural-language names against the
// registry in ONE pass (zero queries when nothing is tagged; free-text
// passthrough while dimensions_enabled is off). The resolved default is
// staged top-level; each item keeps only its own resolved bag — the
// executor merges item-over-default at commit time.
const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions')
const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags(
supabase,
companyId,
[defaultDimensions, ...lineItemsExt.map((_li, idx) => lineDimensionsMap.get(idx + 1))],
)
const resolvedDefaultDimensions = resolvedDimBags[0]
// Translate extracted line items into the supplier_invoice_items shape.
// Priority: line_overrides → per-line accountSuggestion → supplier.default_expense_account → 4000.
const lineItems = lineItemsExt.map((li, idx) => {
const lineNumber = idx + 1
const dimensions = resolvedDimBags[idx + 1]
return {
line_number: lineNumber,
description: (li.description as string) ?? `Position ${lineNumber}`,
@@ -7530,6 +7672,7 @@ export const tools: McpTool[] = [
account_number: lineOverrideMap.get(lineNumber) ?? (li.accountSuggestion as string | null) ?? supplierDefaultExpenseAccount ?? '4000',
vat_rate: Number(li.vat_rate ?? li.vatRate) || 0,
vat_amount: Number(li.vat_amount ?? li.vatAmount) || 0,
...(dimensions && Object.keys(dimensions).length > 0 ? { dimensions } : {}),
}
})
@@ -7548,6 +7691,9 @@ export const tools: McpTool[] = [
total: Math.round(total * 100) / 100,
notes: (args.notes as string | undefined) ?? null,
items: lineItems,
...(resolvedDefaultDimensions && Object.keys(resolvedDefaultDimensions).length > 0
? { default_dimensions: resolvedDefaultDimensions }
: {}),
}
const previewData = {
@@ -7568,6 +7714,9 @@ export const tools: McpTool[] = [
total: params.total,
line_count: lineItems.length,
items_preview: lineItems.slice(0, 5),
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
will: 'register supplier invoice (status=registered), attach the inbox document, post a registration journal entry on confirm — leverantörsskuld (2440) credited and the cost/VAT split debited per the per-line VAT rules',
}
+24
View File
@@ -291,6 +291,9 @@ export const CreateInvoiceItemSchema = z
accrual_period_start: isoDate.nullable().optional(),
accrual_period_end: isoDate.nullable().optional(),
accrual_balance_account: deferredRevenueAccount.nullable().optional(),
// Dimensions PR7: per-item bag merged over the invoice's
// default_dimensions on the revenue line this item books to.
dimensions: DimensionsBagSchema.optional(),
})
.superRefine((item, ctx) => {
validateAccrualPeriod(item, ctx)
@@ -359,6 +362,9 @@ export const CreateInvoiceSchema = z.object({
// Per-invoice öresavrundning toggle (display-only). Omitted → stored as null,
// which inherits company_settings.ore_rounding when rendering totals.
ore_rounding: z.boolean().optional(),
// Dimensions PR7: invoice-level bag applied to every generated journal line;
// items[].dimensions merge over it per revenue line.
default_dimensions: DimensionsBagSchema.optional(),
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
})
@@ -491,6 +497,9 @@ export const MarkInvoicePaidSchema = z.object({
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
// Dimensions PR7: user-edited payment lines keep their tags (the
// no-override path re-propagates the invoice's default_dimensions).
dimensions: DimensionsBagSchema.optional(),
})).min(2).optional(),
// Bypass the duplicate-payment guard. Set after the user reviews the
// candidate list returned by INVOICE_PAID_LIKELY_DUPLICATE and confirms
@@ -591,6 +600,9 @@ export const CreateSupplierInvoiceItemSchema = z.object({
accrual_period_start: isoDate.nullable().optional(),
accrual_period_end: isoDate.nullable().optional(),
accrual_balance_account: prepaidExpenseAccount.nullable().optional(),
// Dimensions PR7: per-item bag merged over the invoice's
// default_dimensions on the expense line this item books to.
dimensions: DimensionsBagSchema.optional(),
}).refine(
(item) => {
if (item.vat_amount == null) return true
@@ -641,6 +653,9 @@ export const CreateSupplierInvoiceSchema = z.object({
// For paid_with_private_funds: the date the owner paid out-of-pocket.
// Defaults to invoice_date (common for kvitto where the two coincide).
payment_date: isoDate.optional(),
// Dimensions PR7: invoice-level bag applied to every generated journal line;
// items[].dimensions merge over it per expense line.
default_dimensions: DimensionsBagSchema.optional(),
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
})
@@ -662,6 +677,9 @@ export const MarkSupplierInvoicePaidSchema = z.object({
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
// Dimensions PR7: user-edited payment lines keep their tags (the
// no-override path re-propagates the invoice's default_dimensions).
dimensions: DimensionsBagSchema.optional(),
})).min(2).optional(),
})
@@ -1015,11 +1033,17 @@ export const BulkBookSchema = z
credit_amount: nonNegativeAmount.max(99_999_999, 'Line amount exceeds maximum'),
currency: z.string().min(3).max(3).default('SEK'),
line_description: z.string().max(200).optional(),
// Dimensions PR7: per-line bag, wins over default_dimensions.
dimensions: DimensionsBagSchema.optional(),
})
)
.min(2, 'A verifikat needs at least two lines')
.max(200)
.optional(),
// Dimensions PR7: header-level bag applied to every generated line in
// BOTH the template and manual paths (per-line bags win per key). The
// route merges before calling the RPC.
default_dimensions: DimensionsBagSchema.optional(),
})
.superRefine((data, ctx) => {
const hasExisting = !!data.existing_journal_entry_id
@@ -17,6 +17,8 @@ import {
populateTemplatesFromSieVouchers,
} from '../counterparty-templates'
import type { TemplateUpsertParams } from '../counterparty-templates'
import type { LinePatternEntry } from '@/types'
import type { SIETransactionLine } from '@/lib/import/types'
describe('counterparty-templates', () => {
// ── Normalization ──────────────────────────────────────────
@@ -747,3 +749,150 @@ describe('counterparty-templates', () => {
})
})
})
// ── dimensions propagation (PR7) ─────────────────────────────
describe('dimensions propagation (PR7)', () => {
describe('buildMappingResultFromCounterpartyTemplate — multi-line pattern', () => {
it('materialized business lines carry the pattern bag; VAT and tax lines stay untagged', () => {
const template = makeCategorizationTemplate({
debit_account: '5410',
credit_account: '1930',
line_pattern: [
{ account: '2641', type: 'vat', side: 'debit', vat_rate: 0.25 },
{ account: '5410', type: 'business', side: 'debit', ratio: 0.8, dimensions: { '1': 'KS01' } },
// Even a (bogus) bag on a tax entry must not materialize — only
// business lines are tagged.
{ account: '2710', type: 'tax', side: 'debit', ratio: 0.2, dimensions: { '1': 'KS01' } },
],
})
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
const tx = makeTransaction({ amount: -1250 })
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
expect(result.all_lines_complete).toBe(true)
const business = result.vat_lines.find((l) => l.account_number === '5410')
expect(business?.dimensions).toEqual({ '1': 'KS01' })
expect(result.vat_lines.find((l) => l.account_number === '2641')?.dimensions).toBeUndefined()
expect(result.vat_lines.find((l) => l.account_number === '2710')?.dimensions).toBeUndefined()
})
it('a pattern business line without a bag materializes untagged', () => {
const template = makeCategorizationTemplate({
debit_account: '5410',
credit_account: '1930',
line_pattern: [
{ account: '5410', type: 'business', side: 'debit', ratio: 1 },
],
})
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
expect(result.vat_lines.find((l) => l.account_number === '5410')?.dimensions).toBeUndefined()
})
})
describe('populateTemplatesFromSieVouchers — line-pattern dimension learning', () => {
/**
* Queue-based supabase mock that also records `.insert()` payloads per
* table, so assertions can inspect the stored line_pattern. Follows the
* capture pattern in lib/pending-operations/__tests__/create-invoice-executor.test.ts.
*/
function createCapturingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
const queue = [...results]
const inserts: Record<string, unknown[]> = {}
const from = vi.fn((_table: string) => {
const raw = queue.shift() ?? { data: null, error: null }
const result = { data: raw.data ?? null, error: raw.error ?? null }
const chain: object = new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
if (prop === 'insert') {
return (payload: unknown) => {
;(inserts[_table] ??= []).push(payload)
return chain
}
}
return () => chain
},
},
)
return chain
})
return { supabase: { from }, inserts }
}
function capturedLinePattern(inserts: Record<string, unknown[]>): LinePatternEntry[] {
const rows = inserts['categorization_templates']
expect(rows).toHaveLength(1)
const row = rows[0] as { line_pattern: LinePatternEntry[] | null }
expect(row.line_pattern).not.toBeNull()
return row.line_pattern!
}
it('preserves a business line bag only when EVERY voucher occurrence has the identical bag', async () => {
// Two business accounts → not "simple", so the pattern is stored as
// line_pattern JSONB. All three vouchers tag both accounts identically.
const vouchers = Array.from({ length: 3 }, (_, i) =>
makeSIEVoucher({
description: 'Projektbygget AB',
number: i + 1,
lines: [
{ account: '1930', amount: -1100 },
{ account: '6212', amount: 600, dimensions: { '1': 'KS01' } },
{ account: '5410', amount: 500, dimensions: { '6': 'P001' } },
],
})
)
const { supabase, inserts } = createCapturingSupabase([
{ data: [] }, // pre-fetch existing templates
{ data: null }, // insert
])
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
expect(count).toBe(1)
const pattern = capturedLinePattern(inserts)
expect(pattern.find((e) => e.account === '6212')?.dimensions).toEqual({ '1': 'KS01' })
expect(pattern.find((e) => e.account === '5410')?.dimensions).toEqual({ '6': 'P001' })
})
it('drops the bag when a voucher disagrees — or leaves the line untagged', async () => {
// 6212 conflicts across vouchers (KS01 vs KS02); 5410 is untagged in one
// voucher. Both must lose the bag: a template must never invent a tag
// history does not consistently support.
const linesFor = (v: number): SIETransactionLine[] => [
{ account: '1930', amount: -1100 },
{ account: '6212', amount: 600, dimensions: v === 2 ? { '1': 'KS02' } : { '1': 'KS01' } },
v === 2
? { account: '5410', amount: 500 }
: { account: '5410', amount: 500, dimensions: { '6': 'P001' } },
]
const vouchers = [1, 2, 3].map((v) =>
makeSIEVoucher({ description: 'Projektbygget AB', number: v, lines: linesFor(v) })
)
const { supabase, inserts } = createCapturingSupabase([
{ data: [] }, // pre-fetch existing templates
{ data: null }, // insert
])
const count = await populateTemplatesFromSieVouchers(supabase as never, 'company-1', vouchers)
expect(count).toBe(1)
const pattern = capturedLinePattern(inserts)
expect(pattern.find((e) => e.account === '6212')?.dimensions).toBeUndefined()
expect(pattern.find((e) => e.account === '5410')?.dimensions).toBeUndefined()
})
})
})
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getRevenueAccount, getOutputVatAccount } from '../invoice-entries'
import { roundOre } from '@/lib/money'
import type { Invoice, InvoiceItem, CreateJournalEntryInput } from '@/types'
// Mock the engine so we can capture the input passed to createJournalEntry
@@ -974,6 +975,318 @@ describe('createInvoiceJournalEntry — ROT/RUT-avdrag', () => {
})
})
describe('dimensions propagation (PR7) — createInvoiceJournalEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('1510 + VAT lines carry the invoice default; revenue lines carry the merged item-over-default bag', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ description: 'A', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150, dimensions: { '6': 'P001' } }),
makeItem({ id: 'item-2', description: 'B', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100, dimensions: { '1': 'KS02' } }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.dimensions).toEqual({ '1': 'KS01' })
const vat2611 = input.lines.filter((l) => l.account_number === '2611')
expect(vat2611).toHaveLength(1)
expect(vat2611[0].dimensions).toEqual({ '1': 'KS01' })
// Same vat_rate + account, DIFFERENT bags → separate revenue lines
// (aggregation identity = account + bag).
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(2)
expect(rev3001[0].credit_amount).toBe(600)
expect(rev3001[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(rev3001[1].credit_amount).toBe(400)
// The item bag wins per key over the invoice default.
expect(rev3001[1].dimensions).toEqual({ '1': 'KS02' })
})
it('items with the identical merged bag still aggregate into one revenue line', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ description: 'A', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150, dimensions: { '6': 'P001' } }),
makeItem({ id: 'item-2', description: 'B', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100, dimensions: { '6': 'P001' } }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(1)
expect(rev3001[0].credit_amount).toBe(1000)
expect(rev3001[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('per-rate rounding remainder is absorbed by the last dimension bucket (balanced against 1510)', async () => {
// Same account, same 25% rate — split only by the dimensions bag. The
// rate-level total (20.01) is the balance anchor; independent per-bucket
// rounding would give 2 × 10.01 = 20.02, so the last bucket must absorb.
const invoice = makeInvoice({
subtotal: 20.01,
vat_amount: 5.0,
total: 25.01,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'A', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5 }),
makeItem({ id: 'item-2', description: 'B', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5, dimensions: { '6': 'P001' } }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(2)
expect(rev3001[0].dimensions).toBeUndefined()
expect(rev3001[1].dimensions).toEqual({ '6': 'P001' })
const revSum = rev3001.reduce((s, l) => s + l.credit_amount, 0)
expect(roundOre(revSum)).toBe(20.01)
const debit = roundOre(input.lines.reduce((s, l) => s + l.debit_amount, 0))
const credit = roundOre(input.lines.reduce((s, l) => s + l.credit_amount, 0))
expect(debit).toBe(credit)
expect(debit).toBe(25.01)
})
it('ROT 1513 line carries the item merged bag', async () => {
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({
quantity: 1,
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
dimensions: { '6': 'P001' },
}),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1513 = input.lines.find((l) => l.account_number === '1513')
expect(debit1513?.debit_amount).toBe(3000)
expect(debit1513?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// 1510 still carries the default only.
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.dimensions).toEqual({ '1': 'KS01' })
})
it('fallback path (no items) carries the invoice default on revenue + VAT lines', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01', '6': 'P001' },
items: [],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.find((l) => l.account_number === '3001')
expect(rev3001?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const vat2611 = input.lines.find((l) => l.account_number === '2611')
expect(vat2611?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('no default and no item bags → line.dimensions stays undefined', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
items: [
makeItem({ unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const debit1510 = input.lines.find((l) => l.account_number === '1510')
// toEqual ignores undefined-valued keys — the line shape is unchanged.
expect(debit1510).toEqual({
account_number: '1510',
debit_amount: 1250,
credit_amount: 0,
line_description: 'Faktura 1001',
})
expect(debit1510?.dimensions).toBeUndefined()
expect(input.lines.find((l) => l.account_number === '3001')?.dimensions).toBeUndefined()
expect(input.lines.find((l) => l.account_number === '2611')?.dimensions).toBeUndefined()
})
})
describe('dimensions propagation (PR7) — createInvoicePaymentJournalEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('SEK payment: both 1930 and 1510 carry the invoice default', async () => {
const invoice = makeInvoice({ total: 1250, default_dimensions: { '1': 'KS01', '6': 'P001' } })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
for (const line of input.lines) {
expect(line.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
}
})
it('FX payment: the 3960 kursvinst line carries the default too', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
default_dimensions: { '6': 'P001' },
})
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', 200)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
for (const account of ['1930', '1510', '3960']) {
const line = input.lines.find((l) => l.account_number === account)
expect(line?.dimensions).toEqual({ '6': 'P001' })
}
})
it('FX loss payment: the 7960 kursförlust line carries the default too', async () => {
const invoice = makeInvoice({
currency: 'EUR',
exchange_rate: 11.5,
total: 1000,
total_sek: 11500,
default_dimensions: { '6': 'P001' },
})
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15', -300)
const input = mockedCreateEntry.mock.calls[0][3]
const loss7960 = input.lines.find((l) => l.account_number === '7960')
expect(loss7960?.dimensions).toEqual({ '6': 'P001' })
})
it('no default bag → payment lines carry no dimensions', async () => {
const invoice = makeInvoice({ total: 1250 })
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.dimensions).toBeUndefined()
}
})
})
describe('dimensions propagation (PR7) — createInvoiceCashEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('1930 carries the default; revenue carries the merged item bag; VAT carries the default', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250, dimensions: { '6': 'P001' } }),
],
})
await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
const input = mockedCreateEntry.mock.calls[0][3]
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.dimensions).toEqual({ '1': 'KS01' })
const rev3001 = input.lines.find((l) => l.account_number === '3001')
expect(rev3001?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const vat2611 = input.lines.find((l) => l.account_number === '2611')
expect(vat2611?.dimensions).toEqual({ '1': 'KS01' })
})
})
describe('dimensions propagation (PR7) — createCreditNoteJournalEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('swapped lines keep the item bags; 1510 carries the credit note default', async () => {
const creditNote = makeInvoice({
invoice_number: 'KR-1001',
subtotal: -1000,
vat_amount: -198,
total: -1198,
vat_treatment: 'standard_25',
default_dimensions: { '1': 'KS01' },
items: [
makeItem({ quantity: -1, unit_price: 600, line_total: -600, vat_rate: 25, vat_amount: -150, dimensions: { '6': 'P001' } }),
makeItem({ id: 'item-2', quantity: -1, unit_price: 400, line_total: -400, vat_rate: 12, vat_amount: -48 }),
],
})
await createCreditNoteJournalEntry(null as never, 'company-1', 'user-1', creditNote)
const input = mockedCreateEntry.mock.calls[0][3]
// The reversed (debit) revenue lines keep the merged item bags.
const debit3001 = input.lines.find((l) => l.account_number === '3001')
expect(debit3001?.debit_amount).toBe(600)
expect(debit3001?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
const debit3002 = input.lines.find((l) => l.account_number === '3002')
expect(debit3002?.dimensions).toEqual({ '1': 'KS01' })
// VAT lines carry the default only.
expect(input.lines.find((l) => l.account_number === '2611')?.dimensions).toEqual({ '1': 'KS01' })
expect(input.lines.find((l) => l.account_number === '2621')?.dimensions).toEqual({ '1': 'KS01' })
// 1510 carries the credit note's own default bag.
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(1198)
expect(credit1510?.dimensions).toEqual({ '1': 'KS01' })
})
})
describe('createInvoiceCashEntry — ROT/RUT-avdrag', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -210,3 +210,81 @@ describe('proposePaymentLines', () => {
})
})
})
describe('proposePaymentLines — dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' }
it('accrual: every proposed line carries a copy of the invoice default bag', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput(), default_dimensions: bag },
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(2)
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
// A copy, not the shared reference — editing one line must not mutate
// the invoice bag or a sibling line.
expect(line.dimensions).not.toBe(bag)
}
expect(lines[0].dimensions).not.toBe(lines[1].dimensions)
})
it('accrual with FX difference: the 3960 line carries the bag too', () => {
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({
total: 1000,
total_sek: 10000,
currency: 'EUR',
exchange_rate: 10,
}),
default_dimensions: bag,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
exchangeRateDifference: 500,
})
expect(lines).toHaveLength(3)
expect(lines[2].account_number).toBe('3960')
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
}
})
it('cash: payment, revenue and VAT lines all carry the bag', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput(), default_dimensions: bag },
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(3)
expect(lines.map((l) => l.account_number)).toEqual(['1930', '3001', '2611'])
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
}
})
it('absent or empty bag → no dimensions key on any line', () => {
const withoutBag = proposePaymentLines({
invoice: makeInvoiceInput(),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
for (const line of withoutBag) {
expect('dimensions' in line).toBe(false)
}
const withEmptyBag = proposePaymentLines({
invoice: { ...makeInvoiceInput(), default_dimensions: {} },
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
for (const line of withEmptyBag) {
expect('dimensions' in line).toBe(false)
}
})
})
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest'
import { proposeSendLines } from '../propose-send-lines'
import type { InvoiceItem, VatTreatment } from '@/types'
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: 'item-1',
invoice_id: 'inv-1',
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
sort_order: 0,
created_at: '2025-01-01',
...overrides,
}
}
function makeInvoiceInput(overrides: Partial<{
invoice_number: string
total: number
total_sek: number | null
subtotal: number
subtotal_sek: number | null
vat_amount: number
vat_amount_sek: number | null
currency: string
exchange_rate: number | null
vat_treatment: VatTreatment
items: InvoiceItem[]
default_dimensions: Record<string, string> | null
}> = {}) {
return {
invoice_number: '2025-001',
total: 12500,
total_sek: null,
subtotal: 10000,
subtotal_sek: null,
vat_amount: 2500,
vat_amount_sek: null,
currency: 'SEK',
exchange_rate: null,
vat_treatment: 'standard_25' as VatTreatment,
items: [makeItem()],
...overrides,
}
}
describe('proposeSendLines', () => {
it('single VAT rate → debit 1510, credit 3001, credit 2611', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput(),
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(3)
expect(lines[0]).toEqual({
account_number: '1510',
debit_amount: '12500',
credit_amount: '',
line_description: 'Försäljning faktura 2025-001',
})
expect(lines[1]).toEqual({
account_number: '3001',
debit_amount: '',
credit_amount: '10000',
line_description: 'Försäljning faktura 2025-001',
})
expect(lines[2]).toEqual({
account_number: '2611',
debit_amount: '',
credit_amount: '2500',
line_description: 'Utgående moms 25%',
})
})
describe('dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' }
it('every proposed line carries a copy of the invoice default bag', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({ default_dimensions: bag }),
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(3)
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
// A copy, not the shared reference — editing one line must not mutate
// the invoice bag or a sibling line.
expect(line.dimensions).not.toBe(bag)
}
expect(lines[0].dimensions).not.toBe(lines[1].dimensions)
})
it('mixed rates: 1510 + both revenue and both VAT lines carry the bag', () => {
const items = [
makeItem({ id: 'i1', vat_rate: 25, line_total: 8000, vat_amount: 2000, unit_price: 8000 }),
makeItem({ id: 'i2', vat_rate: 12, line_total: 2000, vat_amount: 240, unit_price: 2000 }),
]
const lines = proposeSendLines({
invoice: makeInvoiceInput({
total: 12240,
subtotal: 10000,
vat_amount: 2240,
items,
default_dimensions: bag,
}),
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(5)
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
}
})
it('absent or empty bag → no dimensions key on any line', () => {
const withoutBag = proposeSendLines({
invoice: makeInvoiceInput(),
entityType: 'enskild_firma',
})
for (const line of withoutBag) {
expect('dimensions' in line).toBe(false)
}
const withEmptyBag = proposeSendLines({
invoice: makeInvoiceInput({ default_dimensions: {} }),
entityType: 'enskild_firma',
})
for (const line of withEmptyBag) {
expect('dimensions' in line).toBe(false)
}
})
})
})
@@ -1713,3 +1713,281 @@ describe('createSupplierInvoicePrivatelyPaidEntry', () => {
expect(debit6110[0].debit_amount).toBe(600)
})
})
// ============================================================
// dimensions propagation (PR7)
// ============================================================
describe('dimensions propagation (PR7) — createSupplierInvoiceRegistrationEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense lines carry merged item-over-default bags; 2641 and 2440 carry the default', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000,
vat_amount: 2000,
total: 10000,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ id: 'si-item-1', line_total: 5000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
makeItem({ id: 'si-item-2', line_total: 3000, account_number: '6200', vat_rate: 0.25, dimensions: { '1': 'KS02' } }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
// Same account, DIFFERENT bags → two separate expense lines
// (aggregation identity = account + bag).
const debit6200 = findByAccount(input.lines, '6200')
expect(debit6200).toHaveLength(2)
expect(debit6200[0].debit_amount).toBe(5000)
expect(debit6200[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(debit6200[1].debit_amount).toBe(3000)
// The item bag wins per key over the invoice default.
expect(debit6200[1].dimensions).toEqual({ '1': 'KS02' })
// VAT and AP legs carry the default only.
expect(findByAccount(input.lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2440')[0].dimensions).toEqual({ '1': 'KS01' })
assertBalanced(input)
})
it('items with the identical merged bag still aggregate into one expense line', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000,
vat_amount: 2000,
total: 10000,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ id: 'si-item-1', line_total: 5000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
makeItem({ id: 'si-item-2', line_total: 3000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
const debit6200 = findByAccount(input.lines, '6200')
expect(debit6200).toHaveLength(1)
expect(debit6200[0].debit_amount).toBe(8000)
expect(debit6200[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('reverse charge: fiktiv moms + basbelopp lines carry the default; expense keeps the merged bag', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000,
vat_amount: 0,
total: 10000,
reverse_charge: true,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ line_total: 10000, account_number: '6540', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'eu_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6540')[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// RC fiktiv moms pair + basis pair carry the default only.
expect(findByAccount(input.lines, '2645')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2614')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '4535')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '4598')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2440')[0].dimensions).toEqual({ '1': 'KS01' })
assertBalanced(input)
})
it('no default and no item bags → line.dimensions stays undefined', async () => {
const invoice = makeSupplierInvoice({ subtotal: 8000, vat_amount: 2000, total: 10000 })
const items = [makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25 })]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
const debit6200 = findByAccount(input.lines, '6200')[0]
// toEqual ignores undefined-valued keys — the line shape is unchanged.
expect(debit6200).toEqual({
account_number: '6200',
debit_amount: 8000,
credit_amount: 0,
line_description: 'Leverantörsfaktura LF-001 (ankomstnr 1)',
})
expect(debit6200.dimensions).toBeUndefined()
expect(findByAccount(input.lines, '2641')[0].dimensions).toBeUndefined()
expect(findByAccount(input.lines, '2440')[0].dimensions).toBeUndefined()
})
})
describe('dimensions propagation (PR7) — createSupplierInvoicePaymentEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('standard payment: both 2440 and payment-account lines carry the default', async () => {
const invoice = makeSupplierInvoice({ default_dimensions: { '1': 'KS01', '6': 'P001' } })
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(2)
for (const line of input.lines) {
expect(line.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
}
})
it('FX payment: the 3960/7960 result lines carry the default too', async () => {
const invoice = makeSupplierInvoice({ default_dimensions: { '6': 'P001' } })
// Gain: paid less SEK than booked → credit 3960.
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01', 200
)
let input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '3960')[0].dimensions).toEqual({ '6': 'P001' })
expect(findByAccount(input.lines, '2440')[0].dimensions).toEqual({ '6': 'P001' })
expect(findByAccount(input.lines, '1930')[0].dimensions).toEqual({ '6': 'P001' })
// Loss: debit 7960.
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01', -300
)
input = mockedCreateEntry.mock.calls[1][3]
expect(findByAccount(input.lines, '7960')[0].dimensions).toEqual({ '6': 'P001' })
})
it('no default bag → payment lines carry no dimensions', async () => {
const invoice = makeSupplierInvoice()
await createSupplierInvoicePaymentEntry(
null as never, 'company-1', 'user-1', invoice, 10000, '2024-07-01'
)
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.dimensions).toBeUndefined()
}
})
})
describe('dimensions propagation (PR7) — createSupplierInvoiceCashEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense lines carry merged bags; 2641 and payment account carry the default', async () => {
const invoice = makeSupplierInvoice({
subtotal: 8000,
vat_amount: 2000,
total: 10000,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6200')[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(findByAccount(input.lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '1930')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
assertBalanced(input)
})
})
describe('dimensions propagation (PR7) — createSupplierInvoicePrivatelyPaidEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense lines carry merged bags; 2641 and the owner account carry the default', async () => {
const invoice = makeSupplierInvoice({
subtotal: 400,
vat_amount: 100,
total: 500,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '6110')[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(findByAccount(input.lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2893')[0].dimensions).toEqual({ '1': 'KS01' })
assertBalanced(input)
})
})
describe('dimensions propagation (PR7) — createSupplierCreditNoteEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense credits keep the item bags; 2440 and 2641 carry the credit note default', async () => {
const creditNote = makeSupplierInvoice({
is_credit_note: true,
subtotal: -8000,
vat_amount: -2000,
total: -10000,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ id: 'si-item-1', line_total: -5000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
makeItem({ id: 'si-item-2', line_total: -3000, account_number: '6200', vat_rate: 0.25, dimensions: { '6': 'P002' } }),
]
await createSupplierCreditNoteEntry(
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
// Two credit lines on the same account, split by the item bags.
const credit6200 = findByAccount(input.lines, '6200')
expect(credit6200).toHaveLength(2)
expect(credit6200[0].credit_amount).toBe(5000)
expect(credit6200[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(credit6200[1].credit_amount).toBe(3000)
expect(credit6200[1].dimensions).toEqual({ '1': 'KS01', '6': 'P002' })
expect(findByAccount(input.lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2440')[0].dimensions).toEqual({ '1': 'KS01' })
assertBalanced(input)
})
})
@@ -553,3 +553,134 @@ describe('buildDomesticExpenseLines', () => {
}
})
})
describe('createTransactionJournalEntry — dimensions propagation (PR7)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense: tags ONLY the business debit line — bank and VAT lines stay untagged', async () => {
const tx = makeTransaction({ amount: -1250, description: 'Software license' })
const vatLines: VatJournalLine[] = [
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms 25%' },
]
const mapping = makeMappingResult({
debit_account: '5410',
credit_account: '1930',
vat_lines: vatLines,
dimensions: { '1': 'KS01', '6': 'P001' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
const debit5410 = input.lines.find(l => l.account_number === '5410')
expect(debit5410?.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(input.lines.find(l => l.account_number === '2641')?.dimensions).toBeUndefined()
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
})
it('income: tags ONLY the revenue credit line — bank and output-VAT lines stay untagged', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Sales income' })
const vatLines: VatJournalLine[] = [
{ account_number: '2611', debit_amount: 0, credit_amount: 2500, description: 'Utgående moms 25%' },
]
const mapping = makeMappingResult({
debit_account: '1930',
credit_account: '3001',
vat_lines: vatLines,
dimensions: { '6': 'P001' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find(l => l.account_number === '3001')?.dimensions).toEqual({ '6': 'P001' })
expect(input.lines.find(l => l.account_number === '2611')?.dimensions).toBeUndefined()
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
})
it('all_lines_complete: each vat_lines[i].dimensions is used per line, with NO fallback to mappingResult.dimensions', async () => {
const tx = makeTransaction({ amount: -1250, description: 'Multi-line pattern' })
const vatLines: VatJournalLine[] = [
{ account_number: '5410', debit_amount: 1000, credit_amount: 0, description: 'Kostnad', dimensions: { '6': 'P001' } },
// No dimensions on the VAT line — must NOT inherit the categorize-level bag.
{ account_number: '2641', debit_amount: 250, credit_amount: 0, description: 'Ingående moms' },
]
const mapping = makeMappingResult({
debit_account: '5410',
credit_account: '1930',
all_lines_complete: true,
vat_lines: vatLines,
// Would mis-tag the VAT line if any fallback existed.
dimensions: { '1': 'LEAK' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(3)
expect(input.lines.find(l => l.account_number === '5410')?.dimensions).toEqual({ '6': 'P001' })
expect(input.lines.find(l => l.account_number === '2641')?.dimensions).toBeUndefined()
// Settlement line stays untagged.
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
assertBalanced(input)
})
it('all_lines_complete income: per-line bags authoritative on the credit side too', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Multi-line income' })
const vatLines: VatJournalLine[] = [
{ account_number: '3001', debit_amount: 0, credit_amount: 10000, description: 'Försäljning', dimensions: { '1': 'KS01' } },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500, description: 'Utgående moms' },
]
const mapping = makeMappingResult({
debit_account: '1930',
credit_account: '3001',
all_lines_complete: true,
vat_lines: vatLines,
dimensions: { '1': 'LEAK' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find(l => l.account_number === '3001')?.dimensions).toEqual({ '1': 'KS01' })
expect(input.lines.find(l => l.account_number === '2611')?.dimensions).toBeUndefined()
expect(input.lines.find(l => l.account_number === '1930')?.dimensions).toBeUndefined()
})
it('default_private path never tags — even when a bag is set on the mapping', async () => {
const tx = makeTransaction({ amount: -500, description: 'Lunch privat' })
const mapping = makeMappingResult({
debit_account: '2013',
credit_account: '1930',
default_private: true,
dimensions: { '1': 'KS01' },
})
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.dimensions).toBeUndefined()
}
})
it('no bag on the mapping → business line stays untagged', async () => {
const tx = makeTransaction({ amount: -299 })
const mapping = makeMappingResult({ debit_account: '5410', credit_account: '1930' })
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
const input = mockedCreateEntry.mock.calls[0][3]
for (const line of input.lines) {
expect(line.dimensions).toBeUndefined()
}
})
})
+40 -5
View File
@@ -8,6 +8,7 @@ import {
generateReverseChargeLines,
getVatRate,
} from './vat-entries'
import { dimensionsBagKey } from './dimension-resolver'
import type {
CategorizationTemplate,
CategorizationTemplateSource,
@@ -379,6 +380,11 @@ function buildMultiLineMappingResult(
debit_amount: entry.side === 'debit' ? amount : 0,
credit_amount: entry.side === 'credit' ? amount : 0,
description: '',
// Dimensions PR7: business lines carry the pattern's learned bag;
// VAT/tax/rounding lines stay untagged.
...(entry.type === 'business' && entry.dimensions
? { dimensions: entry.dimensions }
: {}),
})
}
}
@@ -653,11 +659,12 @@ interface VoucherLinePattern {
* Returns null if the voucher can't be represented as a pattern.
*/
function extractVoucherLinePattern(
lines: { account: string; amount: number }[]
lines: { account: string; amount: number; dimensions?: Record<string, string> }[]
): VoucherLinePattern | null {
const settlement: { account: string; amount: number }[] = []
const vat: { account: string; amount: number }[] = []
const business: { account: string; amount: number }[] = []
type PatternLine = { account: string; amount: number; dimensions?: Record<string, string> }
const settlement: PatternLine[] = []
const vat: PatternLine[] = []
const business: PatternLine[] = []
for (const line of lines) {
if (isSettlementAccount(line.account)) {
@@ -712,6 +719,11 @@ function extractVoucherLinePattern(
type: isTaxAccount(b.account) ? 'tax' : 'business',
side: b.amount >= 0 ? 'debit' : 'credit',
ratio: Math.round(ratio * 10000) / 10000,
// Dimensions PR7: carry the source line's bag so SIE-learned templates
// keep tagging like the history did (dropped in averaging on conflict).
...(b.dimensions && Object.keys(b.dimensions).length > 0
? { dimensions: b.dimensions }
: {}),
})
}
@@ -773,7 +785,18 @@ function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePattern
if (voucherPatterns.length === 1) return normalizeRatios(voucherPatterns[0].entries)
// Collect all accounts across all patterns
const accountMap = new Map<string, { type: LinePatternEntry['type']; side: LinePatternEntry['side']; ratios: number[]; vat_rate?: number }>()
const accountMap = new Map<string, {
type: LinePatternEntry['type']
side: LinePatternEntry['side']
ratios: number[]
vat_rate?: number
// Dimensions PR7: conservative — a bag survives averaging only when EVERY
// occurrence of the account carries the identical bag. A single
// disagreeing (or untagged) voucher drops it: a template must never
// invent a tag history doesn't consistently support.
dimensions?: Record<string, string>
dimensionsConsistent: boolean
}>()
for (const vp of voucherPatterns) {
// Normalize per-voucher ratios before averaging
@@ -786,11 +809,20 @@ function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePattern
side: entry.side,
ratios: entry.ratio !== undefined ? [entry.ratio] : [],
vat_rate: entry.vat_rate,
dimensions: entry.dimensions,
dimensionsConsistent: true,
})
} else {
if (entry.ratio !== undefined) {
existing.ratios.push(entry.ratio)
}
if (
existing.dimensionsConsistent &&
dimensionsBagKey(existing.dimensions) !== dimensionsBagKey(entry.dimensions)
) {
existing.dimensionsConsistent = false
existing.dimensions = undefined
}
}
}
}
@@ -804,6 +836,9 @@ function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePattern
if (data.ratios.length > 0) {
entry.ratio = Math.round((data.ratios.reduce((s, r) => s + r, 0) / data.ratios.length) * 10000) / 10000
}
if (data.dimensionsConsistent && data.dimensions && Object.keys(data.dimensions).length > 0) {
entry.dimensions = data.dimensions
}
entries.push(entry)
}
+37
View File
@@ -97,6 +97,43 @@ export function coerceDimensionsBag(raw: unknown): LineDimensions | undefined {
return Object.keys(dims).length > 0 ? dims : undefined
}
/**
* Merge a line/item-level dimensions bag over a document-level default
* (producers, PR7: invoice default_dimensions under item.dimensions). The
* override wins per key; an explicit empty-string override clears the key —
* the same clear-semantics as normalizeLineDimensions, which does the final
* cleanup. Returns undefined when the merged bag is empty so callers can
* assign it to an optional field without writing `{}` noise.
*/
export function mergeDimensionBags(
base?: LineDimensions | null,
override?: LineDimensions | null
): LineDimensions | undefined {
if (!base && !override) return undefined
const merged = normalizeLineDimensions({
dimensions: { ...(base ?? {}), ...(override ?? {}) },
})
return Object.keys(merged).length > 0 ? merged : undefined
}
/**
* Stable serialization of a dimensions bag for grouping keys, so generators
* that aggregate amounts per account can keep items with different dimension
* tags on separate journal lines (account + bag = the aggregation identity).
* Key order is canonicalized; '' means "no dimensions". Callers must pass a
* NORMALIZED bag (mergeDimensionBags/normalizeLineDimensions output) — an
* unnormalized bag ('01' vs '1', untrimmed values) would key differently
* from its normalized twin.
*/
export function dimensionsBagKey(dimensions?: LineDimensions): string {
if (!dimensions) return ''
return JSON.stringify(
Object.keys(dimensions)
.sort()
.map((key) => [key, dimensions[key]])
)
}
/**
* Derive the legacy mirror columns from the canonical map. Pure function —
* divergence between `dimensions` and cost_center/project is impossible as
+94 -25
View File
@@ -1,6 +1,12 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { resolveBookingAccount } from './accruals/account-suggestions'
import {
coerceDimensionsBag,
dimensionsBagKey,
mergeDimensionBags,
type LineDimensions,
} from './dimension-resolver'
import { generateSalesVatLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
@@ -55,6 +61,13 @@ function buildInvoiceDescription(
* schedules may pass true (invoice entry + credit note) — the cash-method
* entry books revenue directly even if a line carries stale accrual fields,
* since no schedule would ever dissolve the interim balance.
*
* options.defaultDimensions (dimensions PR7): the invoice-level bag. Revenue
* lines carry item.dimensions merged over it (item wins per key) — the merged
* bag is part of the aggregation identity, so two items on the same
* rate+account but different tags stay on separate lines. VAT lines carry
* the default only (the VAT account is a function of the treatment, never of
* a specific item).
*/
function generatePerRateLines(
items: InvoiceItem[],
@@ -63,7 +76,7 @@ function generatePerRateLines(
invoiceTagText: string,
currency?: string | null,
exchangeRate?: number | null,
options?: { deferAccruals?: boolean }
options?: { deferAccruals?: boolean; defaultDimensions?: LineDimensions }
): CreateJournalEntryLineInput[] {
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
@@ -85,7 +98,9 @@ function generatePerRateLines(
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (!hasPerLineVat) {
// Legacy fallback: single rate from invoice level
// Legacy fallback: single rate from invoice level. All items collapse
// into one revenue line, so only the invoice default can apply here —
// legacy rows predate per-item tagging anyway.
const revenueAccount = getRevenueAccount(invoiceVatTreatment, entityType)
const subtotal = items.reduce((sum, item) => sum + item.line_total, 0)
const subtotalSek = toSek(subtotal)
@@ -94,6 +109,7 @@ function generatePerRateLines(
debit_amount: 0,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${invoiceTagText}`,
dimensions: options?.defaultDimensions,
})
const totalVat = items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
@@ -107,6 +123,7 @@ function generatePerRateLines(
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${invoiceTagText}`,
dimensions: options?.defaultDimensions,
})
} else {
const vatLines = generateSalesVatLines({
@@ -114,20 +131,29 @@ function generatePerRateLines(
baseAmount: subtotal,
direction: 'sales',
})
lines.push(...vatLines)
lines.push(...vatLines.map((line) => ({
...line,
dimensions: options?.defaultDimensions,
})))
}
}
return lines
}
// Group items by vat_rate (preserve first-seen rate order). Within each rate,
// sub-group revenue by the resolved BAS account so a per-line/article account
// override produces its own credit line. VAT stays aggregated per rate (the
// VAT account is a function of the treatment, never of the revenue override).
// sub-group revenue by the resolved BAS account + merged dimensions bag so a
// per-line/article account override — or a per-item dimension tag — produces
// its own credit line. VAT stays aggregated per rate (the VAT account is a
// function of the treatment, never of the revenue override).
type RevenueBucket = {
account: string
dimensions?: LineDimensions
subtotal: number
}
type RateGroup = {
vatAmount: number
// resolved revenue account -> summed line_total (first-seen account order)
byAccount: Map<string, number>
// account + dims bag -> bucket (first-seen order)
buckets: Map<string, RevenueBucket>
}
const rateGroups = new Map<number, RateGroup>()
@@ -151,9 +177,14 @@ function generatePerRateLines(
? plAccount
: resolveBookingAccount('revenue', item, plAccount)
const group = rateGroups.get(rate) ?? { vatAmount: 0, byAccount: new Map<string, number>() }
const dimensions = mergeDimensionBags(options?.defaultDimensions, item.dimensions)
const bucketKey = `${account}\u0000${dimensionsBagKey(dimensions)}`
const group = rateGroups.get(rate) ?? { vatAmount: 0, buckets: new Map<string, RevenueBucket>() }
group.vatAmount += item.vat_amount || 0
group.byAccount.set(account, (group.byAccount.get(account) ?? 0) + item.line_total)
const bucket = group.buckets.get(bucketKey) ?? { account, dimensions, subtotal: 0 }
bucket.subtotal += item.line_total
group.buckets.set(bucketKey, bucket)
rateGroups.set(rate, group)
}
@@ -165,26 +196,28 @@ function generatePerRateLines(
// The rate-level rounded subtotal is the balance anchor — identical to the
// pre-override single-account behaviour. When a rate splits across multiple
// accounts, distribute that exact total so independent per-account rounding
// can never introduce a 1-öre imbalance against the 1510 debit: every
// account but the last rounds normally; the last absorbs the remainder.
// buckets (account and/or dimensions), distribute that exact total so
// independent per-bucket rounding can never introduce a 1-öre imbalance
// against the 1510 debit: every bucket but the last rounds normally; the
// last absorbs the remainder.
const rateSubtotalSek = Math.round(
toSek(Array.from(group.byAccount.values()).reduce((sum, v) => sum + v, 0)) * 100
toSek(Array.from(group.buckets.values()).reduce((sum, b) => sum + b.subtotal, 0)) * 100
) / 100
const accounts = Array.from(group.byAccount.entries())
const buckets = Array.from(group.buckets.values())
let allocated = 0
accounts.forEach(([account, subtotal], idx) => {
const isLast = idx === accounts.length - 1
buckets.forEach((bucket, idx) => {
const isLast = idx === buckets.length - 1
const credit = isLast
? Math.round((rateSubtotalSek - allocated) * 100) / 100
: Math.round(toSek(subtotal) * 100) / 100
: Math.round(toSek(bucket.subtotal) * 100) / 100
allocated = Math.round((allocated + credit) * 100) / 100
lines.push({
account_number: account,
account_number: bucket.account,
debit_amount: 0,
credit_amount: credit,
line_description: `Försäljning faktura ${invoiceTagText}`,
dimensions: bucket.dimensions,
})
})
@@ -196,6 +229,7 @@ function generatePerRateLines(
debit_amount: 0,
credit_amount: roundedVat,
line_description: `Utgående moms ${rate}% faktura ${invoiceTagText}`,
dimensions: options?.defaultDimensions,
})
}
}
@@ -223,6 +257,7 @@ function generateRotRutLines(
invoiceTagText: string,
currency?: string | null,
exchangeRate?: number | null,
defaultDimensions?: LineDimensions,
): { lines: CreateJournalEntryLineInput[]; totalSek: number } {
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
@@ -255,6 +290,8 @@ function generateRotRutLines(
debit_amount: amountSek,
credit_amount: 0,
line_description: `${kind}-avdrag faktura ${invoiceTagText}`,
// Per-item line — carries the item's merged bag like its revenue line.
dimensions: mergeDimensionBags(defaultDimensions, item.dimensions),
})
}
@@ -305,6 +342,9 @@ export async function createInvoiceJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
const tag = options?.numberOverride ?? invoiceTag(invoice)
// Dimensions PR7: the invoice default rides every generated line; item bags
// merge over it inside generatePerRateLines/generateRotRutLines.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
@@ -315,7 +355,7 @@ export async function createInvoiceJournalEntry(
invoice.currency, invoice.exchange_rate,
// Schedules are created right after this entry commits (send/mark-sent
// flows), so deferring to 29xx here is safe.
{ deferAccruals: true }
{ deferAccruals: true, defaultDimensions }
))
} else {
// Fallback: no items available, use invoice-level amounts
@@ -327,6 +367,7 @@ export async function createInvoiceJournalEntry(
debit_amount: 0,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${tag}`,
dimensions: defaultDimensions,
})
if (invoice.vat_amount > 0) {
@@ -338,6 +379,7 @@ export async function createInvoiceJournalEntry(
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${tag}`,
dimensions: defaultDimensions,
})
} else {
const vatLines = generateSalesVatLines({
@@ -345,7 +387,10 @@ export async function createInvoiceJournalEntry(
baseAmount: invoice.subtotal,
direction: 'sales',
})
creditLines.push(...vatLines)
creditLines.push(...vatLines.map((line) => ({
...line,
dimensions: defaultDimensions,
})))
}
}
}
@@ -355,7 +400,7 @@ export async function createInvoiceJournalEntry(
// balanced (debits 1510 + 1513 = credits revenue + VAT). The customer
// only owes the post-deduction amount; Skatteverket pays the rest.
const rotRut = invoice.items && invoice.items.length > 0
? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate)
? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate, defaultDimensions)
: { lines: [], totalSek: 0 }
// Debit: Kundfordringar — balance guarantee: debit = sum of all credit
@@ -371,6 +416,7 @@ export async function createInvoiceJournalEntry(
debit_amount: arAmount,
credit_amount: 0,
line_description: `Faktura ${tag}`,
dimensions: defaultDimensions,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
@@ -423,6 +469,10 @@ export async function createInvoicePaymentJournalEntry(
customerName,
invoice.id,
)
// Dimensions PR7: the payment voucher re-propagates the linked invoice's
// default bag onto every leg — incl. the FX result lines, so a project's
// kursvinst/kursförlust stays inside the project P&L.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// When paymentAmount is provided, use it for the 1930/1510 line amounts.
// Otherwise use the full invoice total (backward compatible).
@@ -489,6 +539,12 @@ export async function createInvoicePaymentJournalEntry(
)
}
if (defaultDimensions) {
// Copy per line — a shared bag object would let one line's mutation
// leak into every other line (same contract as proposal stamping).
for (const line of lines) line.dimensions = { ...defaultDimensions }
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
@@ -533,6 +589,9 @@ export async function createCreditNoteJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
const tag = invoiceTag(creditNote)
const lineSuffix = originalVoucherRef ? ` (avser ${originalVoucherRef})` : ''
// Dimensions PR7: the credit note's bag (copied from the original at credit
// time) so the reversal nets against the same dimension cells in reports.
const defaultDimensions = coerceDimensionsBag(creditNote.default_dimensions)
// Generate reversed revenue + VAT lines per rate group (debit side for credit notes)
const debitLines: CreateJournalEntryLineInput[] = []
@@ -545,7 +604,7 @@ export async function createCreditNoteJournalEntry(
// Credit-note items carry the original's accrual fields so the reversal
// hits the same 29xx interim account; the original's schedule is
// cancelled/stornoed by the credit flow.
{ deferAccruals: true }
{ deferAccruals: true, defaultDimensions }
)
for (const line of creditLines) {
debitLines.push({
@@ -566,6 +625,7 @@ export async function createCreditNoteJournalEntry(
debit_amount: absSubtotal,
credit_amount: 0,
line_description: `Kreditfaktura ${tag}`,
dimensions: defaultDimensions,
})
if (absVat > 0) {
@@ -575,6 +635,7 @@ export async function createCreditNoteJournalEntry(
debit_amount: absVat,
credit_amount: 0,
line_description: `Moms kreditfaktura ${tag}${lineSuffix}`,
dimensions: defaultDimensions,
})
}
}
@@ -588,6 +649,7 @@ export async function createCreditNoteJournalEntry(
debit_amount: 0,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: `Kreditfaktura ${tag}`,
dimensions: defaultDimensions,
})
const baseDescription = buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id)
@@ -631,6 +693,9 @@ export async function createInvoiceCashEntry(
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
const tag = invoiceTag(invoice)
// Dimensions PR7: kontantmetoden books revenue at payment, so this IS the
// producer path for cash-method companies — same merge rules as issuance.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Credit lines: revenue + VAT per rate group (compute first to guarantee balance)
const creditLines: CreateJournalEntryLineInput[] = []
@@ -638,7 +703,8 @@ export async function createInvoiceCashEntry(
if (invoice.items && invoice.items.length > 0) {
creditLines.push(...generatePerRateLines(
invoice.items, invoice.vat_treatment, entityType, tag,
invoice.currency, invoice.exchange_rate
invoice.currency, invoice.exchange_rate,
{ defaultDimensions }
))
} else {
// Fallback: invoice-level amounts
@@ -650,6 +716,7 @@ export async function createInvoiceCashEntry(
debit_amount: 0,
credit_amount: subtotalSek,
line_description: `Försäljning faktura ${tag}`,
dimensions: defaultDimensions,
})
if (invoice.vat_amount > 0) {
@@ -660,6 +727,7 @@ export async function createInvoiceCashEntry(
debit_amount: 0,
credit_amount: vatSek,
line_description: `Utgående moms faktura ${tag}`,
dimensions: defaultDimensions,
})
}
}
@@ -669,7 +737,7 @@ export async function createInvoiceCashEntry(
// life; the rest comes from Skatteverket later. We model that by
// splitting the debit: 1930 = total - deduction, 1513 = deduction.
const rotRut = invoice.items && invoice.items.length > 0
? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate)
? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate, defaultDimensions)
: { lines: [], totalSek: 0 }
// Debit: Företagskonto — balance guarantee: debit = sum of credit lines
@@ -684,6 +752,7 @@ export async function createInvoiceCashEntry(
debit_amount: bankAmount,
credit_amount: 0,
line_description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName, invoice.id),
dimensions: defaultDimensions,
})
lines.push(...rotRut.lines)
+18 -3
View File
@@ -23,6 +23,14 @@ export interface ProposePaymentLinesInput {
exchange_rate?: number | null
vat_treatment: VatTreatment
items?: InvoiceItem[]
/**
* Dimensions PR7: the invoice's default bag. Stamped on every proposed
* line — the payment dialog always submits its (editable) lines, so the
* preview IS the booked entry and must re-propagate the tag like the
* no-override generator path does. Per-item bags are not split out here
* (the preview groups per rate); users can retag lines in the grid.
*/
default_dimensions?: Record<string, string> | null
}
accountingMethod: 'accrual' | 'cash'
entityType: EntityType
@@ -65,10 +73,17 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
const paymentAccount = input.paymentAccount || '1930'
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
if (accountingMethod === 'accrual') {
return proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference)
const lines = accountingMethod === 'accrual'
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference)
: proposeCashLines(invoice, paymentAccount, desc, entityType)
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
const bag = invoice.default_dimensions
if (bag && Object.keys(bag).length > 0) {
return lines.map((line) => ({ ...line, dimensions: { ...bag } }))
}
return proposeCashLines(invoice, paymentAccount, desc, entityType)
return lines
}
function proposeAccrualLines(
+25
View File
@@ -23,6 +23,12 @@ export interface ProposeSendLinesInput {
exchange_rate?: number | null
vat_treatment: VatTreatment
items?: InvoiceItem[]
/**
* Dimensions PR7: the invoice's default bag, stamped on every proposed
* line so the preview matches what createInvoiceJournalEntry books
* (display-only — the send routes book via the generator).
*/
default_dimensions?: Record<string, string> | null
}
entityType: EntityType
}
@@ -41,6 +47,25 @@ function toFormAmount(n: number): string {
*/
export function proposeSendLines(input: ProposeSendLinesInput): FormLine[] {
const { invoice, entityType } = input
const lines: FormLine[] = stampProposalDimensions(
buildSendLines(invoice, entityType),
invoice.default_dimensions
)
return lines
}
function stampProposalDimensions(
lines: FormLine[],
bag?: Record<string, string> | null
): FormLine[] {
if (!bag || Object.keys(bag).length === 0) return lines
return lines.map((line) => ({ ...line, dimensions: { ...bag } }))
}
function buildSendLines(
invoice: ProposeSendLinesInput['invoice'],
entityType: EntityType
): FormLine[] {
const lines: FormLine[] = []
const isForeign = invoice.currency !== 'SEK'
const desc = invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'
+117 -45
View File
@@ -7,6 +7,12 @@ import {
isReverseChargeBasisAccount,
resolveReverseChargeRate,
} from './vat-entries'
import {
coerceDimensionsBag,
dimensionsBagKey,
mergeDimensionBags,
type LineDimensions,
} from './dimension-resolver'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -33,6 +39,37 @@ function buildSupplierDescription(
return suffix ? `${base} ${suffix}` : base
}
/**
* Aggregate item amounts per (booking account, merged dimensions bag) —
* dimensions PR7. The merged bag (item.dimensions over the invoice's
* default_dimensions) is part of the aggregation identity so two items on the
* same account but different tags stay on separate journal lines instead of
* collapsing. Insertion order is first-seen, matching the old per-account map.
*/
interface ExpenseBucket {
account: string
dimensions?: LineDimensions
amount: number
}
function groupExpenseBuckets(
items: SupplierInvoiceItem[],
resolveAccount: (item: SupplierInvoiceItem) => string,
toSek: (item: SupplierInvoiceItem) => number,
defaultDimensions?: LineDimensions
): ExpenseBucket[] {
const buckets = new Map<string, ExpenseBucket>()
for (const item of items) {
const account = resolveAccount(item)
const dimensions = mergeDimensionBags(defaultDimensions, item.dimensions)
const key = `${account}\u0000${dimensionsBagKey(dimensions)}`
const bucket = buckets.get(key) ?? { account, dimensions, amount: 0 }
bucket.amount += toSek(item)
buckets.set(key, bucket)
}
return [...buckets.values()]
}
/**
* Create journal entry when a supplier invoice is registered (accrual method)
*
@@ -68,27 +105,30 @@ export async function createSupplierInvoiceRegistrationEntry(
const lines: CreateJournalEntryLineInput[] = []
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const isForeign = invoice.currency !== 'SEK'
// Dimensions PR7: expense lines carry item bags merged over the invoice
// default; every other line (VAT, RC/basis, 2440) carries the default only.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Aggregate expense amounts by account number and convert to SEK.
// Aggregate expense amounts by (account, dimensions) and convert to SEK.
// Periodiserade lines book their net to the 17xx interim account instead
// of the cost account (resolveBookingAccount); VAT and 2440 are untouched —
// moms is never deferred (redovisas på fakturadatum).
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
const current = expenseByAccount.get(bookingAccount) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(bookingAccount, current + itemSek)
}
const expenseBuckets = groupExpenseBuckets(
items,
(item) => resolveBookingAccount('expense', item, item.account_number),
(item) => resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate),
defaultDimensions
)
// Debit: Expense accounts (in SEK)
const debitLines: CreateJournalEntryLineInput[] = []
for (const [accountNumber, amount] of expenseByAccount) {
for (const bucket of expenseBuckets) {
debitLines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: bucket.dimensions,
})
}
lines.push(...debitLines)
@@ -124,11 +164,11 @@ export async function createSupplierInvoiceRegistrationEntry(
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines)
lines.push(...rcLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
lines.push(...basisLines)
lines.push(...basisLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
}
}
}
@@ -142,6 +182,7 @@ export async function createSupplierInvoiceRegistrationEntry(
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
@@ -156,6 +197,7 @@ export async function createSupplierInvoiceRegistrationEntry(
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
@@ -202,6 +244,9 @@ export async function createSupplierInvoicePaymentEntry(
const desc = buildSupplierDescription('Utbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: the payment voucher re-propagates the linked invoice's
// default bag onto every leg (incl. FX result lines) — see the stamp below.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
if (exchangeRateDifference && exchangeRateDifference !== 0) {
// Foreign currency with exchange rate difference
@@ -259,6 +304,12 @@ export async function createSupplierInvoicePaymentEntry(
})
}
if (defaultDimensions) {
// Copy per line — a shared bag object would let one line's mutation
// leak into every other line (same contract as proposal stamping).
for (const line of lines) line.dimensions = { ...defaultDimensions }
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
@@ -320,25 +371,29 @@ export async function createSupplierInvoiceCashEntry(
const desc = buildSupplierDescription('Kontantbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: kontantmetoden books the expense at payment — same merge
// rules as the registration entry.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Expense debit lines tracked separately so a sub-öre translation residual
// can be folded into the largest one (öresavrundning step below).
const expenseLines: CreateJournalEntryLineInput[] = []
// Aggregate expense amounts by account number and convert to SEK
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, effectiveRate)
expenseByAccount.set(item.account_number, current + itemSek)
}
// Aggregate expense amounts by (account, dimensions) and convert to SEK
const expenseBuckets = groupExpenseBuckets(
items,
(item) => item.account_number,
(item) => resolveSekAmount(item.line_total, null, invoice.currency, effectiveRate),
defaultDimensions
)
// Debit: Expense accounts (in SEK)
for (const [accountNumber, amount] of expenseByAccount) {
for (const bucket of expenseBuckets) {
const line: CreateJournalEntryLineInput = {
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: bucket.dimensions,
}
lines.push(line)
expenseLines.push(line)
@@ -367,11 +422,11 @@ export async function createSupplierInvoiceCashEntry(
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines)
lines.push(...rcLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
lines.push(...basisLines)
lines.push(...basisLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
}
}
}
@@ -386,6 +441,7 @@ export async function createSupplierInvoiceCashEntry(
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
@@ -418,6 +474,7 @@ export async function createSupplierInvoiceCashEntry(
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
@@ -465,20 +522,24 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
const ownerAccount = entityType === 'aktiebolag' ? '2893' : '2018'
const desc = buildSupplierDescription('Eget utlägg', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: this IS the utlägg path — billable-expense-to-project
// tagging rides the same merge rules as the registration entry.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Debit: Expense accounts (in SEK), aggregated per account
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
}
for (const [accountNumber, amount] of expenseByAccount) {
// Debit: Expense accounts (in SEK), aggregated per (account, dimensions)
const expenseBuckets = groupExpenseBuckets(
items,
(item) => item.account_number,
(item) => resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate),
defaultDimensions
)
for (const bucket of expenseBuckets) {
lines.push({
account_number: accountNumber,
debit_amount: Math.round(amount * 100) / 100,
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: bucket.dimensions,
})
}
@@ -492,6 +553,7 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
@@ -504,6 +566,7 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
debit_amount: 0,
credit_amount: Math.round(totalDebits * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
@@ -542,26 +605,30 @@ export async function createSupplierCreditNoteEntry(
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomstnr ${creditNote.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: items are the ORIGINAL invoice's (see below), so their
// bags reverse against the same dimension cells; the credit note's own
// default (copied from the original at credit time) rides the other legs.
const defaultDimensions = coerceDimensionsBag(creditNote.default_dimensions)
// Credit: Expense accounts (reverse, in SEK). The caller passes the
// ORIGINAL invoice's items so deferred lines reverse against the same 17xx
// interim account they were registered on (the schedule's posted
// dissolutions are stornoed separately by cancelSchedulesForSource).
const creditLines: CreateJournalEntryLineInput[] = []
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
const current = expenseByAccount.get(bookingAccount) || 0
const itemSek = Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate))
expenseByAccount.set(bookingAccount, current + itemSek)
}
const expenseBuckets = groupExpenseBuckets(
items,
(item) => resolveBookingAccount('expense', item, item.account_number),
(item) => Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate)),
defaultDimensions
)
for (const [accountNumber, amount] of expenseByAccount) {
for (const bucket of expenseBuckets) {
creditLines.push({
account_number: accountNumber,
account_number: bucket.account,
debit_amount: 0,
credit_amount: Math.round(amount * 100) / 100,
credit_amount: Math.round(bucket.amount * 100) / 100,
line_description: desc,
dimensions: bucket.dimensions,
})
}
@@ -595,12 +662,14 @@ export async function createSupplierCreditNoteEntry(
debit_amount: 0,
credit_amount: fiktivVat,
line_description: `Omvänd fiktiv ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
lines.push({
account_number: outputAccount,
debit_amount: fiktivVat,
credit_amount: 0,
line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
@@ -618,6 +687,7 @@ export async function createSupplierCreditNoteEntry(
debit_amount: line.credit_amount,
credit_amount: line.debit_amount,
line_description: line.line_description,
dimensions: defaultDimensions,
})
}
}
@@ -633,6 +703,7 @@ export async function createSupplierCreditNoteEntry(
debit_amount: 0,
credit_amount: amount,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
@@ -648,6 +719,7 @@ export async function createSupplierCreditNoteEntry(
debit_amount: Math.round((totalCredits - totalDebits) * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
+15 -2
View File
@@ -1,5 +1,6 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { coerceDimensionsBag } from './dimension-resolver'
import { extractNetAmount, extractVatAmount } from './vat-entries'
import { InvalidMappingResultError } from '@/lib/bookkeeping/errors'
import { createLogger } from '@/lib/logger'
@@ -74,6 +75,11 @@ export async function createTransactionJournalEntry(
transaction.exchange_rate
)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: the bag tags the business (expense/revenue) lines only —
// bank/settlement and VAT lines stay untagged. In the multi-line template
// path each pattern line carries its own bag instead (LinePatternEntry).
// The private path books to a balance account (2013/2893) — never tagged.
const businessDimensions = coerceDimensionsBag(mappingResult.dimensions)
if (mappingResult.default_private) {
// Private expense — use entity-specific account from mappingResult
@@ -99,13 +105,16 @@ export async function createTransactionJournalEntry(
: (mappingResult.debit_account || '1930')
if (isExpense) {
// All non-settlement lines (business, VAT, tax, rounding)
// All non-settlement lines (business, VAT, tax, rounding). Per-line bags
// are authoritative here — the pattern marks business lines only, so no
// fallback to the categorize-level bag (it would mis-tag VAT/tax lines).
for (const line of mappingResult.vat_lines) {
lines.push({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
line_description: line.description || transaction.description,
dimensions: coerceDimensionsBag(line.dimensions),
})
}
// Credit bank for full amount
@@ -125,13 +134,14 @@ export async function createTransactionJournalEntry(
line_description: transaction.description,
...(isForeign ? currencyMeta : {}),
})
// All non-settlement lines
// All non-settlement lines — per-line bags authoritative (see above).
for (const line of mappingResult.vat_lines) {
lines.push({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
line_description: line.description || transaction.description,
dimensions: coerceDimensionsBag(line.dimensions),
})
}
}
@@ -163,6 +173,7 @@ export async function createTransactionJournalEntry(
debit_amount: netAmount,
credit_amount: 0,
line_description: transaction.description,
dimensions: businessDimensions,
})
} else {
// No VAT handling - debit full amount to expense account
@@ -171,6 +182,7 @@ export async function createTransactionJournalEntry(
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
dimensions: businessDimensions,
})
}
@@ -208,6 +220,7 @@ export async function createTransactionJournalEntry(
debit_amount: 0,
credit_amount: netAmount,
line_description: transaction.description,
dimensions: businessDimensions,
})
// Credit output VAT
for (const vatLine of mappingResult.vat_lines) {
+10
View File
@@ -54,6 +54,8 @@ export interface InvoiceWriteItemInput {
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
/** Dimensions PR7: per-item bag merged over the invoice default at booking. */
dimensions?: Record<string, string>
}
export interface InvoiceWriteInput {
@@ -69,6 +71,8 @@ export interface InvoiceWriteInput {
ore_rounding?: boolean
deduction_personnummer?: string
deduction_housing_designation?: string
/** Dimensions PR7: invoice-level bag applied to every generated journal line. */
default_dimensions?: Record<string, string>
items: InvoiceWriteItemInput[]
}
@@ -102,6 +106,7 @@ export type InvoiceWriteFields = {
deduction_total: number
deduction_personnummer_encrypted: string | null
deduction_personnummer_last4: string | null
default_dimensions: Record<string, string>
}
export type InvoiceWriteItemRow = {
@@ -125,6 +130,7 @@ export type InvoiceWriteItemRow = {
accrual_period_start: string | null
accrual_period_end: string | null
accrual_balance_account: string | null
dimensions: Record<string, string>
}
export type BuildInvoiceWriteResult =
@@ -344,6 +350,8 @@ export async function buildInvoiceWriteData(params: {
deduction_total: deductionTotal,
deduction_personnummer_encrypted: deductionPersonnummerEncrypted,
deduction_personnummer_last4: deductionPersonnummerLast4,
// Dimensions PR7: stored as-is; the generators coerce + merge at booking.
default_dimensions: input.default_dimensions ?? {},
}
const itemRows: InvoiceWriteItemRow[] = items.map((item, index) => {
@@ -372,6 +380,7 @@ export async function buildInvoiceWriteData(params: {
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
dimensions: {},
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
@@ -425,6 +434,7 @@ export async function buildInvoiceWriteData(params: {
documentType === 'invoice' && !deductionType && item.accrual_period_start && item.accrual_period_end
? (item.accrual_balance_account ?? DEFAULT_DEFERRED_REVENUE_ACCOUNT)
: null,
dimensions: item.dimensions ?? {},
}
})
@@ -182,3 +182,86 @@ describe('commitPendingOperation: create_invoice', () => {
})
})
})
describe('commitPendingOperation: create_invoice — dimensions propagation (PR7)', () => {
it('staged default_dimensions lands on the invoices row and item bags on invoice_items rows', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
default_dimensions: { '1': 'KS01' },
items: [
{
description: 'Konsulttimmar',
quantity: 1,
unit: 'tim',
unit_price: 1000,
vat_rate: 25,
dimensions: { '6': 'P001' },
},
{ line_type: 'text', description: 'Avser vecka 23', quantity: 0, unit: '', unit_price: 0, vat_rate: 0 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices'][0]).toMatchObject({ default_dimensions: { '1': 'KS01' } })
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows).toHaveLength(2)
expect(itemRows[0]).toMatchObject({ line_type: 'product', dimensions: { '6': 'P001' } })
// Text rows never carry a bag.
expect(itemRows[1]).toMatchObject({ line_type: 'text', dimensions: {} })
})
it('defaults to {} when no bags are staged', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
items: [{ description: 'Konsulttimmar', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices'][0]).toMatchObject({ default_dimensions: {} })
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows[0]).toMatchObject({ dimensions: {} })
})
it('coerces an INVALID staged bag away — the insert gets {} (drift/tamper gate)', async () => {
const { supabase, inserts } = createCapturingSupabase(queueFor({ vat_registered: true }))
const op = makePendingOp({
params: {
customer_id: 'cust-1',
// '0' is not a valid SIE dimension number — the whole bag is rejected.
default_dimensions: { '0': 'X' },
items: [
{
description: 'Konsulttimmar',
quantity: 1,
unit: 'tim',
unit_price: 1000,
vat_rate: 25,
// Empty code fails the schema — the whole bag is rejected.
dimensions: { '1': '' },
},
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(inserts['invoices'][0]).toMatchObject({ default_dimensions: {} })
const itemRows = inserts['invoice_items'][0] as Array<Record<string, unknown>>
expect(itemRows[0]).toMatchObject({ dimensions: {} })
})
})
@@ -576,3 +576,145 @@ describe('commitPendingOperation: create_supplier_invoice_from_inbox', () => {
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
})
describe('commitPendingOperation: create_supplier_invoice_from_inbox — dimensions propagation (PR7)', () => {
/**
* Capture both the supplier_invoices parent insert and the
* supplier_invoice_items rows (cash method — no JE, shortest queue).
*/
function withInsertCapture(supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']) {
const captured: {
invoice: Record<string, unknown> | null
items: Array<Record<string, unknown>> | null
} = { invoice: null, items: null }
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
if (table === 'supplier_invoices') {
return {
insert: (row: Record<string, unknown>) => {
captured.invoice = row
return {
select: () => ({
single: () =>
Promise.resolve({
data: makeSupplierInvoice({ id: 'inv-dims', supplier_invoice_number: 'INV-100' }),
error: null,
}),
}),
}
},
}
}
if (table === 'supplier_invoice_items') {
return {
insert: (rows: Array<Record<string, unknown>>) => {
captured.items = rows
return Promise.resolve({ data: null, error: null })
},
}
}
return (originalFrom as (t: string) => unknown)(table)
})
return captured
}
/** Queue for the cash-method path with both inserts intercepted above. */
function enqueueCashFlow(enqueue: ReturnType<typeof createQueuedMockSupabase>['enqueue']) {
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
}) // inbox fetch
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
}) // supplier fetch
enqueue({ data: 42, error: null }) // arrival number RPC
enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
}
it('staged default_dimensions lands on the supplier_invoices row and item bags on the item rows', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const captured = withInsertCapture(supabase)
enqueueCashFlow(enqueue)
const op = makePendingOp()
op.params = {
...(op.params as Record<string, unknown>),
default_dimensions: { '1': 'KS01' },
items: [
{
line_number: 1,
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '6530',
vat_rate: 0.25,
vat_amount: 250,
dimensions: { '6': 'P001' },
},
{
line_number: 2,
description: 'Frakt',
quantity: 1,
unit: 'st',
unit_price: 100,
line_total: 100,
account_number: '5710',
vat_rate: 0.25,
vat_amount: 25,
// No dims staged on this row.
},
],
}
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(captured.invoice).toMatchObject({ default_dimensions: { '1': 'KS01' } })
expect(captured.items).toHaveLength(2)
expect(captured.items![0]).toMatchObject({ account_number: '6530', dimensions: { '6': 'P001' } })
// Absent bag defaults to {} on the row.
expect(captured.items![1]).toMatchObject({ account_number: '5710', dimensions: {} })
})
it('defaults to {} when absent and coerces an INVALID staged bag away (drift/tamper gate)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const captured = withInsertCapture(supabase)
enqueueCashFlow(enqueue)
const op = makePendingOp()
op.params = {
...(op.params as Record<string, unknown>),
// '0' is not a valid SIE dimension number — the whole bag is rejected.
default_dimensions: { '0': 'X' },
items: [
{
line_number: 1,
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '6530',
vat_rate: 0.25,
vat_amount: 250,
// Empty code fails the schema — the whole bag is rejected.
dimensions: { '1': '' },
},
],
}
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(captured.invoice).toMatchObject({ default_dimensions: {} })
expect(captured.items![0]).toMatchObject({ dimensions: {} })
})
})
@@ -43,12 +43,24 @@ vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
}
})
vi.mock('@/lib/transactions/categorize-core', async () => {
const actual =
await vi.importActual<typeof import('@/lib/transactions/categorize-core')>(
'@/lib/transactions/categorize-core'
)
return {
...actual,
categorizeMatchedTransaction: vi.fn(),
}
})
import { commitPendingOperation } from '../commit'
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport } from '@/lib/import/sie-import'
import { commitAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
@@ -861,3 +873,83 @@ describe('commitPendingOperation: link_document_to_voucher', () => {
expect(result.http_status).toBe(409)
})
})
// ─── categorize_transaction — dimensions propagation (PR7) ──────────
describe('commitPendingOperation: categorize_transaction — dimensions propagation (PR7)', () => {
it('threads the staged dimensions bag into categorizeMatchedTransaction opts', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: {
transaction_id: 'tx-1',
category: 'office_supplies',
dimensions: { '1': 'KS01', '6': 'P001' },
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(categorizeMatchedTransaction).toHaveBeenCalledTimes(1)
const call = vi.mocked(categorizeMatchedTransaction).mock.calls[0]
expect(call[1]).toBe('user-1')
expect(call[2]).toBe('company-1')
expect(call[3]).toBe('tx-1')
expect(call[4].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
})
it('coerces an INVALID staged bag to undefined (drift/tamper gate)', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: {
transaction_id: 'tx-1',
category: 'office_supplies',
// '0' is not a valid SIE dimension number — the whole bag is rejected
// and booking proceeds without dimensions.
dimensions: { '0': 'X' },
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
expect(opts.dimensions).toBeUndefined()
})
it('passes no dimensions when nothing was staged', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'office_supplies' },
})
await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
expect(opts.dimensions).toBeUndefined()
})
})
+24
View File
@@ -222,6 +222,8 @@ async function commitCategorizeTransaction(
vatAmount,
notes,
allowDuplicate: params.allow_duplicate === true,
// Dimensions PR7: resolved at staging; coerce is the drift/tamper gate.
dimensions: coerceDimensionsBag(params.dimensions),
})
}
@@ -692,7 +694,11 @@ async function commitCreateInvoice(
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
article_id?: string | null; revenue_account?: string | null
line_type?: 'product' | 'text'
dimensions?: Record<string, string>
}>
// Dimensions PR7: bags were resolved against the registry at staging time
// (resolveDimensionBags in the MCP tool); coerce is the drift/tamper gate.
const defaultDimensions = coerceDimensionsBag(params.default_dimensions)
// Free-text rows carry no amounts and never book. The MCP staging tool does
// not accept line_type today, but the totals math must stay identical to
@@ -794,6 +800,7 @@ async function commitCreateInvoice(
our_reference: (params.our_reference as string) || null,
your_reference: (params.your_reference as string) || null,
notes: (params.notes as string) || null,
default_dimensions: defaultDimensions ?? {},
})
.select()
.single()
@@ -818,6 +825,7 @@ async function commitCreateInvoice(
vat_amount: 0,
article_id: null,
revenue_account: null,
dimensions: {},
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
@@ -838,6 +846,7 @@ async function commitCreateInvoice(
// account; null falls back to the VAT-treatment-derived account.
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
dimensions: coerceDimensionsBag(item.dimensions) ?? {},
}
})
@@ -2067,6 +2076,8 @@ async function commitCreateSupplierInvoiceFromInbox(
const vatTreatment = (params.vat_treatment as string) || 'standard_25'
const notes = (params.notes as string | null) ?? null
const rawItems = (params.items as Array<Record<string, unknown>> | undefined) ?? []
// Dimensions PR7: resolved at staging time; coerce is the drift/tamper gate.
const defaultDimensions = coerceDimensionsBag(params.default_dimensions)
if (!inboxItemId || !supplierId || !supplierInvoiceNumber || !invoiceDate || rawItems.length === 0) {
return {
@@ -2171,6 +2182,7 @@ async function commitCreateSupplierInvoiceFromInbox(
remaining_amount: totalRounded,
document_id: documentId,
notes,
default_dimensions: defaultDimensions ?? {},
})
.select()
.single()
@@ -2227,6 +2239,7 @@ async function commitCreateSupplierInvoiceFromInbox(
reverse_charge_rate: reverseCharge
? ([0.06, 0.12, 0.25].includes(Number(item.reverse_charge_rate)) ? Number(item.reverse_charge_rate) : null)
: null,
dimensions: coerceDimensionsBag(item.dimensions) ?? {},
}
})
@@ -2434,6 +2447,8 @@ async function commitCreditSupplierInvoice(
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: id,
// Dimensions PR7: copy so the reversal nets against the same cells.
default_dimensions: original.default_dimensions ?? {},
})
.select()
.single()
@@ -2452,6 +2467,7 @@ async function commitCreditSupplierInvoice(
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
dimensions: item.dimensions ?? {},
}))
await supabase.from('supplier_invoice_items').insert(creditItems)
@@ -2559,6 +2575,8 @@ async function commitCreditInvoice(
our_reference: original.our_reference,
notes: reason || `Krediterar faktura ${original.invoice_number}`,
credited_invoice_id: id,
// Dimensions PR7: copy so the reversal nets against the same cells.
default_dimensions: original.default_dimensions ?? {},
status: 'sent',
})
.select()
@@ -2580,6 +2598,7 @@ async function commitCreditInvoice(
vat_amount?: number
revenue_account?: string | null
article_id?: string | null
dimensions?: Record<string, string>
}) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
@@ -2595,6 +2614,8 @@ async function commitCreditInvoice(
// VAT-derived 3001) so the override account doesn't keep a dangling balance.
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
// Same reasoning for the per-item bag (dimensions PR7).
dimensions: item.dimensions ?? {},
}))
const { error: itemsError } = await supabase
@@ -2722,6 +2743,8 @@ async function commitConvertInvoice(
notes: proforma.notes,
document_type: 'invoice',
converted_from_id: id,
// Dimensions PR7: the converted invoice books with the proforma's bag.
default_dimensions: proforma.default_dimensions ?? {},
})
.select()
.single()
@@ -2751,6 +2774,7 @@ async function commitConvertInvoice(
vat_amount: item.vat_amount ?? 0,
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
dimensions: item.dimensions ?? {},
}))
if (items.length > 0) {
+11 -1
View File
@@ -59,6 +59,12 @@ export interface CategorizeMatchedTransactionOpts {
* account, and the caller surfaces the skip.
*/
allowDuplicate?: boolean
/**
* Dimensions PR7: bag applied to the business (expense/revenue) lines of the
* generated verifikat — bank/VAT lines stay untagged. Resolved against the
* registry at staging time (MCP) or picked in the UI.
*/
dimensions?: Record<string, string>
}
// ── Helper: ensure a fiscal period covers the date ──────────────────
@@ -151,7 +157,7 @@ export async function categorizeMatchedTransaction(
*/
exclude?: BookingDuplicateExclusions,
): Promise<CategorizeCoreResult> {
const { category, vatTreatment, vatAmount, notes, allowDuplicate } = opts
const { category, vatTreatment, vatAmount, notes, allowDuplicate, dimensions } = opts
const { data: transaction, error: fetchError } = await supabase
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
@@ -243,6 +249,10 @@ export async function categorizeMatchedTransaction(
const mappingResult = buildMappingResultFromCategory(
category, transaction as Transaction, isBusiness, entityType, vatTreatment, vatAmount
)
// Dimensions PR7: tag the business lines of the generated verifikat.
if (dimensions && Object.keys(dimensions).length > 0) {
mappingResult.dimensions = dimensions
}
if (!mappingResult.debit_account || !mappingResult.credit_account) {
return { error: `No account mapping for category "${category}" with entity type "${entityType}".`, status: 400 }
+9
View File
@@ -2022,6 +2022,7 @@
"manual_remove_line": "Remove line",
"manual_description_placeholder": "Description (optional)",
"docs_inherit_hint": "{count, plural, one {# supporting document will follow (7-year retention per BFL 7 kap)} other {# supporting documents will follow (7-year retention per BFL 7 kap)}}",
"dimensions_hint": "Cost centre/project is applied to the voucher's lines.",
"cancel": "Cancel",
"confirm": "Confirm booking"
},
@@ -2169,6 +2170,10 @@
"invoice_editor": {
"row_menu_set_account": "Set sales account",
"row_menu_remove_account": "Remove sales account",
"row_menu_set_dimensions": "Set cost centre/project",
"row_menu_remove_dimensions": "Remove cost centre/project",
"dimensions_default_hint": "Cost centre/project applies to all rows without their own tagging.",
"row_dimensions_inherit_hint": "Empty fields inherit the invoice default ({dims}).",
"revenue_account_label": "Sales account",
"revenue_account_hint": "Leave blank to derive the account from the VAT rate. Ignored for reverse charge and export.",
"ore_rounding_label": "Öre rounding",
@@ -2750,6 +2755,10 @@
"description_placeholder": "Description",
"row_label": "Row {index}",
"remove_row_aria": "Remove row {index}",
"row_dimensions_aria": "Cost centre/project for row {index}",
"row_dimensions_title": "Cost centre/project",
"row_dimensions_inherit_hint": "Empty fields inherit the invoice default ({dims}).",
"dimensions_default_hint": "Cost centre/project applies to all rows without their own tagging.",
"ai_totals_label": "From invoice (AI):",
"ai_net": "Net {amount}",
"ai_vat": "VAT {amount}",
+9
View File
@@ -2022,6 +2022,7 @@
"manual_remove_line": "Ta bort rad",
"manual_description_placeholder": "Beskrivning (valfritt)",
"docs_inherit_hint": "{count, plural, one {# verifikationsunderlag följer med (sparas i 7 år enligt BFL 7 kap)} other {# verifikationsunderlag följer med (sparas i 7 år enligt BFL 7 kap)}}",
"dimensions_hint": "Kostnadsställe/projekt sätts på verifikatets rader.",
"cancel": "Avbryt",
"confirm": "Bekräfta bokföring"
},
@@ -2169,6 +2170,10 @@
"invoice_editor": {
"row_menu_set_account": "Ange försäljningskonto",
"row_menu_remove_account": "Ta bort försäljningskonto",
"row_menu_set_dimensions": "Ange kostnadsställe/projekt",
"row_menu_remove_dimensions": "Ta bort kostnadsställe/projekt",
"dimensions_default_hint": "Kostnadsställe/projekt gäller alla rader utan egen märkning.",
"row_dimensions_inherit_hint": "Tomma fält ärver fakturans standard ({dims}).",
"revenue_account_label": "Försäljningskonto",
"revenue_account_hint": "Lämna tomt för att härleda kontot från momssatsen. Ignoreras för omvänd skattskyldighet och export.",
"ore_rounding_label": "Öresavrundning",
@@ -2750,6 +2755,10 @@
"description_placeholder": "Beskrivning",
"row_label": "Rad {index}",
"remove_row_aria": "Ta bort rad {index}",
"row_dimensions_aria": "Kostnadsställe/Projekt för rad {index}",
"row_dimensions_title": "Kostnadsställe/Projekt",
"row_dimensions_inherit_hint": "Tomma fält ärver fakturans standard ({dims}).",
"dimensions_default_hint": "Kostnadsställe/projekt gäller alla rader utan egen märkning.",
"ai_totals_label": "Från fakturan (AI):",
"ai_net": "Netto {amount}",
"ai_vat": "Moms {amount}",
@@ -0,0 +1,54 @@
-- Dimensions PR7 (producers): source documents carry dimension tags so the
-- entry generators can propagate them onto journal lines.
--
-- invoices.default_dimensions / supplier_invoices.default_dimensions
-- {sie_dim_no: code} bag applied to EVERY generated line (issuance,
-- payment, credit — payment vouchers re-propagate from the linked
-- invoice under both accounting methods).
-- invoice_items.dimensions / supplier_invoice_items.dimensions
-- per-item bag merged OVER the invoice default on the revenue/expense
-- line that item books to (item wins per key).
--
-- Same shape + CHECK as journal_entry_lines.dimensions (20260702084500).
-- No indexes: these columns are read via their parent row when generating
-- entries, never containment-queried — reporting queries hit the GIN on
-- journal_entry_lines. NOT NULL DEFAULT '{}' is metadata-only on PG11+.
--
-- pg-test: covered-by — plain column adds with a type CHECK, no
-- trigger/RPC/RLS/DEFERRABLE change. Propagation logic is TS-side
-- (lib/bookkeeping/{invoice,supplier-invoice}-entries.ts unit tests).
ALTER TABLE public.invoices
ADD COLUMN default_dimensions jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.invoices
ADD CONSTRAINT invoices_default_dimensions_is_object
CHECK (jsonb_typeof(default_dimensions) = 'object');
ALTER TABLE public.invoice_items
ADD COLUMN dimensions jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.invoice_items
ADD CONSTRAINT invoice_items_dimensions_is_object
CHECK (jsonb_typeof(dimensions) = 'object');
ALTER TABLE public.supplier_invoices
ADD COLUMN default_dimensions jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.supplier_invoices
ADD CONSTRAINT supplier_invoices_default_dimensions_is_object
CHECK (jsonb_typeof(default_dimensions) = 'object');
ALTER TABLE public.supplier_invoice_items
ADD COLUMN dimensions jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.supplier_invoice_items
ADD CONSTRAINT supplier_invoice_items_dimensions_is_object
CHECK (jsonb_typeof(dimensions) = 'object');
COMMENT ON COLUMN public.invoices.default_dimensions IS
'Dimension bag {sie_dim_no: code} applied to every journal line generated from this invoice; invoice_items.dimensions merges over it per revenue line. See lib/bookkeeping/invoice-entries.ts.';
COMMENT ON COLUMN public.invoice_items.dimensions IS
'Per-item dimension bag merged over invoices.default_dimensions on the revenue line this item books to.';
COMMENT ON COLUMN public.supplier_invoices.default_dimensions IS
'Dimension bag {sie_dim_no: code} applied to every journal line generated from this supplier invoice; supplier_invoice_items.dimensions merges over it per expense line. See lib/bookkeeping/supplier-invoice-entries.ts.';
COMMENT ON COLUMN public.supplier_invoice_items.dimensions IS
'Per-item dimension bag merged over supplier_invoices.default_dimensions on the expense line this item books to.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,392 @@
-- Dimensions PR7 (producers): bulk_book_transactions persists per-line
-- dimension bags.
--
-- The RPC is the ONE journal-line writer that bypasses the TS engine (it
-- inlines the insert + commit_journal_entry), so it must replicate what
-- lib/bookkeeping/dimension-resolver.ts does on the engine path:
-- * store the bag on journal_entry_lines.dimensions, and
-- * derive the dual-write mirror columns cost_center/project from keys
-- '1'/'6' (lineDimensionColumns) — mirrors must NEVER be set
-- independently of the bag.
--
-- Bag handling mirrors DimensionsBagSchema: object type enforced (reject,
-- not coerce — a non-object bag is a malformed payload, same posture as
-- BULK_BOOK_NEGATIVE_LINE), keys must be canonical SIE dimension numbers
-- (no leading zeros), values are trimmed and empties dropped. No registry
-- validation here by design: engine-side registry validation is soft
-- (toggle-governed, PR3) and both callers already resolve against the
-- registry — the route via the DimensionCombobox UI + Zod bag schema, the
-- MCP staged path via resolveDimensionBags at staging time.
--
-- Function body otherwise byte-identical to 20260602121000 (the latest
-- definition; 20260619130100 deliberately left this RPC unguarded because
-- its in-function auth.uid() membership check IS the tenant guard).
-- CREATE OR REPLACE resets proconfig — SET search_path restated.
--
-- pg-test: tests/pg/bulk-book-dimensions.pg.test.ts (new lines-with-dims
-- coverage; existing bulk-book behaviour covered by tests/pg/bulk-book*.).
CREATE OR REPLACE FUNCTION public.bulk_book_transactions(
p_tx_ids uuid[],
p_existing_journal_entry_id uuid,
p_new_entry jsonb,
p_company_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_tx RECORD;
v_tx_date date;
v_total_amount numeric := 0;
v_total_amount_abs numeric;
v_direction text;
v_tx_count int := 0;
v_voucher RECORD;
v_voucher_bank_net numeric := 0;
v_fiscal_period_id uuid;
v_period_is_closed boolean;
v_period_locked_at timestamptz;
v_journal_entry_id uuid;
v_voucher_series text := 'A';
v_voucher_number int;
v_entry_description text;
v_line jsonb;
v_line_account text;
v_line_debit numeric;
v_line_credit numeric;
v_line_currency text;
v_line_dims jsonb;
v_lines_total_debit numeric := 0;
v_lines_total_credit numeric := 0;
v_lines_bank_net numeric := 0;
v_sort_order int := 0;
v_docs_linked int := 0;
v_target_je uuid;
v_invalid_accounts text[];
v_now timestamptz := now();
v_caller uuid := auth.uid();
BEGIN
IF v_caller IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED');
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = v_caller AND company_id = p_company_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNAUTHORIZED');
END IF;
IF p_tx_ids IS NULL OR array_length(p_tx_ids, 1) IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_TXS');
END IF;
IF (p_existing_journal_entry_id IS NULL AND p_new_entry IS NULL)
OR (p_existing_journal_entry_id IS NOT NULL AND p_new_entry IS NOT NULL) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_PAYLOAD');
END IF;
FOR v_tx IN
SELECT * FROM public.transactions
WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
ORDER BY id
FOR UPDATE
LOOP
v_tx_count := v_tx_count + 1;
IF v_tx.journal_entry_id IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED',
'details', jsonb_build_object('tx_id', v_tx.id));
END IF;
IF EXISTS (
SELECT 1 FROM public.transaction_voucher_links tvl
WHERE tvl.transaction_id = v_tx.id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ALREADY_BOOKED',
'details', jsonb_build_object('tx_id', v_tx.id, 'via', 'transaction_voucher_links'));
END IF;
IF v_tx.amount = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TX_ZERO_AMOUNT',
'details', jsonb_build_object('tx_id', v_tx.id));
END IF;
IF v_tx_date IS NULL THEN
v_tx_date := v_tx.date;
ELSIF v_tx_date <> v_tx.date THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DATE_MISMATCH',
'details', jsonb_build_object('first_date', v_tx_date, 'other_date', v_tx.date));
END IF;
IF v_direction IS NULL THEN
v_direction := CASE WHEN v_tx.amount > 0 THEN 'income' ELSE 'expense' END;
ELSIF (v_direction = 'income' AND v_tx.amount < 0)
OR (v_direction = 'expense' AND v_tx.amount > 0) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_DIRECTION_MISMATCH',
'details', jsonb_build_object('expected', v_direction, 'tx_id', v_tx.id));
END IF;
v_total_amount := v_total_amount + v_tx.amount;
END LOOP;
IF v_tx_count = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND');
END IF;
IF v_tx_count <> COALESCE(array_length(p_tx_ids, 1), 0) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_TXS_NOT_FOUND',
'details', jsonb_build_object('expected', array_length(p_tx_ids, 1), 'found', v_tx_count));
END IF;
v_total_amount_abs := ABS(v_total_amount);
IF p_existing_journal_entry_id IS NOT NULL THEN
SELECT * INTO v_voucher FROM public.journal_entries
WHERE id = p_existing_journal_entry_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_JE_NOT_FOUND',
'details', jsonb_build_object('journal_entry_id', p_existing_journal_entry_id));
END IF;
IF v_voucher.status <> 'posted' THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_JE_NOT_POSTED',
'details', jsonb_build_object('status', v_voucher.status));
END IF;
SELECT COALESCE(SUM(debit_amount - credit_amount), 0) INTO v_voucher_bank_net
FROM public.journal_entry_lines
WHERE journal_entry_id = p_existing_journal_entry_id
AND length(account_number) = 4
AND account_number BETWEEN '1900' AND '1999';
IF ABS(v_voucher_bank_net - v_total_amount) > 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH',
'details', jsonb_build_object(
'tx_sum', v_total_amount, 'voucher_bank_net', v_voucher_bank_net));
END IF;
FOR v_tx IN
SELECT * FROM public.transactions
WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
ORDER BY id
LOOP
INSERT INTO public.transaction_voucher_links
(user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
VALUES
(v_caller, p_company_id, v_tx.id, p_existing_journal_entry_id, v_tx.amount, 'bank_line');
END LOOP;
IF v_tx_count = 1 THEN
UPDATE public.transactions
SET journal_entry_id = p_existing_journal_entry_id,
reconciliation_method = 'manual',
is_business = TRUE,
updated_at = v_now
WHERE id = p_tx_ids[1];
ELSE
UPDATE public.transactions
SET is_business = TRUE, updated_at = v_now
WHERE id = ANY(p_tx_ids);
END IF;
v_target_je := p_existing_journal_entry_id;
v_voucher_series := v_voucher.voucher_series;
v_voucher_number := v_voucher.voucher_number;
ELSE
v_entry_description := p_new_entry->>'description';
IF v_entry_description IS NULL OR LENGTH(TRIM(v_entry_description)) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_MISSING_DESCRIPTION');
END IF;
IF jsonb_typeof(p_new_entry->'lines') IS DISTINCT FROM 'array'
OR jsonb_array_length(p_new_entry->'lines') < 2 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_LINES');
END IF;
WITH submitted AS (
SELECT DISTINCT value->>'account_number' AS acct
FROM jsonb_array_elements(p_new_entry->'lines')
)
SELECT array_agg(s.acct ORDER BY s.acct) INTO v_invalid_accounts
FROM submitted s
WHERE NOT EXISTS (
SELECT 1 FROM public.chart_of_accounts coa
WHERE coa.account_number = s.acct
AND coa.company_id = p_company_id
AND coa.is_active = true
);
IF v_invalid_accounts IS NOT NULL AND array_length(v_invalid_accounts, 1) > 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_ACCOUNT',
'details', jsonb_build_object('invalid_accounts', v_invalid_accounts));
END IF;
FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines')
LOOP
v_line_account := v_line->>'account_number';
v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0);
v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0);
IF v_line_debit < 0 OR v_line_credit < 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NEGATIVE_LINE',
'details', jsonb_build_object('account', v_line_account));
END IF;
IF v_line_debit > 0 AND v_line_credit > 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_BOTH_SIDES_NONZERO',
'details', jsonb_build_object('account', v_line_account));
END IF;
-- Dimensions PR7: a present-but-malformed bag is a payload error, not
-- something to silently coerce (same posture as the amount guards).
IF v_line ? 'dimensions'
AND jsonb_typeof(v_line->'dimensions') IS DISTINCT FROM 'object' THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_INVALID_DIMENSIONS',
'details', jsonb_build_object('account', v_line_account));
END IF;
v_lines_total_debit := v_lines_total_debit + v_line_debit;
v_lines_total_credit := v_lines_total_credit + v_line_credit;
IF length(v_line_account) = 4 AND v_line_account BETWEEN '1900' AND '1999' THEN
v_lines_bank_net := v_lines_bank_net + v_line_debit - v_line_credit;
END IF;
END LOOP;
IF ABS(v_lines_total_debit - v_lines_total_credit) > 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_UNBALANCED',
'details', jsonb_build_object(
'debit_sum', v_lines_total_debit, 'credit_sum', v_lines_total_credit));
END IF;
IF ABS(v_lines_bank_net - v_total_amount) > 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_AMOUNT_MISMATCH',
'details', jsonb_build_object(
'tx_sum', v_total_amount,
'lines_bank_net', v_lines_bank_net));
END IF;
SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
FROM public.fiscal_periods
WHERE company_id = p_company_id AND v_tx_date BETWEEN period_start AND period_end
ORDER BY period_start DESC LIMIT 1;
IF v_fiscal_period_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_NO_FISCAL_PERIOD',
'details', jsonb_build_object('tx_date', v_tx_date));
END IF;
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BULK_BOOK_PERIOD_LOCKED',
'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id));
END IF;
v_journal_entry_id := gen_random_uuid();
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
(v_journal_entry_id, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
v_tx_date, v_entry_description, 'manual', 'draft');
v_sort_order := 0;
FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_entry->'lines')
LOOP
v_line_account := v_line->>'account_number';
v_line_debit := COALESCE((v_line->>'debit_amount')::numeric, 0);
v_line_credit := COALESCE((v_line->>'credit_amount')::numeric, 0);
v_line_currency := COALESCE(v_line->>'currency', 'SEK');
-- Dimensions PR7: normalize the bag the way DimensionsBagSchema +
-- normalizeLineDimensions do on the engine path — keys must be
-- canonical SIE dim numbers (no leading zeros), values trimmed,
-- empties dropped. Mirrors derived from the stored bag, never set
-- independently (lineDimensionColumns contract).
SELECT COALESCE(jsonb_object_agg(d.key, btrim(d.value)), '{}'::jsonb)
INTO v_line_dims
FROM jsonb_each_text(COALESCE(v_line->'dimensions', '{}'::jsonb)) AS d
WHERE d.key ~ '^[1-9][0-9]*$' AND btrim(d.value) <> '';
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description, dimensions, cost_center, project)
VALUES
(v_journal_entry_id, v_line_account, v_line_debit, v_line_credit, v_line_currency,
COALESCE((v_line->>'sort_order')::int, v_sort_order),
v_line->>'line_description',
v_line_dims,
NULLIF(v_line_dims->>'1', ''),
NULLIF(v_line_dims->>'6', ''));
v_sort_order := v_sort_order + 1;
END LOOP;
SELECT voucher_number INTO v_voucher_number
FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
FOR v_tx IN
SELECT * FROM public.transactions
WHERE id = ANY(p_tx_ids) AND company_id = p_company_id
ORDER BY id
LOOP
INSERT INTO public.transaction_voucher_links
(user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
VALUES
(v_caller, p_company_id, v_tx.id, v_journal_entry_id, v_tx.amount, 'bank_line');
END LOOP;
IF v_tx_count = 1 THEN
UPDATE public.transactions
SET journal_entry_id = v_journal_entry_id,
is_business = TRUE,
updated_at = v_now
WHERE id = p_tx_ids[1];
ELSE
UPDATE public.transactions
SET is_business = TRUE, updated_at = v_now
WHERE id = ANY(p_tx_ids);
END IF;
v_target_je := v_journal_entry_id;
END IF;
WITH linked AS (
UPDATE public.document_attachments AS d
SET journal_entry_id = v_target_je,
updated_at = v_now
FROM public.transactions AS t
WHERE t.id = ANY(p_tx_ids)
AND t.company_id = p_company_id
AND t.document_id = d.id
AND d.company_id = p_company_id
AND d.journal_entry_id IS NULL
RETURNING d.id
)
SELECT COUNT(*)::int INTO v_docs_linked FROM linked;
RETURN jsonb_build_object(
'ok', true,
'mode', CASE WHEN p_existing_journal_entry_id IS NOT NULL THEN 'link_existing' ELSE 'create_new' END,
'journal_entry_id', v_target_je,
'voucher_series', v_voucher_series,
'voucher_number', v_voucher_number,
'linked_tx_count', v_tx_count,
'tx_sum', v_total_amount,
'docs_linked', v_docs_linked
);
END;
$$;
COMMENT ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) IS
'Bulk-book N bank transactions sharing the same date into a single combined verifikat (samlingsverifikation per BFL 5 kap 6§). PR #610 round 2: chart_of_accounts allowlist enforced inside the RPC; doc inheritance CTE tenant-scoped both sides; bank-leg range guarded by length(4) + BETWEEN. Dimensions PR7: p_new_entry lines may carry a dimensions bag {sie_dim_no: code}; stored on journal_entry_lines.dimensions with cost_center/project mirrors derived from keys 1/6.';
-- CREATE OR REPLACE preserves privileges; restated for auditability.
REVOKE ALL ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.bulk_book_transactions(uuid[], uuid, jsonb, uuid) TO authenticated;
NOTIFY pgrst, 'reload schema';
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Covers 20260702201000_bulk_book_transactions_dimensions (dimensions PR7):
*
* - p_new_entry lines may carry a dimensions bag {sie_dim_no: code}. The
* RPC stores it on journal_entry_lines.dimensions AND derives the
* dual-write mirror columns cost_center/project from keys '1'/'6'
* (lineDimensionColumns contract — mirrors never set independently).
* - Bag normalization mirrors DimensionsBagSchema: non-canonical keys
* (leading zeros, non-numeric) and blank values are dropped; values are
* trimmed.
* - Lines without a bag store '{}' with NULL mirrors (column default
* semantics preserved).
* - A present-but-malformed bag (non-object) is rejected with
* BULK_BOOK_INVALID_DIMENSIONS before any insert.
*/
async function insertTransaction(params: {
userId: string
companyId: string
amount: number
date?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.transactions
(id, user_id, company_id, date, description, amount, currency, category)
VALUES ($1, $2, $3, $4, 'Bank tx', $5, 'SEK', 'uncategorized')`,
[id, params.userId, params.companyId, params.date ?? '2026-06-05', params.amount],
)
return id
}
async function seedTenant() {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
await insertFiscalPeriod({
userId,
companyId,
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
})
await getPool().query(
`INSERT INTO public.chart_of_accounts
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance, is_active)
SELECT $1, $2, n, name, cls, atype, nbal, true
FROM (VALUES
('1930', 'Bankkonto', 1, 'asset', 'debit'),
('2611', 'Utgående moms 25%', 2, 'liability', 'credit'),
('3001', 'Försäljning 25% moms', 3, 'revenue', 'credit')
) AS t(n, name, cls, atype, nbal)`,
[userId, companyId],
)
return { userId, companyId }
}
interface RpcResult {
ok: boolean
code?: string
journal_entry_id?: string
}
interface LineRow {
account_number: string
dimensions: Record<string, string>
cost_center: string | null
project: string | null
}
/**
* Run the RPC and read back the created lines INSIDE the same
* withUserContext transaction — the harness rolls the context back after
* the callback, so any assertion data must be captured in here.
*/
async function callBulkBook(
userId: string,
companyId: string,
txIds: string[],
newEntry: unknown,
): Promise<{ result: RpcResult; lines: LineRow[]; linkCount: number }> {
return withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
`SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
[txIds, null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
let lines: LineRow[] = []
if (result.journal_entry_id) {
const lr = await client.query<LineRow>(
`SELECT account_number, dimensions, cost_center, project
FROM public.journal_entry_lines
WHERE journal_entry_id = $1 ORDER BY sort_order`,
[result.journal_entry_id],
)
lines = lr.rows
}
const links = await client.query(
`SELECT 1 FROM public.transaction_voucher_links WHERE transaction_id = ANY($1::uuid[])`,
[txIds],
)
return { result, lines, linkCount: links.rowCount ?? 0 }
})
}
describe('bulk_book_transactions — line dimensions (PR7)', () => {
it('stores the bag and derives cost_center/project mirrors from keys 1/6', async () => {
const { userId, companyId } = await seedTenant()
const tx = await insertTransaction({ userId, companyId, amount: 500 })
const { result, lines } = await callBulkBook(userId, companyId, [tx], {
description: 'Samlingsverifikation med dimensioner',
lines: [
{ account_number: '1930', debit_amount: 500, credit_amount: 0, currency: 'SEK' },
{
account_number: '3001', debit_amount: 0, credit_amount: 400, currency: 'SEK',
dimensions: { '1': 'KS01', '6': 'P001' },
},
{
account_number: '2611', debit_amount: 0, credit_amount: 100, currency: 'SEK',
dimensions: { '6': 'P001' },
},
],
})
expect(result.ok).toBe(true)
expect(lines).toHaveLength(3)
const bank = lines.find((l) => l.account_number === '1930')!
const revenue = lines.find((l) => l.account_number === '3001')!
const vat = lines.find((l) => l.account_number === '2611')!
expect(bank.dimensions).toEqual({})
expect(bank.cost_center).toBeNull()
expect(bank.project).toBeNull()
expect(revenue.dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(revenue.cost_center).toBe('KS01')
expect(revenue.project).toBe('P001')
expect(vat.dimensions).toEqual({ '6': 'P001' })
expect(vat.cost_center).toBeNull()
expect(vat.project).toBe('P001')
})
it('normalizes the bag: drops non-canonical keys and blank values, trims values', async () => {
const { userId, companyId } = await seedTenant()
const tx = await insertTransaction({ userId, companyId, amount: 250 })
const { result, lines } = await callBulkBook(userId, companyId, [tx], {
description: 'Normaliseringstest',
lines: [
{ account_number: '1930', debit_amount: 250, credit_amount: 0, currency: 'SEK' },
{
account_number: '3001', debit_amount: 0, credit_amount: 250, currency: 'SEK',
// '01' (leading zero), 'abc' (non-numeric) and blank values must be
// dropped; ' KS02 ' must be stored trimmed.
dimensions: { '01': 'GHOST', abc: 'X', '7': ' ', '1': ' KS02 ' },
},
],
})
expect(result.ok).toBe(true)
expect(lines).toHaveLength(2)
const revenue = lines.find((l) => l.account_number === '3001')!
expect(revenue.dimensions).toEqual({ '1': 'KS02' })
expect(revenue.cost_center).toBe('KS02')
expect(revenue.project).toBeNull()
})
it('rejects a present-but-malformed bag with BULK_BOOK_INVALID_DIMENSIONS', async () => {
const { userId, companyId } = await seedTenant()
const tx = await insertTransaction({ userId, companyId, amount: 100 })
const { result, linkCount } = await callBulkBook(userId, companyId, [tx], {
description: 'Trasig dimensionspayload',
lines: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, currency: 'SEK' },
{
account_number: '3001', debit_amount: 0, credit_amount: 100, currency: 'SEK',
dimensions: 'not-an-object',
},
],
})
expect(result.ok).toBe(false)
expect(result.code).toBe('BULK_BOOK_INVALID_DIMENSIONS')
// Nothing booked: the tx must remain unlinked.
expect(linkCount).toBe(0)
})
it('keeps dimension-less bookings byte-identical to before (default bag + NULL mirrors)', async () => {
const { userId, companyId } = await seedTenant()
const tx = await insertTransaction({ userId, companyId, amount: 300 })
const { result, lines } = await callBulkBook(userId, companyId, [tx], {
description: 'Utan dimensioner',
lines: [
{ account_number: '1930', debit_amount: 300, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 300, currency: 'SEK' },
],
})
expect(result.ok).toBe(true)
expect(lines).toHaveLength(2)
for (const line of lines) {
expect(line.dimensions).toEqual({})
expect(line.cost_center).toBeNull()
expect(line.project).toBeNull()
}
})
})
+31
View File
@@ -701,6 +701,12 @@ export interface SupplierInvoice {
notes: string | null
// Default dimensions bag ({sie_dim_no: code}, e.g. {"1":"KS01","6":"P001"})
// applied to every generated journal line; item-level `dimensions` merge on
// top of it for the expense lines (dimensions PR7). Stored as jsonb
// DEFAULT '{}'. Optional in TS for pre-migration fixtures.
default_dimensions?: Record<string, string>
created_at: string
updated_at: string
@@ -739,6 +745,10 @@ export interface SupplierInvoiceItem {
accrual_period_end?: string | null
accrual_balance_account?: string | null
// Per-item dimensions bag, merged over the invoice's default_dimensions on
// the expense line this item books to (dimensions PR7). jsonb DEFAULT '{}'.
dimensions?: Record<string, string>
created_at: string
}
@@ -872,6 +882,12 @@ export interface Invoice {
deduction_personnummer_encrypted?: string | null
deduction_personnummer_last4?: string | null
// Default dimensions bag ({sie_dim_no: code}) applied to every journal line
// generated from this invoice (issuance, payment, credit); item-level
// `dimensions` merge on top for the revenue lines (dimensions PR7).
// jsonb DEFAULT '{}'. Optional in TS for pre-migration fixtures.
default_dimensions?: Record<string, string>
created_at: string
updated_at: string
@@ -950,6 +966,10 @@ export interface InvoiceItem {
/** Lägenhetsnummer. Optional, used for ROT in flerbostadshus. */
apartment_number?: string | null
// Per-item dimensions bag, merged over the invoice's default_dimensions on
// the revenue line this item books to (dimensions PR7). jsonb DEFAULT '{}'.
dimensions?: Record<string, string>
created_at: string
}
@@ -1483,6 +1503,10 @@ export interface MappingResult {
vat_lines: VatJournalLine[]
all_lines_complete?: boolean // when true, vat_lines contains ALL non-settlement lines
description: string
// Dimensions bag applied to the business (expense/revenue) lines of the
// generated entry — from a counterparty template's line pattern or an
// explicit categorize param (dimensions PR7). Bank/VAT lines stay untagged.
dimensions?: Record<string, string>
}
// VAT journal line (auto-generated)
@@ -1491,6 +1515,9 @@ export interface VatJournalLine {
debit_amount: number
credit_amount: number
description: string
// Set on business-type lines materialized from a LinePatternEntry that
// carries dimensions (dimensions PR7); VAT/tax lines stay untagged.
dimensions?: Record<string, string>
}
// Categorization template source
@@ -1503,6 +1530,10 @@ export interface LinePatternEntry {
side: 'debit' | 'credit'
ratio?: number // proportion of NON-VAT amount (business + tax ratios sum to ~1.0)
vat_rate?: number // applied to FULL amount via rate/(1+rate) (vat type only)
// Dimensions bag ({sie_dim_no: code}) learned from the source vouchers'
// lines; applied to the materialized line on booking (dimensions PR7).
// Only preserved by learning when every occurrence agrees.
dimensions?: Record<string, string>
}
// Per-tenant counterparty-based categorization template