Files
accounted/lib/invoices/book-invoice-deferred.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

189 lines
6.3 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import type { Logger } from '@/lib/logger'
import type { Customer, EntityType, Invoice, InvoiceItem } from '@/types'
// Statuses where the revenue entry can still be created afterwards. Paid
// invoices are excluded: their payment flow has already booked the full
// cash-style entry (mark-paid routes on the missing journal-entry link), so
// booking the sale now would double-post revenue.
export const INVOICE_BOOKABLE_STATUSES = ['sent', 'overdue']
export interface BookDeferredWarning {
code: string
message: string
}
export type BookInvoiceDeferredResult =
| {
ok: true
/** The invoice row as returned by the CAS-guarded link update. */
invoice: Invoice
journalEntryId: string
warnings: BookDeferredWarning[]
}
| {
ok: false
/** Typed bookkeeping error: map with errorResponse()/getErrorMessage(). */
kind: 'domain'
error: unknown
}
| {
ok: false
kind: 'code'
errorCode: string
details?: Record<string, unknown>
}
/**
* The deferred "Bokför" core (#967): create the revenue verifikat for an
* already-sent invoice and claim it with a CAS-guarded link. Shared by
* POST /api/invoices/[id]/book and POST /api/invoices/bulk-book so the two
* can never drift apart. The caller is responsible for the eligibility
* guards (real invoice, bookable status, unbooked, accrual method).
*/
export async function bookInvoiceDeferred(opts: {
supabase: SupabaseClient
companyId: string
userId: string
invoice: Invoice & {
customer?: (Customer & { name?: string }) | { name?: string } | null
items?: InvoiceItem[] | null
}
entityType: EntityType
log: Logger
}): Promise<BookInvoiceDeferredResult> {
const { supabase, companyId, userId, invoice, entityType, log } = opts
const id = invoice.id
let journalEntry
try {
journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
userId,
invoice as Invoice,
entityType,
invoice.customer?.name,
)
} catch (err) {
if (isBookkeepingError(err)) {
return { ok: false, kind: 'domain', error: err }
}
log.error('deferred invoice booking failed', err as Error, { invoiceId: id })
return { ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_FAILED' }
}
// Returns null ONLY when no fiscal period covers invoice_date (other
// failures throw). Nothing was posted, so a plain error is safe.
if (!journalEntry) {
return {
ok: false,
kind: 'code',
errorCode: 'INVOICE_BOOK_NO_FISCAL_PERIOD',
details: { invoiceDate: invoice.invoice_date },
}
}
// CAS-guarded link: only claim the invoice if it is still unbooked, still
// in a bookable status, and still uncredited. A concurrent
// book/mark-paid/credit that got there first would otherwise leave this
// entry double-posting revenue, so cancel it.
const { data: linked, error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.in('status', INVOICE_BOOKABLE_STATUSES)
.is('credited_invoice_id', null)
.select()
.single()
if (linkError || !linked) {
await cancelOrphanedPaymentEntry(
supabase,
companyId,
userId,
journalEntry.id,
'Bokföring av kundfaktura avbröts: fakturan bokfördes samtidigt av en annan begäran.',
)
return { ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_CONFLICT' }
}
const warnings: BookDeferredWarning[] = []
// The send flow archived the exact delivered PDF before this deferred
// journal entry existed. Attach the newest successful delivery snapshot now.
const { data: deliveryDocumentId, error: deliveryDocumentError } = await supabase.rpc(
'latest_sent_invoice_delivery_document',
{ p_company_id: companyId, p_invoice_id: id },
)
if (deliveryDocumentError) {
log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, {
invoiceId: id,
})
warnings.push({
code: 'PDF_LINK_FAILED',
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
})
} else if (typeof deliveryDocumentId === 'string') {
try {
await linkToJournalEntry(
supabase,
companyId,
deliveryDocumentId,
journalEntry.id,
)
} catch (err) {
log.error('failed to link delivered invoice PDF on deferred booking', err as Error, {
invoiceId: id,
documentId: deliveryDocumentId,
})
warnings.push({
code: 'PDF_LINK_FAILED',
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
})
}
}
// Periodiseringar ride on the revenue entry, so they can only be created
// now. Non-blocking: the entry is committed (immutable); a schedule
// failure is surfaced as a warning and retried from the periodiseringar
// page.
try {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId,
userId,
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
entityType,
)
if (accrual.failed > 0) {
warnings.push({
code: 'ACCRUAL_SCHEDULE_FAILED',
message:
'Fakturan bokfördes, men en eller flera periodiseringar kunde inte ' +
'skapas. Kontrollera under Bokföring → Periodiseringar.',
})
}
} catch (err) {
log.error('accrual schedule creation failed on deferred booking', err as Error, { invoiceId: id })
warnings.push({
code: 'ACCRUAL_SCHEDULE_FAILED',
message:
'Fakturan bokfördes, men periodiseringarna kunde inte skapas. ' +
'Kontrollera under Bokföring → Periodiseringar.',
})
}
return { ok: true, invoice: linked as Invoice, journalEntryId: journalEntry.id, warnings }
}