* 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>
273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { NON_ISSUED_INVOICE_STATUSES_FILTER } from '@/lib/invoices/matchable-statuses'
|
|
|
|
/**
|
|
* A followable reference from a verifikation back to its underlag: the customer
|
|
* or supplier invoice that identifies what the affärshändelse avser and who the
|
|
* motpart is.
|
|
*
|
|
* Surfacing these makes the verifieringskedja traceable from the verifikat side,
|
|
* not only from the invoice side (BFL 5 kap 7§: hänvisning till underlag;
|
|
* BFNAR 2013:2: the verification chain must be followable in both directions).
|
|
*
|
|
* Bank transactions are deliberately excluded: a bank line is the trace of the
|
|
* affärshändelse, not its underlag. Counting it as underlag would wrongly silence
|
|
* the "saknar underlag" warning for expenses that still genuinely need a kvitto.
|
|
*/
|
|
export type UnderlagReferenceType = 'invoice' | 'supplier_invoice'
|
|
|
|
export interface UnderlagReference {
|
|
type: UnderlagReferenceType
|
|
id: string
|
|
/** invoice_number / supplier_invoice_number: the UI builds the label from this. */
|
|
number: string
|
|
/**
|
|
* Retained source document owned by a referenced supplier invoice, if any.
|
|
*
|
|
* Set ONLY when the document is anchored to a journal entry
|
|
* (document_attachments.journal_entry_id IS NOT NULL), because that is the
|
|
* exact condition every missing-underlag surface uses: the
|
|
* verifikat_without_documents / transactions_without_documents RPCs,
|
|
* /api/documents/counts and the transactions list all require an anchored
|
|
* doc, since only anchored docs sit behind the WORM deletion guards. Handing
|
|
* out a floating doc here made the verifikat view display an underlag while
|
|
* the list kept warning "Underlag saknas" on the same row (support case
|
|
* 2026-07-27). The reference itself is still returned either way, so the
|
|
* verifieringskedja stays followable; only the attachment claim is withheld.
|
|
*/
|
|
document_id?: string
|
|
}
|
|
|
|
interface InvoiceRow {
|
|
id: string
|
|
invoice_number: string
|
|
}
|
|
|
|
interface SupplierInvoiceRow {
|
|
id: string
|
|
supplier_invoice_number: string
|
|
document_id?: string | null
|
|
/** Embedded document row; see UnderlagReference.document_id for why. */
|
|
document?: { journal_entry_id: string | null } | { journal_entry_id: string | null }[] | null
|
|
}
|
|
|
|
/** Columns every supplier-invoice lookup below needs, incl. the anchor check. */
|
|
const SUPPLIER_INVOICE_COLUMNS =
|
|
'id, supplier_invoice_number, document_id, document:document_attachments(journal_entry_id)'
|
|
|
|
/**
|
|
* A supplier invoice's document only counts as this verifikation's underlag
|
|
* when it is anchored to a journal entry: an unanchored doc is outside the WORM
|
|
* deletion guards, so the missing-underlag surfaces refuse to accept it and
|
|
* this resolver must refuse too.
|
|
*/
|
|
function anchoredDocumentId(row: SupplierInvoiceRow): string | undefined {
|
|
if (!row.document_id) return undefined
|
|
const document = Array.isArray(row.document) ? row.document[0] : row.document
|
|
return document?.journal_entry_id ? row.document_id : undefined
|
|
}
|
|
|
|
/**
|
|
* Resolve every customer/supplier invoice linked to a verifikation, across all
|
|
* the deterministic FK paths the engine uses to book one:
|
|
* - invoices.journal_entry_id (faktureringsmetod registration / direct)
|
|
* - invoice_payments.journal_entry_id (kontantmetod inbetalning / delbetalning)
|
|
* - supplier_invoices.registration_journal_entry_id / payment_journal_entry_id
|
|
* - supplier_invoice_payments.journal_entry_id (delbetalning)
|
|
*
|
|
* Every query is company-scoped (defense in depth alongside RLS). Results are
|
|
* deduplicated by id, so an invoice reachable via several paths appears once.
|
|
*/
|
|
export async function getJournalEntryUnderlagReferences(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
journalEntryId: string,
|
|
): Promise<UnderlagReference[]> {
|
|
// --- Customer invoices ---------------------------------------------------
|
|
const invoices = new Map<string, string>()
|
|
|
|
// Direct link (faktureringsmetod registration, or invoices.journal_entry_id).
|
|
// Issued invoices only: a draft or cancelled invoice is no underlag, and the
|
|
// verifikat page counts these references as underlag (same verdict as the
|
|
// missing-underlag surfaces: NON_ISSUED_INVOICE_STATUSES).
|
|
const directInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
|
supabase.from('invoices').select('id, invoice_number')
|
|
.eq('company_id', companyId).eq('journal_entry_id', journalEntryId)
|
|
.not('status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
|
.order('id', { ascending: true }).range(from, to),
|
|
)
|
|
|
|
for (const inv of (directInvoices ?? []) as InvoiceRow[]) {
|
|
invoices.set(inv.id, inv.invoice_number)
|
|
}
|
|
|
|
// Payment rows (kontantmetod inbetalning, partial payments) → invoice_payments.
|
|
const paymentRows = await fetchAllRows<{ id: string; invoice_id: string | null }>(({ from, to }) =>
|
|
supabase.from('invoice_payments').select('id, invoice_id')
|
|
.eq('journal_entry_id', journalEntryId).order('id', { ascending: true }).range(from, to),
|
|
)
|
|
|
|
const paymentInvoiceIds = new Set<string>()
|
|
for (const row of (paymentRows ?? []) as { invoice_id: string | null }[]) {
|
|
if (row.invoice_id && !invoices.has(row.invoice_id)) paymentInvoiceIds.add(row.invoice_id)
|
|
}
|
|
|
|
if (paymentInvoiceIds.size > 0) {
|
|
const paidInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
|
supabase.from('invoices').select('id, invoice_number')
|
|
.eq('company_id', companyId).in('id', Array.from(paymentInvoiceIds))
|
|
.not('status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
|
.order('id', { ascending: true }).range(from, to),
|
|
)
|
|
|
|
for (const inv of (paidInvoices ?? []) as InvoiceRow[]) {
|
|
invoices.set(inv.id, inv.invoice_number)
|
|
}
|
|
}
|
|
|
|
// --- Supplier invoices ---------------------------------------------------
|
|
const supplierInvoices = new Map<string, { number: string; documentId?: string }>()
|
|
|
|
// Registration booking (accrual) on the invoice itself.
|
|
const registrationLinks = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
|
|
supabase.from('supplier_invoices').select(SUPPLIER_INVOICE_COLUMNS)
|
|
.eq('company_id', companyId).eq('registration_journal_entry_id', journalEntryId)
|
|
.order('id', { ascending: true }).range(from, to),
|
|
)
|
|
|
|
for (const si of (registrationLinks ?? []) as SupplierInvoiceRow[]) {
|
|
const documentId = anchoredDocumentId(si)
|
|
supplierInvoices.set(si.id, {
|
|
number: si.supplier_invoice_number,
|
|
...(documentId ? { documentId } : {}),
|
|
})
|
|
}
|
|
|
|
// Payment booking on the invoice itself.
|
|
const paymentLinks = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
|
|
supabase.from('supplier_invoices').select(SUPPLIER_INVOICE_COLUMNS)
|
|
.eq('company_id', companyId).eq('payment_journal_entry_id', journalEntryId)
|
|
.order('id', { ascending: true }).range(from, to),
|
|
)
|
|
|
|
for (const si of (paymentLinks ?? []) as SupplierInvoiceRow[]) {
|
|
const documentId = anchoredDocumentId(si)
|
|
supplierInvoices.set(si.id, {
|
|
number: si.supplier_invoice_number,
|
|
...(documentId ? { documentId } : {}),
|
|
})
|
|
}
|
|
|
|
// Partial-payment rows → supplier_invoice_payments.
|
|
const supplierPaymentRows = await fetchAllRows<{ id: string; supplier_invoice_id: string | null }>(
|
|
({ from, to }) => supabase.from('supplier_invoice_payments').select('id, supplier_invoice_id')
|
|
.eq('journal_entry_id', journalEntryId).order('id', { ascending: true }).range(from, to),
|
|
)
|
|
|
|
const supplierPaymentIds = new Set<string>()
|
|
for (const row of (supplierPaymentRows ?? []) as { supplier_invoice_id: string | null }[]) {
|
|
if (row.supplier_invoice_id && !supplierInvoices.has(row.supplier_invoice_id)) {
|
|
supplierPaymentIds.add(row.supplier_invoice_id)
|
|
}
|
|
}
|
|
|
|
if (supplierPaymentIds.size > 0) {
|
|
const paidSupplierInvoices = await fetchAllRows<SupplierInvoiceRow>(({ from, to }) =>
|
|
supabase.from('supplier_invoices').select(SUPPLIER_INVOICE_COLUMNS)
|
|
.eq('company_id', companyId).in('id', Array.from(supplierPaymentIds))
|
|
.order('id', { ascending: true }).range(from, to),
|
|
)
|
|
|
|
for (const si of (paidSupplierInvoices ?? []) as SupplierInvoiceRow[]) {
|
|
const documentId = anchoredDocumentId(si)
|
|
supplierInvoices.set(si.id, {
|
|
number: si.supplier_invoice_number,
|
|
...(documentId ? { documentId } : {}),
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Assemble ------------------------------------------------------------
|
|
const references: UnderlagReference[] = []
|
|
for (const [id, number] of invoices) references.push({ type: 'invoice', id, number })
|
|
for (const [id, supplierInvoice] of supplierInvoices) {
|
|
references.push({
|
|
type: 'supplier_invoice',
|
|
id,
|
|
number: supplierInvoice.number,
|
|
...(supplierInvoice.documentId ? { document_id: supplierInvoice.documentId } : {}),
|
|
})
|
|
}
|
|
return references
|
|
}
|
|
|
|
/**
|
|
* Batch form of the customer-invoice arm above, for the surfaces that decide
|
|
* "saknar underlag" for many verifikat at once: which register invoices point
|
|
* at each of the given journal entries, through the two links the register
|
|
* keeps (invoices.journal_entry_id for the registration booking,
|
|
* invoice_payments.journal_entry_id for a kontantmetod inbetalning, a
|
|
* delbetalning, or "matcha mot befintligt verifikat").
|
|
*
|
|
* An entry that appears in the result is backed by that invoice under BFL
|
|
* 5 kap 7 § (hänvisning till underlag): the invoice Accounted issued is the
|
|
* verifikation for the sale, and the payment row identifies the inbetalning.
|
|
* This is the TS mirror of the customer arm in the verifikat_without_documents
|
|
* / transactions_without_documents RPCs (migration 20260906135702, #2298):
|
|
* every TS surface (journal-list filter, documents/counts, transactions list)
|
|
* must reach the same verdict as the dashboard badge and the MCP tools.
|
|
*
|
|
* Values are invoice ids per journal entry id, direct link first and then
|
|
* payment rows in id order, deduplicated. Only entries with at least one link
|
|
* to an ISSUED invoice are present: a draft or cancelled invoice is no
|
|
* document, so it cannot back a verifikat (NON_ISSUED_INVOICE_STATUSES, the
|
|
* counterpart of the anchored-document requirement on the supplier arm).
|
|
* Every query is company-scoped (defense in depth alongside RLS).
|
|
*
|
|
* Callers pass at most one PostgREST `.in()` chunk (the ~150-id URL-length
|
|
* convention in lib/worklist/categories.ts). The two queries run in a fixed
|
|
* order (invoices, then invoice_payments) so queued test mocks stay simple.
|
|
*/
|
|
export async function getInvoiceReferencesForJournalEntries(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
journalEntryIds: readonly string[],
|
|
): Promise<Map<string, string[]>> {
|
|
const result = new Map<string, string[]>()
|
|
if (journalEntryIds.length === 0) return result
|
|
const ids = [...journalEntryIds]
|
|
|
|
const add = (journalEntryId: string | null | undefined, invoiceId: string | null | undefined) => {
|
|
if (!journalEntryId || !invoiceId) return
|
|
const list = result.get(journalEntryId)
|
|
if (!list) result.set(journalEntryId, [invoiceId])
|
|
else if (!list.includes(invoiceId)) list.push(invoiceId)
|
|
}
|
|
|
|
const direct = await fetchAllRows<{ id: string; journal_entry_id: string | null }>(
|
|
({ from, to }) =>
|
|
supabase.from('invoices').select('id, journal_entry_id')
|
|
.eq('company_id', companyId).in('journal_entry_id', ids)
|
|
.not('status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
|
.order('id', { ascending: true }).range(from, to),
|
|
)
|
|
for (const row of direct) add(row.journal_entry_id, row.id)
|
|
|
|
// The invoice's status rides along as an inner embed so the filter drops
|
|
// payment rows of non-issued invoices server-side (one query, no id list).
|
|
const payments = await fetchAllRows<{
|
|
id: string
|
|
invoice_id: string | null
|
|
journal_entry_id: string | null
|
|
}>(({ from, to }) =>
|
|
supabase.from('invoice_payments').select('id, invoice_id, journal_entry_id, invoices!inner(status)')
|
|
.eq('company_id', companyId).in('journal_entry_id', ids)
|
|
.not('invoices.status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
|
.order('id', { ascending: true }).range(from, to),
|
|
)
|
|
for (const row of payments) add(row.journal_entry_id, row.invoice_id)
|
|
|
|
return result
|
|
}
|