Files
accounted/app/api/invoices/[id]/send/__tests__/route.test.ts
T
MattssonandClaude Fable 5.1 32721b9f61 feat(invoices): diagonal UTKAST/DRAFT watermark on draft PDFs instead of the top-margin banner (#2441)
* feat(invoices): mark draft PDFs with a diagonal UTKAST/DRAFT watermark instead of a banner

Why the problem occurred: the draft marking was a boxed yellow banner in
the page's top margin. It stayed out of the flow (#2369) but still read as
UI chrome pasted on a document, and carried a two-line legal sentence that
nobody reads on a preview.

What was removed: the banner block, its three styles and the four legal
sentences (sv+en). The draft state is now one word, bold, rotated -35deg at
14% opacity, centred on every page, the way a stamp marks paper. The
download dialog (#2399) already explains why a draft is not a valid
invoice before the file exists, so the PDF does not repeat it.

Why this shape: rotation and opacity sit on a padded wrapper View so the
word turns about its own centre and the Text keeps a plain type style.
The overlay is absolutely positioned over the page box and `fixed`, so the
document underneath previews pixel-identical to the final print; a test
asserts the first row sits at the same y as on a sent invoice. BETALD and
MAKULERAD banners are unchanged (separate concern).

Fixes #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB

* fix(invoices): darken the draft watermark so it survives a greyscale print

Skeptic refutation: #6b7280 at 0.14 composites to about 92% brightness on
white, which a monochrome print or greyscale scan drops, and a numbered
draft otherwise prints the FAKTURA title, its number and an OCR like an
issued invoice. Now #4b5563 at 0.3 (about 79% brightness), with a test
pinning the composited grey between 70% and 85% so neither extreme can
creep back in.

Refs #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(invoices): paint the draft watermark last so opaque boxes cannot cover it

Skeptic refutation (correctness and regression, independently): the
overlay was the first child of the Page. react-pdf paints children in
document order and `fixed` does not hoist, so the customer box and the
full-width payment section (opaque #f5f5f5 / #f8f9fa) painted over the
word. On a two-page draft the last page, the one with totals, bankgiro
and OCR, lost the word entirely.

The overlay is now the last child of the Page, behind a single
isDraftMarked flag that also keeps the cancelled > draft > paid banner
precedence. A new test inflates the rendered PDF content streams and
asserts the UTKAST glyph run comes after the last rectangle fill on
every page, so the element tree alone can no longer pass while the
paint order is wrong.

Refs #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(invoices): note the English DRAFT label in the download-decision comment

CodeRabbit on #2441: the comment said every draft is stamped UTKAST; an
English document says DRAFT.

Refs #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:11:43 +02:00

996 lines
37 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
makeCompanySettings,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
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,
}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
const mockSendEmail = vi.fn()
const mockIsConfigured = vi.fn()
// The sender resolver reads company_sending_domains; keep it out of the
// queued-mock sequence (its own tests live in lib/email/__tests__).
vi.mock('@/lib/email/invoice-sender', () => ({
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
isConfigured: () => mockIsConfigured(),
}),
}))
const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
subject: string
html: string
text: string
replyTo?: string
fromName?: string
filename: string
pdfBuffer: Buffer
}) => ({
...(await input.emailService.sendEmail({
to: input.to,
cc: input.cc,
bcc: input.bcc,
subject: input.subject,
html: input.html,
text: input.text,
replyTo: input.replyTo,
fromName: input.fromName,
attachments: [{
filename: input.filename,
content: input.pdfBuffer,
contentType: 'application/pdf',
}],
})),
deliveryId: 'delivery-1',
documentId: 'document-1',
}))
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
InvoiceDeliverySnapshotError: class InvoiceDeliverySnapshotError extends Error {},
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
sendTrackedInvoiceEmail: (...args: unknown[]) => mockSendTrackedInvoiceEmail(...args as [never]),
}))
const mockLinkToJournalEntry = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura F-2024001'),
}))
const mockCreateInvoiceJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoiceJournalEntry: (...args: unknown[]) =>
mockCreateInvoiceJournalEntry(...args),
}))
const mockIssueCreditNote = vi.fn()
vi.mock('@/lib/invoices/issue-credit-note', () => ({
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
}))
const mockCreateSchedules = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
}))
// The sandbox guard issues a company_settings query at the top of the route;
// short-circuit it in tests since the queued mock-supabase is shaped for the
// route's existing fetch chain, not an extra pre-flight read.
vi.mock('@/lib/sandbox/guard', () => ({
guardSandbox: vi.fn().mockResolvedValue(null),
isSandboxCompany: vi.fn().mockResolvedValue(false),
sandboxBlockedResponse: vi.fn(),
}))
vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: vi.fn().mockResolvedValue(null),
}))
import { POST } from '../route'
describe('POST /api/invoices/[id]/send', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
const company = makeCompanySettings({ accounting_method: 'accrual', bankgiro: '123-4567' })
const invoice = makeInvoice({
id: 'inv-1',
status: 'draft',
customer,
items: [
{
id: 'item-1',
invoice_id: 'inv-1',
sort_order: 0,
description: 'Consulting',
quantity: 10,
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z',
},
],
})
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockIsConfigured.mockReturnValue(true)
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockIssueCreditNote.mockResolvedValue({
complete: true,
journalEntryId: 'credit-je-1',
journalEntryRequired: true,
failures: [],
})
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 503 when email service is not configured', async () => {
mockIsConfigured.mockReturnValue(false)
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(503)
})
it('returns 404 when invoice not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_FOUND')
})
it('returns 400 when invoice is cancelled (makulerad)', async () => {
const cancelledInvoice = makeInvoice({
id: 'inv-1',
status: 'cancelled',
invoice_number: 'F-2026001',
items: [],
})
enqueue({ data: cancelledInvoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_CANCELLED')
})
it.each(['sent', 'paid', 'overdue', 'partially_paid', 'credited'] as const)(
'returns 409 and posts no journal entry when invoice status is %s',
async (issuedStatus) => {
const issuedInvoice = makeInvoice({
id: 'inv-1',
status: issuedStatus,
customer,
items: [],
})
enqueue({ data: issuedInvoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_ALREADY_SENT')
expect(mockSendEmail).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
},
)
it('skips journal entry, archive and event when a concurrent request won the status flip', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-race' })
// Optimistic-locked flip matches 0 rows: another request already sent it.
enqueue({ data: [], error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
partial?: boolean
partial_failures?: Array<{ step: string }>
}>(response)
// The email did go out, so the response is still a (partial) success.
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.partial).toBe(true)
expect(body.partial_failures?.some((f) => f.step === 'status_update')).toBe(true)
// The winning request owns the bookkeeping: no second verifikat here.
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(emitSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.sent' })
)
})
it('defers the journal entry when the status flip errors (row stays draft, retry re-books once)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-fliperr' })
// Status flip hits a DB error: the invoice remains 'draft'.
enqueue({ data: null, error: { message: 'connection reset' } })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
partial?: boolean
partial_failures?: Array<{ step: string; reason: string }>
}>(response)
expect(status).toBe(200)
expect(body.partial).toBe(true)
expect(body.partial_failures?.some((f) => f.step === 'status_update')).toBe(true)
// No entry now: the retry (invoice still draft) runs the full pipeline
// and posts exactly one, instead of this request + the retry posting two.
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when customer has no email', async () => {
const noEmailInvoice = makeInvoice({
id: 'inv-1',
customer: makeCustomer({ email: null }),
items: [],
})
enqueue({ data: noEmailInvoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL')
})
it('returns 400 when the stored customer email is malformed', async () => {
enqueue({
data: makeInvoice({
id: 'inv-1',
customer: makeCustomer({ email: 'not-an-email' }),
items: [],
}),
error: null,
})
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 404 when company settings not found', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_COMPANY_SETTINGS_MISSING')
})
it.each(['SEK', 'EUR'] as const)(
'does not allocate a number or send a %s invoice without a matching payment account',
async (currency) => {
const invoiceWithoutAccount = makeInvoice({
...invoice,
invoice_number: null,
currency,
})
enqueue({ data: invoiceWithoutAccount, error: null })
enqueue({
data: {
...company,
invoice_payment_accounts: {},
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
},
error: null,
})
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
},
)
it('does not allocate a number or send when the registered company has no VAT number', async () => {
enqueue({ data: makeInvoice({ ...invoice, invoice_number: null }), error: null })
enqueue({ data: { ...company, vat_registered: true, vat_number: null }, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_VAT_NUMBER_MISSING')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects custom recipients from a non-admin company member before allocation', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
enqueue({ data: { role: 'member' }, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { additional_cc: ['external@test.se'] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects a custom recipient collision before allocation', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
enqueue({ data: { role: 'admin' }, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { additional_cc: ['KUND@test.se'] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { collisions: Array<{ conflicts_with: string }> } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.collisions).toEqual([
expect.objectContaining({ conflicts_with: 'to' }),
])
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects a combined recipient set over the limit before allocation', async () => {
enqueue({ data: invoice, error: null })
enqueue({
data: {
...company,
invoice_email_cc_addresses: Array.from(
{ length: 19 },
(_, index) => `fixed-${index}@test.se`,
),
invoice_email_bcc_addresses: [],
},
error: null,
})
enqueue({ data: { role: 'admin' }, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { additional_bcc: ['archive@test.se'] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { recipient_count: number } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS')
expect(body.error.details.recipient_count).toBe(21)
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects fixed routing over the total limit without a custom-recipient role query', async () => {
enqueue({ data: invoice, error: null })
enqueue({
data: {
...company,
email: 'legacy@test.se',
invoice_email_cc_addresses: Array.from(
{ length: 19 },
(_, index) => `fixed-${index}@test.se`,
),
invoice_email_bcc_addresses: ['archive@test.se'],
},
error: null,
})
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { recipient_count: number } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS')
expect(body.error.details.recipient_count).toBe(21)
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('sends invoice email, updates status, creates journal entry for accrual', async () => {
// Fetch invoice
enqueue({ data: invoice, error: null })
// Fetch company settings
enqueue({
data: {
...company,
invoice_email_cc_addresses: ['fixed-copy@test.se'],
invoice_email_bcc_addresses: ['fixed-archive@test.se'],
},
error: null,
})
// Authorize the per-send CC and BCC additions.
enqueue({ data: { role: 'owner' }, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update invoice status to 'sent' (optimistic lock: returns the matched row)
enqueue({ data: [{ id: 'inv-1' }], error: null })
// Update invoice with journal_entry_id
enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: {
additional_cc: ['case-owner@test.se'],
additional_bcc: ['extra-archive@test.se'],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
messageId: string
recipient_counts: { to: number; cc: number }
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.messageId).toBe('msg-1')
expect(body.recipient_counts).toEqual({ to: 1, cc: 2 })
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({
companyId: 'company-1',
invoiceId: 'inv-1',
cc: ['fixed-copy@test.se', 'case-owner@test.se'],
bcc: ['fixed-archive@test.se', 'extra-archive@test.se'],
}),
)
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: ['kund@test.se'],
cc: ['fixed-copy@test.se', 'case-owner@test.se'],
bcc: ['fixed-archive@test.se', 'extra-archive@test.se'],
subject: 'Faktura F-2024001',
})
)
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma'
)
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.sent' })
)
})
it('issues and books a credit-note draft through the email send flow', async () => {
const creditNote = makeInvoice({
id: 'credit-1',
invoice_number: 'KR-F-2024001',
status: 'draft',
credited_invoice_id: 'inv-1',
currency: 'EUR',
customer,
items: (invoice.items ?? []).map((item) => ({
...item,
invoice_id: 'credit-1',
quantity: -Math.abs(item.quantity),
line_total: -Math.abs(item.line_total),
vat_amount: -Math.abs(item.vat_amount ?? 0),
})),
subtotal: -10000,
vat_amount: -2500,
total: -12500,
})
const original = {
id: 'inv-1',
invoice_number: 'F-2024001',
status: 'sent',
journal_entry_id: 'original-je-1',
paid_at: null,
paid_amount: null,
total: 12500,
}
enqueue({ data: creditNote, error: null })
enqueue({ data: company, error: null })
enqueue({ data: original, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'credit-message-1' })
enqueue({ data: [{ id: 'credit-1' }], error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean; message: string }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.message).toContain('Kreditfakturan har skickats')
expect(mockIssueCreditNote).toHaveBeenCalledWith(
expect.objectContaining({
companyId: 'company-1',
creditNote: expect.objectContaining({ id: 'credit-1' }),
originalInvoice: original,
accountingMethod: 'accrual',
}),
)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(InvoicePDF).toHaveBeenCalledWith(
expect.objectContaining({ originalInvoiceNumber: 'F-2024001' }),
)
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
filename: 'Test Firma x Test AB Kreditfaktura nr KR-F-2024001 20240615.pdf',
}),
],
}),
)
expect(emitSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.sent' }),
)
})
it('does not email a credit note when its bookkeeping cannot be completed', async () => {
const creditNote = makeInvoice({
id: 'credit-1',
invoice_number: 'KR-F-2024001',
status: 'draft',
credited_invoice_id: 'inv-1',
customer,
items: invoice.items,
})
enqueue({ data: creditNote, error: null })
enqueue({ data: company, error: null })
enqueue({
data: {
id: 'inv-1',
invoice_number: 'F-2024001',
status: 'sent',
journal_entry_id: 'original-je-1',
paid_at: null,
paid_amount: null,
total: 12500,
},
error: null,
})
enqueue({ data: [{ id: 'credit-1' }], error: null })
mockIssueCreditNote.mockResolvedValue({
complete: false,
journalEntryId: null,
journalEntryRequired: true,
failures: [{ step: 'journal_entry', reason: 'Perioden är låst' }],
})
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/credit-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'credit-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('retries delivery for an already-issued credit note after provider failure', async () => {
const creditNote = makeInvoice({
id: 'credit-1',
invoice_number: 'KR-F-2024001',
status: 'sent',
credited_invoice_id: 'inv-1',
customer,
items: invoice.items,
})
enqueue({ data: creditNote, error: null })
enqueue({ data: company, error: null })
enqueue({
data: {
id: 'inv-1',
invoice_number: 'F-2024001',
status: 'credited',
journal_entry_id: 'original-je-1',
paid_at: null,
paid_amount: null,
total: 12500,
},
error: null,
})
mockSendEmail.mockResolvedValue({ success: true, messageId: 'retry-message-1' })
const response = await POST(
createMockRequest('/api/invoices/credit-1/send', { method: 'POST' }),
createMockRouteParams({ id: 'credit-1' }),
)
expect(response.status).toBe(200)
expect(mockIssueCreditNote).toHaveBeenCalledTimes(1)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
})
it('skips journal entry for cash method', async () => {
const cashCompany = makeCompanySettings({ accounting_method: 'cash', bankgiro: '123-4567' })
enqueue({ data: invoice, error: null })
enqueue({ data: cashCompany, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' })
// Update invoice status
enqueue({ data: [{ id: 'inv-1' }], error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('does not fail when journal entry creation fails (non-blocking)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-3' })
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
// Update invoice status
enqueue({ data: [{ id: 'inv-1' }], error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
})
it('assigns an invoice number when sending a draft with no number', async () => {
const draftWithoutNumber = makeInvoice({
id: 'inv-1',
status: 'draft',
invoice_number: null,
customer,
items: invoice.items,
})
// Fetch invoice (no number)
enqueue({ data: draftWithoutNumber, error: null })
// Fetch company settings
enqueue({ data: company, error: null })
// ensureInvoiceNumber: rpc generate_invoice_number (RPC now persists internally)
enqueue({ data: 'F-2026010', error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-99' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update status to 'sent'
enqueue({ data: [{ id: 'inv-1' }], error: null })
// Update with journal_entry_id
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(mockSupabase.rpc).toHaveBeenCalledWith('generate_invoice_number', {
p_company_id: 'company-1',
p_invoice_id: 'inv-1',
p_document_type: 'invoice',
})
// The journal entry should see the freshly-assigned number
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ invoice_number: 'F-2026010' }),
'enskild_firma'
)
})
it('does not re-assign number when draft already has one (idempotency)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-100' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-2' })
enqueue({ data: [{ id: 'inv-1' }], error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
})
it('does NOT consume an invoice number when PDF render fails (preflight)', async () => {
const draftWithoutNumber = makeInvoice({
id: 'inv-1',
status: 'draft',
invoice_number: null,
customer,
items: invoice.items,
})
enqueue({ data: draftWithoutNumber, error: null })
enqueue({ data: company, error: null })
// First render call (the preflight) throws.
mockRenderToBuffer.mockRejectedValueOnce(new Error('PDF render exploded'))
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_PDF_RENDER_FAILED')
// Critical: counter must not have advanced.
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
// Email must not have been attempted.
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 500 when email sending fails', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: false, error: 'SMTP error' })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
// Provider errors map to a safe retryable response without leaking provider text.
expect(status).toBe(502)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_PROVIDER_FAILED')
expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true)
})
it('does not call the provider when delivery history cannot be saved', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendTrackedInvoiceEmail.mockRejectedValueOnce(new Error('snapshot insert failed'))
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: unknown }>(response)
expect(status).toBe(500)
expect((body.error as { code: string }).code).toBe('INVOICE_SEND_SNAPSHOT_FAILED')
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not allocate an invoice number when delivery reservation fails', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockReserveInvoiceDelivery.mockRejectedValueOnce(new Error('reservation failed'))
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('INVOICE_SEND_SNAPSHOT_FAILED')
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
expect(mockSendTrackedInvoiceEmail).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 400 on malformed lines before any email is sent', async () => {
enqueue({ data: invoice, error: null }) // ownership fetch precedes validation
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { lines: [{ account_number: 'bad', debit_amount: -1 }] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 400 on unbalanced lines before any email is sent', async () => {
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: {
lines: [
{ account_number: '1510', debit_amount: 100, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 90 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_UNBALANCED')
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('books user-edited lines verbatim and skips accrual schedules', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice
enqueue({ data: company, error: null }) // settings
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-lines' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-20' })
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status flip
enqueue({ data: null, error: null }) // journal_entry_id link
const lines = [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '3041', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
]
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { lines },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
// User-edited lines book exactly as reviewed: no accrual schedules.
expect(mockCreateSchedules).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma',
undefined,
expect.objectContaining({
customLines: [
expect.objectContaining({ account_number: '1510', debit_amount: 12500 }),
expect.objectContaining({ account_number: '3041', credit_amount: 10000 }),
expect.objectContaining({ account_number: '2611', credit_amount: 2500 }),
],
})
)
})
it('renders the final PDF as if already sent (no UTKAST banner)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-banner' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: [{ id: 'inv-1' }], error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
// Final render: invoice already has an invoice_number on the fixture, so
// preflight is skipped and InvoicePDF is called exactly once. The status
// passed in must be 'sent': otherwise pdf-template.tsx renders the
// "UTKAST" watermark on the customer's PDF.
expect(vi.mocked(InvoicePDF)).toHaveBeenCalledTimes(1)
const renderArgs = vi.mocked(InvoicePDF).mock.calls[0][0]
expect(renderArgs.invoice.status).toBe('sent')
expect(renderArgs.invoice.invoice_number).toBe('F-2024001')
})
})