feat(supplier-invoices): "Vem betalade?" control replaces the paid privately switch and books an open utlägg (#2362)

The supplier-invoice form asks who paid with the same control as the
Underlag pane (Företaget / Jag, privat / En anställd / Ingen ännu) instead
of its own switch under Förval. A person paying is an utlägg: the route
hands the invoice to registerExpenseClaim with the invoice's kontering as
the claim's lines, so the verifikat and the expense_claims row come from
the same writer as the Underlag pane, the person shows up under "Betala ut
utlägg" on Hem and the bank matcher closes the debt. Employees book on
2820 with employee_id; the owner's blank name falls back to the shared
label so Hem groups one person.

Also routes a person-paid inbox document through the core route with
inbox_item_id: the extension's convert endpoint never read
paid_with_private_funds, so the old switch was silently dropped whenever
a receipt was attached. The second entry generator, the Förval switch,
the outline "Registrera & markera som betald" button and the duplicated
owner/employee picker are removed; PayerChoiceSelect and the claimant
fields move to components/expenses so core and the extension share them.

Closes #2332


Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:04:17 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 6776cb4fc6
commit ebbe50c0f3
20 changed files with 1318 additions and 575 deletions
@@ -541,3 +541,67 @@ describe('deleteExpenseClaim', () => {
expect(result).toEqual({ ok: false, code: 'NOT_FOUND' })
})
})
describe('registerExpenseClaim: custom lines from a supplier invoice', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
findFiscalPeriodMock.mockResolvedValue('period-1')
createJournalEntryMock.mockResolvedValue({ id: 'je-1' })
})
it('carries each line dimension bag onto the posted verifikat (dimensions PR7)', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } }) // companies entity_type
enqueue({ data: { id: 'claim-1' } }) // insert
enqueue({ data: null }) // update
const result = await registerExpenseClaim(sb, COMPANY, USER, {
description: 'Faktura LF-001, Pressbyrån (ankomstnr 1)',
expense_date: '2026-09-01',
amount: 500,
vat_amount: 100,
currency: 'SEK',
expense_account: '6110',
claimant_name: 'Ägare',
lines: [
{ account_number: '6110', debit_amount: 400, credit_amount: 0, dimensions: { '1': 'KS01', '6': 'P001' } },
{ account_number: '2641', debit_amount: 100, credit_amount: 0, dimensions: { '1': 'KS01' } },
{ account_number: '2893', debit_amount: 0, credit_amount: 500, dimensions: { '1': 'KS01' } },
],
})
expect(result.ok).toBe(true)
const input = createJournalEntryMock.mock.calls[0][3]
const byAccount = Object.fromEntries(
input.lines.map((l: { account_number: string }) => [l.account_number, l]),
)
expect(byAccount['6110'].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(byAccount['2641'].dimensions).toEqual({ '1': 'KS01' })
expect(byAccount['2893'].dimensions).toEqual({ '1': 'KS01' })
// A line without a bag posts without the key, not with dimensions: undefined.
expect(byAccount['2893'].credit_amount).toBe(500)
})
it('a line without a bag posts without a dimensions key', async () => {
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: { id: 'claim-1' } })
enqueue({ data: null })
await registerExpenseClaim(sb, COMPANY, USER, {
description: 'Kvitto',
expense_date: '2026-09-01',
amount: 100,
vat_amount: 0,
currency: 'SEK',
expense_account: '5410',
claimant_name: 'Ägare',
lines: [
{ account_number: '5410', debit_amount: 100, credit_amount: 0 },
{ account_number: '2893', debit_amount: 0, credit_amount: 100 },
],
})
const input = createJournalEntryMock.mock.calls[0][3]
for (const line of input.lines) expect(line).not.toHaveProperty('dimensions')
})
})
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import {
OWNER_FALLBACK_NAME,
PAYER_ORDER,
isPersonPayer,
resolveExpenseLiabilityAccount,
} from '../payer'
describe('resolveExpenseLiabilityAccount', () => {
it('an employee is always 2820, whatever the entity type', () => {
expect(resolveExpenseLiabilityAccount('aktiebolag', 'employee')).toBe('2820')
expect(resolveExpenseLiabilityAccount('enskild_firma', 'employee')).toBe('2820')
expect(resolveExpenseLiabilityAccount(null, 'employee')).toBe('2820')
})
it('the owner is a creditor in an AB (2893) and makes an egen insättning in an EF (2018)', () => {
expect(resolveExpenseLiabilityAccount('aktiebolag', 'owner')).toBe('2893')
expect(resolveExpenseLiabilityAccount('enskild_firma', 'owner')).toBe('2018')
})
it('an unknown entity type falls back to the AB rule, never to 2018', () => {
expect(resolveExpenseLiabilityAccount(undefined, 'owner')).toBe('2893')
expect(resolveExpenseLiabilityAccount('handelsbolag', 'owner')).toBe('2893')
})
})
describe('isPersonPayer', () => {
it('only owner and employee are people', () => {
expect(isPersonPayer('owner')).toBe(true)
expect(isPersonPayer('employee')).toBe(true)
expect(isPersonPayer('company')).toBe(false)
expect(isPersonPayer('unpaid')).toBe(false)
expect(isPersonPayer(null)).toBe(false)
})
it('the select lists every answer exactly once', () => {
expect([...PAYER_ORDER].sort()).toEqual(['company', 'employee', 'owner', 'unpaid'])
})
it('the owner fallback label is the one Hem groups on', () => {
expect(OWNER_FALLBACK_NAME).toBe('Ägare')
})
})