feat(invoices): allocate-on-save, makulera flow, manual invoice picker (#405)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-06 22:49:56 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent b89abf64b1
commit 97db09a3ff
16 changed files with 747 additions and 116 deletions
+30 -31
View File
@@ -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
</p>
</>
)}
{invoice.invoice_number ? (
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border rounded-lg mt-2">
<AlertTriangle className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
) : (
<Button
variant="outline"
className="w-full text-destructive hover:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
Ta bort utkast
</Button>
)}
<Button
variant="outline"
className="w-full text-destructive hover:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
Makulera utkast
</Button>
</>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
@@ -956,17 +947,25 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
</div>
{/* 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. */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ta bort fakturautkast</DialogTitle>
<DialogTitle>Makulera fakturautkast</DialogTitle>
<DialogDescription>
Är du säker på att du vill ta bort utkastet? Detta kan inte ångras.
<span className="mt-2 block text-muted-foreground">
Inget löpnummer har tilldelats — fakturaserien påverkas inte.
</span>
{invoice.invoice_number ? (
<>
Fakturan markeras som makulerad och sparas i fakturalistan med status <strong>Makulerad</strong>.
<span className="mt-2 block text-muted-foreground">
Fakturanumret {invoice.invoice_number} behålls för att hålla nummerserien obruten enligt ML 17 kap 24§.
</span>
</>
) : (
<>
Utkastet markeras som makulerat. Detta kan inte ångras.
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -975,7 +974,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
<Button variant="destructive" onClick={deleteInvoice} disabled={isDeleting}>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Ta bort
Makulera
</Button>
</DialogFooter>
</DialogContent>
+8 -2
View File
@@ -78,6 +78,7 @@ export default function NewInvoicePage() {
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
const [hasBankDetails, setHasBankDetails] = useState<boolean | null>(null)
const [showBankSetup, setShowBankSetup] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const pendingCustomerRef = useRef<Customer | null>(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.'}
+10 -4
View File
@@ -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() {
<SelectItem value="proforma">Proforma</SelectItem>
<SelectItem value="delivery_note">Följesedel</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
<SelectItem value="cancelled">Makulerade</SelectItem>
</SelectContent>
</Select>
{/* Desktop: tab bar */}
@@ -221,6 +226,7 @@ export default function InvoicesPage() {
<TabsTrigger value="proforma">Proforma</TabsTrigger>
<TabsTrigger value="delivery_note">Följesedel</TabsTrigger>
<TabsTrigger value="credit">Kredit</TabsTrigger>
<TabsTrigger value="cancelled">Makulerade</TabsTrigger>
</TabsList>
</Tabs>
</div>
+115 -1
View File
@@ -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<TransactionWithInvoice | null>(null)
// Invoice picker dialog (manual match)
const [invoicePickerOpen, setInvoicePickerOpen] = useState(false)
const [invoicePickerTransaction, setInvoicePickerTransaction] = useState<TransactionWithInvoice | null>(null)
const [isMatchingFromPicker, setIsMatchingFromPicker] = useState(false)
// Quick review dialog (suggestion review before booking)
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
const [quickReview, setQuickReview] = useState<QuickReviewState | null>(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)
}}
/>
<div className="pt-2 border-t">
<div className="pt-2 border-t space-y-1">
{templatePickerTransaction && templatePickerTransaction.amount > 0 && (
<Button
variant="ghost"
size="sm"
className="w-full text-muted-foreground"
onClick={() => {
const tx = templatePickerTransaction
setTemplatePickerOpen(false)
setInvoicePickerTransaction(tx)
setInvoicePickerOpen(true)
}}
>
Matcha med faktura...
</Button>
)}
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={handleManualBooking}>
Ange konton manuellt...
</Button>
@@ -903,6 +987,36 @@ export default function TransactionsPage() {
</DialogContent>
</Dialog>
<Dialog
open={invoicePickerOpen}
onOpenChange={(open) => {
if (isMatchingFromPicker) return
setInvoicePickerOpen(open)
if (!open) setInvoicePickerTransaction(null)
}}
>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Matcha med faktura</DialogTitle>
</DialogHeader>
{invoicePickerTransaction && (
<>
<div className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm">
<span className="truncate text-muted-foreground">{invoicePickerTransaction.description}</span>
<span className="font-medium tabular-nums flex-shrink-0 ml-3 text-success">
+{formatCurrency(invoicePickerTransaction.amount, invoicePickerTransaction.currency)}
</span>
</div>
<InvoicePicker
transaction={invoicePickerTransaction}
onSelect={handleSelectInvoiceFromPicker}
isProcessing={isMatchingFromPicker}
/>
</>
)}
</DialogContent>
</Dialog>
<QuickReviewDialog
key={quickReview?.transaction.id ?? '' + String(quickReview?.category) + String(quickReview?.templateId) + String(quickReview?.template?.id)}
open={quickReviewOpen}
+36 -14
View File
@@ -55,7 +55,7 @@ describe('DELETE /api/invoices/[id]', () => {
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' }),
+25 -30
View File
@@ -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 } })
}
@@ -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',
+8
View File
@@ -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, {
+50 -3
View File
@@ -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)', () => {
+51
View File
@@ -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(*)')
@@ -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<typeof makeInvoice>[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' })
@@ -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 },
+182
View File
@@ -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<OpenInvoice[]>([])
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 (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Laddar fakturor...
</div>
)
}
if (invoices.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">Inga öppna fakturor att matcha mot.</p>
</div>
)
}
return (
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök fakturanummer eller kund..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
autoFocus
/>
</div>
<div className="space-y-1.5 max-h-[55vh] overflow-y-auto pr-1">
{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 (
<button
key={invoice.id}
type="button"
onClick={() => 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'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-medium text-sm">
{invoice.invoice_number ?? '(utan nummer)'}
</span>
{invoice.status === 'overdue' && (
<span className="text-[10px] uppercase tracking-wide text-destructive">
Förfallen
</span>
)}
{invoice.status === 'partially_paid' && (
<span className="text-[10px] uppercase tracking-wide text-warning-foreground">
Delbetald
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{invoice.customer?.name || 'Okänd kund'} · Förfaller{' '}
{formatDate(invoice.due_date)}
</p>
</div>
<div className="text-right flex-shrink-0">
<p
className={cn(
'text-sm font-medium tabular-nums',
exact && 'text-success'
)}
>
{formatCurrency(remaining, invoice.currency)}
</p>
{exact && <p className="text-[10px] text-success">Exakt match</p>}
</div>
</div>
</button>
)
})}
{sorted.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-4">
Ingen faktura matchar &quot;{search}&quot;
</p>
)}
</div>
</div>
)
}
+19 -10
View File
@@ -292,6 +292,11 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
'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<string, StructuredErrorEntry> = {
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.',
},
}
+38 -6
View File
@@ -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 (
<Document>
<Page size="A4" style={styles.page}>
{/* 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' ? (
<View style={styles.cancelledBanner}>
<Text style={styles.cancelledBannerTitle}>MAKULERAD – inte en giltig faktura</Text>
<Text style={styles.cancelledBannerText}>
{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.'}
</Text>
</View>
) : (invoice.status === 'draft' || !invoice.invoice_number) && (
<View style={styles.draftBanner}>
<Text style={styles.draftBannerTitle}>UTKAST – inte en giltig faktura</Text>
<Text style={styles.draftBannerText}>
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.'}
</Text>
</View>
)}
+123 -15
View File
@@ -334,6 +334,115 @@ function skipVoucher(ctx: CompanyCtx, fy: number, n: number): void {
}
}
async function closeYearForSeed(ctx: CompanyCtx, fy: number): Promise<void> {
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<string, number>()
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<void> {
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<string, number>()
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<CompanyCtx> {
console.log('[2] Creating Konsult AB')
const companyId = await createCompany(userId, 'Konsult AB', '5591234567', 'aktiebolag')
@@ -1257,21 +1366,14 @@ async function seedFY2026Konsult(
customers: Record<string, string>,
suppliers: Record<string, string>
): Promise<void> {
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<void> {
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<void> {
{ 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 ──────────────────────────────────────────────────────────────────