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
+25 -134
View File
@@ -1,25 +1,19 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
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 { CompanySettings, 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.
const BOOKABLE_STATUSES = ['sent', 'overdue']
import {
bookInvoiceDeferred,
INVOICE_BOOKABLE_STATUSES,
} from '@/lib/invoices/book-invoice-deferred'
import type { CompanySettings, EntityType } from '@/types'
/**
* POST /api/invoices/[id]/book
*
* The explicit "Bokför" step for companies with defer_invoice_booking (#967):
* one person creates and sends the invoice without bookkeeping, ekonomi books
* the revenue entry here once the kontering is verified.
* the revenue entry here once the kontering is verified. The core lives in
* lib/invoices/book-invoice-deferred.ts, shared with the bulk Bokför route.
*/
export const POST = withRouteContext(
'invoice.book',
@@ -44,7 +38,7 @@ export const POST = withRouteContext(
if (!isRealInvoice || invoice.credited_invoice_id) {
return errorResponseFromCode('INVOICE_BOOK_NOT_BOOKABLE', log, { requestId })
}
if (!BOOKABLE_STATUSES.includes(invoice.status)) {
if (!INVOICE_BOOKABLE_STATUSES.includes(invoice.status)) {
return errorResponseFromCode('INVOICE_BOOK_INVALID_STATUS', log, {
requestId,
details: { currentStatus: invoice.status },
@@ -69,132 +63,29 @@ export const POST = withRouteContext(
}
const entityType = ((settings as Partial<CompanySettings>).entity_type as EntityType) || 'enskild_firma'
let journalEntry
try {
journalEntry = await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name,
)
} catch (err) {
if (isBookkeepingError(err)) {
return errorResponse(err, log, { requestId })
}
log.error('deferred invoice booking failed', err as Error, { invoiceId: id })
return errorResponseFromCode('INVOICE_BOOK_FAILED', log, { requestId })
}
const result = await bookInvoiceDeferred({
supabase,
companyId: companyId!,
userId: user.id,
invoice,
entityType,
log,
})
// 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 errorResponseFromCode('INVOICE_BOOK_NO_FISCAL_PERIOD', log, {
if (!result.ok) {
if (result.kind === 'domain') {
return errorResponse(result.error, log, { requestId })
}
return errorResponseFromCode(result.errorCode, log, {
requestId,
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', BOOKABLE_STATUSES)
.is('credited_invoice_id', null)
.select()
.single()
if (linkError || !linked) {
await cancelOrphanedPaymentEntry(
supabase,
companyId!,
user.id,
journalEntry.id,
'Bokföring av kundfaktura avbröts: fakturan bokfördes samtidigt av en annan begäran.',
)
return errorResponseFromCode('INVOICE_BOOK_CONFLICT', log, { requestId })
}
const warnings: Array<{ code: string; message: string }> = []
// 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!,
user.id,
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.',
...(result.details ? { details: result.details } : {}),
})
}
return NextResponse.json({
data: linked,
journal_entry_id: journalEntry.id,
...(warnings.length > 0 ? { warnings } : {}),
data: result.invoice,
journal_entry_id: result.journalEntryId,
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
})
},
{ requireWrite: true },
+102 -253
View File
@@ -1,36 +1,27 @@
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
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 {
creditNoteNeedsJournalEntry,
issueCreditNote,
type CreditNoteOriginalInvoice,
} from '@/lib/invoices/issue-credit-note'
import {
archiveIssuedInvoicePdf,
issueAndBookInvoice,
type IssuePartialFailure,
} from '@/lib/invoices/issue-and-book-invoice'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseCustomIssuanceLines } 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 { hasRequiredInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type {
AccountingMethod,
CompanySettings,
CreditNote,
Customer,
EntityType,
Invoice,
InvoiceItem,
} from '@/types'
ensureInitialized()
@@ -41,6 +32,9 @@ ensureInitialized()
* Manually marks a draft invoice as sent (for invoices delivered outside the system).
* Under faktureringsmetoden (accrual): creates the journal entry (Debit 1510, Credit 30xx/26xx).
* Under kontantmetoden (cash): no journal entry; booking happens at payment.
*
* The non-credit-note core lives in lib/invoices/issue-and-book-invoice.ts,
* shared with POST /api/invoices/bulk-book; credit notes stay inline here.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.mark_sent',
@@ -112,8 +106,40 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId })
}
if (!isCreditNote) {
const result = await issueAndBookInvoice({
supabase,
companyId,
userId: user.id,
invoice: invoice as Invoice,
settings: settings as CompanySettings,
log,
customLines,
})
if (!result.ok) {
return errorResponseFromCode(result.errorCode, log, {
requestId,
...(result.details ? { details: result.details } : {}),
})
}
return NextResponse.json(
{
success: true,
status: 'sent',
journal_entry_id: result.journalEntryId,
...(result.partialFailures.length > 0
? { partial: true, partial_failures: result.partialFailures }
: {}),
},
{ headers: { 'Cache-Control': 'private, no-store' } },
)
}
// ── Credit-note path ────────────────────────────────────────────────
const invoiceCurrency = (invoice as Invoice).currency
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
if (!hasRequiredInvoicePaymentAccount(settings as CompanySettings, invoice as Invoice)) {
return errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
requestId,
@@ -131,33 +157,27 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
const accountingMethod = (settings.accounting_method || 'accrual') as AccountingMethod
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
let originalInvoice: CreditNoteOriginalInvoice | undefined
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: original } = await supabase
.from('invoices')
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
const { data: original } = await supabase
.from('invoices')
.select('id, invoice_number, status, journal_entry_id, paid_at, paid_amount, total')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
if (!original) {
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', log, { requestId })
}
originalInvoice = original as CreditNoteOriginalInvoice
originalInvoiceNumber = original.invoice_number ?? undefined
if (!original) {
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', log, { requestId })
}
const journalEntryRequired = originalInvoice
? creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
: false
const isRecovery = isCreditNote && invoice.status === 'sent'
const originalInvoice = original as CreditNoteOriginalInvoice
const originalInvoiceNumber = original.invoice_number ?? undefined
const journalEntryRequired = creditNoteNeedsJournalEntry(accountingMethod, originalInvoice)
const isRecovery = invoice.status === 'sent'
if (
isRecovery &&
originalInvoice?.status === 'credited' &&
originalInvoice.status === 'credited' &&
(!journalEntryRequired || !!invoice.journal_entry_id)
) {
return errorResponseFromCode('INVOICE_CREDIT_ALREADY_ISSUED', log, { requestId })
@@ -185,235 +205,71 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
statusFlipped = true
}
// 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: Array<{ step: string; reason: string }> = []
// Custom lines only apply where mark-sent 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 &&
(isCreditNote || !isRealInvoice || !booksInvoicesOnIssue(settings as CompanySettings))
) {
// Custom lines never apply to credit notes; log the mismatch so it stays
// visible in audit review instead of vanishing silently (documented in
// MarkInvoiceSentSchema).
if (customLines) {
log.warn('mark-sent: custom lines ignored (not on accrual book-at-issue path)', {
invoiceId: id,
lineCount: customLines.length,
})
}
if (isCreditNote && originalInvoice) {
const issueResult = await issueCreditNote({
supabase,
companyId,
userId: user.id,
creditNote: invoice as CreditNote,
originalInvoice,
entityType,
accountingMethod,
log,
})
journalEntryId = issueResult.journalEntryId
partialFailures.push(...issueResult.failures)
const partialFailures: IssuePartialFailure[] = []
if (!issueResult.complete) {
// If no immutable entry was created, restoring the draft is safe and
// lets the user fix the period/account issue before trying again.
if (statusFlipped && issueResult.journalEntryRequired && !issueResult.journalEntryId) {
await supabase
.from('invoices')
.update({ status: 'draft' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'sent')
.is('journal_entry_id', null)
}
return errorResponseFromCode(
issueResult.repairRequired
? 'INVOICE_CREDIT_REPAIR_REQUIRED'
: 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
log,
{
requestId,
details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
},
)
}
} else if (isRealInvoice && booksInvoicesOnIssue(settings as CompanySettings)) {
// #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: user.id,
lineCount: customLines.length,
})
}
const journalEntry = customLines
? await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name,
{ customLines },
)
: await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name,
)
if (journalEntry) {
journalEntryId = journalEntry.id
const issueResult = await issueCreditNote({
supabase,
companyId,
userId: user.id,
creditNote: invoice as CreditNote,
originalInvoice,
entityType,
accountingMethod,
log,
})
const journalEntryId = issueResult.journalEntryId
partialFailures.push(...issueResult.failures)
// 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,
user.id,
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 mark-sent: 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 as CompanySettings) && !isCreditNote && !journalEntryId) {
if (statusFlipped) {
const { error: rollbackError } = await supabase
if (!issueResult.complete) {
// If no immutable entry was created, restoring the draft is safe and
// lets the user fix the period/account issue before trying again.
if (statusFlipped && issueResult.journalEntryRequired && !issueResult.journalEntryId) {
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 errorResponseFromCode('INVOICE_MARK_SENT_BOOK_FAILED', log, { requestId })
}
if (partialFailures.some((failure) => failure.step === 'journal_link')) {
return errorResponseFromCode('INVOICE_MARK_SENT_REPAIR_REQUIRED', log, {
requestId,
details: { failure_steps: ['journal_link'] },
})
return errorResponseFromCode(
issueResult.repairRequired
? 'INVOICE_CREDIT_REPAIR_REQUIRED'
: 'INVOICE_CREDIT_ISSUE_INCOMPLETE',
log,
{
requestId,
details: { failure_steps: issueResult.failures.map((failure) => failure.step) },
},
)
}
// Render and archive the PDF as underlag so it remains retrievable even if
// the invoice row is later cancelled. Mirrors the send route.
// the invoice row is later cancelled. Credit notes are real invoices
// (document_type 'invoice'), so this mirrors the non-credit path.
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
if (isRealInvoice) {
try {
const items = (invoice.items as InvoiceItem[] | null ?? []).slice().sort(
(a, b) => a.sort_order - b.sort_order
)
// The DB status flip already happened above, 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 { branding, company: renderCompany } = await prepareInvoicePdfRender(
settings as CompanySettings,
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,
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, user.id, companyId, {
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
}, {
upload_source: 'system',
journal_entry_id: journalEntryId ?? undefined,
})
} catch (err) {
log.error('failed to archive invoice PDF on mark-sent', err as Error)
partialFailures.push({
step: 'pdf_archive',
reason: 'Fakturans PDF kunde inte arkiveras.',
})
}
const pdfFailure = await archiveIssuedInvoicePdf({
supabase,
companyId,
userId: user.id,
invoice: invoice as Invoice,
settings: settings as CompanySettings,
journalEntryId,
originalInvoiceNumber,
log,
})
if (pdfFailure) partialFailures.push(pdfFailure)
}
if (statusFlipped) {
@@ -433,13 +289,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
if (!isCreditNote) {
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: { ...(invoice as Invoice), status: 'sent' }, companyId, userId: user.id },
})
}
return NextResponse.json(
{
success: true,
@@ -0,0 +1,332 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
makeInvoice,
makeCompanySettings,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockIssueAndBook = vi.fn()
vi.mock('@/lib/invoices/issue-and-book-invoice', () => ({
issueAndBookInvoice: (...args: unknown[]) => mockIssueAndBook(...args),
}))
const mockBookDeferred = vi.fn()
vi.mock('@/lib/invoices/book-invoice-deferred', () => ({
bookInvoiceDeferred: (...args: unknown[]) => mockBookDeferred(...args),
INVOICE_BOOKABLE_STATUSES: ['sent', 'overdue'],
}))
import { POST } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
const UUID_1 = '11111111-1111-4111-8111-111111111111'
const UUID_2 = '22222222-2222-4222-8222-222222222222'
const accrualSettings = makeCompanySettings({
accounting_method: 'accrual',
entity_type: 'aktiebolag',
})
function bulkRequest(ids: unknown) {
return POST(createMockRequest('/api/invoices/bulk-book', { method: 'POST', body: { ids } }))
}
function makeDraft(id: string) {
return {
...makeInvoice({ id, status: 'draft' }),
document_type: 'invoice',
credited_invoice_id: null,
journal_entry_id: null,
customer: { name: 'Kunden AB' },
items: [],
}
}
describe('POST /api/invoices/bulk-book', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const { status } = await parseJsonResponse(await bulkRequest([UUID_1]))
expect(status).toBe(401)
})
it('returns 400 for an invalid body', async () => {
const { status } = await parseJsonResponse(await bulkRequest([]))
expect(status).toBe(400)
expect(mockIssueAndBook).not.toHaveBeenCalled()
})
it('returns 400 for non-uuid ids', async () => {
const { status } = await parseJsonResponse(await bulkRequest(['not-a-uuid']))
expect(status).toBe(400)
})
it('rejects the whole batch under kontantmetoden', async () => {
enqueue({ data: { ...accrualSettings, accounting_method: 'cash' }, error: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await bulkRequest([UUID_1]),
)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_BOOK_CASH_METHOD')
expect(mockIssueAndBook).not.toHaveBeenCalled()
expect(mockBookDeferred).not.toHaveBeenCalled()
})
it('reports unknown ids as per-item INVOICE_NOT_FOUND failures', async () => {
enqueue({ data: accrualSettings, error: null })
enqueue({ data: [], error: null }) // invoices fetch finds nothing
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error_code?: string; error?: string }>
summary: { total: number; booked: number; failed: number }
}
}>(await bulkRequest([UUID_1]))
expect(status).toBe(200)
expect(body.data.results).toEqual([
expect.objectContaining({
id: UUID_1,
status: 'failed',
error_code: 'INVOICE_NOT_FOUND',
error: expect.stringContaining('hittas'),
}),
])
expect(body.data.summary).toEqual({ total: 1, booked: 0, failed: 1 })
})
it('issues and books drafts via issueAndBookInvoice', async () => {
enqueue({ data: accrualSettings, error: null })
enqueue({ data: [makeDraft(UUID_1)], error: null })
mockIssueAndBook.mockResolvedValue({ ok: true, journalEntryId: 'je-1', partialFailures: [] })
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; journal_entry_id?: string | null }>
summary: { total: number; booked: number; failed: number }
}
}>(await bulkRequest([UUID_1]))
expect(status).toBe(200)
expect(mockIssueAndBook).toHaveBeenCalledWith(
expect.objectContaining({
supabase: mockSupabase,
companyId: 'company-1',
userId: 'user-1',
invoice: expect.objectContaining({ id: UUID_1 }),
}),
)
// No email path and no custom lines from bulk.
expect(mockIssueAndBook.mock.calls[0][0]).not.toHaveProperty('customLines')
expect(body.data.results).toEqual([
{ id: UUID_1, status: 'booked', journal_entry_id: 'je-1' },
])
expect(body.data.summary).toEqual({ total: 1, booked: 1, failed: 0 })
})
it('rejects drafts per item under deferred booking without issuing them', async () => {
// accrual + defer_invoice_booking: issuing the draft would consume an
// F-number and mark it sent WITHOUT booking anything. The sent invoice in
// the same batch must still book: deferred is exactly the /book case.
enqueue({ data: { ...accrualSettings, defer_invoice_booking: true }, error: null })
enqueue({
data: [makeDraft(UUID_1), { ...makeDraft(UUID_2), status: 'sent' }],
error: null,
})
mockBookDeferred.mockResolvedValue({
ok: true,
invoice: { id: UUID_2 },
journalEntryId: 'je-3',
warnings: [],
})
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error_code?: string; error?: string }>
summary: { total: number; booked: number; failed: number }
}
}>(await bulkRequest([UUID_1, UUID_2]))
expect(status).toBe(200)
// The draft is never touched: no issuance side effects at all.
expect(mockIssueAndBook).not.toHaveBeenCalled()
expect(body.data.results).toEqual([
expect.objectContaining({
id: UUID_1,
status: 'failed',
error_code: 'INVOICE_BOOK_DEFERRED_DRAFT',
error: expect.stringContaining('separat steg'),
}),
{ id: UUID_2, status: 'booked', journal_entry_id: 'je-3' },
])
expect(body.data.summary).toEqual({ total: 2, booked: 1, failed: 1 })
})
it('processes duplicated ids exactly once (no second voucher)', async () => {
enqueue({ data: accrualSettings, error: null })
enqueue({ data: [makeDraft(UUID_1)], error: null })
mockIssueAndBook.mockResolvedValue({ ok: true, journalEntryId: 'je-1', partialFailures: [] })
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; journal_entry_id?: string | null }>
summary: { total: number; booked: number; failed: number }
}
}>(await bulkRequest([UUID_1, UUID_1]))
expect(status).toBe(200)
// Without dedupe the second iteration reads the stale pre-loop snapshot,
// passes the already-booked check, and mints a voucher the CAS claim then
// cancels: one cancelled verifikat + gap explanation per duplicate.
expect(mockIssueAndBook).toHaveBeenCalledTimes(1)
expect(body.data.results).toEqual([
{ id: UUID_1, status: 'booked', journal_entry_id: 'je-1' },
])
expect(body.data.summary).toEqual({ total: 1, booked: 1, failed: 0 })
})
it('books sent unbooked invoices via bookInvoiceDeferred', async () => {
enqueue({ data: accrualSettings, error: null })
enqueue({
data: [{ ...makeDraft(UUID_1), status: 'sent' }],
error: null,
})
mockBookDeferred.mockResolvedValue({
ok: true,
invoice: { id: UUID_1 },
journalEntryId: 'je-2',
warnings: [],
})
const { status, body } = await parseJsonResponse<{
data: { results: Array<{ id: string; status: string; journal_entry_id?: string | null }> }
}>(await bulkRequest([UUID_1]))
expect(status).toBe(200)
expect(mockBookDeferred).toHaveBeenCalledWith(
expect.objectContaining({
invoice: expect.objectContaining({ id: UUID_1 }),
entityType: 'aktiebolag',
}),
)
expect(mockIssueAndBook).not.toHaveBeenCalled()
expect(body.data.results).toEqual([
{ id: UUID_1, status: 'booked', journal_entry_id: 'je-2' },
])
})
it('skips already-booked sent invoices without touching the engine', async () => {
enqueue({ data: accrualSettings, error: null })
enqueue({
data: [{ ...makeDraft(UUID_1), status: 'sent', journal_entry_id: 'je-old' }],
error: null,
})
const { body } = await parseJsonResponse<{
data: { results: Array<{ status: string; error_code?: string }> }
}>(await bulkRequest([UUID_1]))
expect(body.data.results[0]).toEqual(
expect.objectContaining({ status: 'failed', error_code: 'INVOICE_BOOK_ALREADY_BOOKED' }),
)
expect(mockBookDeferred).not.toHaveBeenCalled()
})
it('rejects credit notes and non-invoice document types per item', async () => {
enqueue({ data: accrualSettings, error: null })
enqueue({
data: [
{ ...makeDraft(UUID_1), credited_invoice_id: 'inv-0' },
{ ...makeDraft(UUID_2), document_type: 'proforma' },
],
error: null,
})
const { body } = await parseJsonResponse<{
data: { results: Array<{ id: string; status: string; error_code?: string }> }
}>(await bulkRequest([UUID_1, UUID_2]))
expect(body.data.results).toEqual([
expect.objectContaining({ id: UUID_1, status: 'failed', error_code: 'INVOICE_BOOK_NOT_BOOKABLE' }),
expect.objectContaining({ id: UUID_2, status: 'failed', error_code: 'INVOICE_BOOK_NOT_BOOKABLE' }),
])
expect(mockIssueAndBook).not.toHaveBeenCalled()
})
it('rejects paid invoices per item with INVOICE_BOOK_INVALID_STATUS', async () => {
enqueue({ data: accrualSettings, error: null })
enqueue({ data: [{ ...makeDraft(UUID_1), status: 'paid' }], error: null })
const { body } = await parseJsonResponse<{
data: { results: Array<{ status: string; error_code?: string }> }
}>(await bulkRequest([UUID_1]))
expect(body.data.results[0]).toEqual(
expect.objectContaining({ status: 'failed', error_code: 'INVOICE_BOOK_INVALID_STATUS' }),
)
})
it('continues past failures and reports a mixed summary', async () => {
enqueue({ data: accrualSettings, error: null })
enqueue({ data: [makeDraft(UUID_1), makeDraft(UUID_2)], error: null })
mockIssueAndBook
.mockResolvedValueOnce({ ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' })
.mockResolvedValueOnce({ ok: true, journalEntryId: 'je-9', partialFailures: [] })
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error_code?: string; error?: string }>
summary: { total: number; booked: number; failed: number }
}
}>(await bulkRequest([UUID_1, UUID_2]))
expect(status).toBe(200)
expect(mockIssueAndBook).toHaveBeenCalledTimes(2)
expect(body.data.results).toEqual([
expect.objectContaining({
id: UUID_1,
status: 'failed',
error_code: 'INVOICE_MARK_SENT_RACE',
// Per-item errors are Swedish user strings, never raw codes.
error: expect.any(String),
}),
{ id: UUID_2, status: 'booked', journal_entry_id: 'je-9' },
])
expect(body.data.summary).toEqual({ total: 2, booked: 1, failed: 1 })
})
})
+185
View File
@@ -0,0 +1,185 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { InvoicesBulkBookSchema } from '@/lib/api/schemas'
import { issueAndBookInvoice, type IssuableInvoice } from '@/lib/invoices/issue-and-book-invoice'
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
import {
bookInvoiceDeferred,
INVOICE_BOOKABLE_STATUSES,
} from '@/lib/invoices/book-invoice-deferred'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import type { CompanySettings, EntityType } from '@/types'
ensureInitialized()
interface BulkBookItemResult {
id: string
status: 'booked' | 'failed'
journal_entry_id?: string | null
error_code?: string
/** Swedish user-facing message; raw errors stay in logs. */
error?: string
}
function failItem(id: string, errorCode: string): BulkBookItemResult {
return {
id,
status: 'failed',
error_code: errorCode,
error: getErrorEntry(errorCode)?.message_sv ?? 'Något gick fel. Försök igen.',
}
}
/**
* POST /api/invoices/bulk-book
*
* One "Bokför" click for many customer invoices (MCP-created invoices land as
* drafts that used to need individual issuance). Per invoice:
* - draft → issueAndBookInvoice(): F-number + mark sent (NO
* email) + revenue verifikat. Exactly the mark-sent
* semantics; only when the company books at issue,
* deferred-booking companies get a per-item error
* instead of a silent issuance.
* - sent/overdue,
* unbooked → bookInvoiceDeferred(): the /book semantics
* (CAS-guarded claim).
* - anything else → per-item error.
*
* The loop is sequential on purpose: commit_journal_entry assigns voucher
* numbers atomically per call, so a serial loop keeps them gap-free and in
* order. Failures never abort the batch; each item reports its own outcome.
*/
export const POST = withRouteContext(
'invoice.bulk_book',
async (request, { user, supabase, companyId, log, requestId }) => {
const validated = await validateBody(request, InvoicesBulkBookSchema)
if (!validated.success) return validated.response
const { ids } = validated.data
// One settings read for the whole batch: issuance needs the full row
// (payment accounts + PDF branding), booking needs method + entity type.
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (settingsError || !settings) {
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId })
}
// Under kontantmetoden nothing books before payment, so the whole request
// is a no-op: reject it instead of issuing drafts nobody asked to send.
if ((settings.accounting_method || 'accrual') !== 'accrual') {
return errorResponseFromCode('INVOICE_BOOK_CASH_METHOD', log, { requestId })
}
const entityType =
((settings as Partial<CompanySettings>).entity_type as EntityType) || 'enskild_firma'
const { data: invoices, error: fetchError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.in('id', ids)
.eq('company_id', companyId)
if (fetchError) {
log.error('failed to fetch invoices for bulk book', fetchError)
return errorResponseFromCode('INTERNAL_ERROR', log, { requestId })
}
const invoicesById = new Map(
((invoices ?? []) as IssuableInvoice[]).map((invoice) => [invoice.id, invoice]),
)
const results: BulkBookItemResult[] = []
// Dedupe: the loop's already-booked checks read the pre-loop snapshot, so
// a repeated id would pass them twice and commit a second voucher that the
// CAS claim then cancels (a cancelled verifikat + gap explanation per
// duplicate). Each unique id is processed exactly once.
const uniqueIds = [...new Set(ids)]
for (const id of uniqueIds) {
const invoice = invoicesById.get(id)
if (!invoice) {
results.push(failItem(id, 'INVOICE_NOT_FOUND'))
continue
}
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
if (!isRealInvoice || invoice.credited_invoice_id) {
results.push(failItem(id, 'INVOICE_BOOK_NOT_BOOKABLE'))
continue
}
if (invoice.status === 'draft') {
// Deferred-booking companies (#967) issue via mark-sent and book via
// the explicit /book step. issueAndBookInvoice would consume an
// F-number and flip the draft to sent WITHOUT booking anything, an
// irreversible side effect nobody asked for, so the item fails
// before the invoice is touched.
if (!booksInvoicesOnIssue(settings)) {
results.push(failItem(id, 'INVOICE_BOOK_DEFERRED_DRAFT'))
continue
}
const result = await issueAndBookInvoice({
supabase,
companyId,
userId: user.id,
invoice,
settings: settings as CompanySettings,
log,
})
if (!result.ok) {
results.push(failItem(id, result.errorCode))
} else {
results.push({ id, status: 'booked', journal_entry_id: result.journalEntryId })
}
continue
}
if (INVOICE_BOOKABLE_STATUSES.includes(invoice.status)) {
// Worklist-canonical unbooked predicate: journal_entry_id IS NULL.
if (invoice.journal_entry_id) {
results.push(failItem(id, 'INVOICE_BOOK_ALREADY_BOOKED'))
continue
}
const result = await bookInvoiceDeferred({
supabase,
companyId,
userId: user.id,
invoice,
entityType,
log,
})
if (!result.ok) {
if (result.kind === 'domain') {
const code = (result.error as { code?: string } | null)?.code
results.push({
id,
status: 'failed',
...(code ? { error_code: code } : {}),
error: getErrorMessage(result.error),
})
} else {
results.push(failItem(id, result.errorCode))
}
} else {
results.push({ id, status: 'booked', journal_entry_id: result.journalEntryId })
}
continue
}
results.push(failItem(id, 'INVOICE_BOOK_INVALID_STATUS'))
}
const summary = {
total: results.length,
booked: results.filter((r) => r.status === 'booked').length,
failed: results.filter((r) => r.status === 'failed').length,
}
return NextResponse.json({ data: { results, summary } })
},
{ requireWrite: true },
)