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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
dea5e31756
commit
bffa57a565
@@ -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 },
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
Reference in New Issue
Block a user