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:
co-authored by
Claude Fable 5
parent
f8611f2e89
commit
14f7478abb
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user