feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA (#1533)

* feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA

Customer feedback: MCP-created invoices land in Granskning and then sit as
unnumbered drafts that each need individual issuance, and the list filter
gives no signal about where the work is.

- New POST /api/invoices/bulk-book: drafts get an F-number + mark-sent
  semantics (no email) and book inline when the company books at issue;
  sent/overdue unbooked invoices get the deferred /book semantics.
  Sequential loop keeps voucher numbers ordered; per-item Swedish errors.
- Extracted the shared cores into lib/invoices/issue-and-book-invoice.ts
  and lib/invoices/book-invoice-deferred.ts, now used by the per-id
  mark-sent and book routes AND the bulk loop, so they cannot drift.
  Per-id route behavior unchanged (existing route tests untouched, green).
- Invoice list: multi-select with hover-reveal checkboxes (supplier-invoices
  shape), bulkbar with mode-aware action label, ConfirmationDialog with a
  draft/sent breakdown, one aggregate toast. Kontantmetoden hides selection
  entirely.
- ContextPicker: count annotations on every status view via the one shared
  predicate (counts always match rows), active view written back to the URL
  (?status=) for shareable views. No seg/chip row: founder-locked pattern.
- Granskning: after a bulk approve that committed create_invoice ops, the
  summary toast links to /invoices?status=draft to finish with bulk Bokfor.

Verified: npm run lint clean, npm test 13845 passed, npm run check:guards
passed. New tests: bulk-book route (11), issueAndBookInvoice (7),
bookInvoiceDeferred (7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): bulk-book review findings, deferred drafts, dupes, URL params

- Deferred-booking companies (accrual + defer_invoice_booking): a draft in
  bulk-book no longer gets silently ISSUED (F-number consumed, marked sent,
  invoice.sent emitted) while reporting status 'booked' with a null
  journal_entry_id. The draft branch now requires booksInvoicesOnIssue();
  otherwise the item fails per-row with the new INVOICE_BOOK_DEFERRED_DRAFT
  code (Swedish + English) before the invoice is touched.
- Duplicate ids in one request no longer double-book: the second iteration
  read the stale pre-loop snapshot, passed the already-booked check, and
  minted a voucher the CAS claim then cancelled (cancelled verifikat + gap
  explanation per duplicate). Ids are deduped before the loop.
- Bulkbar: the select-all link is hidden when the current view has no
  selectable rows; "Markera alla (0)" only wiped the existing selection.
- Invoice dialog open/close handlers (new invoice, self-billed, ROT/RUT
  payout) rewrite only their own query keys instead of hardcoding
  '/invoices', so the ?status= view write-back survives them.
- /pending: the "Bokfor utkasten" toast CTA is suppressed for kontantmetod
  and deferred-booking companies where the invoice list offers no draft
  bulk Bokfor (dead end); the neutral hint sentence stays.

Tests: deferred-draft rejection (asserts issueAndBookInvoice never called,
sent invoice in the same batch still books) and duplicate-id dedupe (exactly
one booking call); both fail without the route fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-12 21:02:27 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent dea5e31756
commit bffa57a565
14 changed files with 1998 additions and 440 deletions
+7
View File
@@ -802,6 +802,13 @@ export const MarkInvoiceSentSchema = z.object({
})).min(2).optional(),
})
// Bulk Bokför: drafts are issued (F-number + mark-sent semantics, no email)
// and booked when the company books at issue; sent/overdue unbooked invoices
// get the deferred /book semantics. 200 caps one request at two list pages.
export const InvoicesBulkBookSchema = z.object({
ids: z.array(z.string().uuid()).min(1).max(200),
})
export const SendInvoiceSchema = MarkInvoiceSentSchema.extend({
additional_cc: invoiceEmailAddressList.optional(),
additional_bcc: invoiceEmailAddressList.optional(),
+10
View File
@@ -919,6 +919,16 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Vid kontantmetoden bokförs fakturan när den betalas.',
message_en: 'Under the cash method the invoice is booked when it is paid.',
},
// Bulk Bokför on a DRAFT when the company defers invoice booking (#967):
// issuing the draft would consume an F-number and mark it sent without
// booking anything, so the item is rejected before any side effect.
INVOICE_BOOK_DEFERRED_DRAFT: {
httpStatus: 400,
message_sv:
'Företaget bokför fakturor i ett separat steg. Skicka eller markera utkastet som skickat först, bokför sedan.',
message_en:
'This company books invoices in a separate step. Send or mark the draft as sent first, then book it.',
},
INVOICE_BOOK_NO_FISCAL_PERIOD: {
httpStatus: 400,
message_sv: 'Inget öppet räkenskapsår täcker fakturadatumet. Skapa räkenskapsåret först.',
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
import { FiscalPeriodNotFoundError } from '@/lib/bookkeeping/errors'
import type { Logger } from '@/lib/logger'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const mockCreateInvoiceJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoiceJournalEntry: (...args: unknown[]) =>
mockCreateInvoiceJournalEntry(...args),
}))
const mockCreateSchedules = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
}))
const mockCancelOrphan = vi.fn()
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphan(...args),
}))
const mockLinkToJournalEntry = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
}))
import { bookInvoiceDeferred, INVOICE_BOOKABLE_STATUSES } from '../book-invoice-deferred'
const log: Logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: () => log,
}
function makeSentInvoice(overrides: Record<string, unknown> = {}) {
return {
...makeInvoice({ id: 'inv-1', status: 'sent' }),
journal_entry_id: null,
credited_invoice_id: null,
document_type: 'invoice' as const,
customer: { name: 'Kunden AB' },
items: [],
...overrides,
}
}
function book(invoice = makeSentInvoice()) {
return bookInvoiceDeferred({
supabase: mockSupabase as never,
companyId: 'company-1',
userId: 'user-1',
invoice: invoice as never,
entityType: 'aktiebolag',
log,
})
}
describe('bookInvoiceDeferred', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockLinkToJournalEntry.mockResolvedValue({ id: 'document-1' })
})
it('exports the bookable statuses the routes guard on', () => {
expect(INVOICE_BOOKABLE_STATUSES).toEqual(['sent', 'overdue'])
})
it('books the revenue entry, claims the invoice and links the delivered PDF', async () => {
const invoice = makeSentInvoice()
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null }) // CAS link
enqueue({ data: 'document-1', error: null }) // delivery-document rpc
const result = await book(invoice)
expect(result).toEqual({
ok: true,
invoice: expect.objectContaining({ journal_entry_id: 'je-1' }),
journalEntryId: 'je-1',
warnings: [],
})
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'aktiebolag',
'Kunden AB',
)
expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'document-1',
'je-1',
)
expect(mockCreateSchedules).toHaveBeenCalled()
})
it('returns INVOICE_BOOK_NO_FISCAL_PERIOD when no period covers the date', async () => {
mockCreateInvoiceJournalEntry.mockResolvedValue(null)
const result = await book()
expect(result).toEqual({
ok: false,
kind: 'code',
errorCode: 'INVOICE_BOOK_NO_FISCAL_PERIOD',
details: { invoiceDate: '2024-06-15' },
})
})
it('cancels the orphaned entry and reports a conflict when the CAS claim loses', async () => {
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: { message: 'no rows' } }) // CAS link fails
const result = await book()
expect(result).toEqual({ ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_CONFLICT' })
expect(mockCancelOrphan).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'user-1',
'je-1',
expect.any(String),
)
})
it('passes typed bookkeeping errors through as domain failures', async () => {
const domainError = new FiscalPeriodNotFoundError()
mockCreateInvoiceJournalEntry.mockRejectedValue(domainError)
const result = await book()
expect(result).toEqual({ ok: false, kind: 'domain', error: domainError })
expect(mockCancelOrphan).not.toHaveBeenCalled()
})
it('maps unknown engine failures to INVOICE_BOOK_FAILED', async () => {
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('network down'))
const result = await book()
expect(result).toEqual({ ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_FAILED' })
})
it('reports accrual-schedule failures as warnings, not errors', async () => {
const invoice = makeSentInvoice()
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null })
enqueue({ data: null, error: null }) // no delivered PDF found
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 1 })
const result = await book(invoice)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.warnings).toEqual([
expect.objectContaining({ code: 'ACCRUAL_SCHEDULE_FAILED' }),
])
}
})
})
@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
makeCompanySettings,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const mockEnsureInvoiceNumber = vi.fn()
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
ensureInvoiceNumber: (...args: unknown[]) => mockEnsureInvoiceNumber(...args),
}))
const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
Document: vi.fn(),
Page: vi.fn(),
Text: vi.fn(),
View: vi.fn(),
StyleSheet: { create: (s: unknown) => s },
}))
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
SHOW_SWISH_ON_INVOICE: false,
}))
const mockCreateInvoiceJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoiceJournalEntry: (...args: unknown[]) =>
mockCreateInvoiceJournalEntry(...args),
}))
const mockCreateSchedules = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
}))
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
const mockRecordManualInvoiceDelivery = vi.fn()
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
}))
import { issueAndBookInvoice } from '../issue-and-book-invoice'
import type { CompanySettings } from '@/types'
const log: Logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: () => log,
}
const customer = makeCustomer({ id: 'cust-1' })
const settings = makeCompanySettings({
accounting_method: 'accrual',
entity_type: 'enskild_firma',
bankgiro: '123-4567',
}) as CompanySettings
function makeDraft(overrides: Record<string, unknown> = {}) {
return {
...makeInvoice({ id: 'inv-1', invoice_number: 'F-2026010', status: 'draft' }),
document_type: 'invoice' as const,
credited_invoice_id: null,
customer,
items: [],
...overrides,
}
}
function issue(invoice = makeDraft(), theSettings = settings) {
return issueAndBookInvoice({
supabase: mockSupabase as never,
companyId: 'company-1',
userId: 'user-1',
invoice: invoice as never,
settings: theSettings,
log,
})
}
describe('issueAndBookInvoice', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockRecordManualInvoiceDelivery.mockResolvedValue({ id: 'delivery-1' })
mockEnsureInvoiceNumber.mockResolvedValue('F-2026010')
})
it('rejects when the payment account is missing, before number allocation', async () => {
const bare = {
...settings,
invoice_payment_accounts: {},
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
} as CompanySettings
const result = await issue(makeDraft({ invoice_number: null }), bare)
expect(result).toEqual({
ok: false,
errorCode: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
details: { currency: 'SEK' },
})
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('fails with INVOICE_CREATE_NUMBER_ASSIGN_FAILED when numbering fails', async () => {
mockEnsureInvoiceNumber.mockRejectedValue(new Error('sequence exhausted'))
const result = await issue()
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_CREATE_NUMBER_ASSIGN_FAILED' })
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns INVOICE_MARK_SENT_RACE when another request flipped the draft first', async () => {
enqueue({ data: [], error: null }) // CAS update matches no row
const result = await issue()
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' })
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('restores the draft and fails closed when the journal entry cannot be created', async () => {
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
enqueue({ data: null, error: null }) // rollback update
const result = await issue()
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_MARK_SENT_BOOK_FAILED' })
// Two invoice updates: the CAS flip and the rollback to draft.
expect(findCalls('invoices', 'update')).toEqual([
[{ status: 'sent' }],
[{ status: 'draft' }],
])
expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('books, links, archives the PDF, records delivery and emits invoice.sent', async () => {
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-7' })
enqueue({ data: null, error: null }) // journal_entry_id link
const emitted: string[] = []
eventBus.on('invoice.sent', async () => {
emitted.push('invoice.sent')
})
const result = await issue()
expect(result).toEqual({ ok: true, journalEntryId: 'je-7', partialFailures: [] })
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma',
customer.name,
)
expect(mockCreateSchedules).toHaveBeenCalled()
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
supabase: mockSupabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'inv-1',
})
expect(emitted).toEqual(['invoice.sent'])
})
it('marks sent WITHOUT booking under deferred booking (#967)', async () => {
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
const result = await issue(makeDraft(), {
...settings,
defer_invoice_booking: true,
} as CompanySettings)
expect(result).toEqual({ ok: true, journalEntryId: null, partialFailures: [] })
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
// Still a real issuance: underlag archived and delivery recorded.
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledTimes(1)
})
it('surfaces PDF-archive failures as partial failures, not errors', async () => {
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-8' })
enqueue({ data: null, error: null }) // journal_entry_id link
mockUploadDocument.mockRejectedValue(new Error('Storage offline'))
const result = await issue()
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.journalEntryId).toBe('je-8')
expect(result.partialFailures).toEqual([
expect.objectContaining({ step: 'pdf_archive' }),
])
}
})
})
+188
View File
@@ -0,0 +1,188 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import type { Logger } from '@/lib/logger'
import type { Customer, EntityType, Invoice, InvoiceItem } from '@/types'
// Statuses where the revenue entry can still be created afterwards. Paid
// invoices are excluded: their payment flow has already booked the full
// cash-style entry (mark-paid routes on the missing journal-entry link), so
// booking the sale now would double-post revenue.
export const INVOICE_BOOKABLE_STATUSES = ['sent', 'overdue']
export interface BookDeferredWarning {
code: string
message: string
}
export type BookInvoiceDeferredResult =
| {
ok: true
/** The invoice row as returned by the CAS-guarded link update. */
invoice: Invoice
journalEntryId: string
warnings: BookDeferredWarning[]
}
| {
ok: false
/** Typed bookkeeping error: map with errorResponse()/getErrorMessage(). */
kind: 'domain'
error: unknown
}
| {
ok: false
kind: 'code'
errorCode: string
details?: Record<string, unknown>
}
/**
* The deferred "Bokför" core (#967): create the revenue verifikat for an
* already-sent invoice and claim it with a CAS-guarded link. Shared by
* POST /api/invoices/[id]/book and POST /api/invoices/bulk-book so the two
* can never drift apart. The caller is responsible for the eligibility
* guards (real invoice, bookable status, unbooked, accrual method).
*/
export async function bookInvoiceDeferred(opts: {
supabase: SupabaseClient
companyId: string
userId: string
invoice: Invoice & {
customer?: (Customer & { name?: string }) | { name?: string } | null
items?: InvoiceItem[] | null
}
entityType: EntityType
log: Logger
}): Promise<BookInvoiceDeferredResult> {
const { supabase, companyId, userId, invoice, entityType, log } = opts
const id = invoice.id
let journalEntry
try {
journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
userId,
invoice as Invoice,
entityType,
invoice.customer?.name,
)
} catch (err) {
if (isBookkeepingError(err)) {
return { ok: false, kind: 'domain', error: err }
}
log.error('deferred invoice booking failed', err as Error, { invoiceId: id })
return { ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_FAILED' }
}
// Returns null ONLY when no fiscal period covers invoice_date (other
// failures throw). Nothing was posted, so a plain error is safe.
if (!journalEntry) {
return {
ok: false,
kind: 'code',
errorCode: 'INVOICE_BOOK_NO_FISCAL_PERIOD',
details: { invoiceDate: invoice.invoice_date },
}
}
// CAS-guarded link: only claim the invoice if it is still unbooked, still
// in a bookable status, and still uncredited. A concurrent
// book/mark-paid/credit that got there first would otherwise leave this
// entry double-posting revenue, so cancel it.
const { data: linked, error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.in('status', INVOICE_BOOKABLE_STATUSES)
.is('credited_invoice_id', null)
.select()
.single()
if (linkError || !linked) {
await cancelOrphanedPaymentEntry(
supabase,
companyId,
userId,
journalEntry.id,
'Bokföring av kundfaktura avbröts: fakturan bokfördes samtidigt av en annan begäran.',
)
return { ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_CONFLICT' }
}
const warnings: BookDeferredWarning[] = []
// The send flow archived the exact delivered PDF before this deferred
// journal entry existed. Attach the newest successful delivery snapshot now.
const { data: deliveryDocumentId, error: deliveryDocumentError } = await supabase.rpc(
'latest_sent_invoice_delivery_document',
{ p_company_id: companyId, p_invoice_id: id },
)
if (deliveryDocumentError) {
log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, {
invoiceId: id,
})
warnings.push({
code: 'PDF_LINK_FAILED',
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
})
} else if (typeof deliveryDocumentId === 'string') {
try {
await linkToJournalEntry(
supabase,
companyId,
deliveryDocumentId,
journalEntry.id,
)
} catch (err) {
log.error('failed to link delivered invoice PDF on deferred booking', err as Error, {
invoiceId: id,
documentId: deliveryDocumentId,
})
warnings.push({
code: 'PDF_LINK_FAILED',
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
})
}
}
// Periodiseringar ride on the revenue entry, so they can only be created
// now. Non-blocking: the entry is committed (immutable); a schedule
// failure is surfaced as a warning and retried from the periodiseringar
// page.
try {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId,
userId,
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
entityType,
)
if (accrual.failed > 0) {
warnings.push({
code: 'ACCRUAL_SCHEDULE_FAILED',
message:
'Fakturan bokfördes, men en eller flera periodiseringar kunde inte ' +
'skapas. Kontrollera under Bokföring → Periodiseringar.',
})
}
} catch (err) {
log.error('accrual schedule creation failed on deferred booking', err as Error, { invoiceId: id })
warnings.push({
code: 'ACCRUAL_SCHEDULE_FAILED',
message:
'Fakturan bokfördes, men periodiseringarna kunde inte skapas. ' +
'Kontrollera under Bokföring → Periodiseringar.',
})
}
return { ok: true, invoice: linked as Invoice, journalEntryId: journalEntry.id, warnings }
}
+368
View File
@@ -0,0 +1,368 @@
import { renderToBuffer } from '@react-pdf/renderer'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { eventBus } from '@/lib/events'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import type { CustomIssuanceLine } from '@/lib/invoices/issuance-custom-lines'
import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { uploadDocument } from '@/lib/core/documents/document-service'
import type { Logger } from '@/lib/logger'
import type {
CompanySettings,
Customer,
EntityType,
Invoice,
InvoiceItem,
} from '@/types'
export interface IssuePartialFailure {
step: string
reason: string
}
/** Joined invoice row as the issuance flows fetch it. */
export type IssuableInvoice = Invoice & {
customer?: (Customer & { name?: string }) | null
items?: InvoiceItem[] | null
}
export type IssueAndBookResult =
| {
ok: true
journalEntryId: string | null
partialFailures: IssuePartialFailure[]
}
| { ok: false; errorCode: string; details?: Record<string, unknown> }
export interface IssueAndBookOptions {
supabase: SupabaseClient
companyId: string
userId: string
/**
* The draft invoice (with customer + items joined). Never a credit note:
* credit-note issuance lives in issue-credit-note.ts and stays on the
* mark-sent route.
*/
invoice: IssuableInvoice
settings: CompanySettings
log: Logger
/**
* User-edited journal lines from the mark-sent body. Bulk paths pass none:
* generated lines book as-is.
*/
customLines?: CustomIssuanceLine[] | null
}
/**
* Archive the issued invoice's PDF as underlag so it remains retrievable even
* if the invoice row is later cancelled. Shared between the mark-sent route
* (real invoices and credit notes) and the bulk Bokför flow. Returns a partial
* failure instead of throwing: the issuance itself already committed.
*/
export async function archiveIssuedInvoicePdf(args: {
supabase: SupabaseClient
companyId: string
userId: string
invoice: IssuableInvoice
settings: CompanySettings
journalEntryId: string | null
originalInvoiceNumber?: string
log: Logger
}): Promise<IssuePartialFailure | null> {
const { supabase, companyId, userId, invoice, settings, journalEntryId, log } = args
try {
const items = ((invoice.items as InvoiceItem[] | null) ?? [])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
// The DB status flip already happened, but the in-memory `invoice` is
// stale and still reads 'draft': override here so the archived underlag
// isn't stamped "UTKAST: inte en giltig faktura".
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
settings,
renderableInvoice.currency,
{ paymentAccountRequired },
)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
customer: invoice.customer as Customer,
items,
company: renderCompany,
originalInvoiceNumber: args.originalInvoiceNumber,
branding,
swishQrDataUrl,
}),
)
const filename = invoicePdfFilename({
companyName: settings.company_name,
customerName: (invoice.customer as Customer).name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote: !!invoice.credited_invoice_id,
})
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(
supabase,
userId,
companyId,
{
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
},
{
upload_source: 'system',
journal_entry_id: journalEntryId ?? undefined,
},
)
return null
} catch (err) {
log.error('failed to archive invoice PDF on mark-sent', err as Error)
return {
step: 'pdf_archive',
reason: 'Fakturans PDF kunde inte arkiveras.',
}
}
}
/**
* Issue a draft invoice without sending an email: assign the F-number, flip
* the status to 'sent', and (under faktureringsmetoden with inline booking)
* create and link the revenue verifikat. Exactly the mark-sent semantics for
* a non-credit-note invoice; used by both POST /api/invoices/[id]/mark-sent
* and POST /api/invoices/bulk-book so the two can never drift apart.
*
* Under kontantmetoden or deferred booking (#967) the invoice is marked sent
* without a journal entry, matching mark-sent.
*/
export async function issueAndBookInvoice(
opts: IssueAndBookOptions,
): Promise<IssueAndBookResult> {
const { supabase, companyId, userId, invoice, settings, log } = opts
const customLines = opts.customLines ?? null
const id = invoice.id
if (!hasRequiredInvoicePaymentAccount(settings, invoice as Invoice)) {
return {
ok: false,
errorCode: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
details: { currency: (invoice as Invoice).currency },
}
}
// Assign the number only after all payment-instruction guards pass.
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
log.error('failed to assign invoice number on mark-sent', err as Error)
return { ok: false, errorCode: 'INVOICE_CREATE_NUMBER_ASSIGN_FAILED' }
}
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
// Compare-and-set prevents two concurrent requests from posting two journal
// entries for the same draft.
const { data: updatedRows, error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (updateError) {
log.error('invoice mark-sent status update failed', updateError)
return { ok: false, errorCode: 'INVOICE_MARK_SENT_STATUS_FAILED' }
}
if (!updatedRows || updatedRows.length === 0) {
return { ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' }
}
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
const partialFailures: IssuePartialFailure[] = []
// Custom lines only apply where issuance books inline; elsewhere they are
// deliberately ignored (documented in MarkInvoiceSentSchema). Log it so the
// mismatch is visible in audit review instead of vanishing silently.
if (customLines && (!isRealInvoice || !booksInvoicesOnIssue(settings))) {
log.warn('mark-sent: custom lines ignored (not on accrual book-at-issue path)', {
invoiceId: id,
lineCount: customLines.length,
})
}
if (isRealInvoice && booksInvoicesOnIssue(settings)) {
// #967: deferred companies fall past this branch (mark sent WITHOUT
// booking); ekonomi books later via POST /api/invoices/[id]/book, like
// under kontantmetoden.
try {
if (customLines) {
// Audit trail: distinguish user-edited bookings from generated ones.
log.info('mark-sent: booking user-edited custom lines', {
invoiceId: id,
userId,
lineCount: customLines.length,
})
}
const journalEntry = customLines
? await createInvoiceJournalEntry(
supabase,
companyId,
userId,
invoice as Invoice,
entityType,
invoice.customer?.name,
{ customLines },
)
: await createInvoiceJournalEntry(
supabase,
companyId,
userId,
invoice as Invoice,
entityType,
invoice.customer?.name,
)
if (journalEntry) {
journalEntryId = journalEntry.id
// Periodiserade lines: create schedules + catch-up dissolutions now
// that the revenue entry exists. Failures are logged, never fatal:
// the verifikat is committed. Skipped when the user edited the lines:
// the generated 29xx deferral may no longer exist in what was booked,
// and a schedule would then dissolve an interim balance that was
// never credited. User-edited lines book exactly as reviewed.
if (!customLines) {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId,
userId,
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
entityType,
)
if (accrual.failed > 0) {
log.error('accrual schedule creation failed on mark-sent', {
failed: accrual.failed,
})
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
}
}
const { error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
if (linkError) {
// Don't fail the issuance: the verifikat committed; only the link
// failed. But log it through the structured logger so it reaches log
// aggregation/alerting: this write silently no-ops when the
// journal_entry_id column is missing (it was absent in prod until the
// 20260613100000 migration), which leaves mark-paid unable to detect
// an already-booked sale.
log.error('mark-sent: journal_entry_id link to invoice failed', linkError, {
journalEntryId: journalEntry.id,
})
partialFailures.push({
step: 'journal_link',
reason: 'Verifikatet skapades men kunde inte kopplas till fakturan.',
})
}
} else {
partialFailures.push({
step: 'journal_entry',
reason: 'Ingen öppen bokföringsperiod hittades för fakturans datum.',
})
}
} catch (err) {
log.error('failed to create invoice journal entry on mark-sent', err as Error)
partialFailures.push({
step: 'journal_entry',
reason: 'Fakturans verifikat kunde inte skapas.',
})
}
}
// Fail-closed only when inline booking was supposed to happen: deferred
// (#967) and cash-method invoices are legitimately unbooked at this point.
if (isRealInvoice && booksInvoicesOnIssue(settings) && !journalEntryId) {
const { error: rollbackError } = await supabase
.from('invoices')
.update({ status: 'draft' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'sent')
.is('journal_entry_id', null)
if (rollbackError) {
log.error('failed to restore draft after mark-sent booking failure', rollbackError)
}
return { ok: false, errorCode: 'INVOICE_MARK_SENT_BOOK_FAILED' }
}
if (partialFailures.some((failure) => failure.step === 'journal_link')) {
return {
ok: false,
errorCode: 'INVOICE_MARK_SENT_REPAIR_REQUIRED',
details: { failure_steps: ['journal_link'] },
}
}
// Render and archive the PDF as underlag so it remains retrievable even if
// the invoice row is later cancelled. Mirrors the send route.
if (isRealInvoice) {
const pdfFailure = await archiveIssuedInvoicePdf({
supabase,
companyId,
userId,
invoice,
settings,
journalEntryId,
log,
})
if (pdfFailure) partialFailures.push(pdfFailure)
}
try {
await recordManualInvoiceDelivery({
supabase,
companyId,
userId,
invoiceId: id,
})
} catch (err) {
log.error('failed to record manual invoice delivery', err as Error)
partialFailures.push({
step: 'delivery_history',
reason: 'Utskicket kunde inte sparas i fakturans historik.',
})
}
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: { ...(invoice as Invoice), status: 'sent' }, companyId, userId },
})
return { ok: true, journalEntryId, partialFailures }
}