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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
6776cb4fc6
commit
ebbe50c0f3
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user