diff --git a/app/api/reports/ar-ledger/pdf/__tests__/route.test.ts b/app/api/reports/ar-ledger/pdf/__tests__/route.test.ts new file mode 100644 index 00000000..682656d3 --- /dev/null +++ b/app/api/reports/ar-ledger/pdf/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/reports/ar-ledger/pdf/route.ts b/app/api/reports/ar-ledger/pdf/route.ts new file mode 100644 index 00000000..37aabf22 --- /dev/null +++ b/app/api/reports/ar-ledger/pdf/route.ts @@ -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 } + ) + } +}) diff --git a/app/api/reports/supplier-ledger/pdf/__tests__/route.test.ts b/app/api/reports/supplier-ledger/pdf/__tests__/route.test.ts new file mode 100644 index 00000000..0733e217 --- /dev/null +++ b/app/api/reports/supplier-ledger/pdf/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/reports/supplier-ledger/pdf/route.ts b/app/api/reports/supplier-ledger/pdf/route.ts new file mode 100644 index 00000000..ee65b5a0 --- /dev/null +++ b/app/api/reports/supplier-ledger/pdf/route.ts @@ -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 } + ) + } +}) diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 0238c7c0..df085981 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect, useCallback } from 'react' import Link from 'next/link' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react' @@ -2035,16 +2036,63 @@ interface SupplierLedgerData { } | null } +// Local calendar date (YYYY-MM-DD) for the reskontra "per datum" default: +// toISOString() is UTC and rolls the date over an hour early in Sweden. +function localIsoDate(): string { + const now = new Date() + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` +} + +// Shared "Per datum" control + export menu header for the two reskontra views +// (#1020/#1021): pick an arbitrary as-of date and export PDF/Excel for it. +function ReskontraToolbar({ + asOfDate, + onAsOfDateChange, + inputId, + exportBase, +}: { + asOfDate: string + onAsOfDateChange: (date: string) => void + inputId: string + exportBase: string +}) { + return ( +
+
+ + { + if (e.target.value) onAsOfDateChange(e.target.value) + }} + className="w-40" + /> +
+ +
+ ) +} + export function SupplierLedgerView({ periodId }: { periodId: string }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [asOfDate, setAsOfDate] = useState(localIsoDate) const fetchData = async () => { setLoading(true) setError(null) try { - const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}`) + const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}&as_of_date=${asOfDate}`) const result = await res.json() if (result.error) { setError(result.error) @@ -2060,7 +2108,7 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) { useEffect(() => { if (periodId) fetchData() - }, [periodId]) + }, [periodId, asOfDate]) if (loading) { return ( @@ -2097,7 +2145,12 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) { return (
- + {/* Summary cards */}
@@ -2815,12 +2868,13 @@ export function ARLedgerView({ periodId }: { periodId: string }) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [expandedCustomers, setExpandedCustomers] = useState>(new Set()) + const [asOfDate, setAsOfDate] = useState(localIsoDate) const fetchData = async () => { setLoading(true) setError(null) try { - const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}`) + const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}&as_of_date=${asOfDate}`) const result = await res.json() if (result.error) { setError(result.error) @@ -2836,7 +2890,7 @@ export function ARLedgerView({ periodId }: { periodId: string }) { useEffect(() => { if (periodId) fetchData() - }, [periodId]) + }, [periodId, asOfDate]) const toggleCustomer = (customerId: string) => { setExpandedCustomers((prev) => { @@ -2885,7 +2939,12 @@ export function ARLedgerView({ periodId }: { periodId: string }) { return (
- + {/* Summary cards */}
diff --git a/lib/reports/__tests__/ar-ledger.test.ts b/lib/reports/__tests__/ar-ledger.test.ts index e49a6711..c6ea1021 100644 --- a/lib/reports/__tests__/ar-ledger.test.ts +++ b/lib/reports/__tests__/ar-ledger.test.ts @@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }> function makeBuilder() { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'order', 'range']) { + for (const m of ['select', 'eq', 'in', 'lte', 'order', 'range']) { b[m] = vi.fn().mockReturnValue(b) } b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) @@ -447,3 +447,124 @@ describe('generateARLedger', () => { expect(report.entries[0].customer_name).toBe('Okänd kund') }) }) + +describe('generateARLedger: historical as-of reconstruction (#1020)', () => { + const invoiceBase = { + customer_id: 'cust-a', + customer: { id: 'cust-a', name: 'Acme AB' }, + invoice_date: '2024-05-01', + due_date: '2024-06-01', + currency: 'SEK', + } + + it('reopens an invoice whose payment came after the as-of date', async () => { + results = [ + // Query 1: invoices (historical path also fetches status='paid') + { + data: [ + { ...invoiceBase, id: 'inv-1', invoice_number: 'F001', total: 5000, paid_amount: 5000, paid_at: '2024-07-01T10:00:00Z', status: 'paid' }, + ], + error: null, + }, + // Query 2: payment rows: the payment is dated after the as-of date + { + data: [{ invoice_id: 'inv-1', amount: 5000, payment_date: '2024-07-01' }], + error: null, + }, + ] + + const report = await generateARLedger(supabase, 'company-1', '2024-06-15') + + expect(report.entries).toHaveLength(1) + expect(report.entries[0].invoices[0].outstanding).toBe(5000) + expect(report.entries[0].invoices[0].paid_amount).toBe(0) + expect(report.total_outstanding).toBe(5000) + expect(report.unpaid_count).toBe(1) + }) + + it('reduces outstanding by payments made on or before the as-of date only', async () => { + results = [ + { + data: [ + { ...invoiceBase, id: 'inv-1', invoice_number: 'F001', total: 10000, paid_amount: 10000, paid_at: '2024-07-05T10:00:00Z', status: 'paid' }, + ], + error: null, + }, + { + data: [ + { invoice_id: 'inv-1', amount: 4000, payment_date: '2024-06-10' }, + { invoice_id: 'inv-1', amount: 6000, payment_date: '2024-07-05' }, + ], + error: null, + }, + ] + + const report = await generateARLedger(supabase, 'company-1', '2024-06-15') + + expect(report.entries[0].invoices[0].paid_amount).toBe(4000) + expect(report.entries[0].invoices[0].outstanding).toBe(6000) + expect(report.total_outstanding).toBe(6000) + }) + + it('skips invoices already settled by the as-of date', async () => { + results = [ + { + data: [ + // Settled before the as-of date: must not appear at all. + { ...invoiceBase, id: 'inv-1', invoice_number: 'F001', total: 1000, paid_amount: 1000, paid_at: '2024-06-01T10:00:00Z', status: 'paid' }, + // Still open: the only row in the report. + { ...invoiceBase, id: 'inv-2', invoice_number: 'F002', total: 2000, paid_amount: 0, status: 'sent' }, + ], + error: null, + }, + { data: [], error: null }, + ] + + const report = await generateARLedger(supabase, 'company-1', '2024-06-15') + + expect(report.entries).toHaveLength(1) + expect(report.entries[0].invoices).toHaveLength(1) + expect(report.entries[0].invoices[0].invoice_number).toBe('F002') + expect(report.total_outstanding).toBe(2000) + expect(report.unpaid_count).toBe(1) + }) + + it('falls back to paid_at for fully paid invoices without payment rows', async () => { + results = [ + { + data: [ + // No payment rows, but paid_at says the payment came after the + // as-of date: the invoice was open on that date. + { ...invoiceBase, id: 'inv-1', invoice_number: 'F001', total: 3000, paid_amount: 3000, paid_at: '2024-08-01T10:00:00Z', status: 'paid' }, + ], + error: null, + }, + { data: [], error: null }, + ] + + const report = await generateARLedger(supabase, 'company-1', '2024-06-15') + + expect(report.entries).toHaveLength(1) + expect(report.entries[0].invoices[0].outstanding).toBe(3000) + expect(report.total_outstanding).toBe(3000) + }) + + it('keeps stored paid_amount for undateable legacy partial payments', async () => { + results = [ + { + data: [ + // No payment rows and no paid_at: the stored partial amount cannot + // be dated, so it is assumed to have stood at the as-of date. + { ...invoiceBase, id: 'inv-1', invoice_number: 'F001', total: 3000, paid_amount: 1000, status: 'sent' }, + ], + error: null, + }, + { data: [], error: null }, + ] + + const report = await generateARLedger(supabase, 'company-1', '2024-06-15') + + expect(report.entries[0].invoices[0].outstanding).toBe(2000) + expect(report.total_outstanding).toBe(2000) + }) +}) diff --git a/lib/reports/__tests__/supplier-ledger.test.ts b/lib/reports/__tests__/supplier-ledger.test.ts index 66f94c93..f7399d3e 100644 --- a/lib/reports/__tests__/supplier-ledger.test.ts +++ b/lib/reports/__tests__/supplier-ledger.test.ts @@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }> function makeBuilder() { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'order', 'range']) { + for (const m of ['select', 'eq', 'in', 'lte', 'order', 'range']) { b[m] = vi.fn().mockReturnValue(b) } b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) @@ -332,3 +332,98 @@ describe('generateSupplierLedger', () => { expect(report.total_current).toBe(100) }) }) + +describe('generateSupplierLedger: historical as-of reconstruction (#1021)', () => { + const invoiceBase = { + supplier_id: 'sup-1', + supplier: { id: 'sup-1', name: 'Leverantören AB' }, + invoice_date: '2024-05-01', + due_date: '2024-06-01', + currency: 'SEK', + } + + it('reopens an invoice whose payment came after the as-of date', async () => { + results = [ + // Query 1: invoices (historical path also fetches status='paid') + { + data: [ + { ...invoiceBase, id: 'si-1', total: 8000, paid_amount: 8000, remaining_amount: 0, paid_at: '2024-07-01T10:00:00Z', status: 'paid' }, + ], + error: null, + }, + // Query 2: payment rows dated after the as-of date + { + data: [{ supplier_invoice_id: 'si-1', amount: 8000, payment_date: '2024-07-01' }], + error: null, + }, + ] + + const report = await generateSupplierLedger(supabase, 'company-1', '2024-06-15') + + expect(report.entries).toHaveLength(1) + expect(report.entries[0].total_outstanding).toBe(8000) + expect(report.total_outstanding).toBe(8000) + expect(report.unpaid_count).toBe(1) + }) + + it('reduces outstanding by payments on or before the as-of date and skips settled invoices', async () => { + results = [ + { + data: [ + // Partially paid at the as-of date: 4 000 of 10 000 paid. + { ...invoiceBase, id: 'si-1', total: 10000, paid_amount: 10000, remaining_amount: 0, paid_at: '2024-07-05T10:00:00Z', status: 'paid' }, + // Fully settled before the as-of date: must not appear. + { ...invoiceBase, id: 'si-2', total: 500, paid_amount: 500, remaining_amount: 0, paid_at: '2024-06-01T10:00:00Z', status: 'paid' }, + ], + error: null, + }, + { + data: [ + { supplier_invoice_id: 'si-1', amount: 4000, payment_date: '2024-06-10' }, + { supplier_invoice_id: 'si-1', amount: 6000, payment_date: '2024-07-05' }, + ], + error: null, + }, + ] + + const report = await generateSupplierLedger(supabase, 'company-1', '2024-06-15') + + expect(report.entries).toHaveLength(1) + expect(report.total_outstanding).toBe(6000) + expect(report.unpaid_count).toBe(1) + }) + + it('falls back to paid_at for fully paid invoices without payment rows', async () => { + results = [ + { + data: [ + { ...invoiceBase, id: 'si-1', total: 3000, paid_amount: 3000, remaining_amount: 0, paid_at: '2024-08-01T10:00:00Z', status: 'paid' }, + ], + error: null, + }, + { data: [], error: null }, + ] + + const report = await generateSupplierLedger(supabase, 'company-1', '2024-06-15') + + expect(report.entries).toHaveLength(1) + expect(report.total_outstanding).toBe(3000) + }) + + it('keeps the live remaining_amount for the live (non-backdated) view', async () => { + // No asOfDate: single query, stored remaining_amount trusted as-is. + results = [ + { + data: [ + { ...invoiceBase, id: 'si-1', total: 3000, paid_amount: 1000, remaining_amount: 2000, status: 'partially_paid' }, + ], + error: null, + }, + ] + + const report = await generateSupplierLedger(supabase, 'company-1') + + expect(report.total_outstanding).toBe(2000) + expect(report.unpaid_count).toBe(1) + }) +}) diff --git a/lib/reports/ar-ledger.ts b/lib/reports/ar-ledger.ts index 149e707c..52db21e8 100644 --- a/lib/reports/ar-ledger.ts +++ b/lib/reports/ar-ledger.ts @@ -1,6 +1,8 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' +import { roundOre } from '@/lib/money' +import { fetchPaymentsAsOf, outstandingAsOf, todayIsoDate, type PaymentsAsOf } from './reskontra-payments' export interface ARInvoiceDetail { invoice_id: string @@ -51,6 +53,11 @@ export interface ARLedgerReport { /** * Generate AR ledger (kundreskontra) with aging analysis. * BFL 5 kap. 4 §: sidoordnad bokföring: outstanding customer invoices with aging. + * + * With a backdated `asOfDate` the ledger is reconstructed as it stood on that + * date: invoices dated on or before it (including ones fully paid since) with + * outstanding amounts recomputed from the payment history (#1020). Without an + * `asOfDate`, or with today/future, the live open-invoice state is used as-is. */ export async function generateARLedger( supabase: SupabaseClient, @@ -58,21 +65,36 @@ export async function generateARLedger( asOfDate?: string ): Promise { const refDate = asOfDate ? new Date(asOfDate) : new Date() + // Backdated reconstruction only kicks in for genuinely historical dates: + // for today/future the stored open-invoice state IS the as-of state, and + // the live view must stay byte-identical to what it always showed. + const isHistorical = !!asOfDate && asOfDate < todayIsoDate() - // Fetch all unpaid/sent/overdue invoices with customer info + // Fetch the ledger population. Live view: open invoices only. Historical + // view: also invoices paid since the as-of date, restricted to invoice + // dates on or before it. Invoices cancelled since are treated as never + // having existed (their cancellation is not reliably dated). // eslint-disable-next-line @typescript-eslint/no-explicit-any let invoices: any[] + let payments: PaymentsAsOf | null = null try { - invoices = await fetchAllRows(({ from, to }) => - supabase + invoices = await fetchAllRows(({ from, to }) => { + let query = supabase .from('invoices') .select('*, customer:customers(id, name)') .eq('company_id', companyId) - .in('status', ['sent', 'overdue', 'credited']) + query = isHistorical + ? query.in('status', ['sent', 'overdue', 'credited', 'paid']).lte('invoice_date', asOfDate!) + : query.in('status', ['sent', 'overdue', 'credited']) + return query // Stable total order for correct paging (see fetch-all.ts). .order('id', { ascending: true }) .range(from, to) - ) + }) + + if (isHistorical) { + payments = await fetchPaymentsAsOf(supabase, 'invoice_payments', 'invoice_id', companyId, asOfDate!) + } } catch { return { entries: [], @@ -109,9 +131,17 @@ export async function generateARLedger( const entry = byCustomer.get(customerId)! const dueDate = new Date(inv.due_date) const daysOverdue = Math.floor((refDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24)) - const paidAmount = Number(inv.paid_amount) || 0 const total = Number(inv.total) || 0 - const outstanding = Math.round((total - paidAmount) * 100) / 100 + const liveOutstanding = roundOre(total - (Number(inv.paid_amount) || 0)) + const outstanding = payments + ? outstandingAsOf(inv, total, liveOutstanding, payments, asOfDate!) + : liveOutstanding + const paidAmount = roundOre(total - outstanding) + + // Historical view: 'paid' invoices are only fetched to catch ones still + // open at the as-of date. One already settled by then adds nothing to the + // reskontra, so skip its zero row instead of listing it. + if (isHistorical && inv.status === 'paid' && outstanding === 0) continue // Aging buckets and totals must be in SEK so they reconcile with account 1510. // Foreign-currency invoices without an exchange_rate cannot be converted: diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index 054596b5..bbed845f 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -264,7 +264,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ descKey: 'desc_kundreskontra', category: 'ledgers', params: 'fiscal', - exports: ['xlsx'], + exports: ['pdf', 'xlsx'], }, { slug: 'supplier-ledger', @@ -272,7 +272,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ descKey: 'desc_supplier_ledger', category: 'ledgers', params: 'fiscal', - exports: ['xlsx'], + exports: ['pdf', 'xlsx'], }, // --- Avstämning (reconciliation) --- diff --git a/lib/reports/reskontra-payments.ts b/lib/reports/reskontra-payments.ts new file mode 100644 index 00000000..3b39938a --- /dev/null +++ b/lib/reports/reskontra-payments.ts @@ -0,0 +1,104 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { roundOre } from '@/lib/money' + +/** + * Payment history for reconstructing a reskontra as of an arbitrary date. + * + * `paidThrough` sums the payment rows dated on or before the as-of date, per + * invoice. `hasRows` marks invoices that have ANY payment rows (any date): + * callers need it to tell "paid, but after the as-of date" (reconstructable, + * paid-through 0) apart from "no payment rows recorded at all" (legacy data, + * fall back to the invoice's own paid_at / stored amounts). + */ +export interface PaymentsAsOf { + paidThrough: Map + hasRows: Set +} + +interface PaymentRow { + amount: number | string | null + payment_date: string +} + +/** + * Fetch the company's payment rows for one of the two invoice ledgers and + * aggregate them per invoice as of `asOfDate` (inclusive). Amounts are in the + * invoice's own currency, matching how the ledger generators convert to SEK + * with the invoice-date exchange_rate. + */ +export async function fetchPaymentsAsOf( + supabase: SupabaseClient, + table: 'invoice_payments' | 'supplier_invoice_payments', + invoiceIdColumn: 'invoice_id' | 'supplier_invoice_id', + companyId: string, + asOfDate: string +): Promise { + const rows = await fetchAllRows>(({ from, to }) => + supabase + .from(table) + .select(`${invoiceIdColumn}, amount, payment_date`) + .eq('company_id', companyId) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) + .range(from, to) + ) + + const paidThrough = new Map() + const hasRows = new Set() + + for (const row of rows) { + const invoiceId = row[invoiceIdColumn] as string | null + if (!invoiceId) continue + hasRows.add(invoiceId) + if (row.payment_date && row.payment_date <= asOfDate) { + const prev = paidThrough.get(invoiceId) ?? 0 + paidThrough.set(invoiceId, roundOre(prev + (Number(row.amount) || 0))) + } + } + + return { paidThrough, hasRows } +} + +/** + * An invoice's outstanding amount (in invoice currency) as of the + * reconstruction date. + * + * Priority order: + * 1. Payment rows exist: they are authoritative. Outstanding is the invoice + * total minus the rows dated on or before the as-of date, including the + * "all payments came later" case, which reopens the full total. + * 2. No rows but the invoice is fully paid (`paid_at` set): paid before or on + * the as-of date means the live (settled) outstanding stands; paid after + * it means the full total was still open. + * 3. No rows and no `paid_at` (legacy partial payments recorded before the + * payment tables carried every settlement): the history cannot be dated, + * so the live outstanding is assumed to have stood at the as-of date. + * This matches what the live ledger reports for the same rows. + */ +export function outstandingAsOf( + invoice: { id: string; paid_at?: string | null }, + total: number, + liveOutstanding: number, + payments: PaymentsAsOf, + asOfDate: string +): number { + if (payments.hasRows.has(invoice.id)) { + const paid = payments.paidThrough.get(invoice.id) ?? 0 + return roundOre(total - paid) + } + if (invoice.paid_at) { + return String(invoice.paid_at).slice(0, 10) <= asOfDate ? liveOutstanding : total + } + return liveOutstanding +} + +/** Local calendar date (YYYY-MM-DD) used to decide whether an as-of date needs + * historical reconstruction at all. */ +export function todayIsoDate(): string { + const now = new Date() + const y = now.getFullYear() + const m = String(now.getMonth() + 1).padStart(2, '0') + const d = String(now.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} diff --git a/lib/reports/reskontra-pdf-template.tsx b/lib/reports/reskontra-pdf-template.tsx new file mode 100644 index 00000000..a57843da --- /dev/null +++ b/lib/reports/reskontra-pdf-template.tsx @@ -0,0 +1,385 @@ +import { + Document, + Page, + Text, + View, + StyleSheet, +} from '@react-pdf/renderer' +import type { CompanySettings } from '@/types' + +const styles = StyleSheet.create({ + page: { + paddingTop: 40, + paddingHorizontal: 40, + paddingBottom: 60, + fontSize: 9, + fontFamily: 'Helvetica', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 20, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: '#d4d4d4', + }, + titleBlock: { + flex: 1, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 4, + }, + subtitle: { + fontSize: 11, + color: '#333', + marginBottom: 2, + }, + asOf: { + fontSize: 10, + color: '#666', + }, + companyInfo: { + textAlign: 'right', + }, + companyName: { + fontSize: 11, + fontWeight: 'bold', + marginBottom: 2, + }, + companyMeta: { + fontSize: 9, + color: '#666', + }, + summaryRow: { + flexDirection: 'row', + gap: 24, + marginBottom: 16, + }, + summaryItem: { + flexDirection: 'column', + }, + summaryLabel: { + fontSize: 8, + color: '#666', + marginBottom: 2, + }, + summaryValue: { + fontSize: 12, + fontWeight: 'bold', + fontFamily: 'Courier', + }, + sectionHeading: { + fontSize: 12, + fontWeight: 'bold', + color: '#1a1a1a', + marginTop: 12, + marginBottom: 6, + paddingBottom: 4, + borderBottomWidth: 1, + borderBottomColor: '#1a1a1a', + }, + tableHeader: { + flexDirection: 'row', + paddingVertical: 3, + borderBottomWidth: 0.8, + borderBottomColor: '#999', + }, + headerCell: { + fontSize: 7.5, + fontWeight: 'bold', + color: '#555', + textTransform: 'uppercase', + }, + row: { + flexDirection: 'row', + paddingVertical: 3, + borderBottomWidth: 0.4, + borderBottomColor: '#e4e4e4', + }, + totalRow: { + flexDirection: 'row', + paddingVertical: 4, + marginTop: 2, + borderTopWidth: 1, + borderTopColor: '#1a1a1a', + }, + colName: { + flex: 1, + paddingRight: 8, + color: '#1a1a1a', + }, + colAmount: { + width: 62, + textAlign: 'right', + fontFamily: 'Courier', + color: '#1a1a1a', + }, + bold: { + fontWeight: 'bold', + }, + // Invoice detail table columns + colInvName: { + flex: 1, + paddingRight: 6, + }, + colInvNumber: { + width: 60, + paddingRight: 6, + }, + colInvDate: { + width: 56, + fontFamily: 'Courier', + }, + colInvAmount: { + width: 62, + textAlign: 'right', + fontFamily: 'Courier', + }, + colInvDays: { + width: 36, + textAlign: 'right', + fontFamily: 'Courier', + }, + colInvCurrency: { + width: 28, + textAlign: 'right', + color: '#666', + }, + fxNote: { + marginTop: 8, + fontSize: 8, + color: '#92400e', + }, + emptyNote: { + fontSize: 9, + color: '#888', + fontStyle: 'italic', + marginTop: 4, + }, + footer: { + position: 'absolute', + bottom: 24, + left: 40, + right: 40, + borderTopWidth: 0.5, + borderTopColor: '#d4d4d4', + paddingTop: 6, + flexDirection: 'row', + justifyContent: 'space-between', + }, + footerText: { + fontSize: 8, + color: '#888', + }, +}) + +function formatAmount(amount: number): string { + return new Intl.NumberFormat('sv-SE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount) +} + +function formatOrgNumber(orgNumber: string): string { + const cleaned = orgNumber.replace(/\D/g, '') + if (cleaned.length === 10) { + return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` + } + return orgNumber +} + +function formatDateSv(iso: string): string { + if (!iso) return '' + return new Date(iso).toLocaleDateString('sv-SE') +} + +export interface ReskontraAgingRow { + name: string + current: number + days_1_30: number + days_31_60: number + days_61_90: number + days_90_plus: number + total_outstanding: number +} + +export interface ReskontraInvoiceRow { + counterparty: string + invoice_number: string + invoice_date: string + due_date: string + outstanding: number + currency: string + days_overdue: number +} + +interface ReskontraPDFProps { + /** 'Kundreskontra' | 'Leverantörsreskontra' */ + title: string + /** 'Kund' | 'Leverantör' */ + counterpartyLabel: string + asOfDate: string + aging: ReskontraAgingRow[] + totals: ReskontraAgingRow + unpaidCount: number + unconvertedFxCount: number + /** Per-invoice detail rows (kundreskontra only). */ + invoices?: ReskontraInvoiceRow[] + company: CompanySettings + generatedAt: string +} + +const AGING_COLUMNS: Array<{ key: keyof ReskontraAgingRow; label: string }> = [ + { key: 'current', label: 'Ej förfallet' }, + { key: 'days_1_30', label: '1-30 dgr' }, + { key: 'days_31_60', label: '31-60 dgr' }, + { key: 'days_61_90', label: '61-90 dgr' }, + { key: 'days_90_plus', label: '90+ dgr' }, + { key: 'total_outstanding', label: 'Totalt' }, +] + +export function ReskontraPDF({ + title, + counterpartyLabel, + asOfDate, + aging, + totals, + unpaidCount, + unconvertedFxCount, + invoices, + company, + generatedAt, +}: ReskontraPDFProps) { + const companyDisplayName = company.company_name || '' + + return ( + + + + + {title} + {companyDisplayName && {companyDisplayName}} + Per datum: {formatDateSv(asOfDate)} + + + {company.company_name && {company.company_name}} + {company.org_number && ( + Org.nr: {formatOrgNumber(company.org_number)} + )} + {company.vat_number && VAT: {company.vat_number}} + + + + + + TOTALT UTESTÅENDE + {formatAmount(totals.total_outstanding)} kr + + + EJ FÖRFALLET + {formatAmount(totals.current)} kr + + + FÖRFALLET + + {formatAmount(totals.total_outstanding - totals.current)} kr + + + + FAKTUROR + {unpaidCount} + + + + Åldersfördelning per {counterpartyLabel.toLowerCase()} + + {aging.length === 0 ? ( + Inga utestående fakturor per detta datum. + ) : ( + + + {counterpartyLabel} + {AGING_COLUMNS.map((col) => ( + + {col.label} + + ))} + + {aging.map((row, i) => ( + + {row.name} + {AGING_COLUMNS.map((col) => ( + + {formatAmount(row[col.key] as number)} + + ))} + + ))} + + Summa + {AGING_COLUMNS.map((col) => ( + + {formatAmount(totals[col.key] as number)} + + ))} + + + )} + + {invoices && invoices.length > 0 && ( + + {/* NOTE: no `break` here: react-pdf 4.x deadlocks in layout when a + break element's section spills across pages (verified against + this template with 40+ rows). The table flows inline instead. */} + + Fakturor + + + {counterpartyLabel} + Fakturanr + Fakturadatum + Förfaller + Utestående + Dgr + Val. + + {invoices.map((inv, i) => ( + + {inv.counterparty} + {inv.invoice_number} + {inv.invoice_date} + {inv.due_date} + {formatAmount(inv.outstanding)} + {inv.days_overdue > 0 ? inv.days_overdue : ''} + {inv.currency} + + ))} + + )} + + {unconvertedFxCount > 0 && ( + + {unconvertedFxCount} faktura i utländsk valuta utan växelkurs ingår inte i beloppen ovan. + + )} + + + + {companyDisplayName} + {company.org_number ? ` · ${formatOrgNumber(company.org_number)}` : ''} + + + `Genererad ${formatDateSv(generatedAt)} · Sida ${pageNumber} av ${totalPages}` + } + /> + + + + ) +} diff --git a/lib/reports/supplier-ledger.ts b/lib/reports/supplier-ledger.ts index 4a67d943..2cfe2e8a 100644 --- a/lib/reports/supplier-ledger.ts +++ b/lib/reports/supplier-ledger.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' +import { fetchPaymentsAsOf, outstandingAsOf, todayIsoDate, type PaymentsAsOf } from './reskontra-payments' export interface SupplierLedgerEntry { supplier_id: string @@ -28,7 +29,12 @@ export interface SupplierLedgerReport { } /** - * Generate supplier ledger (leverantörsreskontra) with aging analysis + * Generate supplier ledger (leverantörsreskontra) with aging analysis. + * + * With a backdated `asOfDate` the ledger is reconstructed as it stood on that + * date: invoices dated on or before it (including ones fully paid since) with + * outstanding amounts recomputed from the payment history (#1021). Without an + * `asOfDate`, or with today/future, the live open-invoice state is used as-is. */ export async function generateSupplierLedger( supabase: SupabaseClient, @@ -36,21 +42,43 @@ export async function generateSupplierLedger( asOfDate?: string ): Promise { const refDate = asOfDate ? new Date(asOfDate) : new Date() + // Backdated reconstruction only for genuinely historical dates: for + // today/future the stored open-invoice state IS the as-of state. + const isHistorical = !!asOfDate && asOfDate < todayIsoDate() - // Fetch all unpaid/partially_paid supplier invoices + // Fetch the ledger population. Live view: open invoices only. Historical + // view: also invoices paid since the as-of date, restricted to invoice + // dates on or before it. Disputed/credited/reversed invoices stay excluded, + // matching the live view's semantics. // eslint-disable-next-line @typescript-eslint/no-explicit-any let invoices: any[] + let payments: PaymentsAsOf | null = null try { - invoices = await fetchAllRows(({ from, to }) => - supabase + invoices = await fetchAllRows(({ from, to }) => { + let query = supabase .from('supplier_invoices') .select('*, supplier:suppliers(id, name)') .eq('company_id', companyId) - .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + query = isHistorical + ? query + .in('status', ['registered', 'approved', 'partially_paid', 'overdue', 'paid']) + .lte('invoice_date', asOfDate!) + : query.in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + return query // Stable total order for correct paging (see fetch-all.ts). .order('id', { ascending: true }) .range(from, to) - ) + }) + + if (isHistorical) { + payments = await fetchPaymentsAsOf( + supabase, + 'supplier_invoice_payments', + 'supplier_invoice_id', + companyId, + asOfDate! + ) + } } catch { return { entries: [], @@ -65,6 +93,7 @@ export async function generateSupplierLedger( // Group by supplier and calculate aging const bySupplier = new Map() let unconvertedFxCount = 0 + let settledSkipped = 0 for (const inv of invoices) { const supplierId = inv.supplier_id @@ -80,6 +109,21 @@ export async function generateSupplierLedger( continue } + // Outstanding in invoice currency: live view trusts the stored + // remaining_amount; a historical view recomputes it from the payment + // history as of the reconstruction date. + const liveOutstanding = Number(inv.remaining_amount) || 0 + const outstandingRaw = payments + ? outstandingAsOf(inv, Number(inv.total) || 0, liveOutstanding, payments, asOfDate!) + : liveOutstanding + + // Historical view: 'paid' invoices are only fetched to catch ones still + // open at the as-of date. One already settled by then adds nothing. + if (payments && inv.status === 'paid' && outstandingRaw === 0) { + settledSkipped += 1 + continue + } + if (!bySupplier.has(supplierId)) { bySupplier.set(supplierId, { supplier_id: supplierId, @@ -96,14 +140,9 @@ export async function generateSupplierLedger( const entry = bySupplier.get(supplierId)! const dueDate = new Date(inv.due_date) const daysOverdue = Math.floor((refDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24)) - // remaining_amount is stored in invoice currency. The 2440 GL line was posted - // in SEK at the invoice-date rate, so we convert here for the reconciliation. - const amount = resolveSekAmount( - Number(inv.remaining_amount) || 0, - null, - inv.currency, - inv.exchange_rate - ) + // Outstanding is in invoice currency. The 2440 GL line was posted in SEK + // at the invoice-date rate, so we convert here for the reconciliation. + const amount = resolveSekAmount(outstandingRaw, null, inv.currency, inv.exchange_rate) if (daysOverdue <= 0) { entry.current += amount @@ -132,7 +171,7 @@ export async function generateSupplierLedger( total_outstanding: Math.round(total_outstanding * 100) / 100, total_current: Math.round(total_current * 100) / 100, total_overdue: Math.round(total_overdue * 100) / 100, - unpaid_count: invoices.length, + unpaid_count: invoices.length - settledSkipped, unconverted_fx_count: unconvertedFxCount, } }