* 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>
168 lines
5.2 KiB
TypeScript
168 lines
5.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase, makeInvoice } from '@/tests/helpers'
|
|
import { FiscalPeriodNotFoundError } from '@/lib/bookkeeping/errors'
|
|
import type { Logger } from '@/lib/logger'
|
|
|
|
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
|
|
|
const mockCreateInvoiceJournalEntry = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
|
createInvoiceJournalEntry: (...args: unknown[]) =>
|
|
mockCreateInvoiceJournalEntry(...args),
|
|
}))
|
|
|
|
const mockCreateSchedules = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
|
|
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
|
|
}))
|
|
|
|
const mockCancelOrphan = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
|
|
cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphan(...args),
|
|
}))
|
|
|
|
const mockLinkToJournalEntry = vi.fn()
|
|
vi.mock('@/lib/core/documents/document-service', () => ({
|
|
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
|
|
}))
|
|
|
|
import { bookInvoiceDeferred, INVOICE_BOOKABLE_STATUSES } from '../book-invoice-deferred'
|
|
|
|
const log: Logger = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
child: () => log,
|
|
}
|
|
|
|
function makeSentInvoice(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
...makeInvoice({ id: 'inv-1', status: 'sent' }),
|
|
journal_entry_id: null,
|
|
credited_invoice_id: null,
|
|
document_type: 'invoice' as const,
|
|
customer: { name: 'Kunden AB' },
|
|
items: [],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function book(invoice = makeSentInvoice()) {
|
|
return bookInvoiceDeferred({
|
|
supabase: mockSupabase as never,
|
|
companyId: 'company-1',
|
|
userId: 'user-1',
|
|
invoice: invoice as never,
|
|
entityType: 'aktiebolag',
|
|
log,
|
|
})
|
|
}
|
|
|
|
describe('bookInvoiceDeferred', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
|
|
mockLinkToJournalEntry.mockResolvedValue({ id: 'document-1' })
|
|
})
|
|
|
|
it('exports the bookable statuses the routes guard on', () => {
|
|
expect(INVOICE_BOOKABLE_STATUSES).toEqual(['sent', 'overdue'])
|
|
})
|
|
|
|
it('books the revenue entry, claims the invoice and links the delivered PDF', async () => {
|
|
const invoice = makeSentInvoice()
|
|
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
|
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null }) // CAS link
|
|
enqueue({ data: 'document-1', error: null }) // delivery-document rpc
|
|
|
|
const result = await book(invoice)
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
invoice: expect.objectContaining({ journal_entry_id: 'je-1' }),
|
|
journalEntryId: 'je-1',
|
|
warnings: [],
|
|
})
|
|
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
|
|
mockSupabase,
|
|
'company-1',
|
|
'user-1',
|
|
expect.objectContaining({ id: 'inv-1' }),
|
|
'aktiebolag',
|
|
'Kunden AB',
|
|
)
|
|
expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
|
|
mockSupabase,
|
|
'company-1',
|
|
'document-1',
|
|
'je-1',
|
|
)
|
|
expect(mockCreateSchedules).toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns INVOICE_BOOK_NO_FISCAL_PERIOD when no period covers the date', async () => {
|
|
mockCreateInvoiceJournalEntry.mockResolvedValue(null)
|
|
|
|
const result = await book()
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
kind: 'code',
|
|
errorCode: 'INVOICE_BOOK_NO_FISCAL_PERIOD',
|
|
details: { invoiceDate: '2024-06-15' },
|
|
})
|
|
})
|
|
|
|
it('cancels the orphaned entry and reports a conflict when the CAS claim loses', async () => {
|
|
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
|
enqueue({ data: null, error: { message: 'no rows' } }) // CAS link fails
|
|
|
|
const result = await book()
|
|
|
|
expect(result).toEqual({ ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_CONFLICT' })
|
|
expect(mockCancelOrphan).toHaveBeenCalledWith(
|
|
mockSupabase,
|
|
'company-1',
|
|
'user-1',
|
|
'je-1',
|
|
expect.any(String),
|
|
)
|
|
})
|
|
|
|
it('passes typed bookkeeping errors through as domain failures', async () => {
|
|
const domainError = new FiscalPeriodNotFoundError()
|
|
mockCreateInvoiceJournalEntry.mockRejectedValue(domainError)
|
|
|
|
const result = await book()
|
|
|
|
expect(result).toEqual({ ok: false, kind: 'domain', error: domainError })
|
|
expect(mockCancelOrphan).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('maps unknown engine failures to INVOICE_BOOK_FAILED', async () => {
|
|
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('network down'))
|
|
|
|
const result = await book()
|
|
|
|
expect(result).toEqual({ ok: false, kind: 'code', errorCode: 'INVOICE_BOOK_FAILED' })
|
|
})
|
|
|
|
it('reports accrual-schedule failures as warnings, not errors', async () => {
|
|
const invoice = makeSentInvoice()
|
|
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
|
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null })
|
|
enqueue({ data: null, error: null }) // no delivered PDF found
|
|
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 1 })
|
|
|
|
const result = await book(invoice)
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) {
|
|
expect(result.warnings).toEqual([
|
|
expect.objectContaining({ code: 'ACCRUAL_SCHEDULE_FAILED' }),
|
|
])
|
|
}
|
|
})
|
|
})
|