feat(supplier-invoices): "Vem betalade?" control replaces the paid privately switch and books an open utlägg (#2362)
The supplier-invoice form asks who paid with the same control as the Underlag pane (Företaget / Jag, privat / En anställd / Ingen ännu) instead of its own switch under Förval. A person paying is an utlägg: the route hands the invoice to registerExpenseClaim with the invoice's kontering as the claim's lines, so the verifikat and the expense_claims row come from the same writer as the Underlag pane, the person shows up under "Betala ut utlägg" on Hem and the bank matcher closes the debt. Employees book on 2820 with employee_id; the owner's blank name falls back to the shared label so Hem groups one person. Also routes a person-paid inbox document through the core route with inbox_item_id: the extension's convert endpoint never read paid_with_private_funds, so the old switch was silently dropped whenever a receipt was attached. The second entry generator, the Förval switch, the outline "Registrera & markera som betald" button and the duplicated owner/employee picker are removed; PayerChoiceSelect and the claimant fields move to components/expenses so core and the extension share them. Closes #2332 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
6776cb4fc6
commit
ebbe50c0f3
@@ -31,12 +31,24 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
}))
|
||||
|
||||
const mockCreateSupplierInvoiceRegistrationEntry = vi.fn()
|
||||
const mockCreateSupplierInvoicePrivatelyPaidEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoiceRegistrationEntry(...args),
|
||||
createSupplierInvoicePrivatelyPaidEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry(...args),
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', async () => {
|
||||
// The privately-paid line builder is pure: keep the real one so the tests
|
||||
// pin the kontering the route hands to the claims writer.
|
||||
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/supplier-invoice-entries')>(
|
||||
'@/lib/bookkeeping/supplier-invoice-entries',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoiceRegistrationEntry(...args),
|
||||
}
|
||||
})
|
||||
|
||||
// A privately paid invoice is an utlägg: the route hands it to the same
|
||||
// claims writer as the Underlag pane instead of posting its own verifikat.
|
||||
const mockRegisterExpenseClaim = vi.fn()
|
||||
vi.mock('@/lib/expenses/expense-claims-service', () => ({
|
||||
registerExpenseClaim: (...args: unknown[]) => mockRegisterExpenseClaim(...args),
|
||||
}))
|
||||
|
||||
const mockLinkToJournalEntry = vi.fn()
|
||||
@@ -684,105 +696,325 @@ describe('POST /api/supplier-invoices', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('SI_CREATE_FAILED')
|
||||
})
|
||||
|
||||
it('books privately-paid invoice via 2893 path for aktiebolag', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-1', status: 'paid' })
|
||||
// ── Privately paid = utlägg ──────────────────────────────────────────────
|
||||
// A person paid the supplier invoice: the route hands the invoice to the
|
||||
// claims writer with the invoice's kontering as the lines, so the verifikat
|
||||
// and the expense_claims row come from the same code as the Underlag pane
|
||||
// and the person shows up under "Betala ut utlägg" on Hem.
|
||||
|
||||
// Fetch supplier
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null })
|
||||
// Fetch company.entity_type (paidPrivately branch)
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
// RPC get_next_arrival_number
|
||||
enqueue({ data: 12 })
|
||||
// Insert invoice
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
const EMPLOYEE_UUID = '550e8400-e29b-41d4-a716-446655440003'
|
||||
const INBOX_UUID = '550e8400-e29b-41d4-a716-446655440004'
|
||||
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-1' })
|
||||
// Update invoice with payment_journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert supplier_invoice_payments row
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'KVITTO-001',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-06-01',
|
||||
paid_with_private_funds: true,
|
||||
items: [
|
||||
{
|
||||
description: 'Kontorsmaterial',
|
||||
quantity: 1,
|
||||
unit_price: 400,
|
||||
account_number: '6110',
|
||||
vat_rate: 0.25,
|
||||
},
|
||||
],
|
||||
function claimOk(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
claim: {
|
||||
id: 'claim-1',
|
||||
journal_entry_id: 'je-priv-1',
|
||||
claimant_name: 'Ägare',
|
||||
liability_account: '2893',
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function privatelyPaidBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
supplier_id: VALID_UUID,
|
||||
supplier_invoice_number: 'KVITTO-001',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-06-01',
|
||||
paid_with_private_funds: true,
|
||||
items: [
|
||||
{ description: 'Kontorsmaterial', quantity: 1, unit_price: 400, account_number: '6110', vat_rate: 0.25 },
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
type ClaimInput = {
|
||||
description: string
|
||||
claimant_name?: string
|
||||
employee_id?: string
|
||||
document_id?: string
|
||||
inbox_item_id?: string
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
|
||||
function claimInput(): ClaimInput {
|
||||
expect(mockRegisterExpenseClaim).toHaveBeenCalledTimes(1)
|
||||
return mockRegisterExpenseClaim.mock.calls[0][3] as ClaimInput
|
||||
}
|
||||
|
||||
function linesByAccount(input: ClaimInput) {
|
||||
return Object.fromEntries(input.lines.map((l) => [l.account_number, l]))
|
||||
}
|
||||
|
||||
it("books a privately paid invoice as the owner's utlägg through the claims writer (AB: 2893)", async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID, name: 'Pressbyrån' })
|
||||
const createdInvoice = makeSupplierInvoice({
|
||||
id: 'si-priv-1',
|
||||
status: 'paid',
|
||||
arrival_number: 12,
|
||||
supplier_invoice_number: 'KVITTO-001',
|
||||
})
|
||||
const response = await POST(request)
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: supplier, error: null }) // supplier
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null }) // company entity_type
|
||||
enqueue({ data: 12 }) // rpc get_next_arrival_number
|
||||
enqueue({ data: createdInvoice, error: null }) // insert invoice
|
||||
enqueue({ data: null, error: null }) // insert items
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null }) // settings
|
||||
mockRegisterExpenseClaim.mockResolvedValue(claimOk())
|
||||
enqueue({ data: null, error: null }) // update payment_journal_entry_id
|
||||
enqueue({ data: null, error: null }) // insert supplier_invoice_payments
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: privatelyPaidBody() })
|
||||
const response = await POST(request, {} as never)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { payment_journal_entry_id: string; registration_journal_entry_id: null }
|
||||
data: {
|
||||
payment_journal_entry_id: string
|
||||
registration_journal_entry_id: null
|
||||
expense_claim: { id: string; claimant_name: string; liability_account: string }
|
||||
}
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.payment_journal_entry_id).toBe('je-priv-1')
|
||||
expect(body.data.registration_journal_entry_id).toBeNull()
|
||||
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).toHaveBeenCalled()
|
||||
expect(body.data.expense_claim).toEqual({ id: 'claim-1', claimant_name: 'Ägare', liability_account: '2893' })
|
||||
// The classic registration path must NOT be touched.
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
|
||||
expect(call[5]).toBe('aktiebolag')
|
||||
|
||||
const [, companyArg, userArg] = mockRegisterExpenseClaim.mock.calls[0]
|
||||
expect(companyArg).toBe('company-1')
|
||||
expect(userArg).toBe('user-1')
|
||||
const input = claimInput()
|
||||
expect(input).toMatchObject({
|
||||
expense_date: '2024-06-01',
|
||||
amount: 500,
|
||||
vat_amount: 100,
|
||||
currency: 'SEK',
|
||||
expense_account: '6110',
|
||||
// No name given: the shared owner label, so Hem groups the owner as one person.
|
||||
claimant_name: 'Ägare',
|
||||
})
|
||||
expect(input.employee_id).toBeUndefined()
|
||||
expect(input.document_id).toBeUndefined()
|
||||
expect(input.description).toContain('KVITTO-001')
|
||||
expect(input.description).toContain('ankomstnr 12')
|
||||
// The invoice's full kontering rides as the claim's lines: no 2440.
|
||||
const byAccount = linesByAccount(input)
|
||||
expect(byAccount['6110'].debit_amount).toBe(400)
|
||||
expect(byAccount['2641'].debit_amount).toBe(100)
|
||||
expect(byAccount['2893'].credit_amount).toBe(500)
|
||||
expect(byAccount['2440']).toBeUndefined()
|
||||
|
||||
// The invoice row says paid from the start and mirrors the payment.
|
||||
const insert = findCall('supplier_invoices', 'insert')?.[0] as Record<string, unknown>
|
||||
expect(insert).toMatchObject({ status: 'paid', paid_with_private_funds: true, remaining_amount: 0 })
|
||||
const payment = findCall('supplier_invoice_payments', 'insert')?.[0] as Record<string, unknown>
|
||||
expect(payment).toMatchObject({ supplier_invoice_id: 'si-priv-1', journal_entry_id: 'je-priv-1', amount: 500 })
|
||||
// The claims writer links the document on this path, never the route.
|
||||
expect(mockLinkToJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes entity_type=enskild_firma so engine credits 2018', async () => {
|
||||
it('enskild firma owner: the claim is an egen insättning on 2018 and the typed name travels', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-2', status: 'paid' })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null }) // vat_registered guard
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: 13 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePrivatelyPaidEntry.mockResolvedValue({ id: 'je-priv-2' })
|
||||
mockRegisterExpenseClaim.mockResolvedValue(claimOk({ liability_account: '2018', claimant_name: 'Anna Ek' }))
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: VALID_UUID,
|
||||
body: privatelyPaidBody({
|
||||
supplier_invoice_number: 'KVITTO-002',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-06-01',
|
||||
paid_with_private_funds: true,
|
||||
items: [
|
||||
{
|
||||
description: 'Lunch klient',
|
||||
quantity: 1,
|
||||
unit_price: 200,
|
||||
account_number: '5810',
|
||||
vat_rate: 0.12,
|
||||
},
|
||||
],
|
||||
},
|
||||
claimant_name: ' Anna Ek ',
|
||||
items: [{ description: 'Lunch klient', quantity: 1, unit_price: 200, account_number: '5810', vat_rate: 0.12 }],
|
||||
}),
|
||||
})
|
||||
const response = await POST(request)
|
||||
const response = await POST(request, {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ data: { expense_claim: { liability_account: string } } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.expense_claim.liability_account).toBe('2018')
|
||||
const input = claimInput()
|
||||
expect(input.claimant_name).toBe('Anna Ek')
|
||||
const byAccount = linesByAccount(input)
|
||||
expect(byAccount['2018'].credit_amount).toBe(224)
|
||||
expect(byAccount['2893']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an employee paid: verified before the arrival number is drawn, claim on 2820 with employee_id', async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-3', status: 'paid' })
|
||||
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
enqueue({ data: { id: EMPLOYEE_UUID }, error: null }) // employee belongs to the company
|
||||
enqueue({ data: 14 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockRegisterExpenseClaim.mockResolvedValue(claimOk({ liability_account: '2820', claimant_name: 'Erik Berg' }))
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: privatelyPaidBody({ employee_id: EMPLOYEE_UUID, claimant_name: 'never used for an employee' }),
|
||||
})
|
||||
const response = await POST(request, {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ data: { expense_claim: { claimant_name: string } } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.expense_claim.claimant_name).toBe('Erik Berg')
|
||||
expect(findCall('employees', 'eq')).toEqual(['id', EMPLOYEE_UUID])
|
||||
const input = claimInput()
|
||||
expect(input.employee_id).toBe(EMPLOYEE_UUID)
|
||||
expect(input.claimant_name).toBeUndefined()
|
||||
const byAccount = linesByAccount(input)
|
||||
expect(byAccount['2820'].credit_amount).toBe(500)
|
||||
expect(byAccount['2893']).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns 404 when the employee is not the company's, before any arrival number is drawn", async () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
enqueue({ data: null, error: null }) // no such employee here
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: privatelyPaidBody({ employee_id: EMPLOYEE_UUID }),
|
||||
})
|
||||
const response = await POST(request, {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("privately paid inbox document: the item's document is the underlag and the item is stamped with the invoice", async () => {
|
||||
const supplier = makeSupplier({ id: VALID_UUID })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-priv-4', status: 'paid', document_id: DOCUMENT_UUID })
|
||||
|
||||
enqueue({
|
||||
data: { id: INBOX_UUID, document_id: DOCUMENT_UUID, created_supplier_invoice_id: null, created_journal_entry_id: null },
|
||||
error: null,
|
||||
}) // inbox item
|
||||
enqueue({ data: { id: DOCUMENT_UUID, journal_entry_id: null }, error: null }) // its document, unlinked
|
||||
enqueue({ data: null, error: null }) // no supplier invoice uses the document yet
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
enqueue({ data: 15 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockRegisterExpenseClaim.mockResolvedValue(claimOk())
|
||||
enqueue({ data: null, error: null }) // update payment_journal_entry_id
|
||||
enqueue({ data: null, error: null }) // insert supplier_invoice_payments
|
||||
enqueue({ data: null, error: null }) // stamp the inbox item
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: privatelyPaidBody({ inbox_item_id: INBOX_UUID }),
|
||||
})
|
||||
const response = await POST(request, {} as never)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const call = mockCreateSupplierInvoicePrivatelyPaidEntry.mock.calls[0]
|
||||
expect(call[5]).toBe('enskild_firma')
|
||||
const input = claimInput()
|
||||
expect(input.document_id).toBe(DOCUMENT_UUID)
|
||||
expect(input.inbox_item_id).toBe(INBOX_UUID)
|
||||
const insert = findCall('supplier_invoices', 'insert')?.[0] as Record<string, unknown>
|
||||
expect(insert.document_id).toBe(DOCUMENT_UUID)
|
||||
expect(findCall('invoice_inbox_items', 'update')?.[0]).toEqual({ created_supplier_invoice_id: 'si-priv-4' })
|
||||
expect(mockLinkToJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses inbox_item_id unless a person paid: the convert endpoint owns the other answers', async () => {
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: privatelyPaidBody({ paid_with_private_funds: false, inbox_item_id: INBOX_UUID }),
|
||||
})
|
||||
const response = await POST(request, {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
|
||||
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an inbox item that is already booked', async () => {
|
||||
enqueue({
|
||||
data: { id: INBOX_UUID, document_id: DOCUMENT_UUID, created_supplier_invoice_id: 'si-old', created_journal_entry_id: null },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: privatelyPaidBody({ inbox_item_id: INBOX_UUID }),
|
||||
})
|
||||
const response = await POST(request, {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
|
||||
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a claims-writer refusal rolls the invoice back and maps the code', async () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
enqueue({ data: 16 })
|
||||
enqueue({ data: makeSupplierInvoice({ id: 'si-priv-5', status: 'paid' }), error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockRegisterExpenseClaim.mockResolvedValue({ ok: false, code: 'FISCAL_PERIOD_NOT_FOUND' })
|
||||
enqueue({ data: null, error: null }) // rollback delete
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: privatelyPaidBody() })
|
||||
const response = await POST(request, {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_NO_FISCAL_PERIOD')
|
||||
expect(findCall('supplier_invoices', 'delete')).toBeDefined()
|
||||
})
|
||||
|
||||
it('a posted-but-unlinked claim is never rolled back: the verifikat is immutable', async () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
|
||||
enqueue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
enqueue({ data: 17 })
|
||||
enqueue({ data: makeSupplierInvoice({ id: 'si-priv-6', status: 'paid' }), error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
mockRegisterExpenseClaim.mockResolvedValue({ ok: false, code: 'LINK_WRITE_FAILED', detail: 'claim x posted as entry y' })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: privatelyPaidBody() })
|
||||
const response = await POST(request, {} as never)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('SI_CREATE_FAILED')
|
||||
expect(findCall('supplier_invoices', 'delete')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('persists manual vat_amount override on items and forwards it to the engine', async () => {
|
||||
@@ -903,7 +1135,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
expect(body.error.code).toBe('SI_CREATE_ACCRUAL_REVERSE_CHARGE')
|
||||
// The guard must fire before anything is persisted or booked.
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
|
||||
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects paid_with_private_funds combined with reverse_charge', async () => {
|
||||
@@ -925,7 +1157,7 @@ describe('POST /api/supplier-invoices', () => {
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_CREATE_INVALID_INPUT')
|
||||
// Make sure we never touched the engine paths.
|
||||
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
|
||||
expect(mockRegisterExpenseClaim).not.toHaveBeenCalled()
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,12 @@ import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import {
|
||||
createSupplierInvoiceRegistrationEntry,
|
||||
createSupplierInvoicePrivatelyPaidEntry,
|
||||
buildSupplierInvoicePrivatelyPaidLines,
|
||||
largestExpenseAccount,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { buildSupplierDescription } from '@/lib/bookkeeping/supplier-invoice-description'
|
||||
import { registerExpenseClaim } from '@/lib/expenses/expense-claims-service'
|
||||
import { OWNER_FALLBACK_NAME, resolveExpenseLiabilityAccount } from '@/lib/expenses/payer'
|
||||
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
@@ -20,7 +24,7 @@ import {
|
||||
} from '@/lib/currency/supplier-invoice-rate'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import type { Currency, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -77,11 +81,46 @@ export const POST = withRouteContext(
|
||||
const body = validation.data
|
||||
const paidPrivately = body.paid_with_private_funds === true
|
||||
|
||||
if (body.document_id) {
|
||||
// A privately paid inbox document: the item's document is the underlag
|
||||
// and the item is settled here (registerExpenseClaim stamps
|
||||
// created_journal_entry_id; the route adds created_supplier_invoice_id).
|
||||
// The extension's convert endpoint registers on 2440 only, so routing the
|
||||
// person-paid case through it would silently drop who paid.
|
||||
let inboxItem: { id: string; document_id: string | null } | null = null
|
||||
if (body.inbox_item_id) {
|
||||
if (!paidPrivately) {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'inbox_item_id is only accepted with paid_with_private_funds' },
|
||||
})
|
||||
}
|
||||
const { data: item, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, created_supplier_invoice_id, created_journal_entry_id')
|
||||
.eq('id', body.inbox_item_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (itemError || !item) {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'inbox_item_id is missing or belongs to another company' },
|
||||
})
|
||||
}
|
||||
if (item.created_supplier_invoice_id || item.created_journal_entry_id) {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: 'inbox item is already booked' },
|
||||
})
|
||||
}
|
||||
inboxItem = { id: item.id as string, document_id: (item.document_id as string | null) ?? null }
|
||||
}
|
||||
const documentId = inboxItem ? inboxItem.document_id : body.document_id ?? null
|
||||
|
||||
if (documentId) {
|
||||
const { data: document, error: documentError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('id', body.document_id)
|
||||
.eq('id', documentId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
@@ -96,7 +135,7 @@ export const POST = withRouteContext(
|
||||
.from('supplier_invoices')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_id', body.document_id)
|
||||
.eq('document_id', documentId)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
@@ -226,6 +265,19 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
entityType = company.entity_type as 'aktiebolag' | 'enskild_firma'
|
||||
if (body.employee_id) {
|
||||
// Checked before the arrival-number sequence is touched: a claim the
|
||||
// service would refuse must not burn an ankomstnummer.
|
||||
const { data: employee } = await supabase
|
||||
.from('employees')
|
||||
.select('id')
|
||||
.eq('id', body.employee_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (!employee) {
|
||||
return errorResponseFromCode('EMPLOYEE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the exchange rate BEFORE the arrival-number sequence is touched:
|
||||
@@ -354,7 +406,7 @@ export const POST = withRouteContext(
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
supplier_id: body.supplier_id,
|
||||
document_id: body.document_id || null,
|
||||
document_id: documentId,
|
||||
arrival_number: arrivalNum,
|
||||
supplier_invoice_number: body.supplier_invoice_number,
|
||||
invoice_date: body.invoice_date,
|
||||
@@ -469,10 +521,12 @@ export const POST = withRouteContext(
|
||||
// silently understates leverantörsskuld (2440) and ingående moms (2641)
|
||||
// for the momsdeklaration. Roll back instead.
|
||||
//
|
||||
// Privately-paid path bypasses both accrual and cash flows: a single
|
||||
// verifikat books the expense + VAT against 2893 (AB) or 2018 (EF) at
|
||||
// registration time, regardless of accounting_method. mark-paid is never
|
||||
// invoked for these (status='paid' from the start).
|
||||
// Privately-paid path bypasses both accrual and cash flows: the invoice is
|
||||
// an utlägg, so one verifikat books the expense + VAT against the payer's
|
||||
// liability account (2893 AB owner / 2018 EF owner / 2820 employee) at
|
||||
// registration time, regardless of accounting_method, and an
|
||||
// expense_claims row keeps the debt open. mark-paid is never invoked for
|
||||
// these (status='paid' from the start).
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, defer_invoice_booking')
|
||||
@@ -485,68 +539,134 @@ export const POST = withRouteContext(
|
||||
const booksOnRegistration = booksInvoicesOnIssue(settings)
|
||||
let registrationJournalEntryId: string | null = null
|
||||
let paymentJournalEntryId: string | null = null
|
||||
let expenseClaim: { id: string; claimant_name: string; liability_account: string } | null = null
|
||||
|
||||
if (paidPrivately && entityType) {
|
||||
// The invoice IS an utlägg registration with the supplier invoice as its
|
||||
// underlag: the same writer as the Underlag pane posts the verifikat and
|
||||
// the expense_claims row, with the invoice's full kontering as the
|
||||
// lines. The person then shows up under "Betala ut utlägg" on Hem and
|
||||
// the bank matcher closes the debt when the transfer is booked.
|
||||
const payer = body.employee_id ? 'employee' : 'owner'
|
||||
const liabilityAccount = resolveExpenseLiabilityAccount(entityType, payer)
|
||||
const claimDescription = buildSupplierDescription(
|
||||
'Faktura',
|
||||
invoice.supplier_invoice_number,
|
||||
supplier.name,
|
||||
`(ankomstnr ${invoice.arrival_number})`,
|
||||
)
|
||||
let claimResult: Awaited<ReturnType<typeof registerExpenseClaim>>
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoicePrivatelyPaidEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
items as SupplierInvoiceItem[],
|
||||
entityType,
|
||||
supplier.name,
|
||||
)
|
||||
if (journalEntry) {
|
||||
paymentJournalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ payment_journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
// Mirror the payment in supplier_invoice_payments so AR/AP and
|
||||
// payment-history queries stay consistent with the mark-paid path.
|
||||
await supabase.from('supplier_invoice_payments').insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
supplier_invoice_id: invoice.id,
|
||||
// For an eget utlägg the actual out-of-pocket date may differ from
|
||||
// the invoice/receipt date: accept an explicit payment_date and
|
||||
// fall back to invoice_date for the common kvitto case.
|
||||
payment_date: body.payment_date ?? invoice.invoice_date,
|
||||
amount: totalRounded,
|
||||
currency: invoice.currency,
|
||||
exchange_rate_difference: 0,
|
||||
journal_entry_id: journalEntry.id,
|
||||
notes: 'Eget utlägg, betalat privat',
|
||||
})
|
||||
} else {
|
||||
// createSupplierInvoicePrivatelyPaidEntry returns null ONLY when no
|
||||
// fiscal period covers invoice_date (every other failure throws and
|
||||
// lands in the catch below). Without this branch the invoice would be
|
||||
// saved as status='paid' with no verifikat: a silent orphan. Roll
|
||||
// back and surface an actionable error, per the fatal-orphan note above.
|
||||
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
|
||||
return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, {
|
||||
requestId,
|
||||
details: { invoiceDate: invoice.invoice_date },
|
||||
})
|
||||
}
|
||||
claimResult = await registerExpenseClaim(supabase, companyId, user.id, {
|
||||
description: claimDescription,
|
||||
expense_date: invoice.invoice_date,
|
||||
amount: totalRounded,
|
||||
vat_amount: roundOre(vatAmount),
|
||||
currency: fx.rate.currency as Currency,
|
||||
exchange_rate: fx.rate.exchangeRate ?? undefined,
|
||||
expense_account: largestExpenseAccount(items as SupplierInvoiceItem[]),
|
||||
employee_id: body.employee_id ?? undefined,
|
||||
claimant_name: payer === 'owner' ? body.claimant_name?.trim() || OWNER_FALLBACK_NAME : undefined,
|
||||
document_id: documentId ?? undefined,
|
||||
inbox_item_id: inboxItem?.id,
|
||||
lines: buildSupplierInvoicePrivatelyPaidLines(
|
||||
invoice as SupplierInvoice,
|
||||
items as SupplierInvoiceItem[],
|
||||
liabilityAccount,
|
||||
claimDescription,
|
||||
),
|
||||
})
|
||||
} catch (err) {
|
||||
// The service removes its own claim row before rethrowing; the invoice
|
||||
// row is ours to roll back (same fatal-orphan rule as the registration
|
||||
// path below).
|
||||
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
|
||||
if (isBookkeepingError(err)) {
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
log.error('failed to create privately-paid journal entry', err as Error, {
|
||||
log.error('failed to book privately paid supplier invoice as utlägg', err as Error, {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return errorResponseFromCode('SI_CREATE_FAILED', log, {
|
||||
requestId,
|
||||
details: {
|
||||
reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown',
|
||||
step: 'privately_paid_journal_entry',
|
||||
step: 'expense_claim',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!claimResult.ok) {
|
||||
if (claimResult.code === 'LINK_WRITE_FAILED') {
|
||||
// The verifikat is posted and immutable: deleting the invoice now
|
||||
// would orphan it. Keep both rows and surface the desync loudly.
|
||||
log.error('expense claim posted but could not be linked', new Error(claimResult.detail ?? claimResult.code), {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return errorResponseFromCode('SI_CREATE_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: claimResult.detail ?? claimResult.code, step: 'expense_claim_link' },
|
||||
})
|
||||
}
|
||||
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
|
||||
if (claimResult.code === 'FISCAL_PERIOD_NOT_FOUND') {
|
||||
return errorResponseFromCode('SI_CREATE_NO_FISCAL_PERIOD', log, {
|
||||
requestId,
|
||||
details: { invoiceDate: invoice.invoice_date },
|
||||
})
|
||||
}
|
||||
if (claimResult.code === 'EMPLOYEE_NOT_FOUND') {
|
||||
return errorResponseFromCode('EMPLOYEE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
if (claimResult.code === 'INVALID_LINES') {
|
||||
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
|
||||
requestId,
|
||||
details: { reason: `expense claim lines: ${claimResult.detail ?? 'invalid'}` },
|
||||
})
|
||||
}
|
||||
log.error('expense claim registration failed', new Error(claimResult.detail ?? claimResult.code), {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return errorResponseFromCode('SI_CREATE_FAILED', log, {
|
||||
requestId,
|
||||
details: { reason: claimResult.code, step: 'expense_claim' },
|
||||
})
|
||||
}
|
||||
|
||||
const claim = claimResult.claim
|
||||
expenseClaim = { id: claim.id, claimant_name: claim.claimant_name, liability_account: claim.liability_account }
|
||||
if (claim.journal_entry_id) {
|
||||
paymentJournalEntryId = claim.journal_entry_id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ payment_journal_entry_id: claim.journal_entry_id })
|
||||
.eq('id', invoice.id)
|
||||
// Mirror the payment in supplier_invoice_payments so AR/AP and
|
||||
// payment-history queries stay consistent with the mark-paid path.
|
||||
await supabase.from('supplier_invoice_payments').insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
supplier_invoice_id: invoice.id,
|
||||
// For an utlägg the actual out-of-pocket date may differ from the
|
||||
// invoice/receipt date: accept an explicit payment_date and fall
|
||||
// back to invoice_date for the common kvitto case.
|
||||
payment_date: body.payment_date ?? invoice.invoice_date,
|
||||
amount: totalRounded,
|
||||
currency: invoice.currency,
|
||||
exchange_rate_difference: 0,
|
||||
journal_entry_id: claim.journal_entry_id,
|
||||
notes: `Utlägg, betalat privat av ${claim.claimant_name}`,
|
||||
})
|
||||
}
|
||||
if (inboxItem) {
|
||||
// registerExpenseClaim stamped created_journal_entry_id (which marks
|
||||
// the item processed); the invoice link is ours.
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ created_supplier_invoice_id: invoice.id })
|
||||
.eq('id', inboxItem.id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
} else if (booksOnRegistration) {
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||||
@@ -627,18 +747,19 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// The utlägg path links the document inside registerExpenseClaim.
|
||||
const primaryJournalEntryId = paymentJournalEntryId || registrationJournalEntryId
|
||||
if (body.document_id && primaryJournalEntryId) {
|
||||
if (documentId && primaryJournalEntryId && !paidPrivately) {
|
||||
try {
|
||||
await linkToJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
body.document_id,
|
||||
documentId,
|
||||
primaryJournalEntryId,
|
||||
)
|
||||
} catch (err) {
|
||||
log.warn('supplier invoice document could not be linked to journal entry', {
|
||||
documentId: body.document_id,
|
||||
documentId,
|
||||
journalEntryId: primaryJournalEntryId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
@@ -675,6 +796,7 @@ export const POST = withRouteContext(
|
||||
items: itemInserts,
|
||||
registration_journal_entry_id: registrationJournalEntryId,
|
||||
payment_journal_entry_id: paymentJournalEntryId,
|
||||
expense_claim: expenseClaim,
|
||||
},
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user