From ebbe50c0f3f63e6f13b2eea90fcded76920f4b5d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 6 Sep 2026 21:04:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(supplier-invoices):=20"Vem=20betalade=3F"?= =?UTF-8?q?=20control=20replaces=20the=20paid=20privately=20switch=20and?= =?UTF-8?q?=20books=20an=20open=20utl=C3=A4gg=20(#2362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../supplier-invoices/__tests__/route.test.ts | 380 ++++++++++++++---- app/api/supplier-invoices/route.ts | 238 ++++++++--- components/expenses/ExpenseClaimantFields.tsx | 117 ++++++ components/expenses/PayerChoiceSelect.tsx | 79 ++++ .../general/InvoiceInboxWorkspace.tsx | 71 +--- .../general/RegisterExpenseDialog.tsx | 93 +---- .../NewSupplierInvoiceForm.tsx | 96 +++-- .../use-supplier-invoice-submit.ts | 59 +-- lib/api/schemas.ts | 11 + .../supplier-invoice-entries.test.ts | 320 ++++++++------- lib/bookkeeping/supplier-invoice-entries.ts | 144 ++++--- .../__tests__/expense-claims-service.test.ts | 64 +++ lib/expenses/__tests__/payer.test.ts | 43 ++ lib/expenses/expense-claims-service.ts | 4 + lib/expenses/payer.ts | 46 +++ .../__tests__/form-payload.test.ts | 78 +++- lib/supplier-invoices/form-payload.ts | 33 +- messages/en.json | 7 +- messages/sv.json | 7 +- skills/accounted-api/references/suppliers.md | 3 + 20 files changed, 1318 insertions(+), 575 deletions(-) create mode 100644 components/expenses/ExpenseClaimantFields.tsx create mode 100644 components/expenses/PayerChoiceSelect.tsx create mode 100644 lib/expenses/__tests__/payer.test.ts create mode 100644 lib/expenses/payer.ts diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index f222543c..8273c913 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -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( + '@/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 = {}) { + return { + ok: true, + claim: { + id: 'claim-1', + journal_entry_id: 'je-priv-1', + claimant_name: 'Ägare', + liability_account: '2893', + ...overrides, }, + } + } + + function privatelyPaidBody(overrides: Record = {}) { + 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 + expect(insert).toMatchObject({ status: 'paid', paid_with_private_funds: true, remaining_amount: 0 }) + const payment = findCall('supplier_invoice_payments', 'insert')?.[0] as Record + 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 + 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() }) }) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index cc524414..444c3224 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -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> 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 } : {}), }) diff --git a/components/expenses/ExpenseClaimantFields.tsx b/components/expenses/ExpenseClaimantFields.tsx new file mode 100644 index 00000000..bef69ab8 --- /dev/null +++ b/components/expenses/ExpenseClaimantFields.tsx @@ -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([]) + 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 ( +
+ + onOwnerNameChange(e.target.value)} + placeholder={OWNER_FALLBACK_NAME} + disabled={disabled} + className={inputClassName} + /> +
+ ) + } + + return ( +
+ + +
+ ) +} diff --git a/components/expenses/PayerChoiceSelect.tsx b/components/expenses/PayerChoiceSelect.tsx new file mode 100644 index 00000000..964a59a0 --- /dev/null +++ b/components/expenses/PayerChoiceSelect.tsx @@ -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 ( +
+

{t('payer_question')}

+ + {selectedHelp &&

{t(selectedHelp)}

} +
+ ) +} diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index db8c5387..4b79ba8f 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -23,7 +23,6 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import { Inbox, @@ -76,6 +75,7 @@ import { } from '@/lib/documents/inbox-kind' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' import RegisterExpenseDialog, { type ExpensePayer } from '@/components/extensions/general/RegisterExpenseDialog' +import { PayerChoiceSelect, type PayerChoice } from '@/components/expenses/PayerChoiceSelect' import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog' import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog' // InboxCustomDomainDialog (egen domän) is built but gated off: see @@ -3092,75 +3092,6 @@ function ProposedBooking({ ) } -// ── Vem betalade? ──────────────────────────────────────────── - -/** - * How an unmatched underlag gets booked, phrased as who paid for it. - * 'company' → match the bank line; 'unpaid' → supplier invoice (2440); - * 'owner' / 'employee' → utlägg against the person's liability account. - */ -export type PayerChoice = 'company' | 'unpaid' | ExpensePayer - -const PAYER_ORDER: PayerChoice[] = ['company', 'owner', 'employee', 'unpaid'] - -/** - * The "Vem betalade?" control: a compact select so the rail keeps its - * primary button above the fold, with the chosen answer's one-line - * consequence under it. Each option in the list carries the same help so - * the choice is made with the consequence visible, not after. - */ -export function PayerChoiceSelect({ - value, - onChange, - accountingMethod, -}: { - value: PayerChoice - onChange: (next: PayerChoice) => void - accountingMethod: AccountingMethod -}) { - const t = useTranslations('inbox_workspace') - // An enskild firma owner makes an egen insättning, not a loan to the - // company: no debt, nothing to pay out, so the help line says so. - const isEf = useCompanyOptional()?.company?.entity_type === 'enskild_firma' - // Företaget carries no help line: the button under it ("Matcha mot - // transaktion") already says what happens. The other answers name the - // liability the company takes on, which is the consequence worth reading. - const helpKey = (choice: PayerChoice): string | null => - choice === 'company' - ? null - : choice === 'owner' && isEf - ? 'payer_help_owner_ef' - : choice === 'unpaid' && accountingMethod === 'cash' - ? 'payer_help_unpaid_cash' - : `payer_help_${choice}` - const selectedHelp = helpKey(value) - return ( -
-

{t('payer_question')}

- - {selectedHelp &&

{t(selectedHelp)}

} -
- ) -} - // ── Fields rail ────────────────────────────────────────────── function FieldsRail({ diff --git a/components/extensions/general/RegisterExpenseDialog.tsx b/components/extensions/general/RegisterExpenseDialog.tsx index bd1f4c18..97efc526 100644 --- a/components/extensions/general/RegisterExpenseDialog.tsx +++ b/components/extensions/general/RegisterExpenseDialog.tsx @@ -14,29 +14,22 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import { ExpenseClaimantFields } from '@/components/expenses/ExpenseClaimantFields' import { useCompanyOptional } from '@/contexts/CompanyContext' import { useAccounts } from '@/lib/reference-data/hooks' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency } from '@/lib/utils' import { roundOre } from '@/lib/money' import { ACCOUNT_NUMBER_RE, ISO_DATE_RE } from '@/lib/invariants' +import { OWNER_FALLBACK_NAME, resolveExpenseLiabilityAccount, type ExpensePayer } from '@/lib/expenses/payer' import type { InvoiceExtractionResult } from '@/types' -/** - * Who paid for the underlag out of their own pocket. The owner's liability - * account follows the entity type (2893 skuld till ägare in an AB, 2018 egen - * insättning in an enskild firma); an employee is always 2820. - */ -export type ExpensePayer = 'owner' | 'employee' +// Who paid for the underlag out of their own pocket. The account rule (2893 +// AB owner / 2018 EF owner / 2820 employee) lives in lib/expenses/payer.ts, +// shared with the supplier-invoice form. +export type { ExpensePayer } interface InboxItemLike { id: string @@ -44,12 +37,6 @@ interface InboxItemLike { extracted_data: InvoiceExtractionResult | null } -interface EmployeeOption { - id: string - first_name: string - last_name: string -} - interface Props { open: boolean onOpenChange: (open: boolean) => void @@ -59,8 +46,6 @@ interface Props { onSuccess: () => void | Promise } -const OWNER_FALLBACK_NAME = 'Ägare' - function todayIso(): string { // Local calendar date: toISOString() is UTC and would date a receipt booked // after midnight CEST to the previous day (wrong period, wrong FX rate). @@ -88,8 +73,7 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer, const { toast } = useToast() const { accounts } = useAccounts() const entityType = useCompanyOptional()?.company?.entity_type ?? null - const ownerLiability = entityType === 'enskild_firma' ? '2018' : '2893' - const liabilityAccount = payer === 'owner' ? ownerLiability : '2820' + const liabilityAccount = resolveExpenseLiabilityAccount(entityType, payer) const data = item.extracted_data const [description, setDescription] = useState('') @@ -99,8 +83,7 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer, const [expenseAccount, setExpenseAccount] = useState('') const [ownerName, setOwnerName] = useState('') const [employeeId, setEmployeeId] = useState('') - const [employees, setEmployees] = useState([]) - const [employeesLoaded, setEmployeesLoaded] = useState(false) + const [employeeName, setEmployeeName] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase() // A foreign receipt carries VAT the company cannot deduct on 2641: the whole @@ -120,29 +103,15 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer, setVatInput(!isForeign && vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0') setExpenseAccount('') setEmployeeId('') + setEmployeeName('') }, [open, item.id, data, isForeign]) - useEffect(() => { - if (!open || payer !== 'employee' || employeesLoaded) return - fetch('/api/salary/employees') - .then((res) => (res.ok ? res.json() : null)) - .then((json) => setEmployees((json?.data ?? []) as EmployeeOption[])) - .catch(() => setEmployees([])) - .finally(() => setEmployeesLoaded(true)) - }, [open, payer, employeesLoaded]) - const amount = parseAmount(amountInput) // Foreign VAT is never deductible here: the field is locked and 0 is what // gets submitted, whatever the extraction said. const vatAmount = isForeign ? 0 : parseAmount(vatInput) const net = roundOre(amount - vatAmount) - const employee = employees.find((e) => e.id === employeeId) ?? null - const claimantName = - payer === 'owner' - ? ownerName.trim() || OWNER_FALLBACK_NAME - : employee - ? `${employee.first_name} ${employee.last_name}`.trim() - : '' + const claimantName = payer === 'owner' ? ownerName.trim() || OWNER_FALLBACK_NAME : employeeName const canSubmit = !isSubmitting && @@ -236,36 +205,18 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
- {payer === 'owner' ? ( -
- - setOwnerName(e.target.value)} - placeholder={OWNER_FALLBACK_NAME} - disabled={isSubmitting} - /> -
- ) : ( -
- - -
- )} + { + setEmployeeId(id) + setEmployeeName(name) + }} + disabled={isSubmitting} + idPrefix="re" + />
diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx index 4fb209b2..bb888380 100644 --- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx +++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx @@ -56,7 +56,10 @@ import { VatRateCell, RcRateSelect, AmountCell } from '@/components/supplier-inv import { useSupplierInvoiceData } from '@/components/supplier-invoices/use-supplier-invoice-data' import { useInboxPrefill, type InboxItemData } from '@/components/supplier-invoices/use-inbox-prefill' import { useSupplierInvoiceSubmit } from '@/components/supplier-invoices/use-supplier-invoice-submit' -import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, Link2, CalendarClock, Tags, FileText } from 'lucide-react' +import { PayerChoiceSelect } from '@/components/expenses/PayerChoiceSelect' +import { ExpenseClaimantFields } from '@/components/expenses/ExpenseClaimantFields' +import { isPersonPayer } from '@/lib/expenses/payer' +import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, CalendarClock, Tags, FileText } from 'lucide-react' import type { Supplier, InvoiceExtractionResult } from '@/types' // The form's line/field shapes live in lib/supplier-invoices/form-payload.ts @@ -270,7 +273,9 @@ export default function NewSupplierInvoiceForm({ reverse_charge: false, payment_reference: '', notes: '', - paid_with_private_funds: false, + payer: 'unpaid', + claimant_name: '', + employee_id: '', // The table starts empty: the ghost entry row (never part of form // state) is the only way rows are born, so no silent prefilled expense // account can ever reach a verifikat unnoticed. @@ -324,7 +329,12 @@ export default function NewSupplierInvoiceForm({ const watchedSupplierId = watch('supplier_id') const watchedCurrency = watch('currency') const watchedExchangeRate = watch('exchange_rate') - const watchedPaidPrivately = watch('paid_with_private_funds') + const watchedPayer = watch('payer') + const watchedClaimantName = watch('claimant_name') + const watchedEmployeeId = watch('employee_id') + // A person paid: the invoice is an utlägg (cost + moms against that + // person's liability, an open claim on Hem) instead of a 2440 payable. + const watchedPaidPrivately = isPersonPayer(watchedPayer) const watchedReverseCharge = watch('reverse_charge') // Watched values used to decide whether the AI-filled indicator should // still be visible. Once the user edits a field, its value no longer @@ -1369,7 +1379,6 @@ export default function NewSupplierInvoiceForm({ showBankPicker, setShowBankPicker, setPendingTransactionId, - submitModeRef, conflict, setConflict, isResolvingConflict, @@ -1393,6 +1402,8 @@ export default function NewSupplierInvoiceForm({ showNoPeriodWarning, canUseAccrual, invoiceNumberInputRef, + // The Utlägg nav row is gated on existing claims in the server layout. + onExpenseRegistered: () => router.refresh(), onMissingField: focusMissingField, }) @@ -1464,7 +1475,6 @@ export default function NewSupplierInvoiceForm({ const forvalChips: string[] = [ willBookAtRegistration ? t('forval_books_at_registration') : t('forval_books_at_payment'), ] - if (watchedPaidPrivately) forvalChips.push(t('chip_paid_privately')) if (watchedReverseCharge) forvalChips.push(t('reverse_charge_label')) if ((watchedCurrency || 'SEK') !== 'SEK') { forvalChips.push( @@ -1486,11 +1496,15 @@ export default function NewSupplierInvoiceForm({ ? t('underlag_caption_attached') : t('underlag_caption_default') + // The primary action follows "Vem betalade?": a person -> book the utlägg, + // the company -> register and match the bank line, no one yet -> register. const primaryLabel = watchedPaidPrivately ? t('register_expense') - : isEF - ? t('register_invoice') - : t('review_and_register') + : watchedPayer === 'company' + ? t('register_and_mark_paid') + : isEF + ? t('register_invoice') + : t('review_and_register') // min-w-0 on the root: DialogContent is display:grid; without it this grid // item's min-width:auto lets the kontering table's min-w force the whole @@ -1801,6 +1815,35 @@ export default function NewSupplierInvoiceForm({ {...register('invoice_date')} />
+ {/* 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. */} + ( + + )} + /> + {isPersonPayer(watchedPayer) && ( + 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 && ( <>
@@ -2164,25 +2207,6 @@ export default function NewSupplierInvoiceForm({
{forvalOpen && (
-
- - ( -