Files
accounted/app/api/reports/ar-ledger/pdf/route.ts
T
Jakob WennbergandClaude Fable 5 14f7478abb 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>
2026-07-16 18:14:45 +02:00

95 lines
3.4 KiB
TypeScript

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 }
)
}
})