* fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) The invoice-to-verifikat link is written on the invoice side only (invoices.journal_entry_id, invoice_payments.journal_entry_id), while the missing-underlag predicate and the periodisk sammanstallning resolved the invoice from the entry's own source columns. A SIE-imported sale matched to its invoice afterwards therefore kept warning "Underlag saknas" and was left out of the EU sales list, although the account-based momsdeklaration showed it and the verifikat page already listed the invoice as its underlag. - verifikat_without_documents / transactions_without_documents: customer- invoice hanvisning arm (BFL 5 kap 7 §), tenant-scoped on the link row; new migration 20260906135702, pinned by a pg-real test. - getInvoiceReferencesForJournalEntries(): one TS mirror of that arm, used by the journal-list filter and bulk exempt, /api/documents/counts (new invoice_references map) and the transactions list; the push cron mirrors it with its global reads. - Journal list: no "Underlag saknas" chip for a covered entry, matching the engine's own invoice rows and the verifikat detail page. - Periodisk sammanstallning: entries fetched by their EU-revenue lines and attributed through every link (engine source_id, invoices.journal_entry_id, invoice_payments.journal_entry_id); kontantmetod invoice_cash_payment entries are filed too, which the old source_type filter dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(underlag): issued invoices only, blocking mixed-customer settlements in PS, chunk-level degrade (#2298 review) - The customer-invoice hanvisning arms (RPCs, both TS resolvers, push cron) now require an ISSUED invoice: status not in ('draft', 'cancelled'), the schema's own definition (migration 20260427150000). NON_ISSUED_INVOICE_ STATUSES in lib/invoices/matchable-statuses.ts is the shared constant; the pg test pins a draft-linked and a cancelled-payment entry as still missing. - Periodisk sammanstallning: one verifikat linked to invoices of different customers is no longer attributed to the first invoice; it is left out of the accumulators and reported once as a blocking MIXED_CUSTOMER_SETTLEMENT naming the voucher, the customer count and the amount. Same-customer settlements are filed in full. - Transactions list: a failed invoice-reference lookup leaves that chunk's verdict unknown (no badges) and continues with the remaining chunks instead of abandoning them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { z } from 'zod'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
|
|
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
|
|
import { parseVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { getInvoiceReferencesForJournalEntries } from '@/lib/core/bookkeeping/journal-entry-references'
|
|
|
|
/**
|
|
* Shared resolution of "posted verifikat that lack underlag", scoped by the
|
|
* journal list's filters. Single TS mirror of the verifikat_without_documents
|
|
* RPC predicate (posted + document-requiring source type, no current-version
|
|
* document, no BFL 5 kap 7 § hänvisning via a supplier invoice whose retained
|
|
* document is anchored to a journal entry or via a customer invoice that
|
|
* points at the entry, no journal_entry_no_doc_required exemption). Used by
|
|
* the bulk "Inget underlag krävs" route and the journal list's
|
|
* missing_underlag filter so the two can never disagree.
|
|
*/
|
|
|
|
export interface MissingUnderlagFilters {
|
|
periodId?: string | null
|
|
/** Single uppercase verifikationsserie (A-Z); null/undefined = all. */
|
|
series?: string | null
|
|
dateFrom?: string | null
|
|
dateTo?: string | null
|
|
/** Free-text ilike over the voucher description. */
|
|
search?: string | null
|
|
}
|
|
|
|
/**
|
|
* The candidate columns carried through resolution: enough for the caller to
|
|
* sort the full missing set without a second round-trip. total_amount is the
|
|
* computed column from migration 20260811100000 (sum of debit lines).
|
|
*/
|
|
export interface MissingUnderlagEntry {
|
|
id: string
|
|
/** Sort columns; absent when the caller asked for ids only. */
|
|
entry_date?: string
|
|
voucher_series?: string | null
|
|
voucher_number?: number | null
|
|
description?: string | null
|
|
total_amount?: number | null
|
|
}
|
|
|
|
/**
|
|
* Sub-query failure. `userMessage` is already mapped through getErrorMessage()
|
|
* (user-facing Swedish), never a raw driver message.
|
|
*/
|
|
export class MissingUnderlagQueryError extends Error {
|
|
constructor(public readonly userMessage: string) {
|
|
super(userMessage)
|
|
}
|
|
}
|
|
|
|
// Journal-entry ids are interpolated into the supplier-invoice .or() filter
|
|
// string below, so they must be UUIDs. They originate from journal_entries.id
|
|
// (DB-sourced, never request input), but this guard keeps the injection-safety
|
|
// contract identical to /api/documents/counts.
|
|
const uuidSchema = z.string().uuid()
|
|
|
|
// 150 keeps the embedded id lists well under PostgREST's URL-length limit:
|
|
// the supplier-invoice .or() below repeats the chunk twice (registration +
|
|
// payment FK), so a larger chunk would risk truncating the GET filter.
|
|
const LOOKUP_CHUNK = 150
|
|
|
|
/**
|
|
* Resolve every posted, document-requiring journal entry matching the filters
|
|
* that currently has neither an underlag nor an exemption. Returns the full
|
|
* missing set (bounded by the tenant's ledger size), ordered by id for
|
|
* stability; callers sort/page as needed.
|
|
*
|
|
* `idOnly` skips the sort columns, notably total_amount, a computed column
|
|
* evaluated per candidate row. The bulk-exempt route (built for post-import
|
|
* floods of thousands of entries) doesn't sort, so it must not pay that
|
|
* per-row aggregate on its full candidate scan.
|
|
*
|
|
* @throws MissingUnderlagQueryError when a sub-query fails.
|
|
*/
|
|
export async function resolveMissingUnderlagEntries(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
filters: MissingUnderlagFilters = {},
|
|
{ idOnly = false }: { idOnly?: boolean } = {},
|
|
): Promise<MissingUnderlagEntry[]> {
|
|
const periodId = filters.periodId ?? null
|
|
const series = filters.series ?? null
|
|
const dateFrom = filters.dateFrom ?? null
|
|
const dateTo = filters.dateTo ?? null
|
|
const search = filters.search?.trim() || null
|
|
|
|
// Candidate entries: posted, document-requiring, matching the active
|
|
// filters. Built from LITERAL select strings and literal column filters
|
|
// only: tests/schema/no-phantom-columns.test.ts statically resolves every
|
|
// query expression against the schema, and a runtime-built select() or
|
|
// .or() string counts against its unresolvable ceiling. That is also why a
|
|
// voucher-label search runs as two separate queries below instead of one
|
|
// .or().
|
|
// Named only for its return TYPE below; called solely in the idOnly branch
|
|
// so exactly one query is ever built per invocation.
|
|
const idSelect = () => supabase.from('journal_entries').select('id')
|
|
const buildCandidateQuery = () => {
|
|
// Two separate literal select() calls (never one call with a computed
|
|
// string). The cast unifies the two builder generics: supabase-js types
|
|
// the select string at the type level, and rows are typed by
|
|
// fetchAllRows<MissingUnderlagEntry> either way.
|
|
let q = idOnly
|
|
? idSelect()
|
|
: (supabase
|
|
.from('journal_entries')
|
|
.select(
|
|
'id, entry_date, voucher_series, voucher_number, description, total_amount',
|
|
) as unknown as ReturnType<typeof idSelect>)
|
|
q = q
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'posted')
|
|
.in('source_type', [...NEEDS_DOC_SOURCE_TYPES])
|
|
if (periodId) q = q.eq('fiscal_period_id', periodId)
|
|
if (series) q = q.eq('voucher_series', series)
|
|
if (dateFrom) q = q.gte('entry_date', dateFrom)
|
|
if (dateTo) q = q.lte('entry_date', dateTo)
|
|
return q
|
|
}
|
|
type CandidateQuery = ReturnType<typeof buildCandidateQuery>
|
|
const fetchCandidates = (refine: (q: CandidateQuery) => CandidateQuery) =>
|
|
fetchAllRows<MissingUnderlagEntry>(({ from, to }) =>
|
|
refine(buildCandidateQuery()).order('id').range(from, to),
|
|
)
|
|
|
|
let candidates: MissingUnderlagEntry[]
|
|
if (search) {
|
|
// Same search semantics as the journal list's direct query: a
|
|
// voucher-label-shaped needle ("A209") also matches series+number, so
|
|
// searching for a voucher by its own label works with the filter on.
|
|
const needle = `%${escapeLikePattern(search)}%`
|
|
const voucher = parseVoucher(search)
|
|
if (voucher) {
|
|
const [byDescription, byLabel] = await Promise.all([
|
|
fetchCandidates((q) => q.ilike('description', needle)),
|
|
fetchCandidates((q) =>
|
|
q.eq('voucher_series', voucher.series).eq('voucher_number', voucher.number),
|
|
),
|
|
])
|
|
// Union, deduped by id, restored to the id order fetchAllRows pages by.
|
|
const seen = new Set<string>()
|
|
const merged: MissingUnderlagEntry[] = []
|
|
for (const entry of [...byDescription, ...byLabel]) {
|
|
if (seen.has(entry.id)) continue
|
|
seen.add(entry.id)
|
|
merged.push(entry)
|
|
}
|
|
merged.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
candidates = merged
|
|
} else {
|
|
candidates = await fetchCandidates((q) => q.ilike('description', needle))
|
|
}
|
|
} else {
|
|
candidates = await fetchCandidates((q) => q)
|
|
}
|
|
|
|
if (candidates.length === 0) return []
|
|
|
|
// Resolve which candidates already have a document or an exemption by
|
|
// querying ONLY for the candidate ids (chunked), rather than loading the
|
|
// company's full document_attachments + journal_entry_no_doc_required tables
|
|
// into memory. Data minimisation + bounded memory for large migrations.
|
|
const candidateIds = candidates.map((e) => e.id)
|
|
const withDoc = new Set<string>()
|
|
const exempt = new Set<string>()
|
|
for (let i = 0; i < candidateIds.length; i += LOOKUP_CHUNK) {
|
|
const chunk = candidateIds.slice(i, i + LOOKUP_CHUNK)
|
|
// Only UUIDs reach the interpolated .or() string (the .in() array filters
|
|
// are already injection-safe); mirrors the guard in documents/counts.
|
|
const chunkInList = `(${chunk.filter((id) => uuidSchema.safeParse(id).success).join(',')})`
|
|
const [docRes, siRefRes, sipRefRes, exemptRes] = await Promise.all([
|
|
supabase
|
|
.from('document_attachments')
|
|
.select('journal_entry_id')
|
|
.eq('company_id', companyId)
|
|
.eq('is_current_version', true)
|
|
.in('journal_entry_id', chunk),
|
|
// BFL 5 kap 7 § hänvisning: an entry referenced by a supplier invoice
|
|
// whose source document is retained AND anchored to a journal entry
|
|
// is NOT missing underlag (only anchored docs sit behind the WORM
|
|
// deletion guards). Mirrors the verifikat_without_documents RPC.
|
|
supabase
|
|
.from('supplier_invoices')
|
|
.select(
|
|
'registration_journal_entry_id, payment_journal_entry_id, document:document_attachments(journal_entry_id)',
|
|
)
|
|
.eq('company_id', companyId)
|
|
.not('document_id', 'is', null)
|
|
.or(
|
|
`registration_journal_entry_id.in.${chunkInList},payment_journal_entry_id.in.${chunkInList}`,
|
|
),
|
|
supabase
|
|
.from('supplier_invoice_payments')
|
|
.select(
|
|
'journal_entry_id, supplier_invoice:supplier_invoices(document_id, document:document_attachments(journal_entry_id))',
|
|
)
|
|
.eq('company_id', companyId)
|
|
.in('journal_entry_id', chunk),
|
|
supabase
|
|
.from('journal_entry_no_doc_required')
|
|
.select('journal_entry_id')
|
|
.eq('company_id', companyId)
|
|
.in('journal_entry_id', chunk),
|
|
])
|
|
for (const res of [docRes, siRefRes, sipRefRes, exemptRes]) {
|
|
if (res.error) throw new MissingUnderlagQueryError(getUserErrorMessage(res.error))
|
|
}
|
|
for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) {
|
|
withDoc.add(r.journal_entry_id)
|
|
}
|
|
for (const r of (siRefRes.data ?? []) as unknown as {
|
|
registration_journal_entry_id: string | null
|
|
payment_journal_entry_id: string | null
|
|
document: { journal_entry_id: string | null } | null
|
|
}[]) {
|
|
if (!r.document?.journal_entry_id) continue // unanchored: not underlag
|
|
if (r.registration_journal_entry_id) withDoc.add(r.registration_journal_entry_id)
|
|
if (r.payment_journal_entry_id) withDoc.add(r.payment_journal_entry_id)
|
|
}
|
|
for (const r of (sipRefRes.data ?? []) as unknown as {
|
|
journal_entry_id: string | null
|
|
supplier_invoice: {
|
|
document_id: string | null
|
|
document: { journal_entry_id: string | null } | null
|
|
} | null
|
|
}[]) {
|
|
if (r.journal_entry_id && r.supplier_invoice?.document?.journal_entry_id) {
|
|
withDoc.add(r.journal_entry_id)
|
|
}
|
|
}
|
|
for (const r of (exemptRes.data ?? []) as { journal_entry_id: string }[]) {
|
|
exempt.add(r.journal_entry_id)
|
|
}
|
|
// BFL 5 kap 7 § hänvisning, customer side (#2298): an entry a register
|
|
// invoice points at (registration link or invoice_payments row, e.g. a
|
|
// SIE-imported sale matched to its invoice afterwards) is backed by that
|
|
// invoice. Mirrors the verifikat_without_documents RPC's customer arm.
|
|
let invoiceRefs: Map<string, string[]>
|
|
try {
|
|
invoiceRefs = await getInvoiceReferencesForJournalEntries(supabase, companyId, chunk)
|
|
} catch (err) {
|
|
throw new MissingUnderlagQueryError(getUserErrorMessage(err))
|
|
}
|
|
for (const journalEntryId of invoiceRefs.keys()) withDoc.add(journalEntryId)
|
|
}
|
|
|
|
return candidates.filter((e) => !withDoc.has(e.id) && !exempt.has(e.id))
|
|
}
|