* 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>
186 lines
6.9 KiB
TypeScript
186 lines
6.9 KiB
TypeScript
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 },
|
|
)
|