Files
accounted/lib/reports/reskontra-payments.ts
T
Jakob Wennberg 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

105 lines
3.7 KiB
TypeScript

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<string, number>
hasRows: Set<string>
}
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<PaymentsAsOf> {
const rows = await fetchAllRows<PaymentRow & Record<string, unknown>>(({ 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<string, number>()
const hasRows = new Set<string>()
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}`
}