diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index d56b5443..db70fff0 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -314,20 +314,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st if (!response.ok) { const data = await response.json() - throw new Error(data.error || 'Kunde inte ta bort fakturan') + throw new Error(data.error || 'Kunde inte makulera fakturan') } toast({ - title: 'Faktura borttagen', + title: 'Faktura makulerad', description: invoice.invoice_number - ? `Utkast ${invoice.invoice_number} har tagits bort` - : 'Utkastet har tagits bort', + ? `Faktura ${invoice.invoice_number} har makulerats. Numret behålls i serien.` + : 'Utkastet har makulerats.', }) router.push('/invoices') } catch (error) { toast({ - title: 'Kunde inte ta bort fakturan', + title: 'Kunde inte makulera fakturan', description: error instanceof Error ? error.message : 'Försök igen.', variant: 'destructive', }) @@ -904,24 +904,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st

)} - {invoice.invoice_number ? ( -
- -

- Utkastet har redan tilldelats löpnummer {invoice.invoice_number} och kan inte tas bort. Försök skicka fakturan igen — om sändningen lyckas behövs inget annat steg. -

-
- ) : ( - - )} + )} {(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && ( @@ -956,17 +947,25 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st - {/* Delete confirmation dialog. Only reachable when invoice_number is null; - numbered drafts surface an inline retry-send notice instead. */} + {/* Cancel confirmation dialog. The invoice transitions to status='cancelled' + and the F-series number is retained so the sequence stays gap-free. */} - Ta bort fakturautkast + Makulera fakturautkast - Är du säker på att du vill ta bort utkastet? Detta kan inte ångras. - - Inget löpnummer har tilldelats — fakturaserien påverkas inte. - + {invoice.invoice_number ? ( + <> + Fakturan markeras som makulerad och sparas i fakturalistan med status Makulerad. + + Fakturanumret {invoice.invoice_number} behålls för att hålla nummerserien obruten enligt ML 17 kap 24§. + + + ) : ( + <> + Utkastet markeras som makulerat. Detta kan inte ångras. + + )} @@ -975,7 +974,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index a490809a..694500ee 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -78,6 +78,7 @@ export default function NewInvoicePage() { const [isCreatingCustomer, setIsCreatingCustomer] = useState(false) const [hasBankDetails, setHasBankDetails] = useState(null) const [showBankSetup, setShowBankSetup] = useState(false) + const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') const pendingCustomerRef = useRef(null) const { @@ -137,7 +138,7 @@ export default function NewInvoicePage() { if (!company?.id) return const { data } = await supabase .from('company_settings') - .select('invoice_default_notes, clearing_number, account_number, bankgiro') + .select('invoice_default_notes, clearing_number, account_number, bankgiro, accounting_method') .eq('company_id', company.id) .single() if (data?.invoice_default_notes) { @@ -147,6 +148,9 @@ export default function NewInvoicePage() { setHasBankDetails( !!(data?.clearing_number && data?.account_number) || !!data?.bankgiro ) + if (data?.accounting_method === 'cash' || data?.accounting_method === 'accrual') { + setAccountingMethod(data.accounting_method) + } } useEffect(() => { @@ -812,7 +816,9 @@ export default function NewInvoicePage() { isSubmitting={isSubmitting} title={watchDocumentType === 'proforma' ? 'Granska proformafaktura' : watchDocumentType === 'delivery_note' ? 'Granska följesedel' : 'Granska faktura'} warningText={watchDocumentType === 'invoice' - ? 'En faktura skapas och en verifikation bokförs. Verifikationen kan inte redigeras direkt, men kan korrigeras via en kreditnota.' + ? accountingMethod === 'cash' + ? 'En faktura skapas och tilldelas ett fakturanummer. Verifikationen bokförs först när fakturan markeras som betald (kontantmetoden).' + : 'En faktura skapas och tilldelas ett fakturanummer. När den skickas eller markeras som skickad bokförs en verifikation, som inte kan redigeras direkt men kan korrigeras via en kreditnota.' : watchDocumentType === 'proforma' ? 'En proformafaktura skapas. Ingen verifikation bokförs. Proforman kan senare konverteras till en riktig faktura.' : 'En följesedel skapas utan priser. Ingen verifikation bokförs.'} diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 895af75d..4773fa77 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -93,13 +93,17 @@ export default function InvoicesPage() { const isCreditNote = !!invoice.credited_invoice_id const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' + // Cancelled invoices are kept in the table for compliance but hidden from + // the default 'Alla' view; they only show up when the user explicitly picks + // the 'Makulerade' tab. const matchesTab = - activeTab === 'all' || + (activeTab === 'all' && invoice.status !== 'cancelled') || (activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice') || (activeTab === 'credit' && isCreditNote) || - (activeTab === 'proforma' && docType === 'proforma') || - (activeTab === 'delivery_note' && docType === 'delivery_note') || - (activeTab !== 'proforma' && activeTab !== 'delivery_note' && invoice.status === activeTab) + (activeTab === 'proforma' && docType === 'proforma' && invoice.status !== 'cancelled') || + (activeTab === 'delivery_note' && docType === 'delivery_note' && invoice.status !== 'cancelled') || + (activeTab === 'cancelled' && invoice.status === 'cancelled') || + (activeTab !== 'all' && activeTab !== 'proforma' && activeTab !== 'delivery_note' && activeTab !== 'cancelled' && invoice.status === activeTab) return matchesSearch && matchesTab }) @@ -209,6 +213,7 @@ export default function InvoicesPage() { Proforma Följesedel Kredit + Makulerade {/* Desktop: tab bar */} @@ -221,6 +226,7 @@ export default function InvoicesPage() { Proforma Följesedel Kredit + Makulerade diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index ed80fe38..822c68b4 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -19,6 +19,7 @@ import TransactionInboxCard from '@/components/transactions/TransactionInboxCard import TransactionHistoryList from '@/components/transactions/TransactionHistoryList' import InboxZeroState from '@/components/transactions/InboxZeroState' import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' +import InvoicePicker from '@/components/transactions/InvoicePicker' import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' @@ -76,6 +77,11 @@ export default function TransactionsPage() { const [templatePickerOpen, setTemplatePickerOpen] = useState(false) const [templatePickerTransaction, setTemplatePickerTransaction] = useState(null) + // Invoice picker dialog (manual match) + const [invoicePickerOpen, setInvoicePickerOpen] = useState(false) + const [invoicePickerTransaction, setInvoicePickerTransaction] = useState(null) + const [isMatchingFromPicker, setIsMatchingFromPicker] = useState(false) + // Quick review dialog (suggestion review before booking) const [quickReviewOpen, setQuickReviewOpen] = useState(false) const [quickReview, setQuickReview] = useState(null) @@ -457,6 +463,69 @@ export default function TransactionsPage() { } } + async function handleSelectInvoiceFromPicker(invoice: Invoice & { customer?: Customer }) { + if (!invoicePickerTransaction) return + const tx = invoicePickerTransaction + setIsMatchingFromPicker(true) + try { + const response = await fetch(`/api/transactions/${tx.id}/match-invoice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invoice_id: invoice.id }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Fakturamatchning misslyckades', + description: getErrorMessage(result, { context: 'transaction' }), + variant: 'destructive', + }) + setIsMatchingFromPicker(false) + return + } + + toast({ + title: 'Faktura matchad', + description: `Faktura ${invoice.invoice_number ?? ''} markerad som betald`, + }) + + setInvoicePickerOpen(false) + setInvoicePickerTransaction(null) + setExitingIds((prev) => new Set(prev).add(tx.id)) + setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1)) + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => + t.id === tx.id + ? { + ...t, + invoice_id: invoice.id, + potential_invoice_id: null, + potential_invoice: undefined, + is_business: true, + category: (result.category ?? 'income_services') as TransactionCategory, + journal_entry_id: result.journal_entry_id, + } + : t + ) + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(tx.id) + return next + }) + setIsMatchingFromPicker(false) + }, 350) + } catch { + toast({ + title: 'Matchning misslyckades', + description: 'Transaktionen kunde inte matchas med fakturan. Försök igen.', + variant: 'destructive', + }) + setIsMatchingFromPicker(false) + } + } + async function handleCreateTransaction(data: CreateTransactionInput) { setIsCreating(true) const { data: { user } } = await supabase.auth.getUser() @@ -895,7 +964,22 @@ export default function TransactionsPage() { handleOpenTemplateReview(templatePickerTransaction, templateId) }} /> -
+
+ {templatePickerTransaction && templatePickerTransaction.amount > 0 && ( + + )} @@ -903,6 +987,36 @@ export default function TransactionsPage() {
+ { + if (isMatchingFromPicker) return + setInvoicePickerOpen(open) + if (!open) setInvoicePickerTransaction(null) + }} + > + + + Matcha med faktura + + {invoicePickerTransaction && ( + <> +
+ {invoicePickerTransaction.description} + + +{formatCurrency(invoicePickerTransaction.amount, invoicePickerTransaction.currency)} + +
+ + + )} +
+
+ { expect(status).toBe(404) }) - it('rejects deletion of a non-draft invoice with INVOICE_DELETE_NOT_DRAFT', async () => { + it('rejects cancellation of a non-draft invoice with INVOICE_DELETE_NOT_DRAFT', async () => { enqueue({ data: { id: 'inv-1', status: 'sent', invoice_number: 'F-2026099', user_id: 'user-1' }, error: null, @@ -71,49 +71,71 @@ describe('DELETE /api/invoices/[id]', () => { expect(body.error.code).toBe('INVOICE_DELETE_NOT_DRAFT') }) - it('rejects deletion of a draft that already has an invoice_number', async () => { + it('cancels a numbered draft, retaining the F-series number', async () => { enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: 'F-2026001', user_id: 'user-1' }, error: null, }) + enqueue({ data: [{ id: 'inv-1' }], error: null }) const response = await DELETE( createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }), createMockRouteParams({ id: 'inv-1' }) ) const { status, body } = await parseJsonResponse<{ - error: { code: string; details?: { invoice_number?: string } } + data: { cancelled: boolean; invoice_number: string | null } }>(response) - expect(status).toBe(400) - expect(body.error.code).toBe('INVOICE_DELETE_NUMBERED') - expect(body.error.details?.invoice_number).toBe('F-2026001') + expect(status).toBe(200) + expect(body.data.cancelled).toBe(true) + expect(body.data.invoice_number).toBe('F-2026001') }) - it('deletes a draft with no invoice_number', async () => { + it('cancels an un-numbered draft (legacy null-number row)', async () => { enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' }, error: null, }) - enqueue({ data: null, error: null }) - enqueue({ data: null, error: null }) + enqueue({ data: [{ id: 'inv-1' }], error: null }) const response = await DELETE( createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }), createMockRouteParams({ id: 'inv-1' }) ) - const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response) + const { status, body } = await parseJsonResponse<{ + data: { cancelled: boolean; invoice_number: string | null } + }>(response) expect(status).toBe(200) - expect(body.data.deleted).toBe(true) + expect(body.data.cancelled).toBe(true) + expect(body.data.invoice_number).toBeNull() }) - it('returns 500 when items delete fails', async () => { + it('returns 409 INVOICE_CANCEL_RACE when status flipped between fetch and update', async () => { enqueue({ - data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' }, + data: { id: 'inv-1', status: 'draft', invoice_number: 'F-2026001', user_id: 'user-1' }, error: null, }) - enqueue({ data: null, error: { message: 'items delete failed' } }) + // Update succeeds with no error but matches 0 rows because the .eq('status','draft') + // guard rejected the row (concurrent send/cancel flipped status in the meantime). + enqueue({ data: [], error: null }) + + const response = await DELETE( + createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }), + createMockRouteParams({ id: 'inv-1' }) + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_CANCEL_RACE') + }) + + it('returns 500 when the cancel update fails', async () => { + enqueue({ + data: { id: 'inv-1', status: 'draft', invoice_number: 'F-2026001', user_id: 'user-1' }, + error: null, + }) + enqueue({ data: null, error: { message: 'cancel update failed' } }) const response = await DELETE( createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }), diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index a5064509..6a2e6ab6 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -5,21 +5,21 @@ import { requireWritePermission } from '@/lib/auth/require-write' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { createLogger } from '@/lib/logger' -const log = createLogger('api.invoices.delete') +const log = createLogger('api.invoices.cancel') /** * DELETE /api/invoices/[id] * - * Permanently deletes a draft invoice and its items. + * Cancels (makulerar) a draft invoice. The row and its F-series number are + * retained — the invoice transitions to status='cancelled'. Keeping the row + * preserves the invoice-number sequence per ML 17 kap 24§ and BFNAR 2013:2, + * so the F-series stays gap-free without any voucher_gap_explanations entry. * - * Two preconditions: - * 1. status === 'draft' — committed invoices are immutable per BFL and - * must be reversed via credit note. - * 2. invoice_number IS NULL — a draft that already holds an F-series - * number is a side effect of an interrupted send/convert/mark-sent. - * Destroying it would orphan the number and create a permanent gap - * in the verifications series. Refuse and let the user retry the - * send instead (ensureInvoiceNumber is idempotent). + * Only drafts may be cancelled this way. Sent / paid invoices are immutable + * per BFL and must be reversed via a credit note instead. + * + * Old drafts predating allocate-on-save may have invoice_number = NULL; those + * still cancel (status flip) without consuming a number — no special-case path. */ export async function DELETE( request: Request, @@ -54,30 +54,25 @@ export async function DELETE( return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log) } - if (invoice.invoice_number !== null) { - return errorResponseFromCode('INVOICE_DELETE_NUMBERED', log, { - details: { invoice_number: invoice.invoice_number }, - }) - } - - const { error: itemsError } = await supabase - .from('invoice_items') - .delete() - .eq('invoice_id', id) - - if (itemsError) { - return NextResponse.json({ error: itemsError.message }, { status: 500 }) - } - - const { error: deleteError } = await supabase + // .select() returns the affected rows so we can detect a TOCTOU race where + // the status flipped between the fetch above and this update. With only the + // .eq('status','draft') guard, a 0-row update returns success and the user + // would see "Makulerad" while the invoice is still in its previous state. + const { data: updated, error: cancelError } = await supabase .from('invoices') - .delete() + .update({ status: 'cancelled', updated_at: new Date().toISOString() }) .eq('id', id) .eq('company_id', companyId) + .eq('status', 'draft') + .select('id') - if (deleteError) { - return NextResponse.json({ error: deleteError.message }, { status: 500 }) + if (cancelError) { + return NextResponse.json({ error: cancelError.message }, { status: 500 }) } - return NextResponse.json({ data: { deleted: true } }) + if (!updated || updated.length === 0) { + return errorResponseFromCode('INVOICE_CANCEL_RACE', log) + } + + return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } }) } diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 4d085875..ada8c6f8 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -131,6 +131,23 @@ describe('POST /api/invoices/[id]/send', () => { expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_FOUND') }) + it('returns 400 when invoice is cancelled (makulerad)', async () => { + const cancelledInvoice = makeInvoice({ + id: 'inv-1', + status: 'cancelled', + invoice_number: 'F-2026001', + items: [], + }) + enqueue({ data: cancelledInvoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_CANCELLED') + }) + it('returns 400 when customer has no email', async () => { const noEmailInvoice = makeInvoice({ id: 'inv-1', diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 9186e0f1..08982760 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -45,6 +45,14 @@ export const POST = withRouteContext( return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId }) } + // A cancelled invoice keeps its F-series number for compliance with ML 17 + // kap 24§ but is not a valid faktura — sending it would silently + // re-activate it (the .update({ status: 'sent' }) below has no status + // guard) and could deliver a "MAKULERAD" PDF as if it were live. + if (invoice.status === 'cancelled') { + return errorResponseFromCode('INVOICE_SEND_CANCELLED', opLog, { requestId }) + } + const customer = invoice.customer as Customer if (!customer.email) { return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, { diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index 7a8327be..0d662f18 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -170,7 +170,7 @@ describe('POST /api/invoices (create invoice)', () => { it('creates invoice with items and emits event', async () => { const customer = makeCustomer({ id: VALID_UUID }) - const createdInvoice = makeInvoice({ id: 'inv-1' }) + const createdInvoice = makeInvoice({ id: 'inv-1', invoice_number: null }) mockGetVatRules.mockReturnValue({ treatment: 'standard_25', @@ -188,12 +188,14 @@ describe('POST /api/invoices (create invoice)', () => { // Fetch customer enqueue({ data: customer, error: null }) - // Insert invoice (no number generated for drafts — assigned at send time) + // Insert invoice (number is null on insert; allocated immediately after items) enqueue({ data: createdInvoice, error: null }) // Insert items enqueue({ data: null, error: null }) + // ensureInvoiceNumber → generate_invoice_number RPC + enqueue({ data: '2026001', error: null }) // Fetch complete invoice - enqueue({ data: { ...createdInvoice, customer, items: [] }, error: null }) + enqueue({ data: { ...createdInvoice, invoice_number: '2026001', customer, items: [] }, error: null }) const emitSpy = vi.spyOn(eventBus, 'emit') @@ -258,6 +260,51 @@ describe('POST /api/invoices (create invoice)', () => { expect(status).toBe(500) expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_ITEMS_FAILED') }) + + it('soft-cancels the invoice when invoice-number allocation fails', async () => { + const customer = makeCustomer({ id: VALID_UUID }) + const createdInvoice = makeInvoice({ id: 'inv-1', invoice_number: null }) + + mockGetVatRules.mockReturnValue({ + treatment: 'standard_25', + rate: 25, + momsRuta: '10', + reverseChargeText: null, + }) + mockCalculateVat.mockReturnValue(2500) + mockGetAvailableVatRates.mockReturnValue([ + { rate: 25, label: '25%', treatment: 'standard_25' }, + { rate: 12, label: '12%', treatment: 'reduced_12' }, + { rate: 6, label: '6%', treatment: 'reduced_6' }, + { rate: 0, label: '0% (momsfri)', treatment: 'exempt' }, + ]) + + enqueue({ data: customer, error: null }) + enqueue({ data: createdInvoice, error: null }) + // Items insertion succeeds + enqueue({ data: null, error: null }) + // generate_invoice_number RPC fails + enqueue({ data: null, error: { message: 'sequence locked' } }) + // Rollback path: re-fetch invoice_number, then soft-cancel. + enqueue({ data: { invoice_number: null }, error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { + customer_id: VALID_UUID, + invoice_date: '2024-06-15', + due_date: '2024-07-15', + currency: 'SEK', + items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_NUMBER_ASSIGN_FAILED') + }) }) describe('POST /api/invoices (create credit note)', () => { diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index f7578f3e..314e8358 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -7,6 +7,7 @@ import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocument import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { Logger } from '@/lib/logger' @@ -232,6 +233,56 @@ export const POST = withRouteContext( }) } + // Allocate F-series number on save (Fortnox-style). The user gets a numbered + // draft they can download and send manually without first lying about + // having sent it. Discarded numbered drafts become 'cancelled' rather than + // deleted, so the F-series stays gap-free per ML 17 kap 24§. + // Delivery notes already have their number from the insert above. + if (documentType === 'invoice' || documentType === 'proforma') { + try { + await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice) + } catch (err) { + // Soft-cancel rather than hard-delete: if generate_invoice_number bumped + // the sequence before failing to write the number back, hard-deleting + // would leave a permanent gap in the F-series in violation of ML 17 kap + // 24§. Re-fetch the row to pick up any partially-written number, then + // flip status='cancelled' so the row (and any allocated number) is + // retained for audit. Log loudly if the cancel itself fails so an + // operator can clean up. + const { data: latest } = await supabase + .from('invoices') + .select('invoice_number') + .eq('id', invoice.id) + .single() + // Guard on status='draft' for symmetry with the DELETE handler — only + // drafts may be cancelled. At this point in the create flow the row + // can't realistically be anything else, but the symmetry prevents a + // future caller adding a status flip between insert and number- + // allocation from accidentally cancelling a posted invoice. + const { error: cancelErr } = await supabase + .from('invoices') + .update({ status: 'cancelled', updated_at: new Date().toISOString() }) + .eq('id', invoice.id) + .eq('company_id', companyId!) + .eq('status', 'draft') + if (cancelErr) { + log.error('invoice number allocation failed AND rollback-cancel failed; row may be orphaned', cancelErr, { + invoiceId: invoice.id, + allocatedNumber: latest?.invoice_number ?? null, + originalError: (err as Error).message, + }) + } else { + log.error('invoice number allocation failed; invoice soft-cancelled', err as Error, { + invoiceId: invoice.id, + allocatedNumber: latest?.invoice_number ?? null, + }) + } + return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { + requestId, + }) + } + } + const { data: completeInvoice } = await supabase .from('invoices') .select('*, customer:customers(*), items:invoice_items(*)') diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index 30b50128..fa5ec61a 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -149,6 +149,27 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_FOUND') }) + it('returns 400 when matching against a proforma (defense-in-depth)', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) + const proforma = makeInvoice({ + id: VALID_UUID, + status: 'sent', + document_type: 'proforma', + } as Parameters[0]) + enqueue({ data: tx, error: null }) + enqueue({ data: proforma, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_INVOICE_TYPE') + }) + it('returns 400 when invoice is not in unpaid state', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' }) diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 195be188..8425aff6 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -80,6 +80,19 @@ export const POST = withRouteContext( return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', txLog, { requestId }) } + // Defense-in-depth: the InvoicePicker UI filters proformas / delivery + // notes out of the candidate list, but a direct API call could still + // pass a proforma id. A proforma is not a faktura per ML 17 kap 24§ — + // no VAT obligation, no binding payment — so matching one against a + // bank receipt would book income and VAT incorrectly. + const docType = (invoice as { document_type?: string }).document_type ?? 'invoice' + if (docType !== 'invoice') { + return errorResponseFromCode('MATCH_INVOICE_NOT_INVOICE_TYPE', txLog, { + requestId, + details: { documentType: docType }, + }) + } + if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') { return errorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, { requestId, @@ -267,6 +280,7 @@ export const POST = withRouteContext( remaining_amount: newRemaining, journal_entry_id: journalEntryId, journal_entry_error: journalEntryError, + category: 'income_services', }) }, { requireWrite: true }, diff --git a/components/transactions/InvoicePicker.tsx b/components/transactions/InvoicePicker.tsx new file mode 100644 index 00000000..b3900756 --- /dev/null +++ b/components/transactions/InvoicePicker.tsx @@ -0,0 +1,182 @@ +'use client' + +import { useState, useEffect, useMemo } from 'react' +import { createClient } from '@/lib/supabase/client' +import { Input } from '@/components/ui/input' +import { formatCurrency, formatDate, cn } from '@/lib/utils' +import { Search, FileText, Loader2 } from 'lucide-react' +import { useCompany } from '@/contexts/CompanyContext' +import type { Invoice, Customer } from '@/types' +import type { TransactionWithInvoice } from './transaction-types' + +type OpenInvoice = Invoice & { customer?: Customer } + +interface InvoicePickerProps { + transaction: TransactionWithInvoice + onSelect: (invoice: OpenInvoice) => void + isProcessing: boolean +} + +export default function InvoicePicker({ transaction, onSelect, isProcessing }: InvoicePickerProps) { + const { company } = useCompany() + const supabase = useMemo(() => createClient(), []) + const [invoices, setInvoices] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [search, setSearch] = useState('') + + useEffect(() => { + if (!company) return + let cancelled = false + async function load() { + setIsLoading(true) + // Filter out fully-settled invoices defensively — match-invoice should + // flip status to 'paid' on full settlement, but a stale 'sent'/'overdue' + // row with remaining_amount=0 would otherwise be selectable here and + // could be matched a second time, double-booking the income. + // Also exclude proformas (PF- series) — proforma is not a faktura per + // ML 17 kap 24§, has no VAT obligation, and must never be matched + // against a bank receipt or trigger a verifikation. + const { data } = await supabase + .from('invoices') + .select('*, customer:customers(*)') + .eq('company_id', company!.id) + .eq('document_type', 'invoice') + .in('status', ['sent', 'overdue', 'partially_paid']) + .gt('remaining_amount', 0) + .order('invoice_date', { ascending: false }) + .limit(200) + if (cancelled) return + setInvoices((data as OpenInvoice[]) || []) + setIsLoading(false) + } + load() + return () => { + cancelled = true + } + }, [company, supabase]) + + const sorted = useMemo(() => { + const txAmount = Math.abs(transaction.amount) + const filtered = !search + ? invoices + : invoices.filter((inv) => { + const q = search.toLowerCase() + return ( + (inv.invoice_number ?? '').toLowerCase().includes(q) || + (inv.customer?.name ?? '').toLowerCase().includes(q) + ) + }) + + return [...filtered].sort((a, b) => { + const remainA = a.remaining_amount ?? a.total + const remainB = b.remaining_amount ?? b.total + const diffA = Math.abs(remainA - txAmount) + const diffB = Math.abs(remainB - txAmount) + if (diffA !== diffB) return diffA - diffB + return b.invoice_date.localeCompare(a.invoice_date) + }) + }, [invoices, search, transaction.amount]) + + if (isLoading) { + return ( +
+ + Laddar fakturor... +
+ ) + } + + if (invoices.length === 0) { + return ( +
+

Inga öppna fakturor att matcha mot.

+
+ ) + } + + return ( +
+
+ + setSearch(e.target.value)} + className="pl-9" + autoFocus + /> +
+ +
+ {sorted.map((invoice) => { + const txAmount = Math.abs(transaction.amount) + const remaining = invoice.remaining_amount ?? invoice.total + const sameCurrency = transaction.currency === invoice.currency + const exact = sameCurrency && Math.abs(remaining - txAmount) < 0.01 + const close = + sameCurrency && + !exact && + txAmount > 0 && + Math.abs(remaining - txAmount) / txAmount < 0.01 + + return ( + + ) + })} + {sorted.length === 0 && ( +

+ Ingen faktura matchar "{search}" +

+ )} +
+
+ ) +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 0c1662ec..fcbe2250 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -292,6 +292,11 @@ const MATCH_INVOICE: Record = { message_sv: 'Fakturan är inte i ett obetalt läge och kan inte matchas.', message_en: 'Invoice is not in an unpaid state.', }, + MATCH_INVOICE_NOT_INVOICE_TYPE: { + httpStatus: 400, + message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.', + message_en: 'Only invoices may be matched to a transaction; proforma and delivery notes have no VAT obligation.', + }, MATCH_INVOICE_ALREADY_PAID: { httpStatus: 409, message_sv: 'Fakturan har redan slutbetalats av en annan förfrågan.', @@ -383,6 +388,11 @@ const INVOICE: Record = { message_sv: 'Fakturaraderna kunde inte sparas.', message_en: 'Invoice items insert failed.', }, + INVOICE_CREATE_NUMBER_ASSIGN_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte tilldela fakturanummer vid skapande.', + message_en: 'Failed to assign invoice number on create.', + }, INVOICE_CREDIT_ORIGINAL_NOT_FOUND: { httpStatus: 404, message_sv: 'Ursprungsfakturan kunde inte hittas.', @@ -445,6 +455,11 @@ const INVOICE: Record = { 'Fakturan skickades men en efterföljande åtgärd misslyckades (verifikation eller PDF-bilaga).', message_en: 'Invoice was sent but a follow-up step (journal entry or PDF) failed.', }, + INVOICE_SEND_CANCELLED: { + httpStatus: 400, + message_sv: 'Makulerade fakturor kan inte skickas. Skapa en ny faktura istället.', + message_en: 'Cancelled invoices cannot be sent; create a new invoice instead.', + }, INVOICE_PAID_NOT_FOUND: { httpStatus: 404, message_sv: 'Fakturan kunde inte hittas.', @@ -483,16 +498,10 @@ const INVOICE: Record = { description: 'Issue a credit note instead of deleting a posted invoice.', }, }, - INVOICE_DELETE_NUMBERED: { - httpStatus: 400, - message_sv: - 'Det här utkastet har redan tilldelats ett löpnummer och kan inte tas bort. Försök skicka det igen — om sändningen lyckas behövs inget annat steg.', - message_en: - 'Draft already has an invoice number assigned; refusing to delete to preserve the number sequence. Retry the send — assignment is idempotent.', - remediation: { - description: - 'Retry sending the invoice; ensureInvoiceNumber is idempotent so no new number will be consumed. If sending is no longer desired, contact support to clean up the orphan number.', - }, + INVOICE_CANCEL_RACE: { + httpStatus: 409, + message_sv: 'Fakturan ändrades samtidigt och kunde inte makuleras. Ladda om och försök igen.', + message_en: 'Invoice was modified concurrently and could not be cancelled. Reload and retry.', }, } diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index ad508bf3..36f2030d 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -234,6 +234,26 @@ const styles = StyleSheet.create({ color: '#856404', textAlign: 'center', }, + cancelledBanner: { + marginBottom: 16, + padding: 10, + backgroundColor: '#f8d7da', + borderWidth: 2, + borderColor: '#721c24', + borderRadius: 4, + }, + cancelledBannerTitle: { + fontSize: 14, + fontWeight: 'bold', + color: '#721c24', + textAlign: 'center', + marginBottom: 2, + }, + cancelledBannerText: { + fontSize: 9, + color: '#721c24', + textAlign: 'center', + }, footer: { position: 'absolute', bottom: 30, @@ -325,15 +345,27 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN return ( - {/* Draft banner — visible warning when this PDF is rendered for an - invoice that has not yet been assigned a löpnummer. ML 17 kap 24§ - requires a unique invoice number; without one the document is not - valid as fakturaunderlag and must not be sent to a customer. */} - {!invoice.invoice_number && ( + {/* Status banner — cancelled takes precedence over draft so a cancelled + row that lacks a number (legacy un-numbered draft that was later + cancelled) still surfaces as MAKULERAD rather than UTKAST. The draft + banner only shows for genuine drafts and for the corrupt-state case + of a non-cancelled invoice that somehow lacks a number. */} + {invoice.status === 'cancelled' ? ( + + MAKULERAD – inte en giltig faktura + + {invoice.invoice_number + ? `Faktura ${invoice.invoice_number} har makulerats. Numret behålls i serien för att hålla nummerföljden obruten enligt ML 17 kap 24§, men dokumentet är inte ett giltigt fakturaunderlag.` + : 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.'} + + + ) : (invoice.status === 'draft' || !invoice.invoice_number) && ( UTKAST – inte en giltig faktura - Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer. + {invoice.invoice_number + ? 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.' + : 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.'} )} diff --git a/scripts/seed-demo-account.ts b/scripts/seed-demo-account.ts index 70dcd923..24b95e4c 100644 --- a/scripts/seed-demo-account.ts +++ b/scripts/seed-demo-account.ts @@ -334,6 +334,115 @@ function skipVoucher(ctx: CompanyCtx, fy: number, n: number): void { } } +async function closeYearForSeed(ctx: CompanyCtx, fy: number): Promise { + const fpId = ctx.fpY[fy] + if (!fpId) throw new Error(`No fiscal period for ${fy}`) + + const { data: rows, error } = await sb + .from('journal_entry_lines') + .select( + 'account_number, debit_amount, credit_amount, journal_entries!inner(fiscal_period_id, company_id, status)' + ) + .eq('journal_entries.company_id', ctx.companyId) + .eq('journal_entries.fiscal_period_id', fpId) + .eq('journal_entries.status', 'posted') + if (error) throw new Error(`closeYearForSeed query: ${error.message}`) + + const nets = new Map() + for (const r of rows ?? []) { + const acc = r.account_number as string + const cls = parseInt(acc[0]) + if (cls < 3 || cls > 8) continue + const net = (Number(r.debit_amount) || 0) - (Number(r.credit_amount) || 0) + nets.set(acc, round2((nets.get(acc) ?? 0) + net)) + } + + const lines: JELine[] = [] + let totalDebit = 0 + let totalCredit = 0 + for (const [acc, net] of nets) { + if (Math.abs(net) < 0.005) continue + if (net > 0) { + lines.push({ account: acc, credit: net, description: `Stängning ${acc}` }) + totalCredit = round2(totalCredit + net) + } else { + lines.push({ account: acc, debit: -net, description: `Stängning ${acc}` }) + totalDebit = round2(totalDebit + -net) + } + } + + if (lines.length === 0) return + + const balancing = round2(totalDebit - totalCredit) + if (balancing > 0) { + lines.push({ account: '2099', credit: balancing, description: 'Årets resultat' }) + } else if (balancing < 0) { + lines.push({ account: '2099', debit: -balancing, description: 'Årets förlust' }) + } + + await postEntry(ctx, fy, dt(fy, 12, 31), `Årsbokslut ${fy}`, 'year_end', lines) +} + +async function postOpeningBalanceFromPriorYear( + ctx: CompanyCtx, + priorFy: number, + nextFy: number +): Promise { + const priorFpId = ctx.fpY[priorFy] + const nextFpId = ctx.fpY[nextFy] + if (!priorFpId || !nextFpId) throw new Error(`Missing fiscal period`) + + const { data: rows, error } = await sb + .from('journal_entry_lines') + .select( + 'account_number, debit_amount, credit_amount, journal_entries!inner(fiscal_period_id, company_id, status)' + ) + .eq('journal_entries.company_id', ctx.companyId) + .eq('journal_entries.fiscal_period_id', priorFpId) + .eq('journal_entries.status', 'posted') + if (error) throw new Error(`postOpeningBalanceFromPriorYear: ${error.message}`) + + const nets = new Map() + for (const r of rows ?? []) { + const acc = r.account_number as string + const cls = parseInt(acc[0]) + if (cls < 1 || cls > 2) continue + const net = (Number(r.debit_amount) || 0) - (Number(r.credit_amount) || 0) + nets.set(acc, round2((nets.get(acc) ?? 0) + net)) + } + + const lines: JELine[] = [] + for (const [acc, net] of nets) { + if (Math.abs(net) < 0.005) continue + if (net > 0) { + lines.push({ account: acc, debit: net, description: `Ingående balans: ${acc}` }) + } else { + lines.push({ account: acc, credit: -net, description: `Ingående balans: ${acc}` }) + } + } + + if (lines.length === 0) return + + const obEntryId = await postEntry( + ctx, + nextFy, + dt(nextFy, 1, 1), + `Ingående balans ${nextFy}`, + 'opening_balance', + lines + ) + + const { error: updErr } = await sb + .from('fiscal_periods') + .update({ + opening_balance_entry_id: obEntryId, + opening_balances_set: true, + }) + .eq('id', nextFpId) + .eq('company_id', ctx.companyId) + if (updErr) throw new Error(`set opening_balance_entry_id: ${updErr.message}`) +} + async function seedKonsultAB(userId: string): Promise { console.log('[2] Creating Konsult AB') const companyId = await createCompany(userId, 'Konsult AB', '5591234567', 'aktiebolag') @@ -1257,21 +1366,14 @@ async function seedFY2026Konsult( customers: Record, suppliers: Record ): Promise { - console.log('[5] FY2026: opening balances + 32 customer invoices + state mix + Stripe + supplier') + console.log('[5] FY2026: close FY2025, derive opening balance, then activity') - // Opening balance 2026 (per prompt: bank IB 142000) - await postEntry( - ctx, - 2026, - dt(2026, 1, 1), - 'Ingående balans 2026', - 'opening_balance', - [ - { account: '1930', debit: 142000, description: 'Bank SEB IB' }, - { account: '2081', credit: 50000, description: 'Aktiekapital' }, - { account: '2091', credit: 92000, description: 'Balanserat resultat' }, - ] - ) + // Close FY2025 P&L → 2099 and derive FY2026 IB from FY2025 class 1-2 balances. + // Without this, FY2025's net profit silently drops out of FY2026's IB + // (compute_prior_opening_balances filters to class 1-2) and balansräkningen + // shows "Balanserar ej". + await closeYearForSeed(ctx, 2025) + await postOpeningBalanceFromPriorYear(ctx, 2025, 2026) const klient = customers['Klient AB'] const berlin = customers['Berlin GmbH'] @@ -1909,7 +2011,7 @@ async function seedInboxAndUncategorized( async function seedHolding(holding: CompanyCtx): Promise { console.log('[H] Holding 2026 IB + dotterbolagsaktier') - await postEntry( + const obEntryId = await postEntry( holding, 2026, dt(2026, 1, 1), @@ -1922,6 +2024,12 @@ async function seedHolding(holding: CompanyCtx): Promise { { account: '2091', credit: 300000, description: 'Balanserat resultat' }, ] ) + const { error } = await sb + .from('fiscal_periods') + .update({ opening_balance_entry_id: obEntryId, opening_balances_set: true }) + .eq('id', holding.fpY[2026]) + .eq('company_id', holding.companyId) + if (error) throw new Error(`Holding set opening_balance_entry_id: ${error.message}`) } // ─── MAIN ──────────────────────────────────────────────────────────────────