Files
accounted/app/api/invoices/[id]/book/route.ts
T
bffa57a565 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>
2026-08-12 21:02:27 +02:00

93 lines
3.4 KiB
TypeScript

import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
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 core lives in
* lib/invoices/book-invoice-deferred.ts, shared with the bulk Bokför route.
*/
export const POST = withRouteContext(
'invoice.book',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const { data: invoice } = await supabase
.from('invoices')
.select('*, customer:customers(name), items:invoice_items(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!invoice) {
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
if (invoice.journal_entry_id) {
return errorResponseFromCode('INVOICE_BOOK_ALREADY_BOOKED', log, { requestId })
}
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
if (!isRealInvoice || invoice.credited_invoice_id) {
return errorResponseFromCode('INVOICE_BOOK_NOT_BOOKABLE', log, { requestId })
}
if (!INVOICE_BOOKABLE_STATUSES.includes(invoice.status)) {
return errorResponseFromCode('INVOICE_BOOK_INVALID_STATUS', log, {
requestId,
details: { currentStatus: invoice.status },
})
}
// The revenue-at-issue entry is a faktureringsmetoden concept; under
// kontantmetoden the sale is booked in full when it is paid.
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId)
.single()
// Fail closed: booking with guessed settings could apply the wrong
// method's or entity type's rules, so a failed/missing settings read aborts.
if (settingsError || !settings) {
log.error('failed to load company settings for deferred booking', settingsError ?? undefined, { invoiceId: id })
return errorResponseFromCode('INVOICE_BOOK_FAILED', log, { requestId })
}
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 result = await bookInvoiceDeferred({
supabase,
companyId: companyId!,
userId: user.id,
invoice,
entityType,
log,
})
if (!result.ok) {
if (result.kind === 'domain') {
return errorResponse(result.error, log, { requestId })
}
return errorResponseFromCode(result.errorCode, log, {
requestId,
...(result.details ? { details: result.details } : {}),
})
}
return NextResponse.json({
data: result.invoice,
journal_entry_id: result.journalEntryId,
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
})
},
{ requireWrite: true },
)