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

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

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

Closes #2332


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:04:17 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 6776cb4fc6
commit ebbe50c0f3
20 changed files with 1318 additions and 575 deletions
+306 -74
View File
@@ -31,12 +31,24 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
})) }))
const mockCreateSupplierInvoiceRegistrationEntry = vi.fn() const mockCreateSupplierInvoiceRegistrationEntry = vi.fn()
const mockCreateSupplierInvoicePrivatelyPaidEntry = vi.fn() vi.mock('@/lib/bookkeeping/supplier-invoice-entries', async () => {
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ // The privately-paid line builder is pure: keep the real one so the tests
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) => // pin the kontering the route hands to the claims writer.
mockCreateSupplierInvoiceRegistrationEntry(...args), const actual = await vi.importActual<typeof import('@/lib/bookkeeping/supplier-invoice-entries')>(
createSupplierInvoicePrivatelyPaidEntry: (...args: unknown[]) => '@/lib/bookkeeping/supplier-invoice-entries',
mockCreateSupplierInvoicePrivatelyPaidEntry(...args), )
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() 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') expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
}) })
it('books privately-paid invoice via 2893 path for aktiebolag', async () => { // ── Privately paid = utlägg ──────────────────────────────────────────────
const supplier = makeSupplier({ id: VALID_UUID }) // A person paid the supplier invoice: the route hands the invoice to the
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-1', status: 'paid' }) // 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 const EMPLOYEE_UUID = '550e8400-e29b-41d4-a716-446655440003'
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard const INBOX_UUID = '550e8400-e29b-41d4-a716-446655440004'
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 })
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-1' }) function claimOk(overrides: Record<string, unknown> = {}) {
// Update invoice with payment_journal_entry_id return {
enqueue({ data: null, error: null }) ok: true,
// Insert supplier_invoice_payments row claim: {
enqueue({ data: null, error: null }) id: 'claim-1',
journal_entry_id: 'je-priv-1',
const request = createMockRequest('/api/supplier-invoices', { claimant_name: 'Ägare',
method: 'POST', liability_account: '2893',
body: { ...overrides,
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 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<{ 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) }>(response)
expect(status).toBe(200) expect(status).toBe(200)
expect(body.data.payment_journal_entry_id).toBe('je-priv-1') expect(body.data.payment_journal_entry_id).toBe('je-priv-1')
expect(body.data.registration_journal_entry_id).toBeNull() 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. // The classic registration path must NOT be touched.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() 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 supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-2', status: 'paid' }) 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: supplier, error: null })
enqueue({ data: { entity_type: 'enskild_firma' }, error: null }) enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
enqueue({ data: 13 }) enqueue({ data: 13 })
enqueue({ data: createdInvoice, error: null }) enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null }) enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'cash' }, error: null }) enqueue({ data: { accounting_method: 'cash' }, error: null })
mockRegisterExpenseClaim.mockResolvedValue(claimOk({ liability_account: '2018', claimant_name: 'Anna Ek' }))
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-2' })
enqueue({ data: null, error: null }) enqueue({ data: null, error: null })
enqueue({ data: null, error: null }) enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices', { const request = createMockRequest('/api/supplier-invoices', {
method: 'POST', method: 'POST',
body: { body: privatelyPaidBody({
supplier_id: VALID_UUID,
supplier_invoice_number: 'KVITTO-002', supplier_invoice_number: 'KVITTO-002',
invoice_date: '2024-06-01', claimant_name: ' Anna Ek ',
due_date: '2024-06-01', items: [{ description: 'Lunch klient', quantity: 1, unit_price: 200, account_number: '5810', vat_rate: 0.12 }],
paid_with_private_funds: true, }),
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) const { status } = await parseJsonResponse(response)
expect(status).toBe(200) expect(status).toBe(200)
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0] const input = claimInput()
expect(call[5]).toBe('enskild_firma') 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 () => { 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') expect(body.error.code).toBe('SI_CREATE_ACCRUAL_REVERSE_CHARGE')
// The guard must fire before anything is persisted or booked. // The guard must fire before anything is persisted or booked.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled() expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
}) })
it('rejects paid_with_private_funds combined with reverse_charge', async () => { 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(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT') expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
// Make sure we never touched the engine paths. // Make sure we never touched the engine paths.
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled() expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
expect(mockCreateSupplierInvoiceRegistrationEntry).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 { eventBus } from '@/lib/events'
import { import {
createSupplierInvoiceRegistrationEntry, createSupplierInvoiceRegistrationEntry,
createSupplierInvoicePrivatelyPaidEntry, buildSupplierInvoicePrivatelyPaidLines,
largestExpenseAccount,
} from '@/lib/bookkeeping/supplier-invoice-entries' } 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 { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions' import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines' import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
@@ -20,7 +24,7 @@ import {
} from '@/lib/currency/supplier-invoice-rate' } from '@/lib/currency/supplier-invoice-rate'
import { roundOre } from '@/lib/money' import { roundOre } from '@/lib/money'
import { linkToJournalEntry } from '@/lib/core/documents/document-service' 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' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized() ensureInitialized()
@@ -77,11 +81,46 @@ export const POST = withRouteContext(
const body = validation.data const body = validation.data
const paidPrivately = body.paid_with_private_funds === true 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 const { data: document, error: documentError } = await supabase
.from('document_attachments') .from('document_attachments')
.select('id, journal_entry_id') .select('id, journal_entry_id')
.eq('id', body.document_id) .eq('id', documentId)
.eq('company_id', companyId) .eq('company_id', companyId)
.maybeSingle() .maybeSingle()
@@ -96,7 +135,7 @@ export const POST = withRouteContext(
.from('supplier_invoices') .from('supplier_invoices')
.select('id') .select('id')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('document_id', body.document_id) .eq('document_id', documentId)
.limit(1) .limit(1)
.maybeSingle() .maybeSingle()
@@ -226,6 +265,19 @@ export const POST = withRouteContext(
}) })
} }
entityType = company.entity_type as 'aktiebolag' | 'enskild_firma' 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: // Resolve the exchange rate BEFORE the arrival-number sequence is touched:
@@ -354,7 +406,7 @@ export const POST = withRouteContext(
user_id: user.id, user_id: user.id,
company_id: companyId, company_id: companyId,
supplier_id: body.supplier_id, supplier_id: body.supplier_id,
document_id: body.document_id || null, document_id: documentId,
arrival_number: arrivalNum, arrival_number: arrivalNum,
supplier_invoice_number: body.supplier_invoice_number, supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date, invoice_date: body.invoice_date,
@@ -469,10 +521,12 @@ export const POST = withRouteContext(
// silently understates leverantörsskuld (2440) and ingående moms (2641) // silently understates leverantörsskuld (2440) and ingående moms (2641)
// for the momsdeklaration. Roll back instead. // for the momsdeklaration. Roll back instead.
// //
// Privately-paid path bypasses both accrual and cash flows: a single // Privately-paid path bypasses both accrual and cash flows: the invoice is
// verifikat books the expense + VAT against 2893 (AB) or 2018 (EF) at // an utlägg, so one verifikat books the expense + VAT against the payer's
// registration time, regardless of accounting_method. mark-paid is never // liability account (2893 AB owner / 2018 EF owner / 2820 employee) at
// invoked for these (status='paid' from the start). // 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 const { data: settings } = await supabase
.from('company_settings') .from('company_settings')
.select('accounting_method, defer_invoice_booking') .select('accounting_method, defer_invoice_booking')
@@ -485,68 +539,134 @@ export const POST = withRouteContext(
const booksOnRegistration = booksInvoicesOnIssue(settings) const booksOnRegistration = booksInvoicesOnIssue(settings)
let registrationJournalEntryId: string | null = null let registrationJournalEntryId: string | null = null
let paymentJournalEntryId: string | null = null let paymentJournalEntryId: string | null = null
let expenseClaim: { id: string; claimant_name: string; liability_account: string } | null = null
if (paidPrivately && entityType) { 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 { try {
const journalEntry = await createSupplierInvoicePrivatelyPaidEntry( claimResult = await registerExpenseClaim(supabase, companyId, user.id, {
supabase, description: claimDescription,
companyId!, expense_date: invoice.invoice_date,
user.id, amount: totalRounded,
invoice as SupplierInvoice, vat_amount: roundOre(vatAmount),
items as SupplierInvoiceItem[], currency: fx.rate.currency as Currency,
entityType, exchange_rate: fx.rate.exchangeRate ?? undefined,
supplier.name, expense_account: largestExpenseAccount(items as SupplierInvoiceItem[]),
) employee_id: body.employee_id ?? undefined,
if (journalEntry) { claimant_name: payer === 'owner' ? body.claimant_name?.trim() || OWNER_FALLBACK_NAME : undefined,
paymentJournalEntryId = journalEntry.id document_id: documentId ?? undefined,
await supabase inbox_item_id: inboxItem?.id,
.from('supplier_invoices') lines: buildSupplierInvoicePrivatelyPaidLines(
.update({ payment_journal_entry_id: journalEntry.id }) invoice as SupplierInvoice,
.eq('id', invoice.id) items as SupplierInvoiceItem[],
// Mirror the payment in supplier_invoice_payments so AR/AP and liabilityAccount,
// payment-history queries stay consistent with the mark-paid path. claimDescription,
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 },
})
}
} catch (err) { } 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) await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
if (isBookkeepingError(err)) { if (isBookkeepingError(err)) {
return errorResponse(err, log, { requestId }) 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, invoiceId: invoice.id,
}) })
return errorResponseFromCode('SI_CREATE_FAILED', log, { return errorResponseFromCode('SI_CREATE_FAILED', log, {
requestId, requestId,
details: { details: {
reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown', 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) { } else if (booksOnRegistration) {
try { try {
const journalEntry = await createSupplierInvoiceRegistrationEntry( 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 const primaryJournalEntryId = paymentJournalEntryId || registrationJournalEntryId
if (body.document_id && primaryJournalEntryId) { if (documentId && primaryJournalEntryId && !paidPrivately) {
try { try {
await linkToJournalEntry( await linkToJournalEntry(
supabase, supabase,
companyId, companyId,
body.document_id, documentId,
primaryJournalEntryId, primaryJournalEntryId,
) )
} catch (err) { } catch (err) {
log.warn('supplier invoice document could not be linked to journal entry', { log.warn('supplier invoice document could not be linked to journal entry', {
documentId: body.document_id, documentId,
journalEntryId: primaryJournalEntryId, journalEntryId: primaryJournalEntryId,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}) })
@@ -675,6 +796,7 @@ export const POST = withRouteContext(
items: itemInserts, items: itemInserts,
registration_journal_entry_id: registrationJournalEntryId, registration_journal_entry_id: registrationJournalEntryId,
payment_journal_entry_id: paymentJournalEntryId, payment_journal_entry_id: paymentJournalEntryId,
expense_claim: expenseClaim,
}, },
...(warnings.length > 0 ? { warnings } : {}), ...(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, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { import {
Inbox, Inbox,
@@ -76,6 +75,7 @@ import {
} from '@/lib/documents/inbox-kind' } from '@/lib/documents/inbox-kind'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
import RegisterExpenseDialog, { type ExpensePayer } from '@/components/extensions/general/RegisterExpenseDialog' 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 NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog' import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
// InboxCustomDomainDialog (egen domän) is built but gated off: see // 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 ────────────────────────────────────────────── // ── Fields rail ──────────────────────────────────────────────
function FieldsRail({ function FieldsRail({
@@ -14,29 +14,22 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { ExpenseClaimantFields } from '@/components/expenses/ExpenseClaimantFields'
import { useCompanyOptional } from '@/contexts/CompanyContext' import { useCompanyOptional } from '@/contexts/CompanyContext'
import { useAccounts } from '@/lib/reference-data/hooks' import { useAccounts } from '@/lib/reference-data/hooks'
import { getErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money' import { roundOre } from '@/lib/money'
import { ACCOUNT_NUMBER_RE, ISO_DATE_RE } from '@/lib/invariants' 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' import type { InvoiceExtractionResult } from '@/types'
/** // Who paid for the underlag out of their own pocket. The account rule (2893
* Who paid for the underlag out of their own pocket. The owner's liability // AB owner / 2018 EF owner / 2820 employee) lives in lib/expenses/payer.ts,
* account follows the entity type (2893 skuld till ägare in an AB, 2018 egen // shared with the supplier-invoice form.
* insättning in an enskild firma); an employee is always 2820. export type { ExpensePayer }
*/
export type ExpensePayer = 'owner' | 'employee'
interface InboxItemLike { interface InboxItemLike {
id: string id: string
@@ -44,12 +37,6 @@ interface InboxItemLike {
extracted_data: InvoiceExtractionResult | null extracted_data: InvoiceExtractionResult | null
} }
interface EmployeeOption {
id: string
first_name: string
last_name: string
}
interface Props { interface Props {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@@ -59,8 +46,6 @@ interface Props {
onSuccess: () => void | Promise<void> onSuccess: () => void | Promise<void>
} }
const OWNER_FALLBACK_NAME = 'Ägare'
function todayIso(): string { function todayIso(): string {
// Local calendar date: toISOString() is UTC and would date a receipt booked // Local calendar date: toISOString() is UTC and would date a receipt booked
// after midnight CEST to the previous day (wrong period, wrong FX rate). // 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 { toast } = useToast()
const { accounts } = useAccounts() const { accounts } = useAccounts()
const entityType = useCompanyOptional()?.company?.entity_type ?? null const entityType = useCompanyOptional()?.company?.entity_type ?? null
const ownerLiability = entityType === 'enskild_firma' ? '2018' : '2893' const liabilityAccount = resolveExpenseLiabilityAccount(entityType, payer)
const liabilityAccount = payer === 'owner' ? ownerLiability : '2820'
const data = item.extracted_data const data = item.extracted_data
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
@@ -99,8 +83,7 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
const [expenseAccount, setExpenseAccount] = useState('') const [expenseAccount, setExpenseAccount] = useState('')
const [ownerName, setOwnerName] = useState('') const [ownerName, setOwnerName] = useState('')
const [employeeId, setEmployeeId] = useState('') const [employeeId, setEmployeeId] = useState('')
const [employees, setEmployees] = useState<EmployeeOption[]>([]) const [employeeName, setEmployeeName] = useState('')
const [employeesLoaded, setEmployeesLoaded] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase() const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase()
// A foreign receipt carries VAT the company cannot deduct on 2641: the whole // 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') setVatInput(!isForeign && vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
setExpenseAccount('') setExpenseAccount('')
setEmployeeId('') setEmployeeId('')
setEmployeeName('')
}, [open, item.id, data, isForeign]) }, [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) const amount = parseAmount(amountInput)
// Foreign VAT is never deductible here: the field is locked and 0 is what // Foreign VAT is never deductible here: the field is locked and 0 is what
// gets submitted, whatever the extraction said. // gets submitted, whatever the extraction said.
const vatAmount = isForeign ? 0 : parseAmount(vatInput) const vatAmount = isForeign ? 0 : parseAmount(vatInput)
const net = roundOre(amount - vatAmount) const net = roundOre(amount - vatAmount)
const employee = employees.find((e) => e.id === employeeId) ?? null const claimantName = payer === 'owner' ? ownerName.trim() || OWNER_FALLBACK_NAME : employeeName
const claimantName =
payer === 'owner'
? ownerName.trim() || OWNER_FALLBACK_NAME
: employee
? `${employee.first_name} ${employee.last_name}`.trim()
: ''
const canSubmit = const canSubmit =
!isSubmitting && !isSubmitting &&
@@ -236,36 +205,18 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{payer === 'owner' ? ( <ExpenseClaimantFields
<div className="space-y-1.5"> payer={payer}
<Label htmlFor="re-owner">{t('expense_owner_name')}</Label> ownerName={ownerName}
<Input onOwnerNameChange={setOwnerName}
id="re-owner" employeeId={employeeId}
value={ownerName} onEmployeeChange={(id, name) => {
onChange={(e) => setOwnerName(e.target.value)} setEmployeeId(id)
placeholder={OWNER_FALLBACK_NAME} setEmployeeName(name)
disabled={isSubmitting} }}
/> disabled={isSubmitting}
</div> idPrefix="re"
) : ( />
<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>
)}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="re-description">{t('expense_description')}</Label> <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 { useSupplierInvoiceData } from '@/components/supplier-invoices/use-supplier-invoice-data'
import { useInboxPrefill, type InboxItemData } from '@/components/supplier-invoices/use-inbox-prefill' import { useInboxPrefill, type InboxItemData } from '@/components/supplier-invoices/use-inbox-prefill'
import { useSupplierInvoiceSubmit } from '@/components/supplier-invoices/use-supplier-invoice-submit' 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' import type { Supplier, InvoiceExtractionResult } from '@/types'
// The form's line/field shapes live in lib/supplier-invoices/form-payload.ts // 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, reverse_charge: false,
payment_reference: '', payment_reference: '',
notes: '', notes: '',
paid_with_private_funds: false, payer: 'unpaid',
claimant_name: '',
employee_id: '',
// The table starts empty: the ghost entry row (never part of form // 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 // state) is the only way rows are born, so no silent prefilled expense
// account can ever reach a verifikat unnoticed. // account can ever reach a verifikat unnoticed.
@@ -324,7 +329,12 @@ export default function NewSupplierInvoiceForm({
const watchedSupplierId = watch('supplier_id') const watchedSupplierId = watch('supplier_id')
const watchedCurrency = watch('currency') const watchedCurrency = watch('currency')
const watchedExchangeRate = watch('exchange_rate') 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') const watchedReverseCharge = watch('reverse_charge')
// Watched values used to decide whether the AI-filled indicator should // Watched values used to decide whether the AI-filled indicator should
// still be visible. Once the user edits a field, its value no longer // still be visible. Once the user edits a field, its value no longer
@@ -1369,7 +1379,6 @@ export default function NewSupplierInvoiceForm({
showBankPicker, showBankPicker,
setShowBankPicker, setShowBankPicker,
setPendingTransactionId, setPendingTransactionId,
submitModeRef,
conflict, conflict,
setConflict, setConflict,
isResolvingConflict, isResolvingConflict,
@@ -1393,6 +1402,8 @@ export default function NewSupplierInvoiceForm({
showNoPeriodWarning, showNoPeriodWarning,
canUseAccrual, canUseAccrual,
invoiceNumberInputRef, invoiceNumberInputRef,
// The Utlägg nav row is gated on existing claims in the server layout.
onExpenseRegistered: () => router.refresh(),
onMissingField: focusMissingField, onMissingField: focusMissingField,
}) })
@@ -1464,7 +1475,6 @@ export default function NewSupplierInvoiceForm({
const forvalChips: string[] = [ const forvalChips: string[] = [
willBookAtRegistration ? t('forval_books_at_registration') : t('forval_books_at_payment'), 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 (watchedReverseCharge) forvalChips.push(t('reverse_charge_label'))
if ((watchedCurrency || 'SEK') !== 'SEK') { if ((watchedCurrency || 'SEK') !== 'SEK') {
forvalChips.push( forvalChips.push(
@@ -1486,11 +1496,15 @@ export default function NewSupplierInvoiceForm({
? t('underlag_caption_attached') ? t('underlag_caption_attached')
: t('underlag_caption_default') : 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 const primaryLabel = watchedPaidPrivately
? t('register_expense') ? t('register_expense')
: isEF : watchedPayer === 'company'
? t('register_invoice') ? t('register_and_mark_paid')
: t('review_and_register') : isEF
? t('register_invoice')
: t('review_and_register')
// min-w-0 on the root: DialogContent is display:grid; without it this grid // 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 // 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')} {...register('invoice_date')}
/> />
</div> </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 && ( {!watchedPaidPrivately && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
@@ -2164,25 +2207,6 @@ export default function NewSupplierInvoiceForm({
</div> </div>
{forvalOpen && ( {forvalOpen && (
<div className="mt-4 border-t border-border"> <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]"> <div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
<label htmlFor="reverse_charge" className="cursor-pointer"> <label htmlFor="reverse_charge" className="cursor-pointer">
{t('reverse_charge_label')} {t('reverse_charge_label')}
@@ -2434,18 +2458,6 @@ export default function NewSupplierInvoiceForm({
> >
{t('cancel')} {t('cancel')}
</button> </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 {/* Always enabled pre-click for writable users: clicking with
something missing routes focus to the first missing field something missing routes focus to the first missing field
(submit-time hard blocks stay in onSubmit). Viewers keep the (submit-time hard blocks stay in onSubmit). Viewers keep the
@@ -2453,7 +2465,6 @@ export default function NewSupplierInvoiceForm({
<Button <Button
type="submit" type="submit"
disabled={isSubmitting || !canWrite} disabled={isSubmitting || !canWrite}
onClick={() => { submitModeRef.current = 'register' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined} title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
> >
{isSubmitting ? ( {isSubmitting ? (
@@ -2514,10 +2525,7 @@ export default function NewSupplierInvoiceForm({
open={showBankPicker} open={showBankPicker}
onOpenChange={(open) => { onOpenChange={(open) => {
setShowBankPicker(open) setShowBankPicker(open)
if (!open) { if (!open) setPendingTransactionId(null)
submitModeRef.current = 'register'
setPendingTransactionId(null)
}
}} }}
targetAmount={total} targetAmount={total}
targetCurrency={watchedCurrency} targetCurrency={watchedCurrency}
@@ -1,12 +1,13 @@
'use client' 'use client'
import { useState, useRef, type RefObject } from 'react' import { useState, type RefObject } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage } from '@/lib/errors/get-error-message'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute' import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { findIllegalVatRateRow } from '@/lib/vat/supplier-invoice-line-checks' 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' import type { InvoiceExtractionResult } from '@/types'
// The existing invoice surfaced on a duplicate-number conflict, used to drive // 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 // envelope's inner object ({ code, message, details }); a few legacy convert
// paths still return a flat string, so accept both. // paths still return a flat string, so accept both.
export interface CreateResult { 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 }> warnings?: Array<{ code: string; message: string }>
error?: error?:
| string | string
@@ -50,6 +56,8 @@ interface UseSupplierInvoiceSubmitParams {
showNoPeriodWarning: boolean showNoPeriodWarning: boolean
canUseAccrual: boolean canUseAccrual: boolean
invoiceNumberInputRef: RefObject<HTMLInputElement | null> 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 * Focus router for the always-enabled primary: called instead of a toast
* when a required field is missing (supplier, invoice number, row account), * when a required field is missing (supplier, invoice number, row account),
@@ -85,6 +93,7 @@ export function useSupplierInvoiceSubmit({
showNoPeriodWarning, showNoPeriodWarning,
canUseAccrual, canUseAccrual,
invoiceNumberInputRef, invoiceNumberInputRef,
onExpenseRegistered,
onMissingField, onMissingField,
}: UseSupplierInvoiceSubmitParams) { }: UseSupplierInvoiceSubmitParams) {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -94,11 +103,6 @@ export function useSupplierInvoiceSubmit({
// Match-on-create state // Match-on-create state
const [showBankPicker, setShowBankPicker] = useState(false) const [showBankPicker, setShowBankPicker] = useState(false)
const [pendingTransactionId, setPendingTransactionId] = useState<string | null>(null) 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 // Conflict state for duplicate-supplier-invoice-number
const [conflict, setConflict] = useState<{ const [conflict, setConflict] = useState<{
@@ -147,8 +151,9 @@ export function useSupplierInvoiceSubmit({
} }
} }
// Single submit endpoint chooser: convert when we came from inbox, plain // Single submit endpoint chooser: convert when we came from inbox and the
// POST otherwise. Both endpoints validate the same CreateSupplierInvoiceSchema // 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, // and return the same canonical error envelope ({ error: { code, message,
// details } }): including the recoverable duplicate-number 409. // details } }): including the recoverable duplicate-number 409.
async function postCreate(data: SupplierInvoiceFormData): Promise<{ async function postCreate(data: SupplierInvoiceFormData): Promise<{
@@ -156,9 +161,7 @@ export function useSupplierInvoiceSubmit({
status: number status: number
result: CreateResult result: CreateResult
}> { }> {
const url = inboxItemId const url = supplierInvoiceCreateUrl(data.payer, inboxItemId)
? `/api/extensions/ext/invoice-inbox/items/${inboxItemId}/convert`
: '/api/supplier-invoices'
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
@@ -281,18 +284,19 @@ export function useSupplierInvoiceSubmit({
return return
} }
if (submitModeRef.current === 'register_and_match') { if (data.payer === 'company') {
// Open the bank-transaction picker; actual create happens on pick. // "Företaget" is register-and-match: open the bank-transaction picker;
// For AB the review dialog is shown after a transaction is picked. // the actual create happens on pick. For AB the review dialog is shown
// after a transaction is picked.
setPendingData(data) setPendingData(data)
setShowBankPicker(true) setShowBankPicker(true)
return 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 // explicit user intent, and the resulting verifikat is just expense + VAT
// against the owner account (2893/2018). Same path for EF. // against that person's liability account. Same path for EF.
if (isEF || data.paid_with_private_funds) { if (isEF || isPersonPayer(data.payer)) {
setPendingData(data) setPendingData(data)
handleDirectSubmit(data) handleDirectSubmit(data)
} else { } else {
@@ -327,11 +331,21 @@ export function useSupplierInvoiceSubmit({
// beforeunload prompt while we navigate away on a successful submit. // beforeunload prompt while we navigate away on a successful submit.
reset(data) reset(data)
if (data.paid_with_private_funds) { if (isPersonPayer(data.payer)) {
const claim = result.data.expense_claim
toast({ toast({
title: t('expense_registered_title'), 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() finishCreate()
setIsSubmitting(false) setIsSubmitting(false)
return return
@@ -376,7 +390,6 @@ export function useSupplierInvoiceSubmit({
}) })
const matchResult = await matchRes.json() const matchResult = await matchRes.json()
setPendingTransactionId(null) setPendingTransactionId(null)
submitModeRef.current = 'register'
if (matchRes.ok) { if (matchRes.ok) {
toast({ toast({
@@ -534,7 +547,6 @@ export function useSupplierInvoiceSubmit({
}) })
const matchResult = await matchRes.json() const matchResult = await matchRes.json()
setIsSubmitting(false) setIsSubmitting(false)
submitModeRef.current = 'register'
if (matchRes.ok) { if (matchRes.ok) {
toast({ toast({
@@ -560,7 +572,6 @@ export function useSupplierInvoiceSubmit({
showBankPicker, showBankPicker,
setShowBankPicker, setShowBankPicker,
setPendingTransactionId, setPendingTransactionId,
submitModeRef,
conflict, conflict,
setConflict, setConflict,
isResolvingConflict, isResolvingConflict,
+11
View File
@@ -1343,6 +1343,17 @@ export const CreateSupplierInvoiceSchema = z.object({
// Per-invoice öresavrundning toggle (display-only). Omitted → stored as null (off). // Per-invoice öresavrundning toggle (display-only). Omitted → stored as null (off).
ore_rounding: z.boolean().optional(), ore_rounding: z.boolean().optional(),
paid_with_private_funds: 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. // For paid_with_private_funds: the date the owner paid out-of-pocket.
// Defaults to invoice_date (common for kvitto where the two coincide). // Defaults to invoice_date (common for kvitto where the two coincide).
payment_date: isoDate.optional(), payment_date: isoDate.optional(),
@@ -91,7 +91,8 @@ const {
createSupplierInvoicePaymentEntry, createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry, createSupplierInvoiceCashEntry,
createSupplierCreditNoteEntry, createSupplierCreditNoteEntry,
createSupplierInvoicePrivatelyPaidEntry, buildSupplierInvoicePrivatelyPaidLines,
largestExpenseAccount,
SupplierInvoiceFxRateMissingError, SupplierInvoiceFxRateMissingError,
} = await import('../supplier-invoice-entries') } = await import('../supplier-invoice-entries')
@@ -996,21 +997,6 @@ describe('supplier invoice booking: foreign currency without an exchange rate',
).rejects.toThrow(SupplierInvoiceFxRateMissingError) ).rejects.toThrow(SupplierInvoiceFxRateMissingError)
expect(mockedCreateEntry).not.toHaveBeenCalled() 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', () => { type ClaimLine = ReturnType<typeof buildSupplierInvoicePrivatelyPaidLines>[number]
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('returns null when no fiscal period found', async () => { function claimLinesByAccount(lines: ClaimLine[], account: string) {
mockedFindFiscalPeriod.mockResolvedValue(null) return lines.filter((l) => l.account_number === account)
const invoice = makeSupplierInvoice() }
const items = [makeItem()]
const result = await createSupplierInvoicePrivatelyPaidEntry( function assertClaimLinesBalanced(lines: ClaimLine[]) {
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag' 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() const DESC = 'Faktura LF-001, Pressbyrån (ankomstnr 1)'
expect(mockedCreateEntry).not.toHaveBeenCalled()
})
it('AB: credits 2893 (D expense + D 2641 + C 2893)', async () => { describe('buildSupplierInvoicePrivatelyPaidLines', () => {
const invoice = makeSupplierInvoice({ it('AB owner: D expense + D 2641, C 2893 with the total; no 2440, no 1930', () => {
subtotal: 400, const invoice = makeSupplierInvoice({ subtotal: 400, vat_amount: 100, total: 500 })
vat_amount: 100,
total: 500,
})
const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })] const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })]
await createSupplierInvoicePrivatelyPaidEntry( const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag', 'Pressbyrån'
)
expect(mockedCreateEntry).toHaveBeenCalledOnce() expect(claimLinesByAccount(lines, '6110')).toHaveLength(1)
const input = mockedCreateEntry.mock.calls[0][3] expect(claimLinesByAccount(lines, '6110')[0].debit_amount).toBe(400)
expect(claimLinesByAccount(lines, '6110')[0].line_description).toBe(DESC)
expect(input.source_type).toBe('supplier_invoice_privately_paid') expect(claimLinesByAccount(lines, '2641')).toHaveLength(1)
expect(claimLinesByAccount(lines, '2641')[0].debit_amount).toBe(100)
const debit6110 = findByAccount(input.lines, '6110') expect(claimLinesByAccount(lines, '2641')[0].line_description).toContain('Ingående moms 25%')
expect(debit6110).toHaveLength(1) // The liability credit is what the claims service checks against the
expect(debit6110[0].debit_amount).toBe(400) // claim total and what the payout later reimburses.
expect(claimLinesByAccount(lines, '2893')).toHaveLength(1)
const debit2641 = findByAccount(input.lines, '2641') expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(500)
expect(debit2641).toHaveLength(1) // AP account 2440 must NOT appear: an utlägg bypasses AP entirely.
expect(debit2641[0].debit_amount).toBe(100) expect(claimLinesByAccount(lines, '2440')).toHaveLength(0)
// Bank account 1930 must NOT appear: the person paid, not the company.
const credit2893 = findByAccount(input.lines, '2893') expect(claimLinesByAccount(lines, '1930')).toHaveLength(0)
expect(credit2893).toHaveLength(1) expect(claimLinesByAccount(lines, '2018')).toHaveLength(0)
expect(credit2893[0].credit_amount).toBe(500) assertClaimLinesBalanced(lines)
// 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)
}) })
it('EF: credits 2018 instead of 2893', async () => { it('credits whatever liability the caller resolved: 2018 for an EF owner, 2820 for an employee', () => {
const invoice = makeSupplierInvoice({ const invoice = makeSupplierInvoice({ subtotal: 400, vat_amount: 100, total: 500 })
subtotal: 400,
vat_amount: 100,
total: 500,
})
const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })] const items = [makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25 })]
await createSupplierInvoicePrivatelyPaidEntry( const ef = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2018', DESC)
null as never, 'company-1', 'user-1', invoice, items, 'enskild_firma', 'Pressbyrån' expect(claimLinesByAccount(ef, '2018')[0].credit_amount).toBe(500)
) expect(claimLinesByAccount(ef, '2893')).toHaveLength(0)
const input = mockedCreateEntry.mock.calls[0][3] const employee = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2820', DESC)
expect(claimLinesByAccount(employee, '2820')[0].credit_amount).toBe(500)
const credit2018 = findByAccount(input.lines, '2018') expect(claimLinesByAccount(employee, '2893')).toHaveLength(0)
expect(credit2018).toHaveLength(1) assertClaimLinesBalanced(employee)
expect(credit2018[0].credit_amount).toBe(500)
expect(findByAccount(input.lines, '2893')).toHaveLength(0)
expect(findByAccount(input.lines, '2440')).toHaveLength(0)
assertBalanced(input)
}) })
it('skips 2641 line when invoice has zero VAT', async () => { it('skips the 2641 line when the invoice has zero VAT', () => {
const invoice = makeSupplierInvoice({ const invoice = makeSupplierInvoice({ subtotal: 500, vat_amount: 0, total: 500 })
subtotal: 500,
vat_amount: 0,
total: 500,
})
const items = [makeItem({ line_total: 500, account_number: '5460', vat_rate: 0 })] const items = [makeItem({ line_total: 500, account_number: '5460', vat_rate: 0 })]
await createSupplierInvoicePrivatelyPaidEntry( const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3] expect(claimLinesByAccount(lines, '5460')[0].debit_amount).toBe(500)
expect(claimLinesByAccount(lines, '2641')).toHaveLength(0)
expect(findByAccount(input.lines, '5460')[0].debit_amount).toBe(500) expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(500)
expect(findByAccount(input.lines, '2641')).toHaveLength(0) assertClaimLinesBalanced(lines)
expect(findByAccount(input.lines, '2893')[0].credit_amount).toBe(500)
assertBalanced(input)
}) })
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 // Lunch (12%) + parking (25%) on the same kvitto
const invoice = makeSupplierInvoice({ const invoice = makeSupplierInvoice({ subtotal: 200, vat_amount: 37, total: 237 })
subtotal: 200,
vat_amount: 36, // 100*0.12 + 100*0.25 = 12 + 25 = 37; off-by-one from rounding
total: 237,
})
const items = [ const items = [
makeItem({ line_total: 100, account_number: '5810', vat_rate: 0.12 }), makeItem({ line_total: 100, account_number: '5810', vat_rate: 0.12 }),
makeItem({ line_total: 100, account_number: '5611', vat_rate: 0.25 }), makeItem({ line_total: 100, account_number: '5611', vat_rate: 0.25 }),
] ]
await createSupplierInvoicePrivatelyPaidEntry( const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3] expect(claimLinesByAccount(lines, '2641')).toHaveLength(2)
expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(237)
// One 2641 line per rate assertClaimLinesBalanced(lines)
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)
}) })
it('aggregates expense lines per account number', async () => { it('a stored VAT override wins over line_total x rate', () => {
// Two items on the same expense account should collapse to one debit line // Bilförmån: 25 % charged, only half deductible.
const invoice = makeSupplierInvoice({ const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 1250, total: 11250 })
subtotal: 600, const items = [makeItem({ line_total: 10000, account_number: '5611', vat_rate: 0.25, vat_amount: 1250 })]
vat_amount: 150,
total: 750, 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 = [ const items = [
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }), makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }),
makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }), makeItem({ line_total: 300, account_number: '6110', vat_rate: 0.25 }),
] ]
await createSupplierInvoicePrivatelyPaidEntry( const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
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') it('keeps invoice-currency amounts: the claims service converts at the claim rate', () => {
expect(debit6110).toHaveLength(1) const invoice = makeSupplierInvoice({
expect(debit6110[0].debit_amount).toBe(600) 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', () => { describe('dimensions propagation (PR7): buildSupplierInvoicePrivatelyPaidLines', () => {
beforeEach(() => { it('expense lines carry merged bags; 2641 and the liability carry the default', () => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
it('expense lines carry merged bags; 2641 and the owner account carry the default', async () => {
const invoice = makeSupplierInvoice({ const invoice = makeSupplierInvoice({
subtotal: 400, subtotal: 400,
vat_amount: 100, 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' } }), makeItem({ line_total: 400, account_number: '6110', vat_rate: 0.25, dimensions: { '6': 'P001' } }),
] ]
await createSupplierInvoicePrivatelyPaidEntry( const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
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' }) it('two items on the same account with different bags stay on separate lines', () => {
expect(findByAccount(input.lines, '2641')[0].dimensions).toEqual({ '1': 'KS01' }) const invoice = makeSupplierInvoice({ subtotal: 600, vat_amount: 0, total: 600 })
expect(findByAccount(input.lines, '2893')[0].dimensions).toEqual({ '1': 'KS01' }) 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) 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 invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 0, total: 10000 })
const items = [ const items = [
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }), makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
] ]
await createSupplierInvoicePrivatelyPaidEntry( const lines = buildSupplierInvoicePrivatelyPaidLines(invoice, items, '2893', DESC)
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3] expect(claimLinesByAccount(lines, '7533')[0].debit_amount).toBe(2426)
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2426) expect(claimLinesByAccount(lines, '2514')[0].credit_amount).toBe(2426)
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2426) // The SLP pair must not inflate what the person is owed.
// The SLP pair must not inflate what the owner is owed. expect(claimLinesByAccount(lines, '2893')[0].credit_amount).toBe(10000)
expect(findByAccount(input.lines, '2893')[0].credit_amount).toBe(10000) assertClaimLinesBalanced(lines)
assertBalanced(input)
}) })
it('credit note reverses the pair (7533 K / 2514 D) and keeps 2440 at the invoice total', async () => { 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 { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money' import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js' import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExpenseClaimLineInput } from '@/lib/expenses/expense-claims-service'
import type { import type {
CreateJournalEntryInput, CreateJournalEntryInput,
CreateJournalEntryLineInput, CreateJournalEntryLineInput,
@@ -557,69 +558,79 @@ export async function createSupplierInvoiceCashEntry(
} }
/** /**
* Create journal entry for an invoice paid with the owner's private funds * Kontering for a supplier invoice someone paid out of their own pocket
* (eget utlägg). The AP leg is bypassed entirely: instead of crediting 2440 * (utlägg), as custom lines for registerExpenseClaim (lib/expenses). The AP
* and later debiting it on mark-paid, the expense lines book straight against * leg is bypassed entirely: instead of crediting 2440 and later debiting it on
* the owner's payable/equity account: * mark-paid, the invoice's lines book straight against the person's liability
* account:
* *
* Debit 5xxx/6xxx (per item) [line_total in SEK] * Debit 5xxx/6xxx (per account + dimensions) [line_total]
* Debit 2641 Ingående moms [VAT per rate] * Debit 2641 Ingående moms [VAT per rate]
* Credit 2893 / 2018 [total incl VAT] * Credit 2893 / 2018 / 2820 [total incl VAT]
* *
* Reverse charge is intentionally not supported here. RC invoices are * Amounts stay in the invoice currency: the claims service converts every
* never "I paid this cash at a kiosk" cases: they're EU/byggtjänster from * line at the claim rate and keeps the liability credit equal to the claim
* registered businesses with formal invoices, which always go through AP. * total to the öre, which is what the payout flow later reimburses. That is
* The API route guards against this combo before calling us. * 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( export function buildSupplierInvoicePrivatelyPaidLines(
supabase: SupabaseClient, invoice: Pick<SupplierInvoice, 'default_dimensions'>,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[], items: SupplierInvoiceItem[],
entityType: 'aktiebolag' | 'enskild_firma', liabilityAccount: string,
supplierName?: string description: string
): Promise<JournalEntry | null> { ): ExpenseClaimLineInput[] {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date) const lines: ExpenseClaimLineInput[] = []
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[] = []
// Dimensions PR7: this IS the utlägg path, billable-expense-to-project // Dimensions PR7: this IS the utlägg path, billable-expense-to-project
// tagging rides the same merge rules as the registration entry. // tagging rides the same merge rules as the registration entry.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions) 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( const expenseBuckets = groupExpenseBuckets(
items, items,
(item) => item.account_number, (item) => item.account_number,
(item) => toSekOrThrow(item.line_total, invoice.currency, invoice.exchange_rate), (item) => item.line_total ?? 0,
defaultDimensions defaultDimensions
) )
for (const bucket of expenseBuckets) { for (const bucket of expenseBuckets) {
const amount = roundOre(bucket.amount)
if (amount === 0) continue
lines.push({ lines.push({
account_number: bucket.account, account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100, debit_amount: amount > 0 ? amount : 0,
credit_amount: 0, credit_amount: amount < 0 ? -amount : 0,
line_description: desc, line_description: description,
dimensions: bucket.dimensions, 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)) { 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) { for (const [rate, amount] of vatByRate) {
if (amount > 0) { const vat = roundOre(amount)
if (vat > 0) {
lines.push({ lines.push({
account_number: '2641', account_number: '2641',
debit_amount: Math.round(amount * 100) / 100, debit_amount: vat,
credit_amount: 0, 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, dimensions: defaultDimensions,
}) })
} }
@@ -628,36 +639,55 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
// Särskild löneskatt på pensionskostnader (SLP): same self-balancing // Särskild löneskatt på pensionskostnader (SLP): same self-balancing
// 7533 D / 2514 K pair as the registration entry. Nets to zero, so the // 7533 D / 2514 K pair as the registration entry. Nets to zero, so the
// owner account below still carries exactly the expense + VAT total. // liability account below still carries exactly the expense + VAT total.
const slpBase = slpBaseSek(items, invoice.currency, invoice.exchange_rate) // 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) { if (slpBase > 0) {
const slpLines = generateSlpLines(slpBase) for (const l of generateSlpLines(slpBase)) {
lines.push(...slpLines.map((l) => ({ ...l, dimensions: defaultDimensions }))) 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 // Credit: the person's liability, balance guarantee. Existing credits (the
// SLP 2514 leg) are subtracted so the pair never inflates what the owner // SLP 2514 leg, a discount bucket) are subtracted so the pair never inflates
// is owed: same guarantee shape as the registration entry's 2440 line. // 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 totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0) const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({ lines.push({
account_number: ownerAccount, account_number: liabilityAccount,
debit_amount: 0, debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100, credit_amount: roundOre(totalDebits - totalCredits),
line_description: desc, line_description: description,
dimensions: defaultDimensions, dimensions: defaultDimensions,
}) })
const input: CreateJournalEntryInput = { return lines
fiscal_period_id: fiscalPeriodId, }
entry_date: invoice.invoice_date,
description: desc,
source_type: 'supplier_invoice_privately_paid',
source_id: invoice.id,
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' }) 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 debit_amount: number
credit_amount: number credit_amount: number
line_description?: string | null line_description?: string | null
/** SIE dimension bag ({sie_dim_no: code}), carried onto the posted line. */
dimensions?: Record<string, string>
} }
export type RegisterExpenseClaimResult = export type RegisterExpenseClaimResult =
@@ -253,6 +255,7 @@ export async function registerExpenseClaim(
? roundOre(l.credit_amount * rate) ? roundOre(l.credit_amount * rate)
: 0, : 0,
line_description: l.line_description?.trim() || desc, line_description: l.line_description?.trim() || desc,
dimensions: l.dimensions,
})) }))
const residual = roundOre( const residual = roundOre(
sumOre(converted.map((l) => l.debit_amount)) - sumOre(converted.map((l) => l.credit_amount)), 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, debit_amount: l.debit_amount,
credit_amount: l.credit_amount, credit_amount: l.credit_amount,
line_description: l.line_description, line_description: l.line_description,
...(l.dimensions ? { dimensions: l.dimensions } : {}),
...(input.currency !== 'SEK' && l.account_number === liability ...(input.currency !== 'SEK' && l.account_number === liability
? { currency: input.currency, amount_in_currency: roundOre(input.amount), exchange_rate: rate } ? { 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 { describe, it, expect } from 'vitest'
import { import {
buildSupplierInvoicePayload, buildSupplierInvoicePayload,
supplierInvoiceCreateUrl,
inferVatTreatment, inferVatTreatment,
vatRateFromAi, vatRateFromAi,
rateToPctString, rateToPctString,
@@ -32,7 +33,9 @@ function makeFormData(overrides: Partial<SupplierInvoiceFormData> = {}): Supplie
reverse_charge: false, reverse_charge: false,
payment_reference: '', payment_reference: '',
notes: '', notes: '',
paid_with_private_funds: false, payer: 'unpaid',
claimant_name: '',
employee_id: '',
items: [makeItem()], items: [makeItem()],
...overrides, ...overrides,
} }
@@ -144,7 +147,7 @@ describe('buildSupplierInvoicePayload', () => {
it('privately paid: empty due_date defaults to invoice_date', () => { it('privately paid: empty due_date defaults to invoice_date', () => {
const payload = buildSupplierInvoicePayload( const payload = buildSupplierInvoicePayload(
makeFormData({ paid_with_private_funds: true, due_date: '' }), makeFormData({ payer: 'owner', due_date: '' }),
makeOpts({ canUseAccrual: false }), makeOpts({ canUseAccrual: false }),
) )
expect(payload.due_date).toBe('2026-08-01') expect(payload.due_date).toBe('2026-08-01')
@@ -153,12 +156,64 @@ describe('buildSupplierInvoicePayload', () => {
it('privately paid: an explicit due_date is kept', () => { it('privately paid: an explicit due_date is kept', () => {
const payload = buildSupplierInvoicePayload( 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 }), makeOpts({ canUseAccrual: false }),
) )
expect(payload.due_date).toBe('2026-09-15') 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', () => { it('reverse charge: vat_rate forced to 0, reverse_charge_rate travels with 0.25 default', () => {
const payload = buildSupplierInvoicePayload( const payload = buildSupplierInvoicePayload(
makeFormData({ makeFormData({
@@ -337,3 +392,20 @@ describe('buildSupplierInvoicePayload', () => {
expect(buildSupplierInvoicePayload(makeFormData(), makeOpts({ oreRounding: false })).ore_rounding).toBe(false) 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 { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { isPersonPayer, type PayerChoice } from '@/lib/expenses/payer'
import type { VatTreatment } from '@/types' import type { VatTreatment } from '@/types'
export interface SupplierInvoiceLineItem { export interface SupplierInvoiceLineItem {
@@ -46,7 +47,12 @@ export interface SupplierInvoiceFormData {
reverse_charge: boolean reverse_charge: boolean
payment_reference: string payment_reference: string
notes: 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[] items: SupplierInvoiceLineItem[]
} }
@@ -101,9 +107,10 @@ export function buildSupplierInvoicePayload(
) { ) {
const { inboxItemId, uploadedDocumentId, oreRounding, defaultDims, canUseAccrual } = opts const { inboxItemId, uploadedDocumentId, oreRounding, defaultDims, canUseAccrual } = opts
const vatTreatment = inferVatTreatment(data.items, data.reverse_charge) 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 // 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. // 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.invoice_date
: data.due_date : data.due_date
return { return {
@@ -119,7 +126,15 @@ export function buildSupplierInvoicePayload(
reverse_charge: data.reverse_charge, reverse_charge: data.reverse_charge,
payment_reference: data.payment_reference || undefined, payment_reference: data.payment_reference || undefined,
notes: data.notes || 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, ore_rounding: oreRounding,
// Invoice-level default dimensions (kostnadsställe/projekt): only sent // Invoice-level default dimensions (kostnadsställe/projekt): only sent
// when the user actually picked something. // 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_books_at_payment": "Posted at payment",
"forval_toggle_open": "Change defaults", "forval_toggle_open": "Change defaults",
"forval_toggle_close": "Close", "forval_toggle_close": "Close",
"chip_paid_privately": "Paid privately",
"chip_ore_rounding_off": "No öre rounding", "chip_ore_rounding_off": "No öre rounding",
"chip_delivery_date": "Delivery date {date}", "chip_delivery_date": "Delivery date {date}",
"chip_notes": "Notes", "chip_notes": "Notes",
@@ -4680,9 +4679,6 @@
"create_and_select": "Create & select", "create_and_select": "Create & select",
"section_accounting": "Accounting", "section_accounting": "Accounting",
"account_from_history": "Account {account} suggested from previous bookings of {counterparty}", "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", "supplier_placeholder": "Select supplier",
"no_suppliers_yet": "No suppliers yet", "no_suppliers_yet": "No suppliers yet",
"add_new_supplier": "+ Add new supplier...", "add_new_supplier": "+ Add new supplier...",
@@ -4806,7 +4802,8 @@
"debit_short": "D {amount}", "debit_short": "D {amount}",
"credit_short": "C {amount}", "credit_short": "C {amount}",
"debit_credit_short": "D {debit} / C {credit}", "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": { "supplier_invoice_detail": {
"title_invoice": "Supplier invoice {number}", "title_invoice": "Supplier invoice {number}",
+2 -5
View File
@@ -4636,7 +4636,6 @@
"forval_books_at_payment": "Bokförs vid betalning", "forval_books_at_payment": "Bokförs vid betalning",
"forval_toggle_open": "Ändra förval", "forval_toggle_open": "Ändra förval",
"forval_toggle_close": "Stäng", "forval_toggle_close": "Stäng",
"chip_paid_privately": "Betald privat",
"chip_ore_rounding_off": "Utan öresavrundning", "chip_ore_rounding_off": "Utan öresavrundning",
"chip_delivery_date": "Leveransdatum {date}", "chip_delivery_date": "Leveransdatum {date}",
"chip_notes": "Anteckningar", "chip_notes": "Anteckningar",
@@ -4680,9 +4679,6 @@
"create_and_select": "Skapa & välj", "create_and_select": "Skapa & välj",
"section_accounting": "Kontering", "section_accounting": "Kontering",
"account_from_history": "Konto {account} föreslaget från tidigare bokföringar av {counterparty}", "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", "supplier_placeholder": "Välj leverantör",
"no_suppliers_yet": "Inga leverantörer än", "no_suppliers_yet": "Inga leverantörer än",
"add_new_supplier": "+ Lägg till ny leverantör...", "add_new_supplier": "+ Lägg till ny leverantör...",
@@ -4806,7 +4802,8 @@
"debit_short": "D {amount}", "debit_short": "D {amount}",
"credit_short": "K {amount}", "credit_short": "K {amount}",
"debit_credit_short": "D {debit} / K {credit}", "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": { "supplier_invoice_detail": {
"title_invoice": "Leverantörsfaktura {number}", "title_invoice": "Leverantörsfaktura {number}",
@@ -119,6 +119,9 @@ Request body:
notes?: string, notes?: string,
ore_rounding?: boolean, ore_rounding?: boolean,
paid_with_private_funds?: boolean, paid_with_private_funds?: boolean,
employee_id?: string,
claimant_name?: string,
inbox_item_id?: string,
payment_date?: string, payment_date?: string,
default_dimensions?: Record<string, 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> }[] 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> }[]