* 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>
189 lines
6.3 KiB
TypeScript
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 }
|
|
}
|