diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts index 4b86e4c3..d15ae966 100644 --- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -52,6 +52,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ mockCreateInvoiceJournalEntry(...args), })) +const mockCreateSchedules = vi.fn() +vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({ + createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args), +})) + const mockIssueCreditNote = vi.fn() vi.mock('@/lib/invoices/issue-credit-note', () => ({ issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args), @@ -105,6 +110,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf')) mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 }) mockIssueCreditNote.mockResolvedValue({ complete: true, journalEntryId: 'credit-je-1', @@ -376,6 +382,121 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { expect(mockUploadDocument).not.toHaveBeenCalled() }) + it('returns 400 when the body has malformed lines', async () => { + enqueue({ data: invoice, error: null }) // ownership fetch precedes validation + const request = createMockRequest('/api/invoices/inv-1/mark-sent', { + method: 'POST', + body: { lines: [{ account_number: 'not-an-account', debit_amount: -5 }] }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 400 when custom lines do not balance', async () => { + enqueue({ data: invoice, error: null }) + const request = createMockRequest('/api/invoices/inv-1/mark-sent', { + method: 'POST', + body: { + lines: [ + { account_number: '1510', debit_amount: 12500, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 10000 }, + ], + }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_UNBALANCED') + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 400 when a row carries both debit and credit', async () => { + enqueue({ data: invoice, error: null }) + const request = createMockRequest('/api/invoices/inv-1/mark-sent', { + method: 'POST', + body: { + lines: [ + { account_number: '1510', debit_amount: 100, credit_amount: 50 }, + { account_number: '3001', debit_amount: 0, credit_amount: 50 }, + ], + }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_INVALID') + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 400 when custom lines use a 29xx interim account', async () => { + enqueue({ data: invoice, error: null }) + const request = createMockRequest('/api/invoices/inv-1/mark-sent', { + method: 'POST', + body: { + lines: [ + { account_number: '1510', debit_amount: 12500, credit_amount: 0 }, + { account_number: '2990', debit_amount: 0, credit_amount: 10000 }, + { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, + ], + }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_INVALID') + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + }) + + it('books user-edited lines verbatim via the customLines override', async () => { + enqueue({ data: invoice, error: null }) // fetch invoice + enqueue({ data: company, error: null }) // settings + enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update + mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-10' }) + enqueue({ data: null, error: null }) // update invoice with journal_entry_id + + const lines = [ + { account_number: '1510', debit_amount: 12500, credit_amount: 0 }, + { account_number: '3041', debit_amount: 0, credit_amount: 10000 }, + { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, + ] + const request = createMockRequest('/api/invoices/inv-1/mark-sent', { + method: 'POST', + body: { lines }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string | null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBe('je-10') + // User-edited lines book exactly as reviewed: no accrual schedules. + expect(mockCreateSchedules).not.toHaveBeenCalled() + expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ id: 'inv-1' }), + 'enskild_firma', + customer.name, + expect.objectContaining({ + customLines: [ + expect.objectContaining({ account_number: '1510', debit_amount: 12500 }), + expect.objectContaining({ account_number: '3041', credit_amount: 10000 }), + expect.objectContaining({ account_number: '2611', credit_amount: 2500 }), + ], + }) + ) + }) + it('renders the archived PDF as if already sent (no UTKAST banner)', async () => { enqueue({ data: invoice, error: null }) // fetch invoice (status: 'draft') enqueue({ data: company, error: null }) // settings diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 00707f01..a5127b63 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -12,6 +12,7 @@ import { } from '@/lib/invoices/issue-credit-note' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' +import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { uploadDocument } from '@/lib/core/documents/document-service' @@ -37,9 +38,23 @@ ensureInitialized() */ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'invoice.mark_sent', - async (_request, { supabase, user, companyId, log, requestId }, { params }) => { + async (request, { supabase, user, companyId, log, requestId }, { params }) => { const { id } = await params + // Optional body. Backwards-compat: callers may POST with no body. Read it + // here but validate only AFTER the ownership fetch below, so callers never + // get payload feedback for invoices outside their company. + let rawBody: unknown + const bodyText = await request.text() + if (bodyText) { + try { + rawBody = JSON.parse(bodyText) + } catch { + // Malformed JSON must not silently fall back to generated lines. + return NextResponse.json({ error: 'Ogiltig förfrågan' }, { status: 400 }) + } + } + // Fetch invoice const { data: invoice, error: invoiceError } = await supabase .from('invoices') @@ -61,6 +76,25 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId }) } + const linesResult = parseCustomIssuanceLines(rawBody) + if (!linesResult.ok) { + if (linesResult.error === 'invalid_body') { + log.warn('mark-sent validation failed', { invoiceId: id }) + return NextResponse.json( + { error: 'Ogiltig förfrågan', details: linesResult.details }, + { status: 400 }, + ) + } + return errorResponseFromCode( + linesResult.error === 'unbalanced' + ? 'INVOICE_MARK_SENT_LINES_UNBALANCED' + : 'INVOICE_MARK_SENT_LINES_INVALID', + log, + { requestId, details: linesResult.details }, + ) + } + const customLines = linesResult.lines + // Assign invoice number now if this draft doesn't have one yet try { await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) @@ -141,6 +175,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( let journalEntryId: string | null = null const partialFailures: Array<{ step: string; reason: string }> = [] + // Custom lines only apply where mark-sent books inline; elsewhere they are + // deliberately ignored (documented in MarkInvoiceSentSchema). Log it so the + // mismatch is visible in audit review instead of vanishing silently. + if ( + customLines && + (isCreditNote || !isRealInvoice || !booksInvoicesOnIssue(settings as CompanySettings)) + ) { + log.warn('mark-sent: custom lines ignored (not on accrual book-at-issue path)', { + invoiceId: id, + lineCount: customLines.length, + }) + } + if (isCreditNote && originalInvoice) { const issueResult = await issueCreditNote({ supabase, @@ -183,37 +230,60 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( // booking); ekonomi books later via POST /api/invoices/[id]/book, like // under kontantmetoden. try { - const journalEntry = await createInvoiceJournalEntry( - supabase, - companyId, - user.id, - invoice as Invoice, - entityType, - invoice.customer?.name - ) + if (customLines) { + // Audit trail: distinguish user-edited bookings from generated ones. + log.info('mark-sent: booking user-edited custom lines', { + invoiceId: id, + userId: user.id, + lineCount: customLines.length, + }) + } + const journalEntry = customLines + ? await createInvoiceJournalEntry( + supabase, + companyId, + user.id, + invoice as Invoice, + entityType, + invoice.customer?.name, + { customLines }, + ) + : await createInvoiceJournalEntry( + supabase, + companyId, + user.id, + invoice as Invoice, + entityType, + invoice.customer?.name, + ) if (journalEntry) { journalEntryId = journalEntry.id // Periodiserade lines: create schedules + catch-up dissolutions now // that the revenue entry exists. Failures are logged, never fatal: - // the verifikat is committed. - const accrual = await createSchedulesForCustomerInvoice( - supabase, - companyId, - user.id, - invoice as Invoice, - (invoice.items as InvoiceItem[] | null) ?? [], - journalEntry.id, - entityType, - ) - if (accrual.failed > 0) { - log.error('accrual schedule creation failed on mark-sent', { - failed: accrual.failed, - }) - partialFailures.push({ - step: 'accrual_schedules', - reason: `${accrual.failed} periodisering(ar) kunde inte skapas`, - }) + // the verifikat is committed. Skipped when the user edited the lines: + // the generated 29xx deferral may no longer exist in what was booked, + // and a schedule would then dissolve an interim balance that was + // never credited. User-edited lines book exactly as reviewed. + if (!customLines) { + const accrual = await createSchedulesForCustomerInvoice( + supabase, + companyId, + user.id, + invoice as Invoice, + (invoice.items as InvoiceItem[] | null) ?? [], + journalEntry.id, + entityType, + ) + if (accrual.failed > 0) { + log.error('accrual schedule creation failed on mark-sent', { + failed: accrual.failed, + }) + partialFailures.push({ + step: 'accrual_schedules', + reason: `${accrual.failed} periodisering(ar) kunde inte skapas`, + }) + } } const { error: linkError } = await supabase diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 380d228f..b66432d9 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -71,6 +71,11 @@ vi.mock('@/lib/invoices/issue-credit-note', () => ({ issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args), })) +const mockCreateSchedules = vi.fn() +vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({ + createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args), +})) + // The sandbox guard issues a company_settings query at the top of the route; // short-circuit it in tests since the queued mock-supabase is shaped for the // route's existing fetch chain, not an extra pre-flight read. @@ -118,6 +123,7 @@ describe('POST /api/invoices/[id]/send', () => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) mockIsConfigured.mockReturnValue(true) mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf')) + mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 }) mockIssueCreditNote.mockResolvedValue({ complete: true, journalEntryId: 'credit-je-1', @@ -606,6 +612,80 @@ describe('POST /api/invoices/[id]/send', () => { expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true) }) + it('returns 400 on malformed lines before any email is sent', async () => { + enqueue({ data: invoice, error: null }) // ownership fetch precedes validation + const request = createMockRequest('/api/invoices/inv-1/send', { + method: 'POST', + body: { lines: [{ account_number: 'bad', debit_amount: -1 }] }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('returns 400 on unbalanced lines before any email is sent', async () => { + enqueue({ data: invoice, error: null }) + const request = createMockRequest('/api/invoices/inv-1/send', { + method: 'POST', + body: { + lines: [ + { account_number: '1510', debit_amount: 100, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 90 }, + ], + }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_UNBALANCED') + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('books user-edited lines verbatim and skips accrual schedules', async () => { + enqueue({ data: invoice, error: null }) // fetch invoice + enqueue({ data: company, error: null }) // settings + mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-lines' }) + mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-20' }) + enqueue({ data: [{ id: 'inv-1' }], error: null }) // status flip + enqueue({ data: null, error: null }) // journal_entry_id link + + const lines = [ + { account_number: '1510', debit_amount: 12500, credit_amount: 0 }, + { account_number: '3041', debit_amount: 0, credit_amount: 10000 }, + { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, + ] + const request = createMockRequest('/api/invoices/inv-1/send', { + method: 'POST', + body: { lines }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(mockSendEmail).toHaveBeenCalledTimes(1) + // User-edited lines book exactly as reviewed: no accrual schedules. + expect(mockCreateSchedules).not.toHaveBeenCalled() + expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ id: 'inv-1' }), + 'enskild_firma', + undefined, + expect.objectContaining({ + customLines: [ + expect.objectContaining({ account_number: '1510', debit_amount: 12500 }), + expect.objectContaining({ account_number: '3041', credit_amount: 10000 }), + expect.objectContaining({ account_number: '2611', credit_amount: 2500 }), + ], + }) + ) + }) + it('renders the final PDF as if already sent (no UTKAST banner)', async () => { enqueue({ data: invoice, error: null }) enqueue({ data: company, error: null }) diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 61cc5437..7b4c82aa 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -21,6 +21,7 @@ import { } from '@/lib/invoices/issue-credit-note' import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links' import { withRouteContext } from '@/lib/api/with-route-context' +import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { guardSandbox } from '@/lib/sandbox/guard' import { requireCapability } from '@/lib/entitlements/has-capability' @@ -39,11 +40,26 @@ ensureInitialized() export const POST = withRouteContext( 'invoice.send', - async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { const { id } = await params const { user, supabase, companyId, log, requestId } = ctx const opLog = log.child({ invoiceId: id }) + // Optional body: user-edited issuance lines. Read here; validated after + // the ownership fetch below (no payload feedback for foreign invoices) + // but still long BEFORE the email leaves: after delivery the pipeline + // only degrades to PARTIAL. Same contract as mark-sent. + let rawBody: unknown + const bodyText = await request.text() + if (bodyText) { + try { + rawBody = JSON.parse(bodyText) + } catch { + // Malformed JSON must not silently fall back to generated lines. + return NextResponse.json({ error: 'Ogiltig förfrågan' }, { status: 400 }) + } + } + // The sandbox must never deliver a real email to a real customer: block // the entire send pipeline (PDF render + Resend send + status flip). const blocked = await guardSandbox(supabase, companyId) @@ -96,6 +112,34 @@ export const POST = withRouteContext( }) } + const linesResult = parseCustomIssuanceLines(rawBody) + if (!linesResult.ok) { + if (linesResult.error === 'invalid_body') { + opLog.warn('send validation failed') + return NextResponse.json( + { error: 'Ogiltig förfrågan', details: linesResult.details }, + { status: 400 }, + ) + } + return errorResponseFromCode( + linesResult.error === 'unbalanced' + ? 'INVOICE_MARK_SENT_LINES_UNBALANCED' + : 'INVOICE_MARK_SENT_LINES_INVALID', + opLog, + { requestId, details: linesResult.details }, + ) + } + const customLines = linesResult.lines + + // Custom lines only apply where send books inline; elsewhere they are + // deliberately ignored (documented in MarkInvoiceSentSchema). Logged for + // audit visibility instead of vanishing silently. + if (customLines && isCreditNote) { + opLog.warn('send: custom lines ignored (credit notes book via issueCreditNote)', { + lineCount: customLines.length, + }) + } + const customer = invoice.customer as Customer if (!customer.email) { return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, { @@ -361,15 +405,42 @@ export const POST = withRouteContext( // #967: deferred companies send WITHOUT booking; ekonomi books later via // POST /api/invoices/[id]/book. The invoice then legitimately sits at // journal_entry_id = NULL until then, like under kontantmetoden. + if ( + customLines && + !isCreditNote && + (!isRealInvoice || !booksInvoicesOnIssue(company as CompanySettings)) + ) { + opLog.warn('send: custom lines ignored (not on accrual book-at-issue path)', { + lineCount: customLines.length, + }) + } + if (statusFlipped && !isCreditNote && isRealInvoice && booksInvoicesOnIssue(company as CompanySettings)) { try { - const journalEntry = await createInvoiceJournalEntry( - supabase, - companyId!, - user.id, - invoice as Invoice, - (company as CompanySettings).entity_type, - ) + if (customLines) { + // Audit trail: distinguish user-edited bookings from generated ones. + opLog.info('send: booking user-edited custom lines', { + userId: user.id, + lineCount: customLines.length, + }) + } + const journalEntry = customLines + ? await createInvoiceJournalEntry( + supabase, + companyId!, + user.id, + invoice as Invoice, + (company as CompanySettings).entity_type, + undefined, + { customLines }, + ) + : await createInvoiceJournalEntry( + supabase, + companyId!, + user.id, + invoice as Invoice, + (company as CompanySettings).entity_type, + ) if (journalEntry) { createdJournalEntryId = journalEntry.id await supabase @@ -380,20 +451,24 @@ export const POST = withRouteContext( // Periodiserade lines: create their schedules + catch-up // dissolutions now that the revenue entry exists. Failures degrade // to PARTIAL: the entry is committed and must not be rolled back. - const accrual = await createSchedulesForCustomerInvoice( - supabase, - companyId!, - user.id, - invoice as Invoice, - items, - journalEntry.id, - (company as CompanySettings).entity_type, - ) - if (accrual.failed > 0) { - partialFailures.push({ - step: 'accrual_schedules', - reason: `${accrual.failed} periodisering(ar) kunde inte skapas`, - }) + // Skipped for user-edited lines: the generated 29xx deferral may + // not exist in what was booked; edited lines book as reviewed. + if (!customLines) { + const accrual = await createSchedulesForCustomerInvoice( + supabase, + companyId!, + user.id, + invoice as Invoice, + items, + journalEntry.id, + (company as CompanySettings).entity_type, + ) + if (accrual.failed > 0) { + partialFailures.push({ + step: 'accrual_schedules', + reason: `${accrual.failed} periodisering(ar) kunde inte skapas`, + }) + } } } } catch (err) { diff --git a/components/invoices/SendInvoiceDialog.tsx b/components/invoices/SendInvoiceDialog.tsx index 51b53644..d00f23f1 100644 --- a/components/invoices/SendInvoiceDialog.tsx +++ b/components/invoices/SendInvoiceDialog.tsx @@ -11,17 +11,24 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent' +import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { proposeSendLines } from '@/lib/bookkeeping/propose-send-lines' import { formatCurrency } from '@/lib/utils' +import { roundOre } from '@/lib/money' import { createClient } from '@/lib/supabase/client' import { getResponseErrorMessage } from '@/lib/errors/get-error-message' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note' -import { Loader2, Mail, Send } from 'lucide-react' -import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types' +import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions' +import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react' +import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' +import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types' interface InvoiceWithRelations extends Invoice { customer: Customer @@ -54,11 +61,25 @@ export default function SendInvoiceDialog({ const isCreditRepair = isCreditNote && invoice.status === 'sent' const [isSubmitting, setIsSubmitting] = useState(false) - const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') const [entityType, setEntityType] = useState('enskild_firma') const [periodName, setPeriodName] = useState('') const [isInitialized, setIsInitialized] = useState(false) const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true) + const [deferBooking, setDeferBooking] = useState(false) + const [accounts, setAccounts] = useState([]) + const [editLines, setEditLines] = useState([]) + const [hasEdited, setHasEdited] = useState(false) + + // The accrual book-at-issue path (both email send and manual mark-sent) + // lets the user adjust the proposed lines before booking (same editor as + // PaymentBookingDialog). Credit notes keep the read-only preview, as do + // invoices with periodiserade rows: the server generator defers those to + // 29xx and creates dissolution schedules, which user-edited lines bypass. + // SEK only: the generated path stamps FX metadata (currency, exchange rate) + // on the receivable line, which custom lines cannot carry. + const hasAccrualItems = (invoice.items ?? []).some((item) => itemHasAccrual(item)) + const editable = + !isCreditNote && shouldBookOnIssue && !hasAccrualItems && invoice.currency === 'SEK' useEffect(() => { if (!open) { @@ -75,15 +96,15 @@ export default function SendInvoiceDialog({ const [settingsResult, periodResult, originalResult] = await Promise.all([ supabase .from('company_settings') - .select('accounting_method, entity_type') + .select('accounting_method, entity_type, defer_invoice_booking') .eq('company_id', company.id) .maybeSingle(), supabase .from('fiscal_periods') .select('name') .eq('company_id', company.id) - .lte('start_date', invoice.invoice_date) - .gte('end_date', invoice.invoice_date) + .lte('period_start', invoice.invoice_date) + .gte('period_end', invoice.invoice_date) .maybeSingle(), invoice.credited_invoice_id ? supabase @@ -102,14 +123,29 @@ export default function SendInvoiceDialog({ if (cancelled) return const method = (settingsResult.data?.accounting_method || 'accrual') as 'accrual' | 'cash' - setAccountingMethod(method) + // #967: deferred companies mark-sent WITHOUT booking; ekonomi books + // later via a separate step, so neither preview nor editor applies. + const bookOnIssue = invoice.credited_invoice_id && originalResult.data + ? creditNoteNeedsJournalEntry(method, originalResult.data) + : method === 'accrual' && !settingsResult.data?.defer_invoice_booking + + // Line editing needs the chart of accounts; only the accrual + // book-at-issue path renders the editor, so skip the fetch elsewhere. + let fetchedAccounts: BASAccount[] = [] + if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) { + const accountsRes = await fetch('/api/bookkeeping/accounts') + if (!accountsRes.ok) throw new Error(t('load_chart_failed')) + const accountsData = await accountsRes.json() + fetchedAccounts = accountsData.data || [] + } + + if (cancelled) return + + setAccounts(fetchedAccounts) setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma') setPeriodName(periodResult.data?.name || '') - setShouldBookOnIssue( - invoice.credited_invoice_id && originalResult.data - ? creditNoteNeedsJournalEntry(method, originalResult.data) - : method === 'accrual', - ) + setDeferBooking(!!settingsResult.data?.defer_invoice_booking) + setShouldBookOnIssue(bookOnIssue) setIsInitialized(true) } catch (err) { if (cancelled) return @@ -149,17 +185,76 @@ export default function SendInvoiceDialog({ }) }, [isInitialized, shouldBookOnIssue, entityType, invoice]) - const { totalDebit, totalCredit } = useMemo(() => { + // Seed the editable grid from the proposal once per open; edits must not be + // clobbered by re-renders, so proposedLines is deliberately not a dependency. + useEffect(() => { + if (!open) { + setEditLines([]) + setHasEdited(false) + return + } + if (isInitialized && editable) { + setEditLines(proposedLines.map((line) => ({ ...line }))) + setHasEdited(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, isInitialized, editable]) + + const activeLines = editable ? editLines : proposedLines + + const { totalDebit, totalCredit, isBalanced, hasOrphanAmounts } = useMemo(() => { let totalDebit = 0 let totalCredit = 0 - for (const line of proposedLines) { - totalDebit += parseFloat(line.debit_amount) || 0 - totalCredit += parseFloat(line.credit_amount) || 0 + // A row carrying an amount but no account would be silently dropped from + // the POST while staying visible in the grid; block submit instead. + let hasOrphanAmounts = false + for (const line of activeLines) { + // Round per line like the server does, so a payload the badge calls + // balanced can never be rejected by the route's rounded check. + const debit = roundOre(parseFloat(line.debit_amount) || 0) + const credit = roundOre(parseFloat(line.credit_amount) || 0) + if ((debit || credit) && !line.account_number) hasOrphanAmounts = true + totalDebit += debit + totalCredit += credit } - return { totalDebit, totalCredit } - }, [proposedLines]) + const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0 + return { totalDebit, totalCredit, isBalanced, hasOrphanAmounts } + }, [activeLines]) + + const updateLine = (index: number, field: keyof FormLine, value: string) => { + setHasEdited(true) + setEditLines((prev) => { + const next = [...prev] + const updated = { ...next[index], [field]: value } + + // Debit/credit exclusion: clear the other when one is entered + if (field === 'debit_amount' && value) { + updated.credit_amount = '' + } else if (field === 'credit_amount' && value) { + updated.debit_amount = '' + } + + next[index] = updated + return next + }) + } + + const addLine = () => { + setHasEdited(true) + setEditLines((prev) => [ + ...prev, + { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, + ]) + } + + const removeLine = (index: number) => { + if (editLines.length <= 2) return + setHasEdited(true) + setEditLines((prev) => prev.filter((_, i) => i !== index)) + } const handleConfirm = async () => { + if (editable && (!isBalanced || hasOrphanAmounts)) return setIsSubmitting(true) try { @@ -167,7 +262,33 @@ export default function SendInvoiceDialog({ ? `/api/invoices/${invoice.id}/send` : `/api/invoices/${invoice.id}/mark-sent` - const response = await fetch(url, { method: 'POST' }) + // Untouched proposal: send no body so the server generates the entry + // itself (per-item revenue accounts, dimensions, FX metadata). Only + // actual edits override the generator. + const apiLines = editable && hasEdited + ? editLines + .filter((l) => l.account_number && (parseFloat(l.debit_amount) || parseFloat(l.credit_amount))) + .map((l) => ({ + account_number: l.account_number, + debit_amount: parseFloat(l.debit_amount) || 0, + credit_amount: parseFloat(l.credit_amount) || 0, + line_description: l.line_description || undefined, + dimensions: + l.dimensions && Object.keys(l.dimensions).length > 0 + ? l.dimensions + : undefined, + })) + : undefined + + const response = await fetch(url, { + method: 'POST', + ...(apiLines + ? { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lines: apiLines }), + } + : {}), + }) if (!response.ok) { throw new Error(await getResponseErrorMessage(response, 'invoice', locale)) @@ -216,7 +337,7 @@ export default function SendInvoiceDialog({ ? shouldBookOnIssue ? t('credit_mark_success_voucher_created') : t('credit_mark_success_no_voucher') - : accountingMethod === 'accrual' + : shouldBookOnIssue ? t('mark_success_voucher_created') : undefined, }) @@ -289,7 +410,162 @@ export default function SendInvoiceDialog({ eller använd «Markera som skickad». )} - {showJournalPreview ? ( + {showJournalPreview && editable ? ( + <> +

+ {t('journal_edit_intro')} +

+ + {/* Mobile card layout */} +
+ {editLines.map((line, index) => ( +
+
+
+ updateLine(index, 'account_number', val)} + /> +
+ +
+
+
+ + updateLine(index, 'debit_amount', e.target.value)} + className="tabular-nums text-right" + inputMode="decimal" + /> +
+
+ + updateLine(index, 'credit_amount', e.target.value)} + className="tabular-nums text-right" + inputMode="decimal" + /> +
+
+
+ ))} + +
+ + {/* Desktop table layout */} +
+
+ {t('account_label')} + {t('debit_label')} + {t('credit_label')} + +
+ + {editLines.map((line, index) => ( +
+
+ updateLine(index, 'account_number', val)} + /> +
+ updateLine(index, 'debit_amount', e.target.value)} + className="tabular-nums text-right" + aria-label={t('debit_label')} + /> + updateLine(index, 'credit_amount', e.target.value)} + className="tabular-nums text-right" + aria-label={t('credit_label')} + /> + +
+ ))} + + +
+ + {/* Balance indicator */} +
+ {isBalanced ? ( + {t('balanced_badge')} + ) : ( + + {t('unbalanced_badge', { delta: formatCurrency(Math.abs(totalDebit - totalCredit)) })} + + )} +
+ {formatCurrency(totalDebit)} / {formatCurrency(totalCredit)} +
+
+ + ) : showJournalPreview ? ( <>

{t('journal_preview_intro')} @@ -311,7 +587,13 @@ export default function SendInvoiceDialog({ ) : (

{!shouldBookOnIssue - ? t(isCreditNote ? 'explain_credit_cash' : 'explain_cash') + ? t( + isCreditNote + ? 'explain_credit_cash' + : deferBooking + ? 'explain_deferred' + : 'explain_cash', + ) : mode === 'email' ? t('explain_email', { email: invoice.customer.email ?? '' }) : t('explain_manual')} @@ -331,7 +613,7 @@ export default function SendInvoiceDialog({