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
parent 6776cb4fc6
commit ebbe50c0f3
20 changed files with 1318 additions and 575 deletions
+306 -74
View File
@@ -31,12 +31,24 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
}))
const mockCreateSupplierInvoiceRegistrationEntry = vi.fn()
const mockCreateSupplierInvoicePrivatelyPaidEntry = vi.fn()
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) =>
mockCreateSupplierInvoiceRegistrationEntry(...args),
createSupplierInvoicePrivatelyPaidEntry: (...args: unknown[]) =>
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', async () => {
// The privately-paid line builder is pure: keep the real one so the tests
// pin the kontering the route hands to the claims writer.
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/supplier-invoice-entries')>(
'@/lib/bookkeeping/supplier-invoice-entries',
)
return {
...actual,
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) =>
mockCreateSupplierInvoiceRegistrationEntry(...args),
}
})
// A privately paid invoice is an utlägg: the route hands it to the same
// claims writer as the Underlag pane instead of posting its own verifikat.
const mockRegisterExpenseClaim = vi.fn()
vi.mock('@/lib/expenses/expense-claims-service', () => ({
registerExpenseClaim: (...args: unknown[]) => mockRegisterExpenseClaim(...args),
}))
const mockLinkToJournalEntry = vi.fn()
@@ -684,105 +696,325 @@ describe('POST /api/supplier-invoices', () => {
expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
})
it('books privately-paid invoice via 2893 path for aktiebolag', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-1', status: 'paid' })
// ── Privately paid = utlägg ──────────────────────────────────────────────
// A person paid the supplier invoice: the route hands the invoice to the
// claims writer with the invoice's kontering as the lines, so the verifikat
// and the expense_claims row come from the same code as the Underlag pane
// and the person shows up under "Betala ut utlägg" on Hem.
// Fetch supplier
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
enqueue({ data: supplier, error: null })
// Fetch company.entity_type (paidPrivately branch)
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
// RPC get_next_arrival_number
enqueue({ data: 12 })
// Insert invoice
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch company settings
enqueue({ data: { accounting_method: 'accrual' }, error: null })
const EMPLOYEE_UUID = '550e8400-e29b-41d4-a716-446655440003'
const INBOX_UUID = '550e8400-e29b-41d4-a716-446655440004'
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-1' })
// Update invoice with payment_journal_entry_id
enqueue({ data: null, error: null })
// Insert supplier_invoice_payments row
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'KVITTO-001',
invoice_date: '2024-06-01',
due_date: '2024-06-01',
paid_with_private_funds: true,
items: [
{
description: 'Kontorsmaterial',
quantity: 1,
unit_price: 400,
account_number: '6110',
vat_rate: 0.25,
},
],
function claimOk(overrides: Record<string, unknown> = {}) {
return {
ok: true,
claim: {
id: 'claim-1',
journal_entry_id: 'je-priv-1',
claimant_name: 'Ägare',
liability_account: '2893',
...overrides,
},
}
}
function privatelyPaidBody(overrides: Record<string, unknown> = {}) {
return {
supplier_id: VALID_UUID,
supplier_invoice_number: 'KVITTO-001',
invoice_date: '2024-06-01',
due_date: '2024-06-01',
paid_with_private_funds: true,
items: [
{ description: 'Kontorsmaterial', quantity: 1, unit_price: 400, account_number: '6110', vat_rate: 0.25 },
],
...overrides,
}
}
type ClaimInput = {
description: string
claimant_name?: string
employee_id?: string
document_id?: string
inbox_item_id?: string
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
function claimInput(): ClaimInput {
expect(mockRegisterExpenseClaim).toHaveBeenCalledTimes(1)
return mockRegisterExpenseClaim.mock.calls[0][3] as ClaimInput
}
function linesByAccount(input: ClaimInput) {
return Object.fromEntries(input.lines.map((l) => [l.account_number, l]))
}
it("books a privately paid invoice as the owner's utlägg through the claims writer (AB: 2893)", async () => {
const supplier = makeSupplier({ id: VALID_UUID, name: 'Pressbyrån' })
const createdInvoice = makeSupplierInvoice({
id: 'si-priv-1',
status: 'paid',
arrival_number: 12,
supplier_invoice_number: 'KVITTO-001',
})
const response = await POST(request)
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
enqueue({ data: supplier, error: null }) // supplier
enqueue({ data: { entity_type: 'aktiebolag' }, error: null }) // company entity_type
enqueue({ data: 12 }) // rpc get_next_arrival_number
enqueue({ data: createdInvoice, error: null }) // insert invoice
enqueue({ data: null, error: null }) // insert items
enqueue({ data: { accounting_method: 'accrual' }, error: null }) // settings
mockRegisterExpenseClaim.mockResolvedValue(claimOk())
enqueue({ data: null, error: null }) // update payment_journal_entry_id
enqueue({ data: null, error: null }) // insert supplier_invoice_payments
const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: privatelyPaidBody() })
const response = await POST(request, {} as never)
const { status, body } = await parseJsonResponse<{
data: { payment_journal_entry_id: string; registration_journal_entry_id: null }
data: {
payment_journal_entry_id: string
registration_journal_entry_id: null
expense_claim: { id: string; claimant_name: string; liability_account: string }
}
}>(response)
expect(status).toBe(200)
expect(body.data.payment_journal_entry_id).toBe('je-priv-1')
expect(body.data.registration_journal_entry_id).toBeNull()
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).toHaveBeenCalled()
expect(body.data.expense_claim).toEqual({ id: 'claim-1', claimant_name: 'Ägare', liability_account: '2893' })
// The classic registration path must NOT be touched.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
expect(call[5]).toBe('aktiebolag')
const [, companyArg, userArg] = mockRegisterExpenseClaim.mock.calls[0]
expect(companyArg).toBe('company-1')
expect(userArg).toBe('user-1')
const input = claimInput()
expect(input).toMatchObject({
expense_date: '2024-06-01',
amount: 500,
vat_amount: 100,
currency: 'SEK',
expense_account: '6110',
// No name given: the shared owner label, so Hem groups the owner as one person.
claimant_name: 'Ägare',
})
expect(input.employee_id).toBeUndefined()
expect(input.document_id).toBeUndefined()
expect(input.description).toContain('KVITTO-001')
expect(input.description).toContain('ankomstnr 12')
// The invoice's full kontering rides as the claim's lines: no 2440.
const byAccount = linesByAccount(input)
expect(byAccount['6110'].debit_amount).toBe(400)
expect(byAccount['2641'].debit_amount).toBe(100)
expect(byAccount['2893'].credit_amount).toBe(500)
expect(byAccount['2440']).toBeUndefined()
// The invoice row says paid from the start and mirrors the payment.
const insert = findCall('supplier_invoices', 'insert')?.[0] as Record<string, unknown>
expect(insert).toMatchObject({ status: 'paid', paid_with_private_funds: true, remaining_amount: 0 })
const payment = findCall('supplier_invoice_payments', 'insert')?.[0] as Record<string, unknown>
expect(payment).toMatchObject({ supplier_invoice_id: 'si-priv-1', journal_entry_id: 'je-priv-1', amount: 500 })
// The claims writer links the document on this path, never the route.
expect(mockLinkToJournalEntry).not.toHaveBeenCalled()
})
it('passes entity_type=enskild_firma so engine credits 2018', async () => {
it('enskild firma owner: the claim is an egen insättning on 2018 and the typed name travels', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-2', status: 'paid' })
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: supplier, error: null })
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
enqueue({ data: 13 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'cash' }, error: null })
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-2' })
mockRegisterExpenseClaim.mockResolvedValue(claimOk({ liability_account: '2018', claimant_name: 'Anna Ek' }))
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
body: privatelyPaidBody({
supplier_invoice_number: 'KVITTO-002',
invoice_date: '2024-06-01',
due_date: '2024-06-01',
paid_with_private_funds: true,
items: [
{
description: 'Lunch klient',
quantity: 1,
unit_price: 200,
account_number: '5810',
vat_rate: 0.12,
},
],
},
claimant_name: ' Anna Ek ',
items: [{ description: 'Lunch klient', quantity: 1, unit_price: 200, account_number: '5810', vat_rate: 0.12 }],
}),
})
const response = await POST(request)
const response = await POST(request, {} as never)
const { status, body } = await parseJsonResponse<{ data: { expense_claim: { liability_account: string } } }>(response)
expect(status).toBe(200)
expect(body.data.expense_claim.liability_account).toBe('2018')
const input = claimInput()
expect(input.claimant_name).toBe('Anna Ek')
const byAccount = linesByAccount(input)
expect(byAccount['2018'].credit_amount).toBe(224)
expect(byAccount['2893']).toBeUndefined()
})
it('an employee paid: verified before the arrival number is drawn, claim on 2820 with employee_id', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-3', status: 'paid' })
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: supplier, error: null })
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
enqueue({ data: { id: EMPLOYEE_UUID }, error: null }) // employee belongs to the company
enqueue({ data: 14 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockRegisterExpenseClaim.mockResolvedValue(claimOk({ liability_account: '2820', claimant_name: 'Erik Berg' }))
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: privatelyPaidBody({ employee_id: EMPLOYEE_UUID, claimant_name: 'never used for an employee' }),
})
const response = await POST(request, {} as never)
const { status, body } = await parseJsonResponse<{ data: { expense_claim: { claimant_name: string } } }>(response)
expect(status).toBe(200)
expect(body.data.expense_claim.claimant_name).toBe('Erik Berg')
expect(findCall('employees', 'eq')).toEqual(['id', EMPLOYEE_UUID])
const input = claimInput()
expect(input.employee_id).toBe(EMPLOYEE_UUID)
expect(input.claimant_name).toBeUndefined()
const byAccount = linesByAccount(input)
expect(byAccount['2820'].credit_amount).toBe(500)
expect(byAccount['2893']).toBeUndefined()
})
it("returns 404 when the employee is not the company's, before any arrival number is drawn", async () => {
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
enqueue({ data: null, error: null }) // no such employee here
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: privatelyPaidBody({ employee_id: EMPLOYEE_UUID }),
})
const response = await POST(request, {} as never)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
})
it("privately paid inbox document: the item's document is the underlag and the item is stamped with the invoice", async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-4', status: 'paid', document_id: DOCUMENT_UUID })
enqueue({
data: { id: INBOX_UUID, document_id: DOCUMENT_UUID, created_supplier_invoice_id: null, created_journal_entry_id: null },
error: null,
}) // inbox item
enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null }) // its document, unlinked
enqueue({ data: null, error: null }) // no supplier invoice uses the document yet
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: supplier, error: null })
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
enqueue({ data: 15 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockRegisterExpenseClaim.mockResolvedValue(claimOk())
enqueue({ data: null, error: null }) // update payment_journal_entry_id
enqueue({ data: null, error: null }) // insert supplier_invoice_payments
enqueue({ data: null, error: null }) // stamp the inbox item
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: privatelyPaidBody({ inbox_item_id: INBOX_UUID }),
})
const response = await POST(request, {} as never)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
expect(call[5]).toBe('enskild_firma')
const input = claimInput()
expect(input.document_id).toBe(DOCUMENT_UUID)
expect(input.inbox_item_id).toBe(INBOX_UUID)
const insert = findCall('supplier_invoices', 'insert')?.[0] as Record<string, unknown>
expect(insert.document_id).toBe(DOCUMENT_UUID)
expect(findCall('invoice_inbox_items', 'update')?.[0]).toEqual({ created_supplier_invoice_id: 'si-priv-4' })
expect(mockLinkToJournalEntry).not.toHaveBeenCalled()
})
it('refuses inbox_item_id unless a person paid: the convert endpoint owns the other answers', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: privatelyPaidBody({ paid_with_private_funds: false, inbox_item_id: INBOX_UUID }),
})
const response = await POST(request, {} as never)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
})
it('refuses an inbox item that is already booked', async () => {
enqueue({
data: { id: INBOX_UUID, document_id: DOCUMENT_UUID, created_supplier_invoice_id: 'si-old', created_journal_entry_id: null },
error: null,
})
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: privatelyPaidBody({ inbox_item_id: INBOX_UUID }),
})
const response = await POST(request, {} as never)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
})
it('a claims-writer refusal rolls the invoice back and maps the code', async () => {
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
enqueue({ data: 16 })
enqueue({ data: makeSupplierInvoice({ id: 'si-priv-5', status: 'paid' }), error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockRegisterExpenseClaim.mockResolvedValue({ ok: false, code: 'FISCAL_PERIOD_NOT_FOUND' })
enqueue({ data: null, error: null }) // rollback delete
const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: privatelyPaidBody() })
const response = await POST(request, {} as never)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_NO_FISCAL_PERIOD')
expect(findCall('supplier_invoices', 'delete')).toBeDefined()
})
it('a posted-but-unlinked claim is never rolled back: the verifikat is immutable', async () => {
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
enqueue({ data: 17 })
enqueue({ data: makeSupplierInvoice({ id: 'si-priv-6', status: 'paid' }), error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockRegisterExpenseClaim.mockResolvedValue({ ok: false, code: 'LINK_WRITE_FAILED', detail: 'claim x posted as entry y' })
const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: privatelyPaidBody() })
const response = await POST(request, {} as never)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('SI_CREATE_FAILED')
expect(findCall('supplier_invoices', 'delete')).toBeUndefined()
})
it('persists manual vat_amount override on items and forwards it to the engine', async () => {
@@ -903,7 +1135,7 @@ describe('POST /api/supplier-invoices', () => {
expect(body.error.code).toBe('SI_CREATE_ACCRUAL_REVERSE_CHARGE')
// The guard must fire before anything is persisted or booked.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
})
it('rejects paid_with_private_funds combined with reverse_charge', async () => {
@@ -925,7 +1157,7 @@ describe('POST /api/supplier-invoices', () => {
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
// Make sure we never touched the engine paths.
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
})
+180 -58
View File
@@ -2,8 +2,12 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import {
createSupplierInvoiceRegistrationEntry,
createSupplierInvoicePrivatelyPaidEntry,
buildSupplierInvoicePrivatelyPaidLines,
largestExpenseAccount,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { buildSupplierDescription } from '@/lib/bookkeeping/supplier-invoice-description'
import { registerExpenseClaim } from '@/lib/expenses/expense-claims-service'
import { OWNER_FALLBACK_NAME, resolveExpenseLiabilityAccount } from '@/lib/expenses/payer'
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
@@ -20,7 +24,7 @@ import {
} from '@/lib/currency/supplier-invoice-rate'
import { roundOre } from '@/lib/money'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
import type { Currency, SupplierInvoice, SupplierInvoiceItem } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -77,11 +81,46 @@ export const POST = withRouteContext(
const body = validation.data
const paidPrivately = body.paid_with_private_funds === true
if (body.document_id) {
// A privately paid inbox document: the item's document is the underlag
// and the item is settled here (registerExpenseClaim stamps
// created_journal_entry_id; the route adds created_supplier_invoice_id).
// The extension's convert endpoint registers on 2440 only, so routing the
// person-paid case through it would silently drop who paid.
let inboxItem: { id: string; document_id: string | null } | null = null
if (body.inbox_item_id) {
if (!paidPrivately) {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'inbox_item_id is only accepted with paid_with_private_funds' },
})
}
const { data: item, error: itemError } = await supabase
.from('invoice_inbox_items')
.select('id, document_id, created_supplier_invoice_id, created_journal_entry_id')
.eq('id', body.inbox_item_id)
.eq('company_id', companyId)
.maybeSingle()
if (itemError || !item) {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'inbox_item_id is missing or belongs to another company' },
})
}
if (item.created_supplier_invoice_id || item.created_journal_entry_id) {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'inbox item is already booked' },
})
}
inboxItem = { id: item.id as string, document_id: (item.document_id as string | null) ?? null }
}
const documentId = inboxItem ? inboxItem.document_id : body.document_id ?? null
if (documentId) {
const { data: document, error: documentError } = await supabase
.from('document_attachments')
.select('id, journal_entry_id')
.eq('id', body.document_id)
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
@@ -96,7 +135,7 @@ export const POST = withRouteContext(
.from('supplier_invoices')
.select('id')
.eq('company_id', companyId)
.eq('document_id', body.document_id)
.eq('document_id', documentId)
.limit(1)
.maybeSingle()
@@ -226,6 +265,19 @@ export const POST = withRouteContext(
})
}
entityType = company.entity_type as 'aktiebolag' | 'enskild_firma'
if (body.employee_id) {
// Checked before the arrival-number sequence is touched: a claim the
// service would refuse must not burn an ankomstnummer.
const { data: employee } = await supabase
.from('employees')
.select('id')
.eq('id', body.employee_id)
.eq('company_id', companyId)
.maybeSingle()
if (!employee) {
return errorResponseFromCode('EMPLOYEE_NOT_FOUND', log, { requestId })
}
}
}
// Resolve the exchange rate BEFORE the arrival-number sequence is touched:
@@ -354,7 +406,7 @@ export const POST = withRouteContext(
user_id: user.id,
company_id: companyId,
supplier_id: body.supplier_id,
document_id: body.document_id || null,
document_id: documentId,
arrival_number: arrivalNum,
supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date,
@@ -469,10 +521,12 @@ export const POST = withRouteContext(
// silently understates leverantörsskuld (2440) and ingående moms (2641)
// for the momsdeklaration. Roll back instead.
//
// Privately-paid path bypasses both accrual and cash flows: a single
// verifikat books the expense + VAT against 2893 (AB) or 2018 (EF) at
// registration time, regardless of accounting_method. mark-paid is never
// invoked for these (status='paid' from the start).
// Privately-paid path bypasses both accrual and cash flows: the invoice is
// an utlägg, so one verifikat books the expense + VAT against the payer's
// liability account (2893 AB owner / 2018 EF owner / 2820 employee) at
// registration time, regardless of accounting_method, and an
// expense_claims row keeps the debt open. mark-paid is never invoked for
// these (status='paid' from the start).
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, defer_invoice_booking')
@@ -485,68 +539,134 @@ export const POST = withRouteContext(
const booksOnRegistration = booksInvoicesOnIssue(settings)
let registrationJournalEntryId: string | null = null
let paymentJournalEntryId: string | null = null
let expenseClaim: { id: string; claimant_name: string; liability_account: string } | null = null
if (paidPrivately && entityType) {
// The invoice IS an utlägg registration with the supplier invoice as its
// underlag: the same writer as the Underlag pane posts the verifikat and
// the expense_claims row, with the invoice's full kontering as the
// lines. The person then shows up under "Betala ut utlägg" on Hem and
// the bank matcher closes the debt when the transfer is booked.
const payer = body.employee_id ? 'employee' : 'owner'
const liabilityAccount = resolveExpenseLiabilityAccount(entityType, payer)
const claimDescription = buildSupplierDescription(
'Faktura',
invoice.supplier_invoice_number,
supplier.name,
`(ankomstnr ${invoice.arrival_number})`,
)
let claimResult: Awaited<ReturnType<typeof registerExpenseClaim>>
try {
const journalEntry = await createSupplierInvoicePrivatelyPaidEntry(
supabase,
companyId!,
user.id,
invoice as SupplierInvoice,
items as SupplierInvoiceItem[],
entityType,
supplier.name,
)
if (journalEntry) {
paymentJournalEntryId = journalEntry.id
await supabase
.from('supplier_invoices')
.update({ payment_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
// Mirror the payment in supplier_invoice_payments so AR/AP and
// payment-history queries stay consistent with the mark-paid path.
await supabase.from('supplier_invoice_payments').insert({
user_id: user.id,
company_id: companyId,
supplier_invoice_id: invoice.id,
// For an eget utlägg the actual out-of-pocket date may differ from
// the invoice/receipt date: accept an explicit payment_date and
// fall back to invoice_date for the common kvitto case.
payment_date: body.payment_date ?? invoice.invoice_date,
amount: totalRounded,
currency: invoice.currency,
exchange_rate_difference: 0,
journal_entry_id: journalEntry.id,
notes: 'Eget utlägg, betalat privat',
})
} else {
// createSupplierInvoicePrivatelyPaidEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws and
// lands in the catch below). Without this branch the invoice would be
// saved as status='paid' with no verifikat: a silent orphan. Roll
// back and surface an actionable error, per the fatal-orphan note above.
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, {
requestId,
details: { invoiceDate: invoice.invoice_date },
})
}
claimResult = await registerExpenseClaim(supabase, companyId, user.id, {
description: claimDescription,
expense_date: invoice.invoice_date,
amount: totalRounded,
vat_amount: roundOre(vatAmount),
currency: fx.rate.currency as Currency,
exchange_rate: fx.rate.exchangeRate ?? undefined,
expense_account: largestExpenseAccount(items as SupplierInvoiceItem[]),
employee_id: body.employee_id ?? undefined,
claimant_name: payer === 'owner' ? body.claimant_name?.trim() || OWNER_FALLBACK_NAME : undefined,
document_id: documentId ?? undefined,
inbox_item_id: inboxItem?.id,
lines: buildSupplierInvoicePrivatelyPaidLines(
invoice as SupplierInvoice,
items as SupplierInvoiceItem[],
liabilityAccount,
claimDescription,
),
})
} catch (err) {
// The service removes its own claim row before rethrowing; the invoice
// row is ours to roll back (same fatal-orphan rule as the registration
// path below).
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
if (isBookkeepingError(err)) {
return errorResponse(err, log, { requestId })
}
log.error('failed to create privately-paid journal entry', err as Error, {
log.error('failed to book privately paid supplier invoice as utlägg', err as Error, {
invoiceId: invoice.id,
})
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
details: {
reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown',
step: 'privately_paid_journal_entry',
step: 'expense_claim',
},
})
}
if (!claimResult.ok) {
if (claimResult.code === 'LINK_WRITE_FAILED') {
// The verifikat is posted and immutable: deleting the invoice now
// would orphan it. Keep both rows and surface the desync loudly.
log.error('expense claim posted but could not be linked', new Error(claimResult.detail ?? claimResult.code), {
invoiceId: invoice.id,
})
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
details: { reason: claimResult.detail ?? claimResult.code, step: 'expense_claim_link' },
})
}
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
if (claimResult.code === 'FISCAL_PERIOD_NOT_FOUND') {
return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, {
requestId,
details: { invoiceDate: invoice.invoice_date },
})
}
if (claimResult.code === 'EMPLOYEE_NOT_FOUND') {
return errorResponseFromCode('EMPLOYEE_NOT_FOUND', log, { requestId })
}
if (claimResult.code === 'INVALID_LINES') {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: `expense claim lines: ${claimResult.detail ?? 'invalid'}` },
})
}
log.error('expense claim registration failed', new Error(claimResult.detail ?? claimResult.code), {
invoiceId: invoice.id,
})
return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId,
details: { reason: claimResult.code, step: 'expense_claim' },
})
}
const claim = claimResult.claim
expenseClaim = { id: claim.id, claimant_name: claim.claimant_name, liability_account: claim.liability_account }
if (claim.journal_entry_id) {
paymentJournalEntryId = claim.journal_entry_id
await supabase
.from('supplier_invoices')
.update({ payment_journal_entry_id: claim.journal_entry_id })
.eq('id', invoice.id)
// Mirror the payment in supplier_invoice_payments so AR/AP and
// payment-history queries stay consistent with the mark-paid path.
await supabase.from('supplier_invoice_payments').insert({
user_id: user.id,
company_id: companyId,
supplier_invoice_id: invoice.id,
// For an utlägg the actual out-of-pocket date may differ from the
// invoice/receipt date: accept an explicit payment_date and fall
// back to invoice_date for the common kvitto case.
payment_date: body.payment_date ?? invoice.invoice_date,
amount: totalRounded,
currency: invoice.currency,
exchange_rate_difference: 0,
journal_entry_id: claim.journal_entry_id,
notes: `Utlägg, betalat privat av ${claim.claimant_name}`,
})
}
if (inboxItem) {
// registerExpenseClaim stamped created_journal_entry_id (which marks
// the item processed); the invoice link is ours.
await supabase
.from('invoice_inbox_items')
.update({ created_supplier_invoice_id: invoice.id })
.eq('id', inboxItem.id)
.eq('company_id', companyId)
}
} else if (booksOnRegistration) {
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
@@ -627,18 +747,19 @@ export const POST = withRouteContext(
}
}
// The utlägg path links the document inside registerExpenseClaim.
const primaryJournalEntryId = paymentJournalEntryId || registrationJournalEntryId
if (body.document_id && primaryJournalEntryId) {
if (documentId && primaryJournalEntryId && !paidPrivately) {
try {
await linkToJournalEntry(
supabase,
companyId,
body.document_id,
documentId,
primaryJournalEntryId,
)
} catch (err) {
log.warn('supplier invoice document could not be linked to journal entry', {
documentId: body.document_id,
documentId,
journalEntryId: primaryJournalEntryId,
error: err instanceof Error ? err.message : String(err),
})
@@ -675,6 +796,7 @@ export const POST = withRouteContext(
items: itemInserts,
registration_journal_entry_id: registrationJournalEntryId,
payment_journal_entry_id: paymentJournalEntryId,
expense_claim: expenseClaim,
},
...(warnings.length > 0 ? { warnings } : {}),
})
@@ -0,0 +1,117 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { OWNER_FALLBACK_NAME, type ExpensePayer } from '@/lib/expenses/payer'
interface EmployeeOption {
id: string
first_name: string
last_name: string
}
/**
* Who the utlägg belongs to, once "Vem betalade?" is a person: the owner's
* name (optional, the shared fallback label otherwise) or one of the
* company's employees. Shared by the Underlag dialog and the supplier-invoice
* form so both post the same claimant to the same claims writer. Employees
* are fetched on first need; the host only sees the chosen id and name.
*/
export function ExpenseClaimantFields({
payer,
ownerName,
onOwnerNameChange,
employeeId,
onEmployeeChange,
disabled,
idPrefix = 'claimant',
className,
labelClassName,
inputClassName,
}: {
payer: ExpensePayer
ownerName: string
onOwnerNameChange: (name: string) => void
employeeId: string
/** The picked employee's id and display name ('' when cleared). */
onEmployeeChange: (id: string, name: string) => void
disabled?: boolean
idPrefix?: string
className?: string
labelClassName?: string
inputClassName?: string
}) {
const t = useTranslations('inbox_workspace')
const [employees, setEmployees] = useState<EmployeeOption[]>([])
const [employeesLoaded, setEmployeesLoaded] = useState(false)
useEffect(() => {
if (payer !== 'employee' || employeesLoaded) return
let cancelled = false
fetch('/api/salary/employees')
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (!cancelled) setEmployees((json?.data ?? []) as EmployeeOption[])
})
.catch(() => {
if (!cancelled) setEmployees([])
})
.finally(() => {
if (!cancelled) setEmployeesLoaded(true)
})
return () => {
cancelled = true
}
}, [payer, employeesLoaded])
if (payer === 'owner') {
return (
<div className={cn('space-y-1.5', className)}>
<Label htmlFor={`${idPrefix}-owner`} className={labelClassName}>
{t('expense_owner_name')}
</Label>
<Input
id={`${idPrefix}-owner`}
value={ownerName}
onChange={(e) => onOwnerNameChange(e.target.value)}
placeholder={OWNER_FALLBACK_NAME}
disabled={disabled}
className={inputClassName}
/>
</div>
)
}
return (
<div className={cn('space-y-1.5', className)}>
<Label htmlFor={`${idPrefix}-employee`} className={labelClassName}>
{t('expense_employee')}
</Label>
<Select
value={employeeId}
onValueChange={(id) => {
const picked = employees.find((e) => e.id === id)
onEmployeeChange(id, picked ? `${picked.first_name} ${picked.last_name}`.trim() : '')
}}
disabled={disabled}
>
<SelectTrigger id={`${idPrefix}-employee`} className={inputClassName}>
<SelectValue
placeholder={employeesLoaded && employees.length === 0 ? t('expense_no_employees') : t('expense_pick_employee')}
/>
</SelectTrigger>
<SelectContent>
{employees.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.first_name} {e.last_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { useTranslations } from 'next-intl'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { PAYER_ORDER, type PayerChoice } from '@/lib/expenses/payer'
import type { AccountingMethod } from '@/types'
export type { ExpensePayer, PayerChoice } from '@/lib/expenses/payer'
/**
* The "Vem betalade?" control, shared by the Underlag pane and the
* supplier-invoice form: a compact select so a rail keeps its primary button
* above the fold, with the chosen answer's one-line consequence under it.
* Each option in the list carries the same help so the choice is made with
* the consequence visible, not after.
*
* 'company' -> match the bank line; 'unpaid' -> supplier invoice (2440);
* 'owner' / 'employee' -> utlägg against the person's liability account.
*/
export function PayerChoiceSelect({
value,
onChange,
accountingMethod,
id,
labelClassName,
disabled,
}: {
value: PayerChoice
onChange: (next: PayerChoice) => void
accountingMethod: AccountingMethod
/** Trigger id, so a host can point a label or focus router at it. */
id?: string
/** Overrides the rail's label style when the control sits in a form grid. */
labelClassName?: string
disabled?: boolean
}) {
const t = useTranslations('inbox_workspace')
// An enskild firma owner makes an egen insättning, not a loan to the
// company: no debt, nothing to pay out, so the help line says so.
const isEf = useCompanyOptional()?.company?.entity_type === 'enskild_firma'
// Företaget carries no help line: the button under it ("Matcha mot
// transaktion") already says what happens. The other answers name the
// liability the company takes on, which is the consequence worth reading.
const helpKey = (choice: PayerChoice): string | null =>
choice === 'company'
? null
: choice === 'owner' && isEf
? 'payer_help_owner_ef'
: choice === 'unpaid' && accountingMethod === 'cash'
? 'payer_help_unpaid_cash'
: `payer_help_${choice}`
const selectedHelp = helpKey(value)
return (
<div className="space-y-1.5">
<p className={labelClassName ?? 'text-[13px] font-medium'}>{t('payer_question')}</p>
<Select value={value} onValueChange={(next) => onChange(next as PayerChoice)} disabled={disabled}>
<SelectTrigger id={id} aria-label={t('payer_question')} className="h-9 text-[13px]">
{/* Explicit children: the items render label + help, and the
trigger must show the label alone. */}
<SelectValue>{t(`payer_${value}`)}</SelectValue>
</SelectTrigger>
{/* Match the trigger width so the two-line options wrap inside the rail
instead of spilling over the document viewer. */}
<SelectContent align="start" className="w-[var(--radix-select-trigger-width)]">
{PAYER_ORDER.map((choice) => (
<SelectItem key={choice} value={choice} className="py-2">
<span className="block text-[13px]">{t(`payer_${choice}`)}</span>
{helpKey(choice) && (
<span className="block text-xs text-muted-foreground">{t(helpKey(choice)!)}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedHelp && <p className="text-xs text-muted-foreground">{t(selectedHelp)}</p>}
</div>
)
}
@@ -23,7 +23,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import {
Inbox,
@@ -76,6 +75,7 @@ import {
} from '@/lib/documents/inbox-kind'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
import RegisterExpenseDialog, { type ExpensePayer } from '@/components/extensions/general/RegisterExpenseDialog'
import { PayerChoiceSelect, type PayerChoice } from '@/components/expenses/PayerChoiceSelect'
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
// InboxCustomDomainDialog (egen domän) is built but gated off: see
@@ -3092,75 +3092,6 @@ function ProposedBooking({
)
}
// ── Vem betalade? ────────────────────────────────────────────
/**
* How an unmatched underlag gets booked, phrased as who paid for it.
* 'company' → match the bank line; 'unpaid' → supplier invoice (2440);
* 'owner' / 'employee' → utlägg against the person's liability account.
*/
export type PayerChoice = 'company' | 'unpaid' | ExpensePayer
const PAYER_ORDER: PayerChoice[] = ['company', 'owner', 'employee', 'unpaid']
/**
* The "Vem betalade?" control: a compact select so the rail keeps its
* primary button above the fold, with the chosen answer's one-line
* consequence under it. Each option in the list carries the same help so
* the choice is made with the consequence visible, not after.
*/
export function PayerChoiceSelect({
value,
onChange,
accountingMethod,
}: {
value: PayerChoice
onChange: (next: PayerChoice) => void
accountingMethod: AccountingMethod
}) {
const t = useTranslations('inbox_workspace')
// An enskild firma owner makes an egen insättning, not a loan to the
// company: no debt, nothing to pay out, so the help line says so.
const isEf = useCompanyOptional()?.company?.entity_type === 'enskild_firma'
// Företaget carries no help line: the button under it ("Matcha mot
// transaktion") already says what happens. The other answers name the
// liability the company takes on, which is the consequence worth reading.
const helpKey = (choice: PayerChoice): string | null =>
choice === 'company'
? null
: choice === 'owner' && isEf
? 'payer_help_owner_ef'
: choice === 'unpaid' && accountingMethod === 'cash'
? 'payer_help_unpaid_cash'
: `payer_help_${choice}`
const selectedHelp = helpKey(value)
return (
<div className="space-y-1.5">
<p className="text-[13px] font-medium">{t('payer_question')}</p>
<Select value={value} onValueChange={(next) => onChange(next as PayerChoice)}>
<SelectTrigger aria-label={t('payer_question')} className="h-9 text-[13px]">
{/* Explicit children: the items render label + help, and the
trigger must show the label alone. */}
<SelectValue>{t(`payer_${value}`)}</SelectValue>
</SelectTrigger>
{/* Match the trigger width so the two-line options wrap inside the rail
instead of spilling over the document viewer. */}
<SelectContent align="start" className="w-[var(--radix-select-trigger-width)]">
{PAYER_ORDER.map((choice) => (
<SelectItem key={choice} value={choice} className="py-2">
<span className="block text-[13px]">{t(`payer_${choice}`)}</span>
{helpKey(choice) && (
<span className="block text-xs text-muted-foreground">{t(helpKey(choice)!)}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedHelp && <p className="text-xs text-muted-foreground">{t(selectedHelp)}</p>}
</div>
)
}
// ── Fields rail ──────────────────────────────────────────────
function FieldsRail({
@@ -14,29 +14,22 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { ExpenseClaimantFields } from '@/components/expenses/ExpenseClaimantFields'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { useAccounts } from '@/lib/reference-data/hooks'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { ACCOUNT_NUMBER_RE, ISO_DATE_RE } from '@/lib/invariants'
import { OWNER_FALLBACK_NAME, resolveExpenseLiabilityAccount, type ExpensePayer } from '@/lib/expenses/payer'
import type { InvoiceExtractionResult } from '@/types'
/**
* Who paid for the underlag out of their own pocket. The owner's liability
* account follows the entity type (2893 skuld till ägare in an AB, 2018 egen
* insättning in an enskild firma); an employee is always 2820.
*/
export type ExpensePayer = 'owner' | 'employee'
// Who paid for the underlag out of their own pocket. The account rule (2893
// AB owner / 2018 EF owner / 2820 employee) lives in lib/expenses/payer.ts,
// shared with the supplier-invoice form.
export type { ExpensePayer }
interface InboxItemLike {
id: string
@@ -44,12 +37,6 @@ interface InboxItemLike {
extracted_data: InvoiceExtractionResult | null
}
interface EmployeeOption {
id: string
first_name: string
last_name: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
@@ -59,8 +46,6 @@ interface Props {
onSuccess: () => void | Promise<void>
}
const OWNER_FALLBACK_NAME = 'Ägare'
function todayIso(): string {
// Local calendar date: toISOString() is UTC and would date a receipt booked
// after midnight CEST to the previous day (wrong period, wrong FX rate).
@@ -88,8 +73,7 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
const { toast } = useToast()
const { accounts } = useAccounts()
const entityType = useCompanyOptional()?.company?.entity_type ?? null
const ownerLiability = entityType === 'enskild_firma' ? '2018' : '2893'
const liabilityAccount = payer === 'owner' ? ownerLiability : '2820'
const liabilityAccount = resolveExpenseLiabilityAccount(entityType, payer)
const data = item.extracted_data
const [description, setDescription] = useState('')
@@ -99,8 +83,7 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
const [expenseAccount, setExpenseAccount] = useState('')
const [ownerName, setOwnerName] = useState('')
const [employeeId, setEmployeeId] = useState('')
const [employees, setEmployees] = useState<EmployeeOption[]>([])
const [employeesLoaded, setEmployeesLoaded] = useState(false)
const [employeeName, setEmployeeName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase()
// A foreign receipt carries VAT the company cannot deduct on 2641: the whole
@@ -120,29 +103,15 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
setVatInput(!isForeign && vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
setExpenseAccount('')
setEmployeeId('')
setEmployeeName('')
}, [open, item.id, data, isForeign])
useEffect(() => {
if (!open || payer !== 'employee' || employeesLoaded) return
fetch('/api/salary/employees')
.then((res) => (res.ok ? res.json() : null))
.then((json) => setEmployees((json?.data ?? []) as EmployeeOption[]))
.catch(() => setEmployees([]))
.finally(() => setEmployeesLoaded(true))
}, [open, payer, employeesLoaded])
const amount = parseAmount(amountInput)
// Foreign VAT is never deductible here: the field is locked and 0 is what
// gets submitted, whatever the extraction said.
const vatAmount = isForeign ? 0 : parseAmount(vatInput)
const net = roundOre(amount - vatAmount)
const employee = employees.find((e) => e.id === employeeId) ?? null
const claimantName =
payer === 'owner'
? ownerName.trim() || OWNER_FALLBACK_NAME
: employee
? `${employee.first_name} ${employee.last_name}`.trim()
: ''
const claimantName = payer === 'owner' ? ownerName.trim() || OWNER_FALLBACK_NAME : employeeName
const canSubmit =
!isSubmitting &&
@@ -236,36 +205,18 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
</DialogHeader>
<div className="space-y-4">
{payer === 'owner' ? (
<div className="space-y-1.5">
<Label htmlFor="re-owner">{t('expense_owner_name')}</Label>
<Input
id="re-owner"
value={ownerName}
onChange={(e) => setOwnerName(e.target.value)}
placeholder={OWNER_FALLBACK_NAME}
disabled={isSubmitting}
/>
</div>
) : (
<div className="space-y-1.5">
<Label htmlFor="re-employee">{t('expense_employee')}</Label>
<Select value={employeeId} onValueChange={setEmployeeId} disabled={isSubmitting}>
<SelectTrigger id="re-employee">
<SelectValue
placeholder={employeesLoaded && employees.length === 0 ? t('expense_no_employees') : t('expense_pick_employee')}
/>
</SelectTrigger>
<SelectContent>
{employees.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.first_name} {e.last_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<ExpenseClaimantFields
payer={payer}
ownerName={ownerName}
onOwnerNameChange={setOwnerName}
employeeId={employeeId}
onEmployeeChange={(id, name) => {
setEmployeeId(id)
setEmployeeName(name)
}}
disabled={isSubmitting}
idPrefix="re"
/>
<div className="space-y-1.5">
<Label htmlFor="re-description">{t('expense_description')}</Label>
@@ -56,7 +56,10 @@ import { VatRateCell, RcRateSelect, AmountCell } from '@/components/supplier-inv
import { useSupplierInvoiceData } from '@/components/supplier-invoices/use-supplier-invoice-data'
import { useInboxPrefill, type InboxItemData } from '@/components/supplier-invoices/use-inbox-prefill'
import { useSupplierInvoiceSubmit } from '@/components/supplier-invoices/use-supplier-invoice-submit'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, Link2, CalendarClock, Tags, FileText } from 'lucide-react'
import { PayerChoiceSelect } from '@/components/expenses/PayerChoiceSelect'
import { ExpenseClaimantFields } from '@/components/expenses/ExpenseClaimantFields'
import { isPersonPayer } from '@/lib/expenses/payer'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, CalendarClock, Tags, FileText } from 'lucide-react'
import type { Supplier, InvoiceExtractionResult } from '@/types'
// The form's line/field shapes live in lib/supplier-invoices/form-payload.ts
@@ -270,7 +273,9 @@ export default function NewSupplierInvoiceForm({
reverse_charge: false,
payment_reference: '',
notes: '',
paid_with_private_funds: false,
payer: 'unpaid',
claimant_name: '',
employee_id: '',
// The table starts empty: the ghost entry row (never part of form
// state) is the only way rows are born, so no silent prefilled expense
// account can ever reach a verifikat unnoticed.
@@ -324,7 +329,12 @@ export default function NewSupplierInvoiceForm({
const watchedSupplierId = watch('supplier_id')
const watchedCurrency = watch('currency')
const watchedExchangeRate = watch('exchange_rate')
const watchedPaidPrivately = watch('paid_with_private_funds')
const watchedPayer = watch('payer')
const watchedClaimantName = watch('claimant_name')
const watchedEmployeeId = watch('employee_id')
// A person paid: the invoice is an utlägg (cost + moms against that
// person's liability, an open claim on Hem) instead of a 2440 payable.
const watchedPaidPrivately = isPersonPayer(watchedPayer)
const watchedReverseCharge = watch('reverse_charge')
// Watched values used to decide whether the AI-filled indicator should
// still be visible. Once the user edits a field, its value no longer
@@ -1369,7 +1379,6 @@ export default function NewSupplierInvoiceForm({
showBankPicker,
setShowBankPicker,
setPendingTransactionId,
submitModeRef,
conflict,
setConflict,
isResolvingConflict,
@@ -1393,6 +1402,8 @@ export default function NewSupplierInvoiceForm({
showNoPeriodWarning,
canUseAccrual,
invoiceNumberInputRef,
// The Utlägg nav row is gated on existing claims in the server layout.
onExpenseRegistered: () => router.refresh(),
onMissingField: focusMissingField,
})
@@ -1464,7 +1475,6 @@ export default function NewSupplierInvoiceForm({
const forvalChips: string[] = [
willBookAtRegistration ? t('forval_books_at_registration') : t('forval_books_at_payment'),
]
if (watchedPaidPrivately) forvalChips.push(t('chip_paid_privately'))
if (watchedReverseCharge) forvalChips.push(t('reverse_charge_label'))
if ((watchedCurrency || 'SEK') !== 'SEK') {
forvalChips.push(
@@ -1486,11 +1496,15 @@ export default function NewSupplierInvoiceForm({
? t('underlag_caption_attached')
: t('underlag_caption_default')
// The primary action follows "Vem betalade?": a person -> book the utlägg,
// the company -> register and match the bank line, no one yet -> register.
const primaryLabel = watchedPaidPrivately
? t('register_expense')
: isEF
? t('register_invoice')
: t('review_and_register')
: watchedPayer === 'company'
? t('register_and_mark_paid')
: isEF
? t('register_invoice')
: t('review_and_register')
// min-w-0 on the root: DialogContent is display:grid; without it this grid
// item's min-width:auto lets the kontering table's min-w force the whole
@@ -1801,6 +1815,35 @@ export default function NewSupplierInvoiceForm({
{...register('invoice_date')}
/>
</div>
{/* Vem betalade? The same control as the Underlag pane: the
answer decides the credit account (2440, the bank line or a
person's liability) and the primary action in the footer. */}
<Controller
name="payer"
control={control}
render={({ field }) => (
<PayerChoiceSelect
id="si-payer"
value={field.value}
onChange={field.onChange}
accountingMethod={accountingMethod}
labelClassName="text-xs font-normal text-muted-foreground"
/>
)}
/>
{isPersonPayer(watchedPayer) && (
<ExpenseClaimantFields
payer={watchedPayer}
ownerName={watchedClaimantName}
onOwnerNameChange={(name) => setValue('claimant_name', name, { shouldDirty: true })}
employeeId={watchedEmployeeId}
onEmployeeChange={(id) => setValue('employee_id', id, { shouldDirty: true })}
idPrefix="si"
className="space-y-2"
labelClassName="text-xs font-normal text-muted-foreground"
inputClassName="h-9"
/>
)}
{!watchedPaidPrivately && (
<>
<div className="space-y-2">
@@ -2164,25 +2207,6 @@ export default function NewSupplierInvoiceForm({
</div>
{forvalOpen && (
<div className="mt-4 border-t border-border">
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
<label htmlFor="paid_with_private_funds" className="cursor-pointer">
{t('paid_privately_label')}
<span className="block text-xs text-muted-foreground">
{isEF ? t('paid_privately_help_ef') : t('paid_privately_help_ab')}
</span>
</label>
<Controller
name="paid_with_private_funds"
control={control}
render={({ field }) => (
<Switch
id="paid_with_private_funds"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
</div>
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
<label htmlFor="reverse_charge" className="cursor-pointer">
{t('reverse_charge_label')}
@@ -2434,18 +2458,6 @@ export default function NewSupplierInvoiceForm({
>
{t('cancel')}
</button>
{!watchedPaidPrivately && (
<Button
type="submit"
variant="outline"
disabled={isSubmitting || !canWrite}
onClick={() => { submitModeRef.current = 'register_and_match' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
<Link2 className="mr-2 h-4 w-4" />
{t('register_and_mark_paid')}
</Button>
)}
{/* Always enabled pre-click for writable users: clicking with
something missing routes focus to the first missing field
(submit-time hard blocks stay in onSubmit). Viewers keep the
@@ -2453,7 +2465,6 @@ export default function NewSupplierInvoiceForm({
<Button
type="submit"
disabled={isSubmitting || !canWrite}
onClick={() => { submitModeRef.current = 'register' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isSubmitting ? (
@@ -2514,10 +2525,7 @@ export default function NewSupplierInvoiceForm({
open={showBankPicker}
onOpenChange={(open) => {
setShowBankPicker(open)
if (!open) {
submitModeRef.current = 'register'
setPendingTransactionId(null)
}
if (!open) setPendingTransactionId(null)
}}
targetAmount={total}
targetCurrency={watchedCurrency}
@@ -1,12 +1,13 @@
'use client'
import { useState, useRef, type RefObject } from 'react'
import { useState, type RefObject } from 'react'
import { useTranslations } from 'next-intl'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { findIllegalVatRateRow } from '@/lib/vat/supplier-invoice-line-checks'
import { rateToPctString, type SupplierInvoiceFormData } from '@/lib/supplier-invoices/form-payload'
import { rateToPctString, supplierInvoiceCreateUrl, type SupplierInvoiceFormData } from '@/lib/supplier-invoices/form-payload'
import { isPersonPayer } from '@/lib/expenses/payer'
import type { InvoiceExtractionResult } from '@/types'
// The existing invoice surfaced on a duplicate-number conflict, used to drive
@@ -22,7 +23,12 @@ export interface ExistingSupplierInvoice {
// envelope's inner object ({ code, message, details }); a few legacy convert
// paths still return a flat string, so accept both.
export interface CreateResult {
data?: { id: string; arrival_number: number }
data?: {
id: string
arrival_number: number
/** Set when the invoice was booked as an utlägg (a person paid). */
expense_claim?: { id: string; claimant_name: string; liability_account: string } | null
}
warnings?: Array<{ code: string; message: string }>
error?:
| string
@@ -50,6 +56,8 @@ interface UseSupplierInvoiceSubmitParams {
showNoPeriodWarning: boolean
canUseAccrual: boolean
invoiceNumberInputRef: RefObject<HTMLInputElement | null>
/** After an utlägg booked: the host refreshes server data (the Utlägg nav gate). */
onExpenseRegistered?: () => void
/**
* Focus router for the always-enabled primary: called instead of a toast
* when a required field is missing (supplier, invoice number, row account),
@@ -85,6 +93,7 @@ export function useSupplierInvoiceSubmit({
showNoPeriodWarning,
canUseAccrual,
invoiceNumberInputRef,
onExpenseRegistered,
onMissingField,
}: UseSupplierInvoiceSubmitParams) {
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -94,11 +103,6 @@ export function useSupplierInvoiceSubmit({
// Match-on-create state
const [showBankPicker, setShowBankPicker] = useState(false)
const [pendingTransactionId, setPendingTransactionId] = useState<string | null>(null)
// The button's onClick and the form's onSubmit run in the same React event
// batch, so a `useState`-backed submitMode would still hold the previous
// render's value when onSubmit reads it. A ref bridges the two synchronous
// handlers; the matching state mirror only drives the review-dialog UI.
const submitModeRef = useRef<'register' | 'register_and_match'>('register')
// Conflict state for duplicate-supplier-invoice-number
const [conflict, setConflict] = useState<{
@@ -147,8 +151,9 @@ export function useSupplierInvoiceSubmit({
}
}
// Single submit endpoint chooser: convert when we came from inbox, plain
// POST otherwise. Both endpoints validate the same CreateSupplierInvoiceSchema
// Single submit endpoint chooser: convert when we came from inbox and the
// company pays, plain POST otherwise (a person paying is an utlägg, which
// only the core route books). Both endpoints validate the same CreateSupplierInvoiceSchema
// and return the same canonical error envelope ({ error: { code, message,
// details } }): including the recoverable duplicate-number 409.
async function postCreate(data: SupplierInvoiceFormData): Promise<{
@@ -156,9 +161,7 @@ export function useSupplierInvoiceSubmit({
status: number
result: CreateResult
}> {
const url = inboxItemId
? `/api/extensions/ext/invoice-inbox/items/${inboxItemId}/convert`
: '/api/supplier-invoices'
const url = supplierInvoiceCreateUrl(data.payer, inboxItemId)
const res = await fetch(url, {
method: 'POST',
@@ -281,18 +284,19 @@ export function useSupplierInvoiceSubmit({
return
}
if (submitModeRef.current === 'register_and_match') {
// Open the bank-transaction picker; actual create happens on pick.
// For AB the review dialog is shown after a transaction is picked.
if (data.payer === 'company') {
// "Företaget" is register-and-match: open the bank-transaction picker;
// the actual create happens on pick. For AB the review dialog is shown
// after a transaction is picked.
setPendingData(data)
setShowBankPicker(true)
return
}
// Privately-paid skips the AB review dialog: the toggle itself is the
// A person paying skips the AB review dialog: the answer itself is the
// explicit user intent, and the resulting verifikat is just expense + VAT
// against the owner account (2893/2018). Same path for EF.
if (isEF || data.paid_with_private_funds) {
// against that person's liability account. Same path for EF.
if (isEF || isPersonPayer(data.payer)) {
setPendingData(data)
handleDirectSubmit(data)
} else {
@@ -327,11 +331,21 @@ export function useSupplierInvoiceSubmit({
// beforeunload prompt while we navigate away on a successful submit.
reset(data)
if (data.paid_with_private_funds) {
if (isPersonPayer(data.payer)) {
const claim = result.data.expense_claim
toast({
title: t('expense_registered_title'),
description: t('arrival_number_label', { number: result.data.arrival_number }),
// An enskild firma owner's claim is an egen insättning (2018): no
// debt, so there is no Att göra row to point at.
description:
claim && claim.liability_account !== '2018'
? t('expense_registered_description', {
name: claim.claimant_name,
number: result.data.arrival_number,
})
: t('arrival_number_label', { number: result.data.arrival_number }),
})
onExpenseRegistered?.()
finishCreate()
setIsSubmitting(false)
return
@@ -376,7 +390,6 @@ export function useSupplierInvoiceSubmit({
})
const matchResult = await matchRes.json()
setPendingTransactionId(null)
submitModeRef.current = 'register'
if (matchRes.ok) {
toast({
@@ -534,7 +547,6 @@ export function useSupplierInvoiceSubmit({
})
const matchResult = await matchRes.json()
setIsSubmitting(false)
submitModeRef.current = 'register'
if (matchRes.ok) {
toast({
@@ -560,7 +572,6 @@ export function useSupplierInvoiceSubmit({
showBankPicker,
setShowBankPicker,
setPendingTransactionId,
submitModeRef,
conflict,
setConflict,
isResolvingConflict,
+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'
}
+2 -5
View File
@@ -4636,7 +4636,6 @@
"forval_books_at_payment": "Posted at payment",
"forval_toggle_open": "Change defaults",
"forval_toggle_close": "Close",
"chip_paid_privately": "Paid privately",
"chip_ore_rounding_off": "No öre rounding",
"chip_delivery_date": "Delivery date {date}",
"chip_notes": "Notes",
@@ -4680,9 +4679,6 @@
"create_and_select": "Create & select",
"section_accounting": "Accounting",
"account_from_history": "Account {account} suggested from previous bookings of {counterparty}",
"paid_privately_label": "I paid this privately",
"paid_privately_help_ef": "Booked as a liability from the company to you (2018 Egen insättning). Refund manually later from the company account.",
"paid_privately_help_ab": "Booked as a liability from the company to you (2893 Skuld till ägare). Refund manually later from the company account.",
"supplier_placeholder": "Select supplier",
"no_suppliers_yet": "No suppliers yet",
"add_new_supplier": "+ Add new supplier...",
@@ -4806,7 +4802,8 @@
"debit_short": "D {amount}",
"credit_short": "C {amount}",
"debit_credit_short": "D {debit} / C {credit}",
"review_accrual_line_info": "Accrued {from} to {to}"
"review_accrual_line_info": "Accrued {from} to {to}",
"expense_registered_description": "The debt to {name} is now in To do. Arrival number: {number}"
},
"supplier_invoice_detail": {
"title_invoice": "Supplier invoice {number}",
+2 -5
View File
@@ -4636,7 +4636,6 @@
"forval_books_at_payment": "Bokförs vid betalning",
"forval_toggle_open": "Ändra förval",
"forval_toggle_close": "Stäng",
"chip_paid_privately": "Betald privat",
"chip_ore_rounding_off": "Utan öresavrundning",
"chip_delivery_date": "Leveransdatum {date}",
"chip_notes": "Anteckningar",
@@ -4680,9 +4679,6 @@
"create_and_select": "Skapa & välj",
"section_accounting": "Kontering",
"account_from_history": "Konto {account} föreslaget från tidigare bokföringar av {counterparty}",
"paid_privately_label": "Jag har betalat detta privat",
"paid_privately_help_ef": "Bokförs som skuld från bolaget till dig (2018 Egen insättning). Återbetalas senare manuellt från företagskontot.",
"paid_privately_help_ab": "Bokförs som skuld från bolaget till dig (2893 Skuld till ägare). Återbetalas senare manuellt från företagskontot.",
"supplier_placeholder": "Välj leverantör",
"no_suppliers_yet": "Inga leverantörer än",
"add_new_supplier": "+ Lägg till ny leverantör...",
@@ -4806,7 +4802,8 @@
"debit_short": "D {amount}",
"credit_short": "K {amount}",
"debit_credit_short": "D {debit} / K {credit}",
"review_accrual_line_info": "Periodiseras {from} till {to}"
"review_accrual_line_info": "Periodiseras {from} till {to}",
"expense_registered_description": "Skulden till {name} finns nu i Att göra. Ankomstnummer: {number}"
},
"supplier_invoice_detail": {
"title_invoice": "Leverantörsfaktura {number}",
@@ -119,6 +119,9 @@ Request body:
notes?: string,
ore_rounding?: boolean,
paid_with_private_funds?: boolean,
employee_id?: string,
claimant_name?: string,
inbox_item_id?: string,
payment_date?: string,
default_dimensions?: Record<string, string>,
items: { description: string, amount?: number, account_number: string, vat_rate?: 0 | 0.06 | 0.12 | 0.25, vat_amount?: number, reverse_charge_rate?: number, apply_slp?: boolean, vat_code?: string, quantity?: number, unit?: string, unit_price?: number, accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[]