Files
accounted/lib/invoices/__tests__/issue-and-book-invoice.test.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

228 lines
7.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
makeCompanySettings,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const mockEnsureInvoiceNumber = vi.fn()
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
ensureInvoiceNumber: (...args: unknown[]) => mockEnsureInvoiceNumber(...args),
}))
const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
Document: vi.fn(),
Page: vi.fn(),
Text: vi.fn(),
View: vi.fn(),
StyleSheet: { create: (s: unknown) => s },
}))
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
SHOW_SWISH_ON_INVOICE: false,
}))
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 mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
const mockRecordManualInvoiceDelivery = vi.fn()
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
}))
import { issueAndBookInvoice } from '../issue-and-book-invoice'
import type { CompanySettings } from '@/types'
const log: Logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: () => log,
}
const customer = makeCustomer({ id: 'cust-1' })
const settings = makeCompanySettings({
accounting_method: 'accrual',
entity_type: 'enskild_firma',
bankgiro: '123-4567',
}) as CompanySettings
function makeDraft(overrides: Record<string, unknown> = {}) {
return {
...makeInvoice({ id: 'inv-1', invoice_number: 'F-2026010', status: 'draft' }),
document_type: 'invoice' as const,
credited_invoice_id: null,
customer,
items: [],
...overrides,
}
}
function issue(invoice = makeDraft(), theSettings = settings) {
return issueAndBookInvoice({
supabase: mockSupabase as never,
companyId: 'company-1',
userId: 'user-1',
invoice: invoice as never,
settings: theSettings,
log,
})
}
describe('issueAndBookInvoice', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockRecordManualInvoiceDelivery.mockResolvedValue({ id: 'delivery-1' })
mockEnsureInvoiceNumber.mockResolvedValue('F-2026010')
})
it('rejects when the payment account is missing, before number allocation', async () => {
const bare = {
...settings,
invoice_payment_accounts: {},
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
} as CompanySettings
const result = await issue(makeDraft({ invoice_number: null }), bare)
expect(result).toEqual({
ok: false,
errorCode: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
details: { currency: 'SEK' },
})
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('fails with INVOICE_CREATE_NUMBER_ASSIGN_FAILED when numbering fails', async () => {
mockEnsureInvoiceNumber.mockRejectedValue(new Error('sequence exhausted'))
const result = await issue()
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_CREATE_NUMBER_ASSIGN_FAILED' })
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns INVOICE_MARK_SENT_RACE when another request flipped the draft first', async () => {
enqueue({ data: [], error: null }) // CAS update matches no row
const result = await issue()
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' })
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('restores the draft and fails closed when the journal entry cannot be created', async () => {
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
enqueue({ data: null, error: null }) // rollback update
const result = await issue()
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_MARK_SENT_BOOK_FAILED' })
// Two invoice updates: the CAS flip and the rollback to draft.
expect(findCalls('invoices', 'update')).toEqual([
[{ status: 'sent' }],
[{ status: 'draft' }],
])
expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('books, links, archives the PDF, records delivery and emits invoice.sent', async () => {
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-7' })
enqueue({ data: null, error: null }) // journal_entry_id link
const emitted: string[] = []
eventBus.on('invoice.sent', async () => {
emitted.push('invoice.sent')
})
const result = await issue()
expect(result).toEqual({ ok: true, journalEntryId: 'je-7', partialFailures: [] })
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma',
customer.name,
)
expect(mockCreateSchedules).toHaveBeenCalled()
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
supabase: mockSupabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'inv-1',
})
expect(emitted).toEqual(['invoice.sent'])
})
it('marks sent WITHOUT booking under deferred booking (#967)', async () => {
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
const result = await issue(makeDraft(), {
...settings,
defer_invoice_booking: true,
} as CompanySettings)
expect(result).toEqual({ ok: true, journalEntryId: null, partialFailures: [] })
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
// Still a real issuance: underlag archived and delivery recorded.
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledTimes(1)
})
it('surfaces PDF-archive failures as partial failures, not errors', async () => {
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-8' })
enqueue({ data: null, error: null }) // journal_entry_id link
mockUploadDocument.mockRejectedValue(new Error('Storage offline'))
const result = await issue()
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.journalEntryId).toBe('je-8')
expect(result.partialFailures).toEqual([
expect.objectContaining({ step: 'pdf_archive' }),
])
}
})
})