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
+11
View File
@@ -1343,6 +1343,17 @@ export const CreateSupplierInvoiceSchema = z.object({
// Per-invoice öresavrundning toggle (display-only). Omitted → stored as null (off).
ore_rounding: z.boolean().optional(),
paid_with_private_funds: z.boolean().optional(),
// For paid_with_private_funds: who paid. An employee (2820) by id, or the
// owner by name (2893 in an AB, 2018 in an enskild firma); an omitted name
// falls back to the shared owner label so Hem groups the owner as one
// person. Both are ignored unless paid_with_private_funds is true.
employee_id: uuid.optional().nullable(),
claimant_name: z.string().trim().max(200).optional(),
// For paid_with_private_funds: the invoice-inbox item whose document is the
// underlag. The route takes the document from the item and settles the item
// itself, so a privately paid inbox document never goes through the
// extension's convert endpoint (which registers on 2440 only).
inbox_item_id: uuid.optional().nullable(),
// 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(),
@@ -91,7 +91,8 @@ const {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
createSupplierCreditNoteEntry,
createSupplierInvoicePrivatelyPaidEntry,
buildSupplierInvoicePrivatelyPaidLines,
largestExpenseAccount,
SupplierInvoiceFxRateMissingError,
} = await import('../supplier-invoice-entries')
@@ -996,21 +997,6 @@ describe('supplier invoice booking: foreign currency without an exchange rate',
).rejects.toThrow(SupplierInvoiceFxRateMissingError)
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('eget utlägg in EUR WITHOUT a rate throws instead of booking 1:1', async () => {
const invoice = makeSupplierInvoice({
subtotal: 1000, vat_amount: 250, total: 1250,
currency: 'EUR', exchange_rate: null,
})
const items = [makeItem({ line_total: 1000, vat_rate: 0.25, account_number: '6200' })]
await expect(
createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
).rejects.toThrow(SupplierInvoiceFxRateMissingError)
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
})
// ============================================================
@@ -1758,163 +1744,175 @@ describe('createSupplierCreditNoteEntry', () => {
})
// ============================================================
// createSupplierInvoicePrivatelyPaidEntry: eget utlägg
// buildSupplierInvoicePrivatelyPaidLines: utlägg (a person paid)
// ============================================================
describe('createSupplierInvoicePrivatelyPaidEntry', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
type ClaimLine = ReturnType<typeof buildSupplierInvoicePrivatelyPaidLines>[number]
it('returns null when no fiscal period found', async () => {
mockedFindFiscalPeriod.mockResolvedValue(null)
const invoice = makeSupplierInvoice()
const items = [makeItem()]
function claimLinesByAccount(lines: ClaimLine[], account: string) {
return lines.filter((l) => l.account_number === account)
}
const result = await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
function assertClaimLinesBalanced(lines: ClaimLine[]) {
const debits = lines.reduce((s, l) => s + l.debit_amount, 0)
const credits = lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round(debits * 100)).toBe(Math.round(credits * 100))
for (const l of lines) {
// Exactly one side per line: the claims service refuses anything else.
expect(l.debit_amount >= 0 && l.credit_amount >= 0).toBe(true)
expect((l.debit_amount > 0) !== (l.credit_amount > 0)).toBe(true)
}
}
expect(result).toBeNull()
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
const DESC = 'Faktura LF-001, Pressbyrån (ankomstnr 1)'
it('AB: credits 2893 (D expense + D 2641 + C 2893)', async () => {
const invoice = makeSupplierInvoice({
subtotal: 400,
vat_amount: 100,
total: 500,
})
describe('buildSupplierInvoicePrivatelyPaidLines', () => {
it('AB owner: D expense + D 2641, C 2893 with the total; no 2440, no 1930', () => {
const invoice = makeSupplierInvoice({ subtotal: 400, vat_amount: 100, total: 500 })
const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag', 'Pressbyrån'
)
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.source_type).toBe('supplier_invoice_privately_paid')
const debit6110 = findByAccount(input.lines, '6110')
expect(debit6110).toHaveLength(1)
expect(debit6110[0].debit_amount).toBe(400)
const debit2641 = findByAccount(input.lines, '2641')
expect(debit2641).toHaveLength(1)
expect(debit2641[0].debit_amount).toBe(100)
const credit2893 = findByAccount(input.lines, '2893')
expect(credit2893).toHaveLength(1)
expect(credit2893[0].credit_amount).toBe(500)
// AP account 2440 must NOT appear: privately-paid bypasses AP entirely.
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
// Bank account 1930 must NOT appear: the owner paid, not the company.
expect(findByAccount(input.lines, '1930')).toHaveLength(0)
// EF owner account 2018 must NOT appear for AB.
expect(findByAccount(input.lines, '2018')).toHaveLength(0)
assertBalanced(input)
expect(claimLinesByAccount(lines, '6110')).toHaveLength(1)
expect(claimLinesByAccount(lines, '6110')[0].debit_amount).toBe(400)
expect(claimLinesByAccount(lines, '6110')[0].line_description).toBe(DESC)
expect(claimLinesByAccount(lines, '2641')).toHaveLength(1)
expect(claimLinesByAccount(lines, '2641')[0].debit_amount).toBe(100)
expect(claimLinesByAccount(lines, '2641')[0].line_description).toContain('Ingående moms 25%')
// The liability credit is what the claims service checks against the
// claim total and what the payout later reimburses.
expect(claimLinesByAccount(lines, '2893')).toHaveLength(1)
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(500)
// AP account 2440 must NOT appear: an utlägg bypasses AP entirely.
expect(claimLinesByAccount(lines, '2440')).toHaveLength(0)
// Bank account 1930 must NOT appear: the person paid, not the company.
expect(claimLinesByAccount(lines, '1930')).toHaveLength(0)
expect(claimLinesByAccount(lines, '2018')).toHaveLength(0)
assertClaimLinesBalanced(lines)
})
it('EF: credits 2018 instead of 2893', async () => {
const invoice = makeSupplierInvoice({
subtotal: 400,
vat_amount: 100,
total: 500,
})
it('credits whatever liability the caller resolved: 2018 for an EF owner, 2820 for an employee', () => {
const invoice = makeSupplierInvoice({ subtotal: 400, vat_amount: 100, total: 500 })
const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'enskild_firma', 'Pressbyrån'
)
const ef = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2018', DESC)
expect(claimLinesByAccount(ef, '2018')[0].credit_amount).toBe(500)
expect(claimLinesByAccount(ef, '2893')).toHaveLength(0)
const input = mockedCreateEntry.mock.calls[0][3]
const credit2018 = findByAccount(input.lines, '2018')
expect(credit2018).toHaveLength(1)
expect(credit2018[0].credit_amount).toBe(500)
expect(findByAccount(input.lines, '2893')).toHaveLength(0)
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
assertBalanced(input)
const employee = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2820', DESC)
expect(claimLinesByAccount(employee, '2820')[0].credit_amount).toBe(500)
expect(claimLinesByAccount(employee, '2893')).toHaveLength(0)
assertClaimLinesBalanced(employee)
})
it('skips 2641 line when invoice has zero VAT', async () => {
const invoice = makeSupplierInvoice({
subtotal: 500,
vat_amount: 0,
total: 500,
})
it('skips the 2641 line when the invoice has zero VAT', () => {
const invoice = makeSupplierInvoice({ subtotal: 500, vat_amount: 0, total: 500 })
const items = [makeItem({ line_total: 500, account_number: '5460', vat_rate: 0 })]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '5460')[0].debit_amount).toBe(500)
expect(findByAccount(input.lines, '2641')).toHaveLength(0)
expect(findByAccount(input.lines, '2893')[0].credit_amount).toBe(500)
assertBalanced(input)
expect(claimLinesByAccount(lines, '5460')[0].debit_amount).toBe(500)
expect(claimLinesByAccount(lines, '2641')).toHaveLength(0)
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(500)
assertClaimLinesBalanced(lines)
})
it('handles mixed-rate kvitto with separate 2641 lines per rate', async () => {
it('mixed-rate kvitto: one 2641 line per rate, liability = sum of debits', () => {
// Lunch (12%) + parking (25%) on the same kvitto
const invoice = makeSupplierInvoice({
subtotal: 200,
vat_amount: 36, // 100*0.12 + 100*0.25 = 12 + 25 = 37; off-by-one from rounding
total: 237,
})
const invoice = makeSupplierInvoice({ subtotal: 200, vat_amount: 37, total: 237 })
const items = [
makeItem({ line_total: 100, account_number: '5810', vat_rate: 0.12 }),
makeItem({ line_total: 100, account_number: '5611', vat_rate: 0.25 }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
const input = mockedCreateEntry.mock.calls[0][3]
// One 2641 line per rate
const vat2641 = findByAccount(input.lines, '2641')
expect(vat2641).toHaveLength(2)
// Credit 2893 = sum of all debits
const totalDebits = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const credit2893 = findByAccount(input.lines, '2893')[0]
expect(Math.round(credit2893.credit_amount * 100)).toBe(Math.round(totalDebits * 100))
assertBalanced(input)
expect(claimLinesByAccount(lines, '2641')).toHaveLength(2)
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(237)
assertClaimLinesBalanced(lines)
})
it('aggregates expense lines per account number', async () => {
// Two items on the same expense account should collapse to one debit line
const invoice = makeSupplierInvoice({
subtotal: 600,
vat_amount: 150,
total: 750,
})
it('a stored VAT override wins over line_total x rate', () => {
// Bilförmån: 25 % charged, only half deductible.
const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 1250, total: 11250 })
const items = [makeItem({ line_total: 10000, account_number: '5611', vat_rate: 0.25, vat_amount: 1250 })]
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
expect(claimLinesByAccount(lines, '2641')[0].debit_amount).toBe(1250)
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(11250)
})
it('aggregates items on the same account into one line', () => {
const invoice = makeSupplierInvoice({ subtotal: 600, vat_amount: 150, total: 750 })
const items = [
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }),
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
const input = mockedCreateEntry.mock.calls[0][3]
expect(claimLinesByAccount(lines, '6110')).toHaveLength(1)
expect(claimLinesByAccount(lines, '6110')[0].debit_amount).toBe(600)
})
const debit6110 = findByAccount(input.lines, '6110')
expect(debit6110).toHaveLength(1)
expect(debit6110[0].debit_amount).toBe(600)
it('keeps invoice-currency amounts: the claims service converts at the claim rate', () => {
const invoice = makeSupplierInvoice({
subtotal: 1000, vat_amount: 250, total: 1250, currency: 'EUR', exchange_rate: 11.5,
})
const items = [makeItem({ line_total: 1000, account_number: '6200', vat_rate: 0.25 })]
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
expect(claimLinesByAccount(lines, '6200')[0].debit_amount).toBe(1000)
expect(claimLinesByAccount(lines, '2641')[0].debit_amount).toBe(250)
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(1250)
})
it('a discount row that nets an account below zero becomes a credit and lowers the liability', () => {
const invoice = makeSupplierInvoice({ subtotal: 900, vat_amount: 0, total: 900 })
const items = [
makeItem({ line_total: 1000, account_number: '4010', vat_rate: 0 }),
makeItem({ line_total: -100, account_number: '3730', vat_rate: 0 }),
]
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
expect(claimLinesByAccount(lines, '3730')[0].credit_amount).toBe(100)
expect(claimLinesByAccount(lines, '3730')[0].debit_amount).toBe(0)
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(900)
assertClaimLinesBalanced(lines)
})
it('drops a bucket that nets to zero instead of posting a 0/0 line', () => {
const invoice = makeSupplierInvoice({ subtotal: 500, vat_amount: 0, total: 500 })
const items = [
makeItem({ line_total: 500, account_number: '6110', vat_rate: 0 }),
makeItem({ line_total: 200, account_number: '6250', vat_rate: 0 }),
makeItem({ line_total: -200, account_number: '6250', vat_rate: 0 }),
]
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
expect(claimLinesByAccount(lines, '6250')).toHaveLength(0)
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(500)
assertClaimLinesBalanced(lines)
})
})
describe('largestExpenseAccount', () => {
it('picks the account of the largest line by magnitude, first line on a tie', () => {
const items = [
makeItem({ line_total: 300, account_number: '6110' }),
makeItem({ line_total: 1200, account_number: '5410' }),
makeItem({ line_total: 1200, account_number: '6250' }),
]
expect(largestExpenseAccount(items)).toBe('5410')
expect(largestExpenseAccount([makeItem({ line_total: -50, account_number: '3730' }), makeItem({ line_total: 20, account_number: '6110' })])).toBe('3730')
})
it('returns an empty string for no items', () => {
expect(largestExpenseAccount([])).toBe('')
})
})
@@ -2125,13 +2123,8 @@ describe('dimensions propagation (PR7): createSupplierInvoiceCashEntry', () => {
})
})
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 () => {
describe('dimensions propagation (PR7): buildSupplierInvoicePrivatelyPaidLines', () => {
it('expense lines carry merged bags; 2641 and the liability carry the default', () => {
const invoice = makeSupplierInvoice({
subtotal: 400,
vat_amount: 100,
@@ -2142,17 +2135,25 @@ describe('dimensions propagation (PR7): createSupplierInvoicePrivatelyPaidEntry'
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 lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
const input = mockedCreateEntry.mock.calls[0][3]
expect(claimLinesByAccount(lines, '6110')[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
expect(claimLinesByAccount(lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' })
expect(claimLinesByAccount(lines, '2893')[0].dimensions).toEqual({ '1': 'KS01' })
assertClaimLinesBalanced(lines)
})
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' })
it('two items on the same account with different bags stay on separate lines', () => {
const invoice = makeSupplierInvoice({ subtotal: 600, vat_amount: 0, total: 600 })
const items = [
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0, dimensions: { '6': 'P001' } }),
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0, dimensions: { '6': 'P002' } }),
]
assertBalanced(input)
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
expect(claimLinesByAccount(lines, '6110')).toHaveLength(2)
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(600)
})
})
@@ -2343,22 +2344,19 @@ describe('SLP pair injection (apply_slp)', () => {
assertBalanced(input)
})
it('privately paid (eget utlägg): pair injected and the owner account stays at the invoice total', async () => {
it('privately paid (utlägg): pair injected and the liability stays at the invoice total', () => {
const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 0, total: 10000 })
const items = [
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2426)
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2426)
// The SLP pair must not inflate what the owner is owed.
expect(findByAccount(input.lines, '2893')[0].credit_amount).toBe(10000)
assertBalanced(input)
expect(claimLinesByAccount(lines, '7533')[0].debit_amount).toBe(2426)
expect(claimLinesByAccount(lines, '2514')[0].credit_amount).toBe(2426)
// The SLP pair must not inflate what the person is owed.
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(10000)
assertClaimLinesBalanced(lines)
})
it('credit note reverses the pair (7533 K / 2514 D) and keeps 2440 at the invoice total', async () => {
+87 -57
View File
@@ -18,6 +18,7 @@ import {
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExpenseClaimLineInput } from '@/lib/expenses/expense-claims-service'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
@@ -557,69 +558,79 @@ export async function createSupplierInvoiceCashEntry(
}
/**
* Create journal entry for an invoice paid with the owner's private funds
* (eget utlägg). The AP leg is bypassed entirely: instead of crediting 2440
* and later debiting it on mark-paid, the expense lines book straight against
* the owner's payable/equity account:
* Kontering for a supplier invoice someone paid out of their own pocket
* (utlägg), as custom lines for registerExpenseClaim (lib/expenses). The AP
* leg is bypassed entirely: instead of crediting 2440 and later debiting it on
* mark-paid, the invoice's lines book straight against the person's liability
* account:
*
* Debit 5xxx/6xxx (per item) [line_total in SEK]
* Debit 2641 Ingående moms [VAT per rate]
* Credit 2893 / 2018 [total incl VAT]
* Debit 5xxx/6xxx (per account + dimensions) [line_total]
* Debit 2641 Ingående moms [VAT per rate]
* Credit 2893 / 2018 / 2820 [total incl VAT]
*
* Reverse charge is intentionally not supported here. RC invoices are
* never "I paid this cash at a kiosk" cases: they're EU/byggtjänster from
* registered businesses with formal invoices, which always go through AP.
* The API route guards against this combo before calling us.
* Amounts stay in the invoice currency: the claims service converts every
* line at the claim rate and keeps the liability credit equal to the claim
* total to the öre, which is what the payout flow later reimburses. That is
* why this is a pure builder and not a second entry generator: the verifikat
* and the expense_claims row come from the same writer as the Underlag pane.
*
* Reverse charge is intentionally not supported here. RC invoices are never
* "I paid this at a kiosk" cases: they're EU/byggtjänster from registered
* businesses with formal invoices, which always go through AP. The route
* guards against this combo before calling us.
*/
export async function createSupplierInvoicePrivatelyPaidEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
export function buildSupplierInvoicePrivatelyPaidLines(
invoice: Pick<SupplierInvoice, 'default_dimensions'>,
items: SupplierInvoiceItem[],
entityType: 'aktiebolag' | 'enskild_firma',
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
}
const ownerAccount = entityType === 'aktiebolag' ? '2893' : '2018'
const desc = buildSupplierDescription('Eget utlägg', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
liabilityAccount: string,
description: string
): ExpenseClaimLineInput[] {
const lines: ExpenseClaimLineInput[] = []
// 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, dimensions)
// Debit: expense accounts, aggregated per (account, dimensions). A bucket
// that nets below zero (discount rows) becomes a credit so every line keeps
// exactly one side; an empty bucket is dropped.
const expenseBuckets = groupExpenseBuckets(
items,
(item) => item.account_number,
(item) => toSekOrThrow(item.line_total, invoice.currency, invoice.exchange_rate),
(item) => item.line_total ?? 0,
defaultDimensions
)
for (const bucket of expenseBuckets) {
const amount = roundOre(bucket.amount)
if (amount === 0) continue
lines.push({
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
debit_amount: amount > 0 ? amount : 0,
credit_amount: amount < 0 ? -amount : 0,
line_description: description,
dimensions: bucket.dimensions,
})
}
// Debit: Ingående moms per rate group (mixed-rate kvitto support)
// Debit: Ingående moms per rate group (mixed-rate kvitto support). Same
// per-item rule as groupVatByRate, without the SEK conversion: a stored
// override wins over the computed line_total x rate.
if (itemsHaveVat(items)) {
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
const vatByRate = new Map<number, number>()
for (const item of items) {
const rate = item.vat_rate ?? 0.25
const storedVat = item.vat_amount ?? 0
const computedVat = rate > 0 ? roundOre((item.line_total ?? 0) * rate) : 0
const vat = storedVat > 0 ? storedVat : computedVat
vatByRate.set(rate, (vatByRate.get(rate) || 0) + vat)
}
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
const vat = roundOre(amount)
if (vat > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(amount * 100) / 100,
debit_amount: vat,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${description}`,
dimensions: defaultDimensions,
})
}
@@ -628,36 +639,55 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
// Särskild löneskatt på pensionskostnader (SLP): same self-balancing
// 7533 D / 2514 K pair as the registration entry. Nets to zero, so the
// owner account below still carries exactly the expense + VAT total.
const slpBase = slpBaseSek(items, invoice.currency, invoice.exchange_rate)
// liability account below still carries exactly the expense + VAT total.
// generateSlpLines is pure arithmetic, so the invoice-currency base is fine.
let slpBase = 0
for (const item of items) {
if (item.apply_slp === true && isSlpPensionAccount(item.account_number)) {
slpBase += item.line_total ?? 0
}
}
if (slpBase > 0) {
const slpLines = generateSlpLines(slpBase)
lines.push(...slpLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
for (const l of generateSlpLines(slpBase)) {
lines.push({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description,
dimensions: defaultDimensions,
})
}
}
// Credit: Owner payable/equity, balance guarantee. Existing credits (the
// SLP 2514 leg) are subtracted so the pair never inflates what the owner
// is owed: same guarantee shape as the registration entry's 2440 line.
// Credit: the person's liability, balance guarantee. Existing credits (the
// SLP 2514 leg, a discount bucket) are subtracted so the pair never inflates
// what the person is owed: same guarantee shape as the registration entry's
// 2440 line.
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: ownerAccount,
account_number: liabilityAccount,
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
credit_amount: roundOre(totalDebits - totalCredits),
line_description: description,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: desc,
source_type: 'supplier_invoice_privately_paid',
source_id: invoice.id,
lines,
}
return lines
}
return createJournalEntry(supabase, companyId, userId, input)
/**
* The account expense_claims.expense_account records for a supplier invoice
* paid privately: the largest cost line's. The column holds one account (the
* Underlag pane books one receipt to one account); the full breakdown lives
* on the verifikat lines. First line wins a tie.
*/
export function largestExpenseAccount(items: SupplierInvoiceItem[]): string {
let best: SupplierInvoiceItem | null = null
for (const item of items) {
if (!best || Math.abs(item.line_total ?? 0) > Math.abs(best.line_total ?? 0)) best = item
}
return best?.account_number ?? ''
}
/**
@@ -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')
})
})
+4
View File
@@ -86,6 +86,8 @@ export interface ExpenseClaimLineInput {
debit_amount: number
credit_amount: number
line_description?: string | null
/** SIE dimension bag ({sie_dim_no: code}), carried onto the posted line. */
dimensions?: Record<string, string>
}
export type RegisterExpenseClaimResult =
@@ -253,6 +255,7 @@ export async function registerExpenseClaim(
? roundOre(l.credit_amount * rate)
: 0,
line_description: l.line_description?.trim() || desc,
dimensions: l.dimensions,
}))
const residual = roundOre(
sumOre(converted.map((l) => l.debit_amount)) - sumOre(converted.map((l) => l.credit_amount)),
@@ -273,6 +276,7 @@ export async function registerExpenseClaim(
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description,
...(l.dimensions ? { dimensions: l.dimensions } : {}),
...(input.currency !== 'SEK' && l.account_number === liability
? { currency: input.currency, amount_in_currency: roundOre(input.amount), exchange_rate: rate }
: {}),
+46
View File
@@ -0,0 +1,46 @@
/**
* Who paid for an underlag: the one question that decides how it is booked.
*
* 'company' -> the bank line is matched (or the supplier invoice is
* registered and marked paid against a picked transaction); 'unpaid' -> a
* supplier invoice on 2440 with a due date; 'owner' / 'employee' -> an
* utlägg: cost + moms are booked at once against that person's liability
* account and an expense_claims row keeps the debt open until it is repaid.
*
* Shared by the Underlag pane, the supplier-invoice form and the
* supplier-invoice route so the answer has one vocabulary and one account
* rule. Framework-free on purpose: routes import it too.
*/
export type ExpensePayer = 'owner' | 'employee'
export type PayerChoice = 'company' | 'unpaid' | ExpensePayer
/** Display order of the answers in the "Vem betalade?" select. */
export const PAYER_ORDER: readonly PayerChoice[] = ['company', 'owner', 'employee', 'unpaid']
export function isPersonPayer(choice: PayerChoice | null | undefined): choice is ExpensePayer {
return choice === 'owner' || choice === 'employee'
}
/**
* The owner's claims are grouped by name on Hem (there is no employee row for
* the owner), so every writer that lets the name default must default to the
* same string or one person shows up as two.
*/
export const OWNER_FALLBACK_NAME = 'Ägare'
export type ExpenseLiabilityAccount = '2893' | '2820' | '2018'
/**
* Liability account for an utlägg. An employee is always 2820 (kortfristiga
* skulder till anställda). The owner's account follows the entity type: an AB
* owner is a creditor (2893 skulder till närstående); an enskild firma owner
* makes an egen insättning (2018), which is equity, not a debt.
*/
export function resolveExpenseLiabilityAccount(
entityType: string | null | undefined,
payer: ExpensePayer,
): ExpenseLiabilityAccount {
if (payer === 'employee') return '2820'
return entityType === 'enskild_firma' ? '2018' : '2893'
}
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
buildSupplierInvoicePayload,
supplierInvoiceCreateUrl,
inferVatTreatment,
vatRateFromAi,
rateToPctString,
@@ -32,7 +33,9 @@ function makeFormData(overrides: Partial<SupplierInvoiceFormData> = {}): Supplie
reverse_charge: false,
payment_reference: '',
notes: '',
paid_with_private_funds: false,
payer: 'unpaid',
claimant_name: '',
employee_id: '',
items: [makeItem()],
...overrides,
}
@@ -144,7 +147,7 @@ describe('buildSupplierInvoicePayload', () => {
it('privately paid: empty due_date defaults to invoice_date', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ paid_with_private_funds: true, due_date: '' }),
makeFormData({ payer: 'owner', due_date: '' }),
makeOpts({ canUseAccrual: false }),
)
expect(payload.due_date).toBe('2026-08-01')
@@ -153,12 +156,64 @@ describe('buildSupplierInvoicePayload', () => {
it('privately paid: an explicit due_date is kept', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ paid_with_private_funds: true, due_date: '2026-09-15' }),
makeFormData({ payer: 'owner', due_date: '2026-09-15' }),
makeOpts({ canUseAccrual: false }),
)
expect(payload.due_date).toBe('2026-09-15')
})
it('owner: the typed name travels trimmed, a blank name is omitted (route applies the fallback)', () => {
const named = buildSupplierInvoicePayload(
makeFormData({ payer: 'owner', claimant_name: ' Anna Ek ' }),
makeOpts({ canUseAccrual: false }),
)
expect(named).toMatchObject({ paid_with_private_funds: true, claimant_name: 'Anna Ek' })
expect(named).not.toHaveProperty('employee_id')
const blank = buildSupplierInvoicePayload(
makeFormData({ payer: 'owner', claimant_name: ' ' }),
makeOpts({ canUseAccrual: false }),
)
expect(blank).not.toHaveProperty('claimant_name')
})
it('employee: employee_id travels and the owner name never does', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ payer: 'employee', employee_id: 'emp-1', claimant_name: 'stale' }),
makeOpts({ canUseAccrual: false }),
)
expect(payload).toMatchObject({ paid_with_private_funds: true, employee_id: 'emp-1' })
expect(payload).not.toHaveProperty('claimant_name')
})
it('company / unpaid: no payer fields, paid_with_private_funds false', () => {
for (const payer of ['company', 'unpaid'] as const) {
const payload = buildSupplierInvoicePayload(
makeFormData({ payer, employee_id: 'emp-1', claimant_name: 'Anna' }),
makeOpts(),
)
expect(payload.paid_with_private_funds).toBe(false)
expect(payload).not.toHaveProperty('employee_id')
expect(payload).not.toHaveProperty('claimant_name')
expect(payload).not.toHaveProperty('inbox_item_id')
}
})
it('privately paid inbox document: inbox_item_id travels, document_id does not', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({ payer: 'employee', employee_id: 'emp-1' }),
makeOpts({ inboxItemId: 'item-1', uploadedDocumentId: 'doc-1', canUseAccrual: false }),
)
expect(payload).toHaveProperty('inbox_item_id', 'item-1')
expect(payload).not.toHaveProperty('document_id')
const company = buildSupplierInvoicePayload(
makeFormData({ payer: 'company' }),
makeOpts({ inboxItemId: 'item-1' }),
)
expect(company).not.toHaveProperty('inbox_item_id')
})
it('reverse charge: vat_rate forced to 0, reverse_charge_rate travels with 0.25 default', () => {
const payload = buildSupplierInvoicePayload(
makeFormData({
@@ -337,3 +392,20 @@ describe('buildSupplierInvoicePayload', () => {
expect(buildSupplierInvoicePayload(makeFormData(), makeOpts({ oreRounding: false })).ore_rounding).toBe(false)
})
})
describe('supplierInvoiceCreateUrl', () => {
it('converts through the inbox when the company pays or has paid', () => {
expect(supplierInvoiceCreateUrl('unpaid', 'item-1')).toBe(
'/api/extensions/ext/invoice-inbox/items/item-1/convert',
)
expect(supplierInvoiceCreateUrl('company', 'item-1')).toBe(
'/api/extensions/ext/invoice-inbox/items/item-1/convert',
)
})
it('books an utlägg through the core route even for an inbox item', () => {
expect(supplierInvoiceCreateUrl('owner', 'item-1')).toBe('/api/supplier-invoices')
expect(supplierInvoiceCreateUrl('employee', 'item-1')).toBe('/api/supplier-invoices')
expect(supplierInvoiceCreateUrl('unpaid', null)).toBe('/api/supplier-invoices')
})
})
+30 -3
View File
@@ -10,6 +10,7 @@
*/
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { isPersonPayer, type PayerChoice } from '@/lib/expenses/payer'
import type { VatTreatment } from '@/types'
export interface SupplierInvoiceLineItem {
@@ -46,7 +47,12 @@ export interface SupplierInvoiceFormData {
reverse_charge: boolean
payment_reference: string
notes: string
paid_with_private_funds: boolean
/** Vem betalade? Decides the endpoint, the primary action and the credit account. */
payer: PayerChoice
/** The owner's name for payer 'owner'; empty means the shared fallback label. */
claimant_name: string
/** employees.id for payer 'employee'. */
employee_id: string
items: SupplierInvoiceLineItem[]
}
@@ -101,9 +107,10 @@ export function buildSupplierInvoicePayload(
) {
const { inboxItemId, uploadedDocumentId, oreRounding, defaultDims, canUseAccrual } = opts
const vatTreatment = inferVatTreatment(data.items, data.reverse_charge)
const paidByPerson = isPersonPayer(data.payer)
// When paid privately, due_date is irrelevant: but the API still requires
// a YYYY-MM-DD value. Default to invoice_date so the field passes validation.
const dueDate = data.paid_with_private_funds && !data.due_date
const dueDate = paidByPerson && !data.due_date
? data.invoice_date
: data.due_date
return {
@@ -119,7 +126,15 @@ export function buildSupplierInvoicePayload(
reverse_charge: data.reverse_charge,
payment_reference: data.payment_reference || undefined,
notes: data.notes || undefined,
paid_with_private_funds: data.paid_with_private_funds,
paid_with_private_funds: paidByPerson,
// Who paid, for the utlägg path: the employee by id, or the owner by the
// typed name (omitted when blank: the route applies the shared fallback).
...(data.payer === 'employee' && data.employee_id ? { employee_id: data.employee_id } : {}),
...(data.payer === 'owner' && data.claimant_name.trim() ? { claimant_name: data.claimant_name.trim() } : {}),
// A privately paid inbox document goes to the core route, which takes the
// document from the item and settles it (the convert endpoint registers
// on 2440 only): see supplierInvoiceCreateUrl.
...(paidByPerson && inboxItemId ? { inbox_item_id: inboxItemId } : {}),
ore_rounding: oreRounding,
// Invoice-level default dimensions (kostnadsställe/projekt): only sent
// when the user actually picked something.
@@ -158,3 +173,15 @@ export function buildSupplierInvoicePayload(
})),
}
}
/**
* Where the editor posts: the inbox convert endpoint when the invoice came
* from an inbox item and the company pays or has paid; the core route when a
* person paid, because only it books an utlägg (the convert endpoint
* registers on 2440 regardless of who paid).
*/
export function supplierInvoiceCreateUrl(payer: PayerChoice, inboxItemId: string | null): string {
return inboxItemId && !isPersonPayer(payer)
? `/api/extensions/ext/invoice-inbox/items/${inboxItemId}/convert`
: '/api/supplier-invoices'
}