feat(reports): reskontra per valfritt datum + PDF-export (#1039)

Kundreskontra and leverantörsreskontra were effectively always "as of
today": the UI never passed a date, the xlsx export ignored the chosen
fiscal year, and no PDF existed.

- Both ledger generators reconstruct the ledger as it stood on a
  backdated as-of date: invoices dated on or before it (including ones
  fully paid since) with outstanding recomputed from the payment-row
  history; paid_at dates row-less full payments; undateable legacy
  amounts degrade to the live values. Today/future dates keep the live
  computation byte-identical.
- New shared reskontra PDF template (aging per counterparty + invoice
  detail for kundreskontra) with PDF routes for both ledgers.
- Both report views get a "Per datum" date control; the export menu
  offers PDF + Excel and passes the chosen date through.

Note: the PDF template deliberately avoids react-pdf's `break` prop:
it deadlocks layout when the section spills across pages (reproduced
at 40+ rows, documented in the template).

Fixes #1020
Fixes #1021

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-16 18:14:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f8611f2e89
commit 14f7478abb
12 changed files with 1318 additions and 32 deletions
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
const mockSupabase = {
auth: { getUser: vi.fn() },
from: vi.fn(),
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
// Stub the PDF renderer so the test never spins up real PDF layout. Provide the
// primitives the template imports at module load (StyleSheet.create runs then).
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.4 test')),
StyleSheet: { create: (s: unknown) => s },
Document: (p: unknown) => p,
Page: (p: unknown) => p,
Text: (p: unknown) => p,
View: (p: unknown) => p,
}))
vi.mock('@/lib/reports/ar-ledger', () => ({
generateARLedger: vi.fn(),
}))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { generateARLedger } from '@/lib/reports/ar-ledger'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function companySettingsQuery(data: unknown) {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data, error: null }),
}
}
function makeLedger() {
return {
entries: [
{
customer_id: 'cust-1',
customer_name: 'Acme AB',
invoices: [
{
invoice_id: 'inv-1',
invoice_number: 'F001',
invoice_date: '2026-05-01',
due_date: '2026-06-01',
total: 1000,
paid_amount: 0,
outstanding: 1000,
outstanding_sek: 1000,
days_overdue: 14,
currency: 'SEK',
},
],
current: 0,
days_1_30: 1000,
days_31_60: 0,
days_61_90: 0,
days_90_plus: 0,
total_outstanding: 1000,
},
],
total_outstanding: 1000,
total_current: 0,
total_overdue: 1000,
unpaid_count: 1,
unconverted_fx_count: 0,
}
}
function makeRequest(query = '') {
return new Request(`http://localhost/api/reports/ar-ledger/pdf${query}`)
}
describe('GET /api/reports/ar-ledger/pdf', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
mockSupabase.from.mockReturnValue(
companySettingsQuery({ company_name: 'Testbolaget AB', org_number: '5566778899' }),
)
vi.mocked(generateARLedger).mockResolvedValue(makeLedger() as never)
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(makeRequest(), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(401)
})
it('returns 400 for a malformed as_of_date', async () => {
const res = await GET(makeRequest('?as_of_date=not-a-date'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(400)
expect(generateARLedger).not.toHaveBeenCalled()
})
it('returns 404 when company settings are missing', async () => {
mockSupabase.from.mockReturnValue(companySettingsQuery(null))
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(404)
})
it('renders a PDF for the requested as-of date', async () => {
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toContain('kundreskontra')
expect(res.headers.get('Content-Disposition')).toContain('20260630')
expect(generateARLedger).toHaveBeenCalledWith(mockSupabase, 'company-1', '2026-06-30')
})
it('defaults to today when no as_of_date is given', async () => {
const res = await GET(makeRequest(), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(200)
const calledWith = vi.mocked(generateARLedger).mock.calls[0][2]
expect(calledWith).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
it('returns 500 when the generator throws', async () => {
vi.mocked(generateARLedger).mockRejectedValue(new Error('boom'))
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(500)
})
})
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { ReskontraPDF, type ReskontraInvoiceRow } from '@/lib/reports/reskontra-pdf-template'
import { withRouteContext } from '@/lib/api/with-route-context'
import { slugifyCompanyName } from '@/lib/reports/xlsx-export'
import type { CompanySettings } from '@/types'
export const GET = withRouteContext('report.ar_ledger.pdf', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const asOfParam = searchParams.get('as_of_date')
if (asOfParam && !/^\d{4}-\d{2}-\d{2}$/.test(asOfParam)) {
return NextResponse.json({ error: 'as_of_date måste vara på formatet ÅÅÅÅ-MM-DD' }, { status: 400 })
}
const asOfDate = asOfParam ?? new Date().toISOString().slice(0, 10)
const { data: companyRow } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (!companyRow) {
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
try {
const ledger = await generateARLedger(supabase, companyId, asOfDate)
const invoices: ReskontraInvoiceRow[] = []
for (const entry of ledger.entries) {
for (const inv of entry.invoices) {
if (inv.outstanding === 0) continue
invoices.push({
counterparty: entry.customer_name,
invoice_number: inv.invoice_number,
invoice_date: inv.invoice_date,
due_date: inv.due_date,
outstanding: inv.outstanding,
currency: inv.currency,
days_overdue: inv.days_overdue,
})
}
}
const pdfBuffer = await renderToBuffer(
ReskontraPDF({
title: 'Kundreskontra',
counterpartyLabel: 'Kund',
asOfDate,
aging: ledger.entries.map((e) => ({
name: e.customer_name,
current: e.current,
days_1_30: e.days_1_30,
days_31_60: e.days_31_60,
days_61_90: e.days_61_90,
days_90_plus: e.days_90_plus,
total_outstanding: e.total_outstanding,
})),
totals: {
name: 'Summa',
current: ledger.total_current,
days_1_30: ledger.entries.reduce((s, e) => s + e.days_1_30, 0),
days_31_60: ledger.entries.reduce((s, e) => s + e.days_31_60, 0),
days_61_90: ledger.entries.reduce((s, e) => s + e.days_61_90, 0),
days_90_plus: ledger.entries.reduce((s, e) => s + e.days_90_plus, 0),
total_outstanding: ledger.total_outstanding,
},
unpaidCount: ledger.unpaid_count,
unconvertedFxCount: ledger.unconverted_fx_count,
invoices,
company: companyRow as CompanySettings,
generatedAt: new Date().toISOString(),
})
)
const companySlug = slugifyCompanyName(companyRow.company_name ?? '')
const parts = ['kundreskontra', companySlug, asOfDate.replace(/-/g, '')].filter(Boolean)
const filename = `${parts.join('-')}.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Kunde inte generera kundreskontra' },
{ status: 500 }
)
}
})
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
const mockSupabase = {
auth: { getUser: vi.fn() },
from: vi.fn(),
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
// Stub the PDF renderer so the test never spins up real PDF layout. Provide the
// primitives the template imports at module load (StyleSheet.create runs then).
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.4 test')),
StyleSheet: { create: (s: unknown) => s },
Document: (p: unknown) => p,
Page: (p: unknown) => p,
Text: (p: unknown) => p,
View: (p: unknown) => p,
}))
vi.mock('@/lib/reports/supplier-ledger', () => ({
generateSupplierLedger: vi.fn(),
}))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function companySettingsQuery(data: unknown) {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data, error: null }),
}
}
function makeLedger() {
return {
entries: [
{
supplier_id: 'sup-1',
supplier_name: 'Leverantören AB',
current: 500,
days_1_30: 0,
days_31_60: 0,
days_61_90: 0,
days_90_plus: 0,
total_outstanding: 500,
},
],
total_outstanding: 500,
total_current: 500,
total_overdue: 0,
unpaid_count: 1,
unconverted_fx_count: 0,
}
}
function makeRequest(query = '') {
return new Request(`http://localhost/api/reports/supplier-ledger/pdf${query}`)
}
describe('GET /api/reports/supplier-ledger/pdf', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
mockSupabase.from.mockReturnValue(
companySettingsQuery({ company_name: 'Testbolaget AB', org_number: '5566778899' }),
)
vi.mocked(generateSupplierLedger).mockResolvedValue(makeLedger() as never)
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(makeRequest(), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(401)
})
it('returns 400 for a malformed as_of_date', async () => {
const res = await GET(makeRequest('?as_of_date=2026-6-1'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(400)
expect(generateSupplierLedger).not.toHaveBeenCalled()
})
it('returns 404 when company settings are missing', async () => {
mockSupabase.from.mockReturnValue(companySettingsQuery(null))
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(404)
})
it('renders a PDF for the requested as-of date', async () => {
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toContain('leverantorsreskontra')
expect(res.headers.get('Content-Disposition')).toContain('20260630')
expect(generateSupplierLedger).toHaveBeenCalledWith(mockSupabase, 'company-1', '2026-06-30')
})
it('returns 500 when the generator throws', async () => {
vi.mocked(generateSupplierLedger).mockRejectedValue(new Error('boom'))
const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never)
expect(res.status).toBe(500)
})
})
@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
import { ReskontraPDF } from '@/lib/reports/reskontra-pdf-template'
import { withRouteContext } from '@/lib/api/with-route-context'
import { slugifyCompanyName } from '@/lib/reports/xlsx-export'
import type { CompanySettings } from '@/types'
export const GET = withRouteContext('report.supplier_ledger.pdf', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const asOfParam = searchParams.get('as_of_date')
if (asOfParam && !/^\d{4}-\d{2}-\d{2}$/.test(asOfParam)) {
return NextResponse.json({ error: 'as_of_date måste vara på formatet ÅÅÅÅ-MM-DD' }, { status: 400 })
}
const asOfDate = asOfParam ?? new Date().toISOString().slice(0, 10)
const { data: companyRow } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (!companyRow) {
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
try {
const ledger = await generateSupplierLedger(supabase, companyId, asOfDate)
const pdfBuffer = await renderToBuffer(
ReskontraPDF({
title: 'Leverantörsreskontra',
counterpartyLabel: 'Leverantör',
asOfDate,
aging: ledger.entries.map((e) => ({
name: e.supplier_name,
current: e.current,
days_1_30: e.days_1_30,
days_31_60: e.days_31_60,
days_61_90: e.days_61_90,
days_90_plus: e.days_90_plus,
total_outstanding: e.total_outstanding,
})),
totals: {
name: 'Summa',
current: ledger.total_current,
days_1_30: ledger.entries.reduce((s, e) => s + e.days_1_30, 0),
days_31_60: ledger.entries.reduce((s, e) => s + e.days_31_60, 0),
days_61_90: ledger.entries.reduce((s, e) => s + e.days_61_90, 0),
days_90_plus: ledger.entries.reduce((s, e) => s + e.days_90_plus, 0),
total_outstanding: ledger.total_outstanding,
},
unpaidCount: ledger.unpaid_count,
unconvertedFxCount: ledger.unconverted_fx_count,
company: companyRow as CompanySettings,
generatedAt: new Date().toISOString(),
})
)
const companySlug = slugifyCompanyName(companyRow.company_name ?? '')
const parts = ['leverantorsreskontra', companySlug, asOfDate.replace(/-/g, '')].filter(Boolean)
const filename = `${parts.join('-')}.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Kunde inte generera leverantörsreskontra' },
{ status: 500 }
)
}
})