* feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund Closes #1693. A paid faktura re-renders with a BETALD banner (paid date and amount) and "Betalt: X" followed by "Att betala: 0"; partially_paid gets the Betalt / Att betala (remaining) rows without a banner. Credit notes and proformas are unchanged. Labels in sv and en. The paid copy is its own document, a betalningsbekräftelse, never the archived original: GET /api/invoices/[id]/pdf?variant=paid refuses anything but status paid (409 INVOICE_PAYMENT_CONFIRMATION_NOT_PAID), names the file Betalningsbekraftelse-<nr>.pdf and never reads or replaces the delivery archive. invoice-pdf-source gains the 'payment_confirmation' re-render reason so the UI caveats it like any re-render. POST /api/invoices/[id]/send-payment-confirmation emails the paid PDF with a dedicated subject/body through the existing email service and recipient routing, without touching status, sent_at, journal entries or invoice_deliveries (no kind column there; logged via the route logger instead). Detail page: the two actions sit inside the Betald card (download paid copy, send confirmation with an up-front confirm dialog), not in the header row. No migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): one-line hint for the betalningsbekräftelse actions 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>
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
createMockRequest,
|
|
createMockRouteParams,
|
|
createQueuedMockSupabase,
|
|
makeCompanySettings,
|
|
makeCustomer,
|
|
makeInvoice,
|
|
} from '@/tests/helpers'
|
|
import { contentDispositionFilename } from '@/lib/api/content-disposition'
|
|
|
|
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
|
const requireAuthMock = vi.fn()
|
|
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
const renderToBufferMock = vi.fn()
|
|
vi.mock('@react-pdf/renderer', () => ({
|
|
renderToBuffer: (...args: unknown[]) => renderToBufferMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/invoices/pdf-template', () => ({
|
|
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
|
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
|
|
SHOW_SWISH_ON_INVOICE: false,
|
|
}))
|
|
|
|
import { GET } from '../route'
|
|
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
|
|
|
describe('GET /api/invoices/[id]/pdf', () => {
|
|
const user = { id: 'user-1', email: 'owner@example.test' }
|
|
const customer = makeCustomer({ name: 'Kund ÅÄÖ AB' })
|
|
const company = makeCompanySettings({ company_name: 'Oppy Sverige', bankgiro: '123-4567' })
|
|
const invoice = makeInvoice({
|
|
id: 'invoice-1',
|
|
invoice_number: '2621',
|
|
invoice_date: '2026-07-21',
|
|
customer,
|
|
items: [],
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
|
|
renderToBufferMock.mockResolvedValue(Buffer.from('pdf-bytes'))
|
|
})
|
|
|
|
it('returns 401 when the caller is not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase: mockSupabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(401)
|
|
})
|
|
|
|
it('returns 404 when the invoice does not exist', async () => {
|
|
enqueue({ data: null, error: { message: 'not found' } })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/missing/pdf'),
|
|
createMockRouteParams({ id: 'missing' }),
|
|
)
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
|
|
it('returns a descriptive UTF-8 filename for the PDF download', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
|
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
|
|
it('forces a download by default', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.headers.get('Content-Disposition')).toMatch(/^attachment;/)
|
|
})
|
|
|
|
it('serves the PDF inline for in-browser review on ?disposition=inline (#1190)', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf', {
|
|
searchParams: { disposition: 'inline' },
|
|
}),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Disposition')).toMatch(/^inline;/)
|
|
// The filename still travels with it, so the browser viewer's own save
|
|
// action produces the same name as the download button would.
|
|
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
|
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
|
|
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
|
})
|
|
|
|
it('keeps the download behaviour for an unknown disposition value', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf', {
|
|
searchParams: { disposition: 'evil' },
|
|
}),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.headers.get('Content-Disposition')).toMatch(/^attachment;/)
|
|
})
|
|
|
|
it('returns 400 before rendering when a foreign payment account is missing', async () => {
|
|
enqueue({ data: { ...invoice, currency: 'EUR' }, error: null })
|
|
enqueue({ data: { ...company, invoice_payment_accounts: {} }, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(renderToBufferMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// #1693: the betalningsbekräftelse variant. Same render, refused unless the
|
|
// faktura is fully paid, named as a payment confirmation, archive untouched.
|
|
describe('?variant=paid', () => {
|
|
const paidInvoice = {
|
|
...invoice,
|
|
status: 'paid',
|
|
paid_amount: 12500,
|
|
remaining_amount: 0,
|
|
paid_at: '2026-08-17T12:00:00+00:00',
|
|
}
|
|
|
|
it('returns 401 when the caller is not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase: mockSupabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf', { searchParams: { variant: 'paid' } }),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(401)
|
|
})
|
|
|
|
it('returns 404 when the invoice does not exist', async () => {
|
|
enqueue({ data: null, error: { message: 'not found' } })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/missing/pdf', { searchParams: { variant: 'paid' } }),
|
|
createMockRouteParams({ id: 'missing' }),
|
|
)
|
|
|
|
expect(response.status).toBe(404)
|
|
})
|
|
|
|
it.each(['sent', 'partially_paid', 'overdue', 'draft'])(
|
|
'returns 409 without rendering when the invoice is %s',
|
|
async (status) => {
|
|
enqueue({ data: { ...invoice, status }, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf', { searchParams: { variant: 'paid' } }),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(409)
|
|
expect(body.error.code).toBe('INVOICE_PAYMENT_CONFIRMATION_NOT_PAID')
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
expect(renderToBufferMock).not.toHaveBeenCalled()
|
|
},
|
|
)
|
|
|
|
it('refuses a paid credit note', async () => {
|
|
enqueue({ data: { ...paidInvoice, credited_invoice_id: 'orig-1' }, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf', { searchParams: { variant: 'paid' } }),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(409)
|
|
})
|
|
|
|
it('renders the paid invoice and names the file as a betalningsbekräftelse', async () => {
|
|
enqueue({ data: paidInvoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf', { searchParams: { variant: 'paid' } }),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('application/pdf')
|
|
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
|
.toBe('Betalningsbekraftelse-2621.pdf')
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
// The template gets the row as stored (status paid), so it is the one
|
|
// that stamps BETALD; the route does not fake a status.
|
|
expect(InvoicePDF).toHaveBeenCalledWith(
|
|
expect.objectContaining({ invoice: expect.objectContaining({ status: 'paid' }) }),
|
|
)
|
|
// Never a document-archive read: the paid copy is always a fresh render.
|
|
expect(mockSupabase.from).not.toHaveBeenCalledWith('invoice_deliveries')
|
|
expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments')
|
|
})
|
|
|
|
it('keeps the invoice filename for an unknown variant value', async () => {
|
|
enqueue({ data: paidInvoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf', { searchParams: { variant: 'evil' } }),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
|
|
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
|
|
})
|
|
})
|
|
|
|
it('marks PDF generation errors as private and non-cacheable', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
renderToBufferMock.mockRejectedValueOnce(new Error('render failed'))
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/invoices/invoice-1/pdf'),
|
|
createMockRouteParams({ id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(response.status).toBe(500)
|
|
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
|
})
|
|
})
|