Files
accounted/lib/bookkeeping/__tests__/category-mapping.test.ts
T
Jakob WennbergandClaude Opus 4.7 7eb8715417 feat(transactions): split-payment allocator — 1 tx → N invoices (#603)
* fix(category-mapping): use leaf BAS accounts instead of group codes

3900, 5800, 6200 are BAS gruppkonton (header codes) and shouldn't carry
postings. Switched the default mappings to the matching leaf accounts:

  - income_other:     3900 -> 3999 (Övriga rörelseintäkter)
  - expense_travel:   5800 -> 5890 (Övriga resekostnader)
  - expense_telecom:  6200 -> 6230 (Datakommunikation)

The fallback for income_other inside getCategoryAccountMapping was also
hardcoded to '3900'; updated to '3999' for consistency.

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

* feat(transactions): split-payment allocator — 1 tx → N invoices

Closes one of the two flows that motivated PR #602's foundation:
allocating a single bank transaction across multiple customer OR
multiple supplier invoices, with one combined verifikat
(samlingsverifikation per BFL 5 kap 6§ st 3).

## Backend (Phase 3a)

- **PL/pgSQL RPC** match_batch_allocate (~400 lines): locks the tx +
  each target invoice with SELECT … FOR UPDATE in id order, validates
  status/currency/remaining/direction before any write, builds the
  combined verifikat via commit_journal_entry (atomically assigns
  voucher_number + flips draft→posted), inserts N rows in
  invoice_payments or supplier_invoice_payments pointing at the same
  JE, advances paid_amount/remaining_amount/status per invoice. Returns
  { ok, journal_entry_id, voucher_number, allocations: [...] } on
  success or { ok: false, code, details } on guard failure. Mixed
  customer+supplier kinds are rejected (v1 scope).

- **Endpoint** POST /api/transactions/[id]/match-batch — thin wrapper
  around the RPC. Validates body via MatchBatchSchema (zod
  discriminatedUnion + superRefine to catch mixed-kinds at the schema
  layer). On RPC success, emits one invoice.match_confirmed or
  supplier_invoice.match_confirmed event per allocation so existing
  subscribers (reminders, automations, processing-history) keep
  working. Maps the structured RPC error envelope to
  errorResponseFromCode.

- **16 new BATCH_* error codes** (sv+en): BATCH_TX_NOT_FOUND,
  BATCH_TX_ALREADY_BOOKED, BATCH_OVERSHOOT, BATCH_AMOUNT_EXCEEDS_TX,
  BATCH_MIXED_KINDS_UNSUPPORTED, BATCH_DIRECTION_MISMATCH,
  BATCH_CURRENCY_MISMATCH, BATCH_PERIOD_LOCKED, BATCH_RPC_FAILED, etc.

## UI (Phase 5a)

- **MatchAllocationDialog** (components/transactions/) — direction-
  aware (positive tx → customer invoices, negative → supplier). Search
  + selectable list of open invoices. Per-row amount input with default
  = min(invoice.remaining, tx_remaining_budget). Live tally with
  green-check balanced state, red overshoot warning, gray leftover
  note. Confirm button disabled on overshoot. POSTs to /match-batch
  and on 200 triggers the same exit animation as single-tx match.

- **Inbox row** gains a second outline icon button (Split icon) next
  to the existing 1:1 match button, gated by the same
  showInvoiceMatchButton predicate. Tooltip explains the direction-
  aware split. Opens MatchAllocationDialog.

- **i18n** strings under tx_match_allocation namespace in sv.json
  and en.json (32 keys each).

## Tests

- tests/pg/match-batch-allocate.pg.test.ts — 5 pg-real tests covering
  combined verifikat shape, overshoot guard, already-booked tx,
  direction mismatch, mixed-kinds rejection.
- app/api/transactions/[id]/match-batch/__tests__/route.test.ts — 5
  unit tests covering schema validation, mixed-kinds, happy path,
  structured-error mapping, raw-error → BATCH_RPC_FAILED.

63 unit tests pass across the touched paths. The RPC migration was
already applied to remote in an earlier Phase 3a session (idempotent
CREATE OR REPLACE FUNCTION; the next replay is a no-op).

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

* fix(match-batch): PR #603 review round 1 + CI fixes

Closes both CI failures and the three real review findings.

## CI fixes

- **pg-real failure**: the RPC declared
  `v_journal_entry_id uuid := uuid_generate_v4()` which fails in the CI
  Postgres image (uuid-ossp extension is off). Switched to
  `gen_random_uuid()` — the codebase standard already used by
  supplier_invoices, invoice_inbox, etc.
- **core-only failure**: my earlier BAS leaf-account commit
  (3900→3999, 5800→5890, 6200→6230) didn't update the matching
  `lib/bookkeeping/__tests__/category-mapping.test.ts` expectations,
  and `getDefaultAccountForCategory`'s fallback for `income_*` was
  still hardcoded to '3900'. Updated both.

## Review findings (greptile)

- **P1 deadlock-stable locking** (`match_batch_allocate.sql:11`): the
  validation `FOR UPDATE` loop ran in caller-supplied array order. Two
  concurrent calls with overlapping invoice sets in opposite orders
  could deadlock and one would abort with `BATCH_RPC_FAILED`. Now
  all three loops (validate, build lines, advance invoices) iterate
  via `SELECT … FROM jsonb_array_elements(…) ORDER BY
  COALESCE(invoice_id, supplier_invoice_id)`, giving a stable global
  lock order regardless of how the caller ordered the JSON array.

- **P1 duplicate-allocation detection** (`match_batch_allocate.sql:163`):
  the same invoice_id listed twice would pass the per-row overshoot
  guard (both iterations read the original `remaining_amount`) and
  the write loop would insert two `invoice_payments` rows for the
  same invoice. Added a `v_seen_ids text[]` check in the validation
  loop and a new `BATCH_DUPLICATE_ALLOCATION` error code (sv + en).
  The dialog already prevents this UI-side via `if (prev[candidate.id]
  return prev` — the RPC guard is the defense-in-depth layer.

- **P2 zod `.positive()`** (`schemas.ts:544`): allocation amount was
  `nonNegativeAmount` (allowing 0), passing schema validation only to
  be rejected by the RPC with `BATCH_INVALID_AMOUNT`. Now
  `z.number().positive(…)` so 0-amount entries fail at the schema
  layer with a per-field path, cleaner 400.

- **P2 strict `> 0` direction check** (`MatchAllocationDialog.tsx:82`):
  used `amount >= 0` to pick customer-side, but a zero-amount tx would
  load customer candidates only to hit `BATCH_TX_ZERO_AMOUNT` at
  submit time after the user has filled in allocations. Switched to
  `> 0` so 0-amount tx never reaches the dialog at all (it's rejected
  by the RPC immediately).

The fourth Greptile comment (the schema P2 about amount validation)
overlaps with the third; addressed in the same edit.

## Verification

- 112 unit tests pass across touched paths
- ESLint clean
- New pg-real test `tests/pg/match-batch-allocate.pg.test.ts` covers
  the dedupe scenario (same supplier invoice listed twice with summing
  amounts that individually pass per-row overshoot)
- RPC patch applied to remote via Supabase MCP

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

* fix(match-batch): PR #603 review round 2 — compliance hardening

Addresses the actionable findings from compliance-swarm and
Swedish-accounting-compliance reviews. Six small RPC changes + two
TS-side guards, all bundled in one follow-up migration.

## Security

- **(GDPR Art.5(1)(f) / ISO A.8.2) Caller verification**: SECURITY
  DEFINER bypasses RLS, and the prior RPC accepted any
  (p_user_id, p_company_id) pair from the route. Now the function
  rejects with new `BATCH_UNAUTHORIZED` (sv+en, HTTP 403) if
  `auth.uid()` is not a member of `p_company_id`. Pattern lifted from
  `harden_invoice_number_rpcs` (#20260510140000).
- **(OWASP V4.2) Allocation cap**: `MatchBatchSchema.allocations` now
  carries `.max(100)` to prevent DoS via unbounded FOR UPDATE locks.

## Swedish accounting correctness

- **source_type per direction**: was hardcoded to `'invoice_paid'` for
  both customer + supplier batches, mis-routing behandlingshistorik
  filters. Customer batches keep `'invoice_paid'`, supplier batches now
  write `'supplier_invoice_paid'`.
- **Fiscal-period determinism**: `LIMIT 1` on the period lookup was
  non-deterministic on overlap (e.g. corrected broken year). Added
  `ORDER BY period_start DESC` so the most recent matching period
  wins.
- **Tolerance harmonisation**: cross-allocation sum used `+0.01`
  tolerance while per-row used `+0.005`. Both now `+0.005` so a
  multi-row batch can't drift ~0.01 SEK while each row passes
  individually.
- **`transactions.category` no longer overwritten**: was forced to
  `'income_services'` (→ BAS 3001 at 25% VAT) for any customer batch,
  misrepresenting reduced-rate / export / EU-service invoices. The
  category is only meaningful 1:1 with a single invoice; batches now
  leave it as-is, mirroring the supplier-side `ELSE category` branch.

## Tests

- `tests/pg/match-batch-allocate.pg.test.ts` now wraps every RPC call
  in `withUserContext(userId)` so `auth.uid()` resolves to the seeded
  owner. Without this the new membership check would have failed all
  existing tests.
- New pg-real test: `rejects with BATCH_UNAUTHORIZED when caller is
  not a member of the company` — outsider user gets explicit refusal.
- New happy-path assertion: `source_type = 'supplier_invoice_paid'`
  on the combined verifikat for supplier batches.

15 unit tests pass on the touched paths. RPC patch applied to remote
via Supabase MCP. Out-of-scope mcp-server changes still parked locally.

Skipped findings (documented in PR comment thread):
  - V8.2.1 ownership pre-check at route layer (RPC enforces it)
  - V4.5 / Art.5(1)(b) narrower API response and event payload —
    typed contracts require the full shapes
  - V2.4 rate-limiting — system-level, applies to all match endpoints
  - A.8.28 client-side RLS reliance — documented architectural choice
  - Direction pre-check at API layer (RPC catches with cleaner code)
  - V16 + Art.32 + Art.5(1)(b) low-severity logging nits

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:45:41 +02:00

331 lines
13 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
getCategoryAccountMapping,
getExpenseAccountForCategory,
getDefaultAccountForCategory,
getDefaultVatTreatmentForCategory,
buildMappingResultFromCategory,
} from '../category-mapping'
import { BAS_REFERENCE } from '../bas-data'
import { makeTransaction } from '@/tests/helpers'
import type { TransactionCategory, VatTreatment } from '@/types'
describe('getCategoryAccountMapping', () => {
describe('income_products uses correct account', () => {
it('maps income_products to 3001 (25% moms)', () => {
const result = getCategoryAccountMapping('income_products', 1000, true)
expect(result.creditAccount).toBe('3001')
})
it('income_products matches income_services account', () => {
const products = getCategoryAccountMapping('income_products', 1000, true)
const services = getCategoryAccountMapping('income_services', 1000, true)
expect(products.creditAccount).toBe(services.creditAccount)
})
})
describe('expense_office maps to 6110 (Kontorsförbrukning)', () => {
it('maps expense_office to 6110 (not 5010 Lokalhyra)', () => {
const result = getCategoryAccountMapping('expense_office', -500, true)
expect(result.debitAccount).toBe('6110')
})
})
describe('expense_education entity-type-aware', () => {
it('defaults to 6991 for enskild_firma', () => {
const result = getCategoryAccountMapping('expense_education', -500, true, 'enskild_firma')
expect(result.debitAccount).toBe('6991')
})
it('uses 7610 for aktiebolag', () => {
const result = getCategoryAccountMapping('expense_education', -500, true, 'aktiebolag')
expect(result.debitAccount).toBe('7610')
})
it('defaults to 6991 when no entityType provided', () => {
const result = getCategoryAccountMapping('expense_education', -500, true)
expect(result.debitAccount).toBe('6991')
})
})
})
describe('getExpenseAccountForCategory', () => {
it('returns null for non-expense categories', () => {
expect(getExpenseAccountForCategory('income_services')).toBeNull()
})
it('returns correct accounts for expense categories', () => {
expect(getExpenseAccountForCategory('expense_equipment')).toBe('5410')
expect(getExpenseAccountForCategory('expense_office')).toBe('6110')
expect(getExpenseAccountForCategory('expense_bank_fees')).toBe('6570')
})
})
describe('getDefaultAccountForCategory', () => {
it('returns expense account for expense categories', () => {
expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410')
expect(getDefaultAccountForCategory('expense_software')).toBe('5420')
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
expect(getDefaultAccountForCategory('expense_office')).toBe('6110')
expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570')
})
it('returns income account for income categories', () => {
expect(getDefaultAccountForCategory('income_services')).toBe('3001')
expect(getDefaultAccountForCategory('income_products')).toBe('3001')
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
})
it('returns private account for enskild firma', () => {
expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013')
})
it('returns private account for aktiebolag', () => {
expect(getDefaultAccountForCategory('private', 'aktiebolag')).toBe('2893')
})
it('returns entity-specific education account', () => {
expect(getDefaultAccountForCategory('expense_education', 'enskild_firma')).toBe('6991')
expect(getDefaultAccountForCategory('expense_education', 'aktiebolag')).toBe('7610')
})
it('returns fallback for uncategorized', () => {
expect(getDefaultAccountForCategory('uncategorized')).toBe('6991')
})
})
describe('buildMappingResultFromCategory', () => {
describe('reverse charge handling', () => {
it('generates fiktiv moms lines for reverse charge expense', () => {
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'reverse_charge')
expect(result.vat_lines).toHaveLength(2)
const debitLine = result.vat_lines.find((l) => l.account_number === '2645')
expect(debitLine).toBeDefined()
expect(debitLine!.debit_amount).toBe(250)
expect(debitLine!.credit_amount).toBe(0)
const creditLine = result.vat_lines.find((l) => l.account_number === '2614')
expect(creditLine).toBeDefined()
expect(creditLine!.debit_amount).toBe(0)
expect(creditLine!.credit_amount).toBe(250)
})
it('does not generate regular input VAT (2641) for reverse charge', () => {
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromCategory('expense_equipment', tx, true, 'enskild_firma', 'reverse_charge')
const hasRegularVat = result.vat_lines.some((l) => l.account_number === '2641')
expect(hasRegularVat).toBe(false)
})
it('does not generate VAT lines for reverse charge on income', () => {
const tx = makeTransaction({ amount: 1000 })
const result = buildMappingResultFromCategory('income_services', tx, true, 'enskild_firma', 'reverse_charge')
expect(result.vat_lines).toHaveLength(0)
})
it('does not generate VAT lines for reverse charge on private transactions', () => {
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromCategory('expense_software', tx, false, 'enskild_firma', 'reverse_charge')
expect(result.vat_lines).toHaveLength(0)
})
})
})
describe('buildMappingResultFromCategory returns non-empty accounts', () => {
const allCategories: TransactionCategory[] = [
'income_services',
'income_products',
'income_other',
'expense_equipment',
'expense_software',
'expense_travel',
'expense_office',
'expense_marketing',
'expense_professional_services',
'expense_education',
'expense_bank_fees',
'expense_card_fees',
'expense_currency_exchange',
'expense_other',
'private',
'uncategorized',
]
it.each(allCategories)('returns non-empty debit_account and credit_account for "%s"', (category) => {
const tx = makeTransaction({ amount: category.startsWith('income') ? 1000 : -1000 })
const isBusiness = category !== 'private'
const result = buildMappingResultFromCategory(category, tx, isBusiness)
expect(result.debit_account).toBeTruthy()
expect(result.credit_account).toBeTruthy()
})
})
describe('getDefaultVatTreatmentForCategory', () => {
it('returns standard_25 for regular expense categories', () => {
expect(getDefaultVatTreatmentForCategory('expense_equipment')).toBe('standard_25')
expect(getDefaultVatTreatmentForCategory('expense_software')).toBe('standard_25')
expect(getDefaultVatTreatmentForCategory('expense_travel')).toBe('standard_25')
})
it('returns standard_25 for income categories', () => {
expect(getDefaultVatTreatmentForCategory('income_services')).toBe('standard_25')
expect(getDefaultVatTreatmentForCategory('income_products')).toBe('standard_25')
})
it('returns null for VAT-exempt categories', () => {
expect(getDefaultVatTreatmentForCategory('expense_bank_fees')).toBeNull()
expect(getDefaultVatTreatmentForCategory('expense_card_fees')).toBeNull()
expect(getDefaultVatTreatmentForCategory('expense_currency_exchange')).toBeNull()
})
it('returns null for private transactions', () => {
expect(getDefaultVatTreatmentForCategory('private')).toBeNull()
})
it('returns null for uncategorized', () => {
expect(getDefaultVatTreatmentForCategory('uncategorized')).toBeNull()
})
})
describe('representation VAT (reduced 12%, ML 13 kap 24-25 §§)', () => {
it('getDefaultVatTreatmentForCategory returns reduced_12 for representation', () => {
expect(getDefaultVatTreatmentForCategory('expense_representation')).toBe('reduced_12')
})
it('getCategoryAccountMapping has vatTreatment: reduced_12 for representation', () => {
const result = getCategoryAccountMapping('expense_representation', -500, true)
expect(result.vatTreatment).toBe('reduced_12')
expect(result.vatDebitAccount).toBe('2641')
})
it('buildMappingResultFromCategory generates 12% VAT line for representation', () => {
const tx = makeTransaction({ amount: -500 })
const result = buildMappingResultFromCategory('expense_representation', tx, true)
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
})
})
describe('income account resolves by VAT treatment', () => {
const cases: [VatTreatment, string][] = [
['standard_25', '3001'],
['reduced_12', '3002'],
['reduced_6', '3003'],
['export', '3305'],
['reverse_charge', '3308'],
['exempt', '3004'],
]
it.each(cases)('income_services with %s maps to %s', (vat, expectedAccount) => {
const result = getCategoryAccountMapping('income_services', 1000, true, 'enskild_firma', vat)
expect(result.creditAccount).toBe(expectedAccount)
})
it.each(cases)('income_products with %s maps to %s', (vat, expectedAccount) => {
const result = getCategoryAccountMapping('income_products', 1000, true, 'enskild_firma', vat)
expect(result.creditAccount).toBe(expectedAccount)
})
it('income_other always returns 3999 regardless of VAT treatment', () => {
for (const vat of ['standard_25', 'reduced_12', 'reduced_6', 'export', 'reverse_charge', 'exempt'] as VatTreatment[]) {
const result = getCategoryAccountMapping('income_other', 1000, true, 'enskild_firma', vat)
expect(result.creditAccount).toBe('3999')
}
})
it('defaults to 3001 when no vatTreatment provided', () => {
const result = getCategoryAccountMapping('income_services', 1000, true)
expect(result.creditAccount).toBe('3001')
})
})
describe('private transaction accounts by entity type and direction', () => {
it('EF withdrawal (amount < 0) uses 2013', () => {
const result = getCategoryAccountMapping('private', -500, false, 'enskild_firma')
expect(result.debitAccount).toBe('2013')
expect(result.creditAccount).toBe('1930')
})
it('EF deposit (amount > 0) uses 2018', () => {
const result = getCategoryAccountMapping('private', 500, false, 'enskild_firma')
expect(result.debitAccount).toBe('1930')
expect(result.creditAccount).toBe('2018')
})
it('AB uses 2893 for both withdrawal and deposit', () => {
const withdrawal = getCategoryAccountMapping('private', -500, false, 'aktiebolag')
expect(withdrawal.debitAccount).toBe('2893')
const deposit = getCategoryAccountMapping('private', 500, false, 'aktiebolag')
expect(deposit.creditAccount).toBe('2893')
})
it('getDefaultAccountForCategory still returns 2013 for EF (default/withdrawal account)', () => {
expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013')
})
})
describe('category default → leaf account guarantee', () => {
// BAS encodes the parent/leaf distinction in account_name via the
// "(gruppkonto)" suffix. Auditors and Skatteverket downstream reporting
// expect postings on leaves, not headers — see migration 03d4b740.
const groupAccountNumbers = new Set<string>()
for (const acct of BAS_REFERENCE) {
if (acct.account_name.includes('(gruppkonto)')) {
groupAccountNumbers.add(acct.account_number)
}
}
const categoriesUnderGuard: TransactionCategory[] = [
'income_services',
'income_products',
'income_other',
'expense_equipment',
'expense_software',
'expense_travel',
'expense_office',
'expense_marketing',
'expense_professional_services',
'expense_representation',
'expense_consumables',
'expense_vehicle',
'expense_telecom',
'expense_education',
'expense_bank_fees',
'expense_card_fees',
'expense_currency_exchange',
'expense_other',
'private',
'uncategorized',
]
it.each(categoriesUnderGuard)('%s default does not resolve to a gruppkonto', (category) => {
const target = getDefaultAccountForCategory(category)
expect(groupAccountNumbers.has(target)).toBe(false)
})
it('uncategorized positive amount does not credit a gruppkonto', () => {
const result = getCategoryAccountMapping('uncategorized', 1000, true)
expect(groupAccountNumbers.has(result.creditAccount)).toBe(false)
})
it('expense_telecom resolves to 6230 (Datakommunikation, leaf)', () => {
expect(getDefaultAccountForCategory('expense_telecom')).toBe('6230')
})
it('expense_travel resolves to 5890 (Övriga resekostnader, leaf)', () => {
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
})
it('income_other resolves to 3999 (Övriga rörelseintäkter, leaf)', () => {
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
})
})