From 97db09a3ff38779954944944a79a023bef5dd17d Mon Sep 17 00:00:00 2001
From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com>
Date: Wed, 6 May 2026 22:49:56 +0200
Subject: [PATCH] feat(invoices): allocate-on-save, makulera flow, manual
invoice picker (#405)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(invoices): allocate-on-save, makulera flow, manual invoice picker
Three coordinated invoice changes:
1. Allocate F-series number when the draft is created (Fortnox-style),
not at send time. Users can download a numbered draft and send it
manually. If number allocation fails, the invoice + items are rolled
back so no orphaned rows remain. Adds INVOICE_CREATE_NUMBER_ASSIGN_FAILED.
2. DELETE /api/invoices/[id] now soft-cancels (status='cancelled') instead
of hard-deleting. The F-series number is retained, keeping the sequence
gap-free per ML 17 kap 24§ and BFNAR 2013:2 — no voucher_gap_explanations
needed. Sent/paid invoices stay immutable (credit note required). Adds
"Makulerade" tab to the invoice list; cancelled invoices are hidden from
"Alla" by default. PDF draft banner stays visible on numbered drafts and
only clears when the invoice is marked sent.
3. New InvoicePicker component lets users manually match an income
transaction to an open invoice from the booking dialog ("Matcha med
faktura..."), complementing the existing auto-match flow.
Also: new-invoice review dialog reads accounting_method from settings and
shows a cash-vs-accrual warning so users know when the verification posts.
seed-demo-account adds year-end closing + opening balance helpers so
multi-year demo data is balanced.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(invoices): address review feedback on PR #405
Greptile P1 + Swedish compliance reviewer findings:
- app/api/invoices/route.ts — replace hard-delete rollback on number-
allocation failure with a soft-cancel (status='cancelled'). 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 invoice_number
first so any partially-written value is logged for operator follow-up.
Log loudly if the cancel itself fails so an orphan row doesn't go
unnoticed.
- app/api/invoices/[id]/route.ts — close TOCTOU race on the cancel
update. The .eq('status','draft') guard prevented data corruption
but Supabase returned error: null with 0 affected rows on a
concurrent flip, and the handler reported success. Add .select('id')
and return new INVOICE_CANCEL_RACE (409) when no row updated.
- components/transactions/InvoicePicker.tsx — memoize createClient()
so the supabase reference is stable across renders. Without this,
including supabase in the useEffect dep array fires the open-invoices
fetch on every render.
- app/(dashboard)/transactions/page.tsx + match-invoice/route.ts —
read category from the match-invoice response instead of hardcoding
'income_services' client-side. Server now echoes the category it
actually booked; client falls back to 'income_services' if absent.
- lib/invoices/pdf-template.tsx — add MAKULERAD banner for cancelled
invoices (red, distinct from the yellow draft banner). A cancelled
invoice PDF previously rendered with no warning if it had a number,
or with the draft banner if it didn't — both could be mistaken for a
valid faktura. Cancelled takes precedence over draft so the legacy
un-numbered-cancelled case is also covered.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(invoices): guard cancelled status on send + rollback symmetry
Two follow-up fixes from the second-round Swedish compliance review on
PR #405:
- app/api/invoices/[id]/send/route.ts — reject sending a cancelled
invoice. The existing flow had no status guard before
.update({ status: 'sent' }), so a cancelled invoice could be silently
re-activated to sent and a "MAKULERAD"-watermarked PDF could be
delivered to the customer as if it were a live faktura. New
INVOICE_SEND_CANCELLED (400) returned at the top of the handler.
- app/api/invoices/route.ts — add .eq('status', 'draft') to the
rollback-cancel update so the rollback is symmetric with the DELETE
handler's only-drafts-may-be-cancelled rule. At the create flow's
current shape the row can't realistically be anything other than
draft, but the symmetry prevents a future caller adding a status flip
between insert and number-allocation from accidentally cancelling a
posted invoice.
mark-sent (rejects non-draft), mark-paid (only sent/overdue), and
convert (explicitly rejects cancelled proformas) already guard
correctly — no changes needed there.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(invoices): InvoicePicker filters settled invoices; drop dead error code
Two cleanups from the third-round Swedish compliance review on PR #405:
- components/transactions/InvoicePicker.tsx — add .gt('remaining_amount', 0)
defensively. The picker filtered by status IN (sent, overdue,
partially_paid), but a stale 'sent' or 'overdue' row with
remaining_amount=0 (data inconsistency) would otherwise be selectable
here and could be matched a second time, double-booking the income —
a direct BFL 5 kap accuracy violation.
- lib/errors/structured-errors.ts — remove INVOICE_DELETE_NUMBERED.
The numbered-draft refusal was replaced by the soft-cancel path
earlier in this PR; the entry has no remaining callers.
Verified-safe and not changed:
- Cancel-without-storno concern: createInvoiceJournalEntry only fires
inside mark-sent (after the draft→sent guard) or send (after the
cancelled-status reject). Drafts never have posted verifications, so
cancelling a draft cannot leave an orphaned bokföringspost.
- Hardcoded category: 'income_services' in match-invoice is a
pre-existing classification concern that warrants a larger refactor
(derive from invoice's revenue accounts) rather than a one-line patch.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(invoices): InvoicePicker excludes proforma invoices
Add .eq('document_type', 'invoice') to the open-invoice query. A
proforma is not a faktura per ML 17 kap 24§ — no VAT obligation, no
binding commercial document — and must never be matched against a
bank receipt. Without this guard a sent proforma could be selected
in the picker, triggering a payment booking and VAT-rate journal
entry that violates BFL 5 kap accuracy rules.
Other findings from the third-round Swedish compliance review were
verified-safe and not changed:
- Cancelled-invoice PDF download path: the MAKULERAD watermark added
earlier in this PR is the safeguard. Blocking the download endpoint
outright would prevent legitimate audit access; the visible banner
prevents the doc being mistaken for a valid faktura.
- Cancel-without-storno: createInvoiceJournalEntry only fires inside
mark-sent / send / pending-operations, all behind status guards.
Drafts never carry a posted verifikation, so cancel can't orphan one.
- Allocate-on-save for proforma uses F-series: not true. The
generate_invoice_number RPC (migration 20260427150100) routes
document_type='proforma' to a separate 'PF-' prefix sequence; the
F-series is untouched.
- closeYearForSeed 2099 → 2091 transfer: real demo-data correctness
issue but a seed-script polish item — separate PR.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(match-invoice): server-side document_type='invoice' guard
The InvoicePicker filter excluding proformas (added in the previous
commit) is client-only. A direct API call to /api/transactions/[id]/
match-invoice with a proforma id would otherwise still book a payment
journal entry against a document that has no VAT obligation per
ML 17 kap 24§. Add a defense-in-depth check after the invoice fetch.
New error code MATCH_INVOICE_NOT_INVOICE_TYPE (400). Test added.
Other findings from the latest compliance review were verified-safe and
not changed:
- Cancelled-invoice PDF download path: /api/invoices/[id]/pdf always
re-renders through InvoicePDF, so the MAKULERAD banner is always
present. The bot's "cached pre-cancellation PDF" scenario does not
apply to this codebase.
- Proforma F-series allocation: the generate_invoice_number RPC routes
document_type='proforma' to a separate 'PF-' prefix; the F-series is
not polluted.
- Soft-cancel rollback gap when number not written: the RPC is a
single-transaction PL/pgSQL function — sequence bump (UPDATE
company_settings) and row write (UPDATE invoices) commit or roll
back together. The "sequence advanced but row null" scenario the
bot describes is impossible by construction; a thrown exception in
the row-write step rolls back the bump.
- closeYearForSeed obeskattade reserver: seed-script demo accuracy,
separate PR.
Co-Authored-By: Claude Opus 4.7 (1M context)
---------
Co-authored-by: Claude Opus 4.7 (1M context)
---
app/(dashboard)/invoices/[id]/page.tsx | 61 +++---
app/(dashboard)/invoices/new/page.tsx | 10 +-
app/(dashboard)/invoices/page.tsx | 14 +-
app/(dashboard)/transactions/page.tsx | 116 ++++++++++-
app/api/invoices/[id]/__tests__/route.test.ts | 50 +++--
app/api/invoices/[id]/route.ts | 55 +++---
.../[id]/send/__tests__/route.test.ts | 17 ++
app/api/invoices/[id]/send/route.ts | 8 +
app/api/invoices/__tests__/route.test.ts | 53 ++++-
app/api/invoices/route.ts | 51 +++++
.../match-invoice/__tests__/route.test.ts | 21 ++
.../transactions/[id]/match-invoice/route.ts | 14 ++
components/transactions/InvoicePicker.tsx | 182 ++++++++++++++++++
lib/errors/structured-errors.ts | 29 ++-
lib/invoices/pdf-template.tsx | 44 ++++-
scripts/seed-demo-account.ts | 138 +++++++++++--
16 files changed, 747 insertions(+), 116 deletions(-)
create mode 100644 components/transactions/InvoicePicker.tsx
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 ──────────────────────────────────────────────────────────────────