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 && (
-
- - ( -