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.
-
-
- ) : (
- setShowDeleteDialog(true)}
- disabled={isDeleting}
- >
-
- Ta bort utkast
-
- )}
+ setShowDeleteDialog(true)}
+ disabled={isDeleting}
+ >
+
+ Makulera utkast
+
>
)}
{(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
{isDeleting && }
- Ta bort
+ Makulera
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 && (
+
{
+ const tx = templatePickerTransaction
+ setTemplatePickerOpen(false)
+ setInvoicePickerTransaction(tx)
+ setInvoicePickerOpen(true)
+ }}
+ >
+ Matcha med faktura...
+
+ )}
Ange konton manuellt...
@@ -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 (
+
onSelect(invoice)}
+ disabled={isProcessing}
+ className={cn(
+ 'w-full text-left rounded-lg border px-3 py-2.5 transition-colors',
+ 'hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring',
+ exact && 'border-success/50 bg-success/5',
+ close && 'border-primary/30',
+ isProcessing && 'opacity-50 pointer-events-none'
+ )}
+ >
+
+
+
+
+
+ {invoice.invoice_number ?? '(utan nummer)'}
+
+ {invoice.status === 'overdue' && (
+
+ Förfallen
+
+ )}
+ {invoice.status === 'partially_paid' && (
+
+ Delbetald
+
+ )}
+
+
+ {invoice.customer?.name || 'Okänd kund'} · Förfaller{' '}
+ {formatDate(invoice.due_date)}
+
+
+
+
+ {formatCurrency(remaining, invoice.currency)}
+
+ {exact &&
Exakt match
}
+
+
+
+ )
+ })}
+ {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 ──────────────────────────────────────────────────────────────────