diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 38fb1198..ece913b8 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -89,6 +89,7 @@ import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' import { fetchMigrationCoverageEnd } from '@/lib/transactions/migration-coverage' import { isImportedTransaction } from '@/lib/transactions/origin' import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status' +import { getInvoiceReferencesForJournalEntries } from '@/lib/core/bookkeeping/journal-entry-references' import { isWithinBounds, resolvePeriodBounds } from '@/lib/transactions/period-filter' import type { FiscalPeriod } from '@/types' @@ -1301,7 +1302,8 @@ export default function TransactionsPage() { // current-version document, which are covered by a supplier invoice's // retained document (BFL 5 kap 7 § hänvisning: registration/payment FK or a // supplier_invoice_payments row: mirrors the verifikat_without_documents - // RPC), and which are exempted via journal_entry_no_doc_required. + // RPC), and which are exempted via journal_entry_no_doc_required; then the + // customer-invoice hänvisning (getInvoiceReferencesForJournalEntries, #2298). // Incremental: only fetches JE ids not yet requested, so // loadMoreTransactions pages are covered without refetching. // Soft-fails to "no badges" on error. @@ -1395,6 +1397,19 @@ export default function TransactionsPage() { jeIdsWithDocs.add(sip.journal_entry_id) } } + // Customer invoices pointing at the JE (registration link or payment + // row) back it under BFL 5 kap 7 §. On a failed lookup this chunk's + // verdict is UNKNOWN: without the references, 'missing' would be a + // false warning and 'has' a false pass, so the chunk gets no badges + // (the same degrade the reads above use) while the remaining chunks + // still get theirs. + let invoiceRefs: Map + try { + invoiceRefs = await getInvoiceReferencesForJournalEntries(supabase, companyId, chunk) + } catch { + continue + } + for (const journalEntryId of invoiceRefs.keys()) jeIdsWithDocs.add(journalEntryId) const exemptIds = new Set( (exemptRes.data ?? []).map((e) => e.journal_entry_id as string), ) diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts index fdf38cdf..1d468268 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -377,6 +377,8 @@ describe('GET /api/bookkeeping/journal-entries', () => { enqueue({ data: [], error: null }) // no SI references enqueue({ data: [], error: null }) // no SI payment-row references enqueue({ data: [{ journal_entry_id: E3 }], error: null }) // E3 exempt + enqueue({ data: [], error: null }) // no invoices pointing at the entries + enqueue({ data: [], error: null }) // no invoice payment rows const fullRow = makeJournalEntry({ id: E2 }) enqueue({ data: [fullRow], error: null }) // page rows @@ -415,6 +417,8 @@ describe('GET /api/bookkeeping/journal-entries', () => { enqueue({ data: [], error: null }) // no SI references enqueue({ data: [], error: null }) // no SI payment-row references enqueue({ data: [], error: null }) // no exemptions + enqueue({ data: [], error: null }) // no invoices pointing at the entries + enqueue({ data: [], error: null }) // no invoice payment rows enqueue({ data: [makeJournalEntry({ id: E2 })], error: null }) // page rows const request = createMockRequest('/api/bookkeeping/journal-entries', { @@ -450,6 +454,8 @@ describe('GET /api/bookkeeping/journal-entries', () => { }) enqueue({ data: [], error: null }) // no SI payment-row references enqueue({ data: [], error: null }) // no exemptions + enqueue({ data: [], error: null }) // no invoices pointing at the entries + enqueue({ data: [], error: null }) // no invoice payment rows enqueue({ data: [makeJournalEntry({ id: E2 })], error: null }) // page rows const request = createMockRequest('/api/bookkeeping/journal-entries', { @@ -464,6 +470,30 @@ describe('GET /api/bookkeeping/journal-entries', () => { expect(body.count).toBe(1) }) + it('treats a customer invoice pointing at the entry as underlag (#2298)', async () => { + // E1: a SIE-imported voucher the user matched to an invoice created in + // Accounted (invoice_payments row). E2: nothing points at it. + enqueue({ data: [candidate(E1, 1), candidate(E2, 2)], error: null }) + enqueue({ data: [], error: null }) // no direct documents + enqueue({ data: [], error: null }) // no SI references + enqueue({ data: [], error: null }) // no SI payment-row references + enqueue({ data: [], error: null }) // no exemptions + enqueue({ data: [], error: null }) // no direct invoice links + enqueue({ data: [{ id: 'pay-1', invoice_id: 'inv-1', journal_entry_id: E1 }], error: null }) + enqueue({ data: [makeJournalEntry({ id: E2 })], error: null }) // page rows + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { missing_underlag: 'true', exclude_draft: 'true' }, + }) + const { status, body } = await parseJsonResponse<{ data: { id: string }[]; count: number }>( + await GET(request, { params: Promise.resolve({}) }) + ) + + expect(status).toBe(200) + expect(body.data.map((e) => e.id)).toEqual([E2]) + expect(body.count).toBe(1) + }) + it('matches a voucher-label search against series+number, like the direct path', async () => { // A voucher-shaped needle fans out to TWO candidate queries (description // ilike, then series+number), unioned by id: a runtime-built .or() @@ -474,6 +504,8 @@ describe('GET /api/bookkeeping/journal-entries', () => { enqueue({ data: [], error: null }) // no SI references enqueue({ data: [], error: null }) // no SI payment-row references enqueue({ data: [], error: null }) // no exemptions + enqueue({ data: [], error: null }) // no invoices pointing at the entries + enqueue({ data: [], error: null }) // no invoice payment rows enqueue({ data: [makeJournalEntry({ id: E1 })], error: null }) // page rows const request = createMockRequest('/api/bookkeeping/journal-entries', { diff --git a/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts b/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts index f70104e8..8bbae416 100644 --- a/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts +++ b/app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts @@ -64,12 +64,17 @@ describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => { expect((await parseJsonResponse(res)).status).toBe(400) }) + // Queue order per candidate chunk mirrors resolveMissingUnderlagEntries: + // documents, SI references, SI payment-row references, exemptions, then the + // customer-invoice resolver (invoices by journal_entry_id, invoice_payments). it('dry_run counts only entries that are missing AND not exempt', async () => { enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a has a document enqueue({ data: [], error: null }) // no SI references with docs enqueue({ data: [], error: null }) // no SI payment-row references enqueue({ data: [{ journal_entry_id: 'b' }], error: null }) // b already exempt + enqueue({ data: [], error: null }) // no invoices pointing at the entries + enqueue({ data: [], error: null }) // no invoice payment rows const res = await POST(makeReq({ dry_run: true })) const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res) expect(status).toBe(200) @@ -108,18 +113,39 @@ describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => { error: null, }) enqueue({ data: [], error: null }) // no exemptions + enqueue({ data: [], error: null }) // no invoices pointing at the entries + enqueue({ data: [], error: null }) // no invoice payment rows const res = await POST(makeReq({ dry_run: true })) const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res) expect(status).toBe(200) expect(body.data.count).toBe(2) // c and d }) + it('dry_run excludes entries a customer invoice points at (#2298)', async () => { + // a: the invoice register links it directly (invoices.journal_entry_id) + // b: a SIE-imported voucher matched to an invoice (invoice_payments row) + // c: genuinely missing + enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates + enqueue({ data: [], error: null }) // no direct documents + enqueue({ data: [], error: null }) // no SI references + enqueue({ data: [], error: null }) // no SI payment-row references + enqueue({ data: [], error: null }) // no exemptions + enqueue({ data: [{ id: 'inv-1', journal_entry_id: 'a' }], error: null }) + enqueue({ data: [{ id: 'pay-1', invoice_id: 'inv-2', journal_entry_id: 'b' }], error: null }) + const res = await POST(makeReq({ dry_run: true }), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res) + expect(status).toBe(200) + expect(body.data.count).toBe(1) // only c + }) + it('marks the missing entries and returns the count', async () => { enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates enqueue({ data: [], error: null }) // no documents enqueue({ data: [], error: null }) // no SI references with docs enqueue({ data: [], error: null }) // no SI payment-row references enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a already exempt + enqueue({ data: [], error: null }) // no invoices pointing at the entries + enqueue({ data: [], error: null }) // no invoice payment rows enqueue({ error: null }) // helper upsert const res = await POST(makeReq({ period_id: null, reason: 'Importerad' })) const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res) diff --git a/app/api/documents/counts/__tests__/route.test.ts b/app/api/documents/counts/__tests__/route.test.ts index 06f12fd4..d68825d7 100644 --- a/app/api/documents/counts/__tests__/route.test.ts +++ b/app/api/documents/counts/__tests__/route.test.ts @@ -33,9 +33,11 @@ beforeEach(() => { ;(getActiveCompanyId as ReturnType).mockResolvedValue('company-1') }) -// Queue order mirrors the route's Promise.all: direct docs, supplier_invoices -// references, supplier_invoice_payments references. The `document` embed -// carries the anchor state (journal_entry_id) of the SI's retained doc. +// Queue order mirrors the route: the Promise.all (direct docs, supplier_invoices +// references, supplier_invoice_payments references), then the customer-invoice +// resolver (invoices by journal_entry_id, invoice_payments by journal_entry_id). +// The `document` embed carries the anchor state (journal_entry_id) of the SI's +// retained doc. function enqueueAll(opts: { direct?: Array<{ id: string; journal_entry_id: string }> si?: Array<{ @@ -51,10 +53,14 @@ function enqueueAll(opts: { document: { journal_entry_id: string | null } | null } | null }> + invoices?: Array<{ id: string; journal_entry_id: string | null }> + payments?: Array<{ id: string; invoice_id: string | null; journal_entry_id: string | null }> }) { enqueue({ data: opts.direct ?? [], error: null }) enqueue({ data: opts.si ?? [], error: null }) enqueue({ data: opts.sip ?? [], error: null }) + enqueue({ data: opts.invoices ?? [], error: null }) + enqueue({ data: opts.payments ?? [], error: null }) } describe('GET /api/documents/counts', () => { @@ -190,4 +196,45 @@ describe('GET /api/documents/counts', () => { const res = await GET(makeReq([JE_A])) expect((await parseJsonResponse(res)).status).toBe(500) }) + + it('reports customer invoices pointing at an entry apart from document counts (#2298)', async () => { + // JE_A: the invoice register links it directly AND a payment row points + // at it (one invoice counted once, plus a second invoice via payment). + // JE_B: only a payment row (a SIE-imported voucher matched to an invoice). + // JE_C: nothing. No document anywhere: `data` stays empty. + enqueueAll({ + invoices: [{ id: 'inv-1', journal_entry_id: JE_A }], + payments: [ + { id: 'pay-1', invoice_id: 'inv-1', journal_entry_id: JE_A }, + { id: 'pay-2', invoice_id: 'inv-2', journal_entry_id: JE_A }, + { id: 'pay-3', invoice_id: 'inv-3', journal_entry_id: JE_B }, + ], + }) + const res = await GET(makeReq([JE_A, JE_B, JE_C]), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ + data: Record + invoice_references: Record + }>(res) + expect(status).toBe(200) + expect(body.data).toEqual({}) + expect(body.invoice_references).toEqual({ [JE_A]: 2, [JE_B]: 1 }) + }) + + it('never returns invoice references for entries the caller did not ask about', async () => { + enqueueAll({ + payments: [{ id: 'pay-1', invoice_id: 'inv-1', journal_entry_id: JE_C }], + }) + const res = await GET(makeReq([JE_A]), { params: Promise.resolve({}) }) + const { body } = await parseJsonResponse<{ invoice_references: Record }>(res) + expect(body.invoice_references).toEqual({}) + }) + + it('returns 500 when the invoice-reference lookup fails', async () => { + enqueue({ data: [], error: null }) + enqueue({ data: [], error: null }) + enqueue({ data: [], error: null }) + enqueue({ data: null, error: { message: 'boom' } }) // invoices by journal_entry_id + const res = await GET(makeReq([JE_A]), { params: Promise.resolve({}) }) + expect((await parseJsonResponse(res)).status).toBe(500) + }) }) diff --git a/app/api/documents/counts/route.ts b/app/api/documents/counts/route.ts index f0ba9b18..3c527362 100644 --- a/app/api/documents/counts/route.ts +++ b/app/api/documents/counts/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { getInvoiceReferencesForJournalEntries } from '@/lib/core/bookkeeping/journal-entry-references' const uuidSchema = z.string().uuid() @@ -12,8 +13,8 @@ const uuidSchema = z.string().uuid() * into a PostgREST .or() filter string, so validation doubles as injection * protection). * - * Counts BOTH direct attachments (document_attachments.journal_entry_id) and - * documents retained on a supplier invoice that references the entry + * `data` counts BOTH direct attachments (document_attachments.journal_entry_id) + * and documents retained on a supplier invoice that references the entry * (registration/payment FK or a supplier_invoice_payments row). BFL 5 kap 7 § * accepts underlag via hänvisning, and the expanded-row view * (JournalEntryAttachments) already lists referenced docs: counting only @@ -23,6 +24,14 @@ const uuidSchema = z.string().uuid() * guards, so they must not silence the missing-underlag warning (mirrors the * verifikat_without_documents RPC). Documents are deduplicated per entry so a * doc that is both directly linked and referenced counts once. + * + * `invoice_references` counts, per requested entry, the customer invoices + * that point at it (invoices.journal_entry_id or an invoice_payments row): + * the customer-side hänvisning (#2298). Kept apart from `data` because a + * register invoice is not a document row: the list must not offer a + * paperclip with nothing behind it, but it must stop warning "Underlag + * saknas" for an entry the verifikat page already lists an invoice on. + * Same verdict as the RPC's customer arm and the verifikat detail page. */ export const GET = withRouteContext('document.counts', async (request, ctx) => { const { supabase, companyId } = ctx @@ -125,6 +134,13 @@ export const GET = withRouteContext('document.counts', async (request, ctx) => { add(row.journal_entry_id, row.supplier_invoice.document_id) } + let invoiceRefs: Map + try { + invoiceRefs = await getInvoiceReferencesForJournalEntries(supabase, companyId, ids) + } catch (err) { + return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 }) + } + // Referenced entries outside the requested set (an SI FK can point at an // entry the caller didn't ask about) must not leak into the response. const requested = new Set(ids) @@ -132,6 +148,10 @@ export const GET = withRouteContext('document.counts', async (request, ctx) => { for (const [journalEntryId, docIds] of docsByEntry) { if (requested.has(journalEntryId)) counts[journalEntryId] = docIds.size } + const invoiceReferences: Record = {} + for (const [journalEntryId, invoiceIds] of invoiceRefs) { + if (requested.has(journalEntryId)) invoiceReferences[journalEntryId] = invoiceIds.length + } - return NextResponse.json({ data: counts }) + return NextResponse.json({ data: counts, invoice_references: invoiceReferences }) }) diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 7da00711..b31a95fd 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -246,6 +246,11 @@ export default function JournalEntryList({ const [count, setCount] = useState(0) const [page, setPage] = useState(0) const [attachmentCounts, setAttachmentCounts] = useState>({}) + // Entries a customer invoice points at (registration link or payment row): + // backed by that invoice under BFL 5 kap 7 § (hänvisning), the same verdict + // the dashboard badge (verifikat_without_documents) and the verifikat page + // reach. Not a document, so no paperclip; but no "Underlag saknas" either. + const [invoiceReferenced, setInvoiceReferenced] = useState>(new Set()) // Counts arrive in a second request, after the rows are already painted. // Until they land, every row looks like it has no underlag, so rendering the // chip eagerly flashes a false "Saknar underlag" compliance warning on every @@ -353,6 +358,7 @@ export default function JournalEntryList({ const fetchAttachmentCounts = useCallback(async (entryIds: string[], isCurrent: () => boolean = () => true) => { if (entryIds.length === 0) { setAttachmentCounts({}) + setInvoiceReferenced(new Set()) setAttachmentCountsLoaded(true) return } @@ -370,18 +376,23 @@ export default function JournalEntryList({ batches.push(entryIds.slice(i, i + COUNTS_BATCH_SIZE)) } try { + const empty = { counts: {} as Record, referenced: [] as string[] } const results = await Promise.all( batches.map(async (batch) => { const res = await fetch( `/api/documents/counts?journal_entry_ids=${batch.join(',')}` ) - if (!res.ok) return {} as Record - const { data } = await res.json() - return (data || {}) as Record + if (!res.ok) return empty + const { data, invoice_references } = await res.json() + return { + counts: (data || {}) as Record, + referenced: Object.keys((invoice_references || {}) as Record), + } }) ) if (!isCurrent()) return - setAttachmentCounts(Object.assign({}, ...results)) + setAttachmentCounts(Object.assign({}, ...results.map((r) => r.counts))) + setInvoiceReferenced(new Set(results.flatMap((r) => r.referenced))) } catch { // Non-critical: silently ignore } finally { @@ -810,8 +821,9 @@ export default function JournalEntryList({ entry.status === 'posted' && NEEDS_ATTACHMENT.has(entry.source_type) && !attachmentCounts[entry.id] && + !invoiceReferenced.has(entry.id) && !noDocRequired.has(entry.id), - [attachmentCounts, noDocRequired], + [attachmentCounts, invoiceReferenced, noDocRequired], ) const handleBatchExempt = async () => { @@ -1631,7 +1643,7 @@ export default function JournalEntryList({ {attachmentCounts[entry.id]} ) : ( - attachmentCountsLoaded && NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( + attachmentCountsLoaded && NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && !invoiceReferenced.has(entry.id) && ( noDocRequired.has(entry.id) ? ( diff --git a/extensions/general/push-notifications/notification-scheduler.ts b/extensions/general/push-notifications/notification-scheduler.ts index 689ffc2f..8e590f73 100644 --- a/extensions/general/push-notifications/notification-scheduler.ts +++ b/extensions/general/push-notifications/notification-scheduler.ts @@ -13,6 +13,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { NotificationType } from '@/types' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/types' +import { NON_ISSUED_INVOICE_STATUSES_FILTER } from '@/lib/invoices/matchable-statuses' import { sendNotificationToUser, readNotificationSettings } from './notification-sender' import { createTaxDeadlinePayload, @@ -332,6 +333,39 @@ export async function sendMissingUnderlagNotifications( } } + // BFL 5 kap 7 § hänvisning, customer side (#2298): an entry an ISSUED + // register invoice points at (registration link or an invoice_payments row, + // e.g. a SIE-imported sale matched to its invoice afterwards) is backed by + // that invoice; a draft or cancelled invoice is no document + // (NON_ISSUED_INVOICE_STATUSES). Global reads like the ones above: this + // cron spans every company. Mirrors the verifikat_without_documents RPC's + // customer arm. + const invoiceLinks = await fetchAllRows<{ journal_entry_id: string | null }>(({ from, to }) => + supabase + .from('invoices') + .select('journal_entry_id') + .not('journal_entry_id', 'is', null) + .not('status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER) + .order('id') + .range(from, to) + ) + for (const inv of invoiceLinks) { + if (inv.journal_entry_id) entriesWithDocs.add(inv.journal_entry_id) + } + + const paymentLinks = await fetchAllRows<{ journal_entry_id: string | null }>(({ from, to }) => + supabase + .from('invoice_payments') + .select('journal_entry_id, invoices!inner(status)') + .not('journal_entry_id', 'is', null) + .not('invoices.status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER) + .order('id') + .range(from, to) + ) + for (const payment of paymentLinks) { + if (payment.journal_entry_id) entriesWithDocs.add(payment.journal_entry_id) + } + // Entries the user has explicitly flagged as "no underlag required" (bank // fees, interest, internal transfers, salary, tax payments). Treated as // satisfied so we don't nag the user about them. diff --git a/lib/bookkeeping/missing-underlag.ts b/lib/bookkeeping/missing-underlag.ts index 74572949..01b83dbd 100644 --- a/lib/bookkeeping/missing-underlag.ts +++ b/lib/bookkeeping/missing-underlag.ts @@ -5,15 +5,17 @@ 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, 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. + * 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 { @@ -233,6 +235,17 @@ export async function resolveMissingUnderlagEntries( 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 + 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)) diff --git a/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts b/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts index cde25e8f..0fd46469 100644 --- a/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts +++ b/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from 'vitest' import type { SupabaseClient } from '@supabase/supabase-js' import { createQueuedMockSupabase } from '@/tests/helpers' -import { getJournalEntryUnderlagReferences } from '../journal-entry-references' +import { + getInvoiceReferencesForJournalEntries, + getJournalEntryUnderlagReferences, +} from '../journal-entry-references' /** * The resolver issues its queries in a fixed `.from()` order, and the queued @@ -156,4 +159,112 @@ describe('getJournalEntryUnderlagReferences', () => { { type: 'supplier_invoice', id: 'si-2', number: 'LF-2' }, ]) }) + + it('asks only for ISSUED customer invoices: a draft or cancelled one is no underlag (#2298)', async () => { + const mock = createQueuedMockSupabase() + mock.enqueueMany([ + { data: [] }, // 1. invoices direct + { data: [{ invoice_id: 'inv-x' }] }, // 2. invoice_payments + { data: [] }, // 3. invoices by id: the cancelled invoice is filtered out server-side + { data: [] }, // 4. supplier registration + { data: [] }, // 5. supplier payment + { data: [] }, // 6. supplier_invoice_payments + ]) + const refs = await getJournalEntryUnderlagReferences( + mock.supabase as unknown as SupabaseClient, + 'company-1', + 'je-1', + ) + expect(refs).toEqual([]) + const notCalls = mock.findCalls('invoices', 'not') + expect(notCalls).toHaveLength(2) + for (const call of notCalls) expect(call).toEqual(['status', 'in', '("draft","cancelled")']) + }) +}) + +/** + * Batch resolver behind every TS mirror of the RPC's customer-invoice arm + * (#2298). Fixed `.from()` order: invoices (by journal_entry_id), then + * invoice_payments (by journal_entry_id). + */ +describe('getInvoiceReferencesForJournalEntries', () => { + const setup = (results: { data: unknown }[]) => { + const mock = createQueuedMockSupabase() + mock.enqueueMany(results) + return mock + } + + it('returns nothing, without a round trip, for an empty id list', async () => { + const mock = setup([]) + const refs = await getInvoiceReferencesForJournalEntries( + mock.supabase as unknown as SupabaseClient, + 'company-1', + [], + ) + expect(refs.size).toBe(0) + expect(mock.supabase.from).not.toHaveBeenCalled() + }) + + it('maps the registration link and payment rows onto their entries, deduplicated', async () => { + const mock = setup([ + { data: [{ id: 'inv-reg', journal_entry_id: 'je-1' }] }, + { + data: [ + // The reported case: a SIE-imported voucher matched to an invoice. + { id: 'pay-a', invoice_id: 'inv-imp', journal_entry_id: 'je-2' }, + // Same invoice already reached through the direct link: once. + { id: 'pay-b', invoice_id: 'inv-reg', journal_entry_id: 'je-1' }, + // One deposit settling two invoices: both are references. + { id: 'pay-c', invoice_id: 'inv-other', journal_entry_id: 'je-2' }, + // Defensive: a row without an invoice id is not a reference. + { id: 'pay-d', invoice_id: null, journal_entry_id: 'je-3' }, + ], + }, + ]) + const refs = await getInvoiceReferencesForJournalEntries( + mock.supabase as unknown as SupabaseClient, + 'company-1', + ['je-1', 'je-2', 'je-3'], + ) + expect(Array.from(refs.entries())).toEqual([ + ['je-1', ['inv-reg']], + ['je-2', ['inv-imp', 'inv-other']], + ]) + }) + + it('scopes both lookups to the company and the given ids', async () => { + const mock = setup([{ data: [] }, { data: [] }]) + await getInvoiceReferencesForJournalEntries( + mock.supabase as unknown as SupabaseClient, + 'company-1', + ['je-1', 'je-2'], + ) + expect(mock.findCalls('invoices', 'eq')).toContainEqual(['company_id', 'company-1']) + expect(mock.findCalls('invoices', 'in')).toContainEqual(['journal_entry_id', ['je-1', 'je-2']]) + expect(mock.findCalls('invoice_payments', 'eq')).toContainEqual(['company_id', 'company-1']) + expect(mock.findCalls('invoice_payments', 'in')).toContainEqual([ + 'journal_entry_id', + ['je-1', 'je-2'], + ]) + }) + + it('asks only for ISSUED invoices on both links, mirroring the RPC status guard', async () => { + const mock = setup([{ data: [] }, { data: [] }]) + await getInvoiceReferencesForJournalEntries( + mock.supabase as unknown as SupabaseClient, + 'company-1', + ['je-1'], + ) + expect(mock.findCalls('invoices', 'not')).toContainEqual(['status', 'in', '("draft","cancelled")']) + // The payment query carries the invoice status as an inner embed and + // filters on it, so a non-issued invoice's payment row never comes back. + expect(mock.findCall('invoice_payments', 'select')).toEqual([ + 'id, invoice_id, journal_entry_id, invoices!inner(status)', + ]) + expect(mock.findCalls('invoice_payments', 'not')).toContainEqual([ + 'invoices.status', + 'in', + '("draft","cancelled")', + ]) + }) }) diff --git a/lib/core/bookkeeping/journal-entry-references.ts b/lib/core/bookkeeping/journal-entry-references.ts index b1c1f1bc..f02f4e1c 100644 --- a/lib/core/bookkeeping/journal-entry-references.ts +++ b/lib/core/bookkeeping/journal-entry-references.ts @@ -1,5 +1,6 @@ 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 @@ -87,9 +88,13 @@ export async function getJournalEntryUnderlagReferences( const invoices = new Map() // 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(({ 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), ) @@ -112,6 +117,7 @@ export async function getJournalEntryUnderlagReferences( const paidInvoices = await fetchAllRows(({ 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), ) @@ -195,3 +201,72 @@ export async function getJournalEntryUnderlagReferences( } 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> { + const result = new Map() + 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 +} diff --git a/lib/invoices/matchable-statuses.ts b/lib/invoices/matchable-statuses.ts index f5ccae6b..335a914e 100644 --- a/lib/invoices/matchable-statuses.ts +++ b/lib/invoices/matchable-statuses.ts @@ -71,3 +71,18 @@ export function isMatchableSupplierInvoice( ): boolean { return getSupplierInvoiceMatchTargetState(candidate) === 'matchable' } + +/** + * Statuses under which an invoice has NOT been issued: no document exists that + * could serve as underlag for a verifikat. The schema says the same thing from + * the other side (migration 20260427150000: an invoice outside these statuses + * must carry an invoice_number). Every reader that treats a customer invoice + * pointing at a verifikat as its underlag (BFL 5 kap 7 § hänvisning) must + * exclude these, in step with the SQL arm in verifikat_without_documents / + * transactions_without_documents (migration 20260906135702, #2298). + */ +export const NON_ISSUED_INVOICE_STATUSES = ['draft', 'cancelled'] as const + +/** PostgREST `not.in` literal for {@link NON_ISSUED_INVOICE_STATUSES}. */ +export const NON_ISSUED_INVOICE_STATUSES_FILTER = + '(' + NON_ISSUED_INVOICE_STATUSES.map((s) => `"${s}"`).join(',') + ')' diff --git a/lib/reports/__tests__/periodisk-sammanstallning.test.ts b/lib/reports/__tests__/periodisk-sammanstallning.test.ts index c7b6ef3a..197a0b1d 100644 --- a/lib/reports/__tests__/periodisk-sammanstallning.test.ts +++ b/lib/reports/__tests__/periodisk-sammanstallning.test.ts @@ -99,55 +99,69 @@ interface InvoiceFx { } | null } +interface LineFx { + account_number: string + debit_amount: number + credit_amount: number +} + // Recent validation so VIES_UNVALIDATED warnings don't fire by default. const RECENT = new Date().toISOString() -// The generator fetches lines via the two-step entry-lines helper -// (lib/bookkeeping/entry-lines.ts): journal_entries first, then -// journal_entry_lines by entry id with the parent reattached under -// `journal_entries`. Each fixture invoice gets one entry (je-). +// The generator fetches the period's entries with their PS-account lines +// embedded (journal_entries + journal_entry_lines!inner, one page here), then +// resolves each entry's invoice: the engine's own entries by source_id, +// everything else through getInvoiceReferencesForJournalEntries (invoices by +// journal_entry_id, then invoice_payments), and finally loads the invoices +// with their customer. Queue order per test: +// 1. journal_entries page (with embedded lines) +// 2. invoices by journal_entry_id only when a non-engine entry exists +// 3. invoice_payments only when a non-engine entry exists +// 4. invoices by id only when some invoice id resolved function je(sourceId: string) { return `je-${sourceId}` } -function entryEU(sourceId: string) { +function entryEU(sourceId: string, lines: LineFx[] = []) { return { id: je(sourceId), - company_id: 'c1', entry_date: '2025-05-15', status: 'posted', source_type: 'invoice_created', source_id: sourceId, + journal_entry_lines: lines, } } -function entryCredit(sourceId: string) { +function entryCredit(sourceId: string, lines: LineFx[] = []) { return { id: je(sourceId), - company_id: 'c1', entry_date: '2025-05-20', status: 'posted', source_type: 'credit_note', source_id: sourceId, + journal_entry_lines: lines, } } -function lineEU(account: string, credit: number, sourceId: string) { +/** A verifikat that did not come from the invoice engine (SIE import, manual). */ +function entryOther(id: string, sourceType: string, lines: LineFx[] = []) { return { - account_number: account, - debit_amount: 0, - credit_amount: credit, - journal_entry_id: je(sourceId), + id, + entry_date: '2025-05-15', + status: 'posted', + source_type: sourceType, + source_id: null as string | null, + journal_entry_lines: lines, } } -function lineCredit(account: string, debit: number, sourceId: string) { - return { - account_number: account, - debit_amount: debit, - credit_amount: 0, - journal_entry_id: je(sourceId), - } +function lineEU(account: string, credit: number): LineFx { + return { account_number: account, debit_amount: 0, credit_amount: credit } +} + +function lineCredit(account: string, debit: number): LineFx { + return { account_number: account, debit_amount: debit, credit_amount: 0 } } function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat = 'DE123456789'): InvoiceFx { @@ -166,7 +180,7 @@ function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat = describe('generatePeriodiskSammanstallning', () => { it('empty period returns zero rows and zero warnings', async () => { - // journal_entries: none match → the line fetch is skipped entirely. + // journal_entries: none match → every lookup is skipped. results = [{ data: [], error: null }] const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) @@ -176,13 +190,12 @@ describe('generatePeriodiskSammanstallning', () => { expect(report.totals.rowCount).toBe(0) expect(report.totals.grand).toBe(0) expect(report.period.label).toBe('Maj 2025') + expect(supabase.from).toHaveBeenCalledTimes(1) }) it('single EU service sale → 1 row, type 3 only', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv-de')], error: null }, - { data: [lineEU('3308', 10000, 'inv-de')], error: null }, + { data: [entryEU('inv-de', [lineEU('3308', 10000)])], error: null }, { data: [invDE()], error: null }, ] @@ -198,17 +211,17 @@ describe('generatePeriodiskSammanstallning', () => { }) expect(report.totals).toMatchObject({ services: 10000, goods: 0, triangulation: 0, grand: 10000, rowCount: 1 }) expect(report.warnings).toEqual([]) + // Engine entries resolve by source_id: no invoice-link round trips. + expect(supabase.from).toHaveBeenCalledTimes(2) }) it('aggregates multiple invoices to same customer', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1'), entryEU('inv2'), entryEU('inv3')], error: null }, { data: [ - lineEU('3308', 4000, 'inv1'), - lineEU('3308', 3500, 'inv2'), - lineEU('3308', 2500, 'inv3'), + entryEU('inv1', [lineEU('3308', 4000)]), + entryEU('inv2', [lineEU('3308', 3500)]), + entryEU('inv3', [lineEU('3308', 2500)]), ], error: null, }, @@ -230,12 +243,10 @@ describe('generatePeriodiskSammanstallning', () => { it('one customer with both services and goods → 1 row with both filled', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1'), entryEU('inv2')], error: null }, { data: [ - lineEU('3308', 7000, 'inv1'), - lineEU('3108', 5000, 'inv2'), + entryEU('inv1', [lineEU('3308', 7000)]), + entryEU('inv2', [lineEU('3108', 5000)]), ], error: null, }, @@ -253,12 +264,10 @@ describe('generatePeriodiskSammanstallning', () => { it('credit invoice nets against original in same period', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1'), entryCredit('cn1')], error: null }, { data: [ - lineEU('3308', 10000, 'inv1'), - lineCredit('3308', 3000, 'cn1'), + entryEU('inv1', [lineEU('3308', 10000)]), + entryCredit('cn1', [lineCredit('3308', 3000)]), ], error: null, }, @@ -276,12 +285,10 @@ describe('generatePeriodiskSammanstallning', () => { it('credit fully cancels → row excluded with ZERO_NET_EXCLUDED warning', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1'), entryCredit('cn1')], error: null }, { data: [ - lineEU('3308', 10000, 'inv1'), - lineCredit('3308', 10000, 'cn1'), + entryEU('inv1', [lineEU('3308', 10000)]), + entryCredit('cn1', [lineCredit('3308', 10000)]), ], error: null, }, @@ -296,9 +303,7 @@ describe('generatePeriodiskSammanstallning', () => { it('customer missing country → MISSING_COUNTRY error and row blocked', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1')], error: null }, - { data: [lineEU('3308', 5000, 'inv1')], error: null }, + { data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null }, { data: [{ id: 'inv1', @@ -316,9 +321,7 @@ describe('generatePeriodiskSammanstallning', () => { it('customer missing vat_number → MISSING_VAT_NUMBER error', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1')], error: null }, - { data: [lineEU('3308', 5000, 'inv1')], error: null }, + { data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null }, { data: [{ id: 'inv1', @@ -336,9 +339,7 @@ describe('generatePeriodiskSammanstallning', () => { it('VAT prefix mismatch surfaces COUNTRY_PREFIX_MISMATCH warning', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1')], error: null }, - { data: [lineEU('3308', 5000, 'inv1')], error: null }, + { data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null }, { data: [{ id: 'inv1', @@ -356,9 +357,7 @@ describe('generatePeriodiskSammanstallning', () => { it('non-EU country on EU account → NON_EU_COUNTRY_ON_EU_ACCOUNT and excluded from CSV', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1')], error: null }, - { data: [lineEU('3308', 5000, 'inv1')], error: null }, + { data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null }, { data: [{ id: 'inv1', @@ -376,9 +375,7 @@ describe('generatePeriodiskSammanstallning', () => { it('Greek customer → country code emitted as EL', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1')], error: null }, - { data: [lineEU('3308', 4200, 'inv1')], error: null }, + { data: [entryEU('inv1', [lineEU('3308', 4200)])], error: null }, { data: [{ id: 'inv1', @@ -395,9 +392,7 @@ describe('generatePeriodiskSammanstallning', () => { it('goods sold in quarterly period → GOODS_SOLD_WITH_QUARTERLY_PERIOD warning', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv1')], error: null }, - { data: [lineEU('3108', 9000, 'inv1')], error: null }, + { data: [entryEU('inv1', [lineEU('3108', 9000)])], error: null }, { data: [{ ...invDE('inv1') }], error: null }, ] @@ -408,13 +403,11 @@ describe('generatePeriodiskSammanstallning', () => { it('sorts rows by country then vat_number', async () => { results = [ - // journal_entries page for the two-step entry-lines fetch - { data: [entryEU('inv-fr'), entryEU('inv-de'), entryEU('inv-at')], error: null }, { data: [ - lineEU('3308', 1000, 'inv-fr'), - lineEU('3308', 2000, 'inv-de'), - lineEU('3308', 3000, 'inv-at'), + entryEU('inv-fr', [lineEU('3308', 1000)]), + entryEU('inv-de', [lineEU('3308', 2000)]), + entryEU('inv-at', [lineEU('3308', 3000)]), ], error: null, }, @@ -433,6 +426,19 @@ describe('generatePeriodiskSammanstallning', () => { expect(report.rows.map(r => r.country)).toEqual(['AT', 'DE', 'FR']) }) + it('an engine entry whose invoice is gone → CUSTOMER_NOT_FOUND error (a data defect, never silence)', async () => { + results = [ + { data: [entryEU('inv-gone', [lineEU('3308', 5000)])], error: null }, + { data: [], error: null }, // invoices by id: nothing + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.warnings.some(w => w.code === 'CUSTOMER_NOT_FOUND' && w.level === 'error')).toBe(true) + expect(report.rows).toHaveLength(1) + expect(report.rows[0].hasBlockingIssue).toBe(true) + }) + it('rejects yearly period type', async () => { await expect( generatePeriodiskSammanstallning(supabase, 'c1', 'yearly' as 'monthly', 2025, 1), @@ -440,6 +446,119 @@ describe('generatePeriodiskSammanstallning', () => { }) }) +// ============================================================ +// Invoice links beyond the engine's own source columns (#2298) +// ============================================================ + +describe('invoice links beyond the engine source columns (#2298)', () => { + it('files a SIE-imported sale matched to its invoice through invoice_payments', async () => { + // The reported case: the importer wrote debit 1930 / credit 3308 with + // source_type 'import', the user created the invoice in Accounted and + // matched it to the imported verifikat (link_invoice_to_voucher). The link + // lives on invoice_payments only; the entry keeps its source columns. + results = [ + { data: [entryOther('je-imp', 'import', [lineEU('3308', 12000)])], error: null }, + { data: [], error: null }, // invoices by journal_entry_id: none + { data: [{ id: 'pay-1', invoice_id: 'inv-de', journal_entry_id: 'je-imp' }], error: null }, + { data: [invDE()], error: null }, + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.warnings).toEqual([]) + expect(report.rows).toHaveLength(1) + expect(report.rows[0]).toMatchObject({ country: 'DE', vatNumber: '123456789', services: 12000 }) + expect(report.totals.services).toBe(12000) + }) + + it('files a manual verifikat the invoice register points at through invoices.journal_entry_id', async () => { + results = [ + { data: [entryOther('je-man', 'manual', [lineEU('3308', 8000)])], error: null }, + { data: [{ id: 'inv-de', journal_entry_id: 'je-man' }], error: null }, + { data: [], error: null }, // invoice_payments: none + { data: [invDE()], error: null }, + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.warnings).toEqual([]) + expect(report.rows).toHaveLength(1) + expect(report.rows[0]).toMatchObject({ country: 'DE', services: 8000 }) + }) + + it('files a kontantmetod inbetalning (invoice_cash_payment) by its source_id', async () => { + // Cash-method companies book revenue at payment, so this is the only + // entry that ever carries their 3308 postings. + const entry = { ...entryOther('je-cash', 'invoice_cash_payment', [lineEU('3308', 6000)]), source_id: 'inv-de' } + results = [ + { data: [entry], error: null }, + { data: [invDE()], error: null }, + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.warnings).toEqual([]) + expect(report.rows[0]).toMatchObject({ country: 'DE', services: 6000 }) + expect(supabase.from).toHaveBeenCalledTimes(2) + }) + + it('leaves an imported 3308 posting no invoice points at out of the filing, silently and without an invoice lookup', async () => { + results = [ + { data: [entryOther('je-loose', 'import', [lineEU('3308', 9000)])], error: null }, + { data: [], error: null }, // invoices by journal_entry_id: none + { data: [], error: null }, // invoice_payments: none + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.rows).toEqual([]) + expect(report.warnings).toEqual([]) + // No invoice ids resolved → the invoices-by-id lookup is skipped. + expect(supabase.from).toHaveBeenCalledTimes(3) + }) + + it('aggregates an engine invoice and a linked import to the same customer into one row', async () => { + results = [ + { + data: [ + entryEU('inv-a', [lineEU('3308', 4000)]), + entryOther('je-imp', 'import', [lineEU('3308', 6000)]), + ], + error: null, + }, + { data: [], error: null }, // invoices by journal_entry_id + { data: [{ id: 'pay-1', invoice_id: 'inv-b', journal_entry_id: 'je-imp' }], error: null }, + { data: [invDE('inv-a'), invDE('inv-b')], error: null }, + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.rows).toHaveLength(1) + expect(report.rows[0].services).toBe(10000) + expect(report.warnings).toEqual([]) + }) + + it('does not double count an entry the engine tagged AND a payment row points at', async () => { + // invoice_cash_payment entries carry source_id = invoice AND an + // invoice_payments row: one posting, one attribution. + const entry = { ...entryOther('je-cash', 'invoice_cash_payment', [lineEU('3308', 6000)]), source_id: 'inv-de' } + results = [ + { data: [entry, entryOther('je-imp', 'import', [lineEU('3308', 1000)])], error: null }, + { data: [], error: null }, + { data: [ + { id: 'pay-1', invoice_id: 'inv-de', journal_entry_id: 'je-cash' }, + { id: 'pay-2', invoice_id: 'inv-de', journal_entry_id: 'je-imp' }, + ], error: null }, + { data: [invDE()], error: null }, + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.rows).toHaveLength(1) + expect(report.rows[0].services).toBe(7000) + }) +}) + // ============================================================ // Reconciliation // ============================================================ @@ -459,8 +578,7 @@ describe('legacy country names on customers (#2028)', () => { const legacy = invDE() legacy.customer!.country = 'Germany' results = [ - { data: [entryEU('inv-de')], error: null }, - { data: [lineEU('3308', 15000, 'inv-de')], error: null }, + { data: [entryEU('inv-de', [lineEU('3308', 15000)])], error: null }, { data: [legacy], error: null }, ] @@ -475,8 +593,7 @@ describe('legacy country names on customers (#2028)', () => { const legacy = invDE() legacy.customer!.country = 'Atlantis' results = [ - { data: [entryEU('inv-de')], error: null }, - { data: [lineEU('3308', 15000, 'inv-de')], error: null }, + { data: [entryEU('inv-de', [lineEU('3308', 15000)])], error: null }, { data: [legacy], error: null }, ] @@ -486,3 +603,105 @@ describe('legacy country names on customers (#2028)', () => { expect(report.warnings.find((w) => w.code === 'NON_EU_COUNTRY_ON_EU_ACCOUNT')?.message).toContain('ATLANTIS') }) }) + +// ============================================================ +// One verifikat settling several invoices (#2298 review) +// ============================================================ + +describe('one verifikat settling several invoices (#2298 review)', () => { + function invFR(id: string): InvoiceFx { + return { + id, + customer: { + id: 'cust-fr', + name: 'FR Customer', + country: 'FR', + vat_number: 'FR999', + vat_number_validated: true, + vat_number_validated_at: RECENT, + }, + } + } + + /** An imported deposit (two PS lines) that a payment row links to two invoices. */ + function settlement(lines: LineFx[]) { + return { + ...entryOther('je-imp', 'import', lines), + voucher_series: 'A', + voucher_number: 7, + } + } + const twoPayments = [ + { id: 'pay-1', invoice_id: 'inv-a', journal_entry_id: 'je-imp' }, + { id: 'pay-2', invoice_id: 'inv-b', journal_entry_id: 'je-imp' }, + ] + + it('same customer on every linked invoice: filed once, in full', async () => { + results = [ + { data: [settlement([lineEU('3308', 10000)])], error: null }, + { data: [], error: null }, // invoices by journal_entry_id + { data: twoPayments, error: null }, + { data: [invDE('inv-a'), invDE('inv-b')], error: null }, + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.warnings).toEqual([]) + expect(report.rows).toHaveLength(1) + expect(report.rows[0]).toMatchObject({ country: 'DE', vatNumber: '123456789', services: 10000 }) + }) + + it('different customers on the linked invoices: blocking MIXED_CUSTOMER_SETTLEMENT, amount left out', async () => { + results = [ + { data: [settlement([lineEU('3308', 6000), lineEU('3108', 4000)])], error: null }, + { data: [], error: null }, + { data: twoPayments, error: null }, + { data: [invDE('inv-a'), invFR('inv-b')], error: null }, + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.rows).toEqual([]) + expect(report.totals.grand).toBe(0) + // Once per verifikat even though it carries two PS lines; blocking, so + // the CSV route refuses the file (it keys on level === 'error'). + expect(report.warnings).toHaveLength(1) + expect(report.warnings[0]).toMatchObject({ + level: 'error', + code: 'MIXED_CUSTOMER_SETTLEMENT', + journalEntryId: 'je-imp', + amount: 10000, + }) + expect(report.warnings[0].message).toContain('A7') + expect(report.warnings[0].message).toContain('2 olika kunder') + }) + + it('two customer rows with the same VAT number still count as different customers', async () => { + results = [ + { data: [settlement([lineEU('3308', 10000)])], error: null }, + { data: [], error: null }, + { data: twoPayments, error: null }, + { data: [invDE('inv-a', 'cust-de'), invDE('inv-b', 'cust-de-duplicate')], error: null }, + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.rows).toEqual([]) + expect(report.warnings.map((w) => w.code)).toEqual(['MIXED_CUSTOMER_SETTLEMENT']) + }) + + it('an engine entry is never a settlement: source_id names exactly one invoice', async () => { + // Even if a payment row also points at it (invoice_cash_payment does), + // the engine's own source_id wins and no settlement check runs. + const entry = { ...entryOther('je-cash', 'invoice_cash_payment', [lineEU('3308', 5000)]), source_id: 'inv-a' } + results = [ + { data: [entry], error: null }, + { data: [invDE('inv-a')], error: null }, + ] + + const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) + + expect(report.warnings).toEqual([]) + expect(report.rows[0]).toMatchObject({ services: 5000 }) + }) +}) diff --git a/lib/reports/periodisk-sammanstallning.ts b/lib/reports/periodisk-sammanstallning.ts index 9df05138..bd527a41 100644 --- a/lib/reports/periodisk-sammanstallning.ts +++ b/lib/reports/periodisk-sammanstallning.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' -import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' +import { chunk } from '@/lib/utils' +import { getInvoiceReferencesForJournalEntries } from '@/lib/core/bookkeeping/journal-entry-references' import { calculatePeriodDates, formatPeriodLabel } from './period-dates' import { calculateVatDeclaration } from './vat-declaration' import { normalizeCountryCode } from '@/lib/vat/country-codes' @@ -17,6 +18,13 @@ import { normalizeCountryCode } from '@/lib/vat/country-codes' * momsdeklaration Ruta 35/38/39 can never drift. See §1.2 of the plan. * * Notes: + * - Which invoice a posting belongs to is resolved through every link the + * register keeps (the engine's source_id, invoices.journal_entry_id, + * invoice_payments.journal_entry_id), so a SIE-imported sale matched to + * its invoice afterwards and a kontantmetod inbetalning are filed too + * (#2298). A 3308/3108 posting no invoice points at is not filed (there + * is no customer to name); the momsdeklaration reconciliation (ruta + * 35/38/39) is where such a gap shows. * - Account 3305/3105 (non-EU export) are NOT in this report: they go to * Ruta 36/40 only. * - Trepartshandel (3107) is included so the report works if someone posts @@ -47,6 +55,8 @@ export type PsWarningCode = | 'CUSTOMER_NOT_FOUND' | 'ZERO_NET_EXCLUDED' | 'GOODS_SOLD_WITH_QUARTERLY_PERIOD' + /** One verifikat linked to invoices of different customers: cannot be split per customer. */ + | 'MIXED_CUSTOMER_SETTLEMENT' export interface PsWarning { level: 'error' | 'warning' @@ -55,6 +65,8 @@ export interface PsWarning { customerId?: string customerName?: string invoiceId?: string + /** The verifikat a MIXED_CUSTOMER_SETTLEMENT warning is about. */ + journalEntryId?: string amount?: number } @@ -105,19 +117,37 @@ const ACCOUNT_TO_BUCKET: Record const PS_ACCOUNTS = Object.keys(ACCOUNT_TO_BUCKET) -interface RawLine { +/** + * Source types the invoice engine writes with `source_id` = the register + * invoice id AND that can carry EU revenue lines: issuance + * (faktureringsmetod), credit notes, and the kontantmetod inbetalning, which + * is where a cash-method company books its revenue at all. + */ +const INVOICE_SOURCED_ENTRY_TYPES = new Set(['invoice_created', 'credit_note', 'invoice_cash_payment']) + +/** Ids per PostgREST `.in()` filter (URL-length convention, lib/worklist/categories.ts). */ +const LINK_LOOKUP_CHUNK = 100 + +interface RawEntryLine { account_number: string debit_amount: number | string credit_amount: number | string - journal_entries: { - company_id: string - entry_date: string - status: string - source_type: string - source_id: string | null - } | null } +interface RawEntry { + id: string + voucher_series: string | null + voucher_number: number | null + entry_date: string + status: string + source_type: string | null + source_id: string | null + /** Only the PS-account lines: the embed is filtered on account_number. */ + journal_entry_lines: RawEntryLine[] | null +} + +type FlatLine = RawEntryLine & { entry: RawEntry } + interface RawInvoice { id: string customer_id: string | null @@ -151,6 +181,34 @@ function round(value: number): number { return Math.round(value) } +/** Voucher label for messages ("A123"), or the id when the entry has none. */ +function voucherLabel(entry: RawEntry): string { + return entry.voucher_number != null + ? `${entry.voucher_series ?? ''}${entry.voucher_number}` + : entry.id +} + +/** Net credit of the entry's PS-account lines: what the file would carry. */ +function entryNet(entry: RawEntry): number { + let net = 0 + for (const line of entry.journal_entry_lines ?? []) { + net += (Number(line.credit_amount) || 0) - (Number(line.debit_amount) || 0) + } + return net +} + +/** + * Who a linked invoice is filed under: the customer row plus the (country, + * VAT number) pair its PS row would carry. Two invoices agree only when all + * of it agrees; an invoice that could not be loaded is its own unknown party. + */ +function customerIdentity(invoice: RawInvoice | undefined, invoiceId: string): string { + const customer = invoice?.customer + if (!customer) return `unknown:${invoiceId}` + const country = (customer.country ?? '').trim().toUpperCase() + return `${customer.id}|${country}|${normalizeVatNumber(customer.vat_number)}` +} + interface Accumulator { country: string vatNumber: string @@ -183,33 +241,58 @@ export async function generatePeriodiskSammanstallning( const { start, end } = calculatePeriodDates(periodType, year, period) - // Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts). - const lines = await fetchEntryLines({ - supabase, - entryColumns: 'company_id, entry_date, status, source_type, source_id', - lineColumns: 'account_number, debit_amount, credit_amount', - filterEntries: (q: EntryLinesQuery) => - q - .eq('company_id', companyId) - .in('status', ['posted', 'reversed']) - // Cash sales on 3308/3108 are not a real flow (EU reverse-charge sales - // always go through AR); excluded to avoid phantom rows. - .in('source_type', ['invoice_created', 'credit_note']) - .gte('entry_date', start) - .lte('entry_date', end), - filterLines: (q: EntryLinesQuery) => q.in('account_number', PS_ACCOUNTS), - }) - - const invoiceIds = Array.from( - new Set( - lines - .map(l => l.journal_entries?.source_id) - .filter((id): id is string => typeof id === 'string'), - ), + // Driven from journal_entries (company + date indexed) with the EU-revenue + // condition as an inner embed: the planner probes journal_entry_lines per + // entry, so only entries carrying a posting on a PS account come back, with + // just those lines. Never the inverse shape (lines with an entries embed): + // see lib/bookkeeping/entry-lines.ts. No source_type filter: which register + // invoice a posting belongs to is resolved below through every link the + // register keeps, not only the engine's own source columns. + const entries = await fetchAllRows(({ from, to }) => + supabase + .from('journal_entries') + .select('id, voucher_series, voucher_number, entry_date, status, source_type, source_id, journal_entry_lines!inner(account_number, debit_amount, credit_amount)') + .eq('company_id', companyId) + .in('status', ['posted', 'reversed']) + .gte('entry_date', start) + .lte('entry_date', end) + .in('journal_entry_lines.account_number', PS_ACCOUNTS) + // Stable total order for correct paging (see fetch-all.ts). + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike<{ data: RawEntry[] | null; error: { message: string } | null }>, ) + // Which register invoice does each posting belong to? Three links; only + // the first lives on the entry itself: + // 1. the engine's own entries: source_id IS the invoice id; + // 2. invoices.journal_entry_id (registration booking, backfilled); + // 3. invoice_payments.journal_entry_id: kontantmetod inbetalning, + // delbetalning, and "matcha mot befintligt verifikat", which is how a + // SIE-imported sale gets its invoice after migration (#2298). + // Following 1 alone (the old source_type filter) dropped every linked + // import and every kontantmetod sale from the filing while the + // account-based momsdeklaration kept showing them in ruta 39. + // Every invoice each entry resolves to. The engine's own entries name one + // (source_id); a linked entry may name several when one inbetalning settled + // several invoices. All of them are loaded so the loop below can tell "two + // invoices, one customer" from "two customers on one posting". + const invoiceIdsByEntry = new Map() + for (const entry of entries) { + if (entry.source_id && INVOICE_SOURCED_ENTRY_TYPES.has(entry.source_type ?? '')) { + invoiceIdsByEntry.set(entry.id, [entry.source_id]) + } + } + const unresolved = entries.filter((e) => !invoiceIdsByEntry.has(e.id)).map((e) => e.id) + for (const ids of chunk(unresolved, LINK_LOOKUP_CHUNK)) { + const refs = await getInvoiceReferencesForJournalEntries(supabase, companyId, ids) + for (const [entryId, invoiceIds] of refs) invoiceIdsByEntry.set(entryId, invoiceIds) + } + + const allInvoiceIds = new Set() + for (const ids of invoiceIdsByEntry.values()) for (const id of ids) allInvoiceIds.add(id) + const invoiceMap = new Map() - if (invoiceIds.length > 0) { + for (const ids of chunk(Array.from(allInvoiceIds), LINK_LOOKUP_CHUNK)) { const invoices = await fetchAllRows(({ from, to }) => supabase .from('invoices') @@ -225,7 +308,8 @@ export async function generatePeriodiskSammanstallning( vat_number_validated_at ) `) - .in('id', invoiceIds) + .eq('company_id', companyId) + .in('id', ids) // Stable total order for correct paging (see fetch-all.ts). .order('id', { ascending: true }) .range(from, to) as unknown as PromiseLike<{ data: RawInvoice[] | null; error: { message: string } | null }>, @@ -233,19 +317,54 @@ export async function generatePeriodiskSammanstallning( for (const inv of invoices) invoiceMap.set(inv.id, inv) } + // One flat line list with its parent entry, in entry-id then line order. + const lines: FlatLine[] = [] + for (const entry of entries) { + for (const line of entry.journal_entry_lines ?? []) lines.push({ ...line, entry }) + } + const accumulators = new Map() const warnings: PsWarning[] = [] let goodsLineSeen = false + // Verifikat already reported as MIXED_CUSTOMER_SETTLEMENT: one warning per + // verifikat, not one per line. + const mixedReported = new Set() for (const line of lines) { - const je = line.journal_entries - if (!je) continue - const sourceId = je.source_id - const invoice = sourceId ? invoiceMap.get(sourceId) : null const bucket = ACCOUNT_TO_BUCKET[line.account_number] if (!bucket) continue + const invoiceIds = invoiceIdsByEntry.get(line.entry.id) + // A manual or imported posting no register invoice points at is not + // filed (see the header). The engine's own entries never take this exit: + // an engine entry whose invoice is gone is a data defect and falls + // through to CUSTOMER_NOT_FOUND below. + if (!invoiceIds && !INVOICE_SOURCED_ENTRY_TYPES.has(line.entry.source_type ?? '')) continue if (bucket === 'goods' || bucket === 'triangulation') goodsLineSeen = true + // One posting, several invoices (a deposit settling more than one): fine + // while they are the same customer, undecidable when they are not. The + // ledger cannot split the line per customer, so the verifikat is kept out + // of the file and reported as blocking, the way CUSTOMER_NOT_FOUND is. + if (invoiceIds && invoiceIds.length > 1) { + const customers = new Set(invoiceIds.map((id) => customerIdentity(invoiceMap.get(id), id))) + if (customers.size > 1) { + if (!mixedReported.has(line.entry.id)) { + mixedReported.add(line.entry.id) + warnings.push({ + level: 'error', + code: 'MIXED_CUSTOMER_SETTLEMENT', + message: + `Verifikat ${voucherLabel(line.entry)} är kopplat till fakturor från ${customers.size} olika kunder ` + + 'och kan inte fördelas per kund i sammanställningen. Kontrollera kopplingarna innan inlämning.', + journalEntryId: line.entry.id, + amount: entryNet(line.entry), + }) + } + continue + } + } + const invoice = invoiceIds ? invoiceMap.get(invoiceIds[0]) ?? null : null + const debit = Number(line.debit_amount) || 0 const credit = Number(line.credit_amount) || 0 const net = credit - debit diff --git a/lib/transactions/underlag-status.ts b/lib/transactions/underlag-status.ts index 956a51ac..8de7b5a0 100644 --- a/lib/transactions/underlag-status.ts +++ b/lib/transactions/underlag-status.ts @@ -5,8 +5,9 @@ import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories' * * - 'has' : the verifikation has at least one current-version document, * or is referenced by a supplier invoice whose source document - * is retained (BFL 5 kap 7 §: hänvisning till underlag); - * callers merge both kinds of ids into jeIdsWithDocs + * is retained, or a customer invoice points at it (BFL 5 kap + * 7 §: hänvisning till underlag); callers merge all three kinds + * of ids into jeIdsWithDocs * - 'missing': the verifikation's source type requires underlag (BFL 5 kap * 7§), has none, and is not exempted via journal_entry_no_doc_required * - 'none' : no statement either way (system-generated source types, diff --git a/supabase/migrations/20260906135702_underlag_customer_invoice_reference.sql b/supabase/migrations/20260906135702_underlag_customer_invoice_reference.sql new file mode 100644 index 00000000..2bd41f51 --- /dev/null +++ b/supabase/migrations/20260906135702_underlag_customer_invoice_reference.sql @@ -0,0 +1,343 @@ +-- Customer-invoice hänvisning in the missing-underlag predicate (#2298). +-- +-- BFL 5 kap 7 §: a verifikation may satisfy the underlag requirement by +-- hänvisning till underlag. Both RPCs below already accept a SUPPLIER invoice +-- reference (an anchored retained document reachable through +-- supplier_invoices / supplier_invoice_payments). The CUSTOMER side was +-- missing: an entry that a register invoice points at is backed by that +-- invoice, which Accounted itself issued and retains (BFL 7 kap), and whose +-- payment record identifies the inbetalning. +-- +-- The link between a customer invoice and its verifikat is written on the +-- invoice side only: invoices.journal_entry_id for the registration booking +-- and invoice_payments.journal_entry_id for a kontantmetod inbetalning, a +-- delbetalning, or "matcha mot befintligt verifikat" (link_invoice_to_voucher). +-- The entry keeps its own source_type/source_id (a posted entry is immutable, +-- and "this came from a SIE import" is an audit fact). So a SIE-imported or +-- manual verifikat that a register invoice was matched to afterwards kept +-- surfacing as "Underlag saknas" even though the verifikat detail page already +-- listed the invoice as its underlag (journal-entry-references.ts). The +-- engine's own invoice source types (invoice_created, invoice_paid, +-- invoice_cash_payment, credit_note) are exempt by omission from the needs-doc +-- list; a linked entry is the same affärshändelse booked before migration and +-- gets the same treatment through the link. +-- +-- Bodies identical to 20260825160000 (verifikat_without_documents) and +-- 20260823001000 (transactions_without_documents) except for the two added +-- NOT EXISTS arms. Same signatures: CREATE OR REPLACE keeps the grants; they +-- are restated for clarity. The transactions surface must stay a strict +-- subset of the verifikat surface, so both get the arms. +-- +-- Keep the needs-doc list in lockstep with NEEDS_DOC_SOURCE_TYPES +-- (lib/worklist/types.ts); the customer arm has TS mirrors in +-- lib/core/bookkeeping/journal-entry-references.ts +-- (getInvoiceReferencesForJournalEntries) used by lib/bookkeeping/ +-- missing-underlag.ts, /api/documents/counts and the transactions list. +-- +-- pg-test: tests/pg/underlag-customer-invoice-reference.pg.test.ts +-- pg-test: tests/pg/document-surfaces-unification.pg.test.ts + +CREATE OR REPLACE FUNCTION public.verifikat_without_documents( + p_company_id uuid, + p_since date DEFAULT NULL, + p_min_amount numeric DEFAULT 0, + p_limit integer DEFAULT 20, + p_offset integer DEFAULT 0 +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_limit integer := least(greatest(coalesce(p_limit, 20), 1), 100); + v_offset integer := greatest(coalesce(p_offset, 0), 0); + v_min numeric := greatest(coalesce(p_min_amount, 0), 0); + v_result jsonb; +BEGIN + IF v_jwt_role IN ('anon', 'authenticated') THEN + IF p_company_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM public.user_company_ids() AS c(id) WHERE c.id = p_company_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'VERIFIKAT_WITHOUT_DOCUMENTS_FORBIDDEN'); + END IF; + END IF; + + WITH candidates AS ( + SELECT + je.id, + je.voucher_series, + je.voucher_number, + je.entry_date, + je.description, + je.source_type, + round(coalesce(sum(l.debit_amount), 0), 2) AS gross_amount + FROM journal_entries je + LEFT JOIN journal_entry_lines l ON l.journal_entry_id = je.id + WHERE je.company_id = p_company_id + AND je.status = 'posted' + -- Only source types whose affärshändelse requires an underlag. + -- Mirrors NEEDS_DOC_SOURCE_TYPES (lib/worklist/types.ts). + AND je.source_type IN ( + 'manual', + 'bank_transaction', + 'supplier_invoice_registered', + 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', + 'import', + 'webshop_order' + ) + -- Superseded document versions do not satisfy BFL underlag. + AND NOT EXISTS ( + SELECT 1 FROM document_attachments d + WHERE d.journal_entry_id = je.id AND d.is_current_version = true + ) + -- Explicitly waived (e.g. internal transfers): user decided no + -- underlag is required; do not resurface to agents. + AND NOT EXISTS ( + SELECT 1 FROM journal_entry_no_doc_required x + WHERE x.journal_entry_id = je.id + ) + -- BFL 5 kap 7 §: hänvisning till underlag. An entry booked from a + -- supplier invoice whose source document is retained is covered by + -- that document even though the doc row hangs on the invoice's other + -- verifikat (registration vs payment). The doc must be ANCHORED + -- (journal_entry_id set): only anchored docs sit behind the WORM + -- deletion guards, so an unanchored doc cannot legally back a posted + -- verifikat and must keep the warning alive. + AND NOT EXISTS ( + SELECT 1 + FROM supplier_invoices si + JOIN document_attachments sd ON sd.id = si.document_id + WHERE si.company_id = p_company_id + AND sd.journal_entry_id IS NOT NULL + AND (si.registration_journal_entry_id = je.id + OR si.payment_journal_entry_id = je.id) + ) + -- Partial payments link through supplier_invoice_payments instead of + -- supplier_invoices.payment_journal_entry_id. + AND NOT EXISTS ( + SELECT 1 + FROM supplier_invoice_payments sip + JOIN supplier_invoices sip_si ON sip_si.id = sip.supplier_invoice_id + JOIN document_attachments sipd ON sipd.id = sip_si.document_id + WHERE sip.journal_entry_id = je.id + AND sip_si.company_id = p_company_id + AND sipd.journal_entry_id IS NOT NULL + ) + -- BFL 5 kap 7 § hänvisning, customer side (#2298): an entry a register + -- invoice points at is backed by that invoice. The invoice Accounted + -- issued IS the verifikation for the sale, and the payment row + -- identifies the inbetalning. Both links are written on the invoice + -- side (registration booking, kontantmetod inbetalning, delbetalning, + -- "matcha mot befintligt verifikat"), so an imported or manual entry + -- keeps its own source_type and must be resolved from here. Tenant + -- scoped on the link row, never on the entry alone. The invoice must + -- be ISSUED: a draft or cancelled invoice is no document (the schema + -- agrees: outside those two statuses an invoice_number is required, + -- migration 20260427150000), the counterpart of the anchored-document + -- requirement on the supplier arms. Mirrors NON_ISSUED_INVOICE_STATUSES + -- (lib/invoices/matchable-statuses.ts). + AND NOT EXISTS ( + SELECT 1 FROM invoices i + WHERE i.company_id = p_company_id + AND i.journal_entry_id = je.id + AND i.status NOT IN ('draft', 'cancelled') + ) + AND NOT EXISTS ( + SELECT 1 + FROM invoice_payments ip + JOIN invoices ipi ON ipi.id = ip.invoice_id + WHERE ip.company_id = p_company_id + AND ip.journal_entry_id = je.id + AND ipi.status NOT IN ('draft', 'cancelled') + ) + AND (p_since IS NULL OR je.entry_date >= p_since) + GROUP BY je.id + HAVING round(coalesce(sum(l.debit_amount), 0), 2) >= v_min + ), + total AS ( + SELECT count(*) AS n FROM candidates + ), + page AS ( + SELECT * FROM candidates + ORDER BY entry_date DESC, voucher_number DESC, id DESC + LIMIT v_limit OFFSET v_offset + ) + SELECT jsonb_build_object( + 'ok', true, + 'total_count', (SELECT n FROM total), + 'verifikat', coalesce( + (SELECT jsonb_agg( + jsonb_build_object( + 'journal_entry_id', p.id, + 'voucher_series', p.voucher_series, + 'voucher_number', p.voucher_number, + 'entry_date', p.entry_date, + 'description', p.description, + 'source_type', p.source_type, + 'gross_amount', p.gross_amount + ) + ORDER BY p.entry_date DESC, p.voucher_number DESC, p.id DESC + ) FROM page p), + '[]'::jsonb + ) + ) + INTO v_result; + + RETURN v_result; +END; +$$; + +REVOKE ALL ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.verifikat_without_documents(uuid, date, numeric, integer, integer) TO authenticated, service_role; + +CREATE OR REPLACE FUNCTION public.transactions_without_documents( + p_company_id uuid, + p_since date DEFAULT NULL, + p_limit integer DEFAULT 20, + p_offset integer DEFAULT 0 +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +DECLARE + v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''); + v_limit integer := least(greatest(coalesce(p_limit, 20), 1), 100); + v_offset integer := greatest(coalesce(p_offset, 0), 0); + v_result jsonb; +BEGIN + IF v_jwt_role IN ('anon', 'authenticated') THEN + IF p_company_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM public.user_company_ids() AS c(id) WHERE c.id = p_company_id + ) THEN + RETURN jsonb_build_object('ok', false, 'code', 'TRANSACTIONS_WITHOUT_DOCUMENTS_FORBIDDEN'); + END IF; + END IF; + + WITH candidates AS ( + SELECT + t.id, + t.date, + t.description, + t.amount, + t.currency, + t.merchant_name, + t.reference, + t.is_business, + t.category, + t.journal_entry_id, + t.cash_account_id, + ca.ledger_account AS cash_account_ledger + FROM transactions t + JOIN journal_entries je ON je.id = t.journal_entry_id + LEFT JOIN cash_accounts ca + ON ca.id = t.cash_account_id + AND ca.company_id = t.company_id + WHERE t.company_id = p_company_id + AND je.status = 'posted' + -- Same predicate as verifikat_without_documents: this surface is the + -- bank-driven subset, keyed on the SAME document truth + -- (document_attachments), never transactions.document_id. + AND je.source_type IN ( + 'manual', + 'bank_transaction', + 'supplier_invoice_registered', + 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', + 'import' + ) + AND NOT EXISTS ( + SELECT 1 FROM document_attachments d + WHERE d.journal_entry_id = je.id AND d.is_current_version = true + ) + AND NOT EXISTS ( + SELECT 1 FROM journal_entry_no_doc_required x + WHERE x.journal_entry_id = je.id + ) + -- BFL 5 kap 7 § hänvisning till underlag (anchored docs only); see + -- verifikat_without_documents. + AND NOT EXISTS ( + SELECT 1 + FROM supplier_invoices si + JOIN document_attachments sd ON sd.id = si.document_id + WHERE si.company_id = p_company_id + AND sd.journal_entry_id IS NOT NULL + AND (si.registration_journal_entry_id = je.id + OR si.payment_journal_entry_id = je.id) + ) + AND NOT EXISTS ( + SELECT 1 + FROM supplier_invoice_payments sip + JOIN supplier_invoices sip_si ON sip_si.id = sip.supplier_invoice_id + JOIN document_attachments sipd ON sipd.id = sip_si.document_id + WHERE sip.journal_entry_id = je.id + AND sip_si.company_id = p_company_id + AND sipd.journal_entry_id IS NOT NULL + ) + -- Customer-invoice hänvisning, issued invoices only (#2298); see + -- verifikat_without_documents. + AND NOT EXISTS ( + SELECT 1 FROM invoices i + WHERE i.company_id = p_company_id + AND i.journal_entry_id = je.id + AND i.status NOT IN ('draft', 'cancelled') + ) + AND NOT EXISTS ( + SELECT 1 + FROM invoice_payments ip + JOIN invoices ipi ON ipi.id = ip.invoice_id + WHERE ip.company_id = p_company_id + AND ip.journal_entry_id = je.id + AND ipi.status NOT IN ('draft', 'cancelled') + ) + AND (p_since IS NULL OR t.date >= p_since) + ), + total AS ( + SELECT count(*) AS n FROM candidates + ), + page AS ( + SELECT * FROM candidates + ORDER BY date DESC, id DESC + LIMIT v_limit OFFSET v_offset + ) + SELECT jsonb_build_object( + 'ok', true, + 'total_count', (SELECT n FROM total), + 'transactions', coalesce( + (SELECT jsonb_agg( + jsonb_build_object( + 'id', p.id, + 'transaction_id', p.id, + 'date', p.date, + 'description', p.description, + 'amount', p.amount, + 'currency', p.currency, + 'merchant_name', p.merchant_name, + 'reference', p.reference, + 'is_business', p.is_business, + 'category', p.category, + 'journal_entry_id', p.journal_entry_id, + 'cash_account_id', p.cash_account_id, + 'cash_account_ledger', p.cash_account_ledger + ) + ORDER BY p.date DESC, p.id DESC + ) FROM page p), + '[]'::jsonb + ) + ) + INTO v_result; + + RETURN v_result; +END; +$$; + +REVOKE ALL ON FUNCTION public.transactions_without_documents(uuid, date, integer, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.transactions_without_documents(uuid, date, integer, integer) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/underlag-customer-invoice-reference.pg.test.ts b/tests/pg/underlag-customer-invoice-reference.pg.test.ts new file mode 100644 index 00000000..0cb1873c --- /dev/null +++ b/tests/pg/underlag-customer-invoice-reference.pg.test.ts @@ -0,0 +1,222 @@ +import { randomUUID } from 'crypto' +import { beforeAll, describe, expect, it } from 'vitest' +import { getPool } from './setup' +import { seedCompany, insertPostedJournalEntry, insertTransaction } from './fixtures' + +/** + * Customer-invoice hänvisning in the missing-underlag predicate (#2298, + * migration 20260906135702). + * + * BFL 5 kap 7 §: a verifikation may satisfy the underlag requirement by + * hänvisning till underlag. An entry that a register invoice points at is + * backed by that invoice: the invoice Accounted issued IS the verifikation + * for the sale, and the payment row identifies the inbetalning. Both links + * are written on the invoice side only (invoices.journal_entry_id, + * invoice_payments.journal_entry_id), so a SIE-imported or manual verifikat + * that an invoice was matched to afterwards keeps its own source_type and must + * be resolved from the link, never by rewriting the posted entry. + * + * Pins, on real Postgres: + * - an 'import' entry linked through invoice_payments is NOT missing underlag; + * - the same shape without a link IS (the needs-doc list still applies); + * - a 'manual' entry referenced by invoices.journal_entry_id is NOT missing; + * - a bank-driven entry linked through invoice_payments leaves BOTH surfaces, + * so transactions_without_documents stays a strict subset; + * - the link row is tenant-scoped: another company's invoice pointing at the + * entry does not silence it. + */ + +type VerifikatResult = { + ok: boolean + total_count?: number + verifikat?: Array<{ journal_entry_id: string; source_type: string }> +} +type TransactionsResult = { + ok: boolean + total_count?: number + transactions?: Array<{ id: string; journal_entry_id: string }> +} + +async function verifikatSurface(companyId: string): Promise { + const { rows } = await getPool().query<{ r: VerifikatResult }>( + `SELECT public.verifikat_without_documents($1, NULL, 0, 100, 0) AS r`, + [companyId], + ) + expect(rows[0].r.ok).toBe(true) + return (rows[0].r.verifikat ?? []).map((v) => v.journal_entry_id) +} + +async function transactionsSurface(companyId: string): Promise { + const { rows } = await getPool().query<{ r: TransactionsResult }>( + `SELECT public.transactions_without_documents($1, NULL, 100, 0) AS r`, + [companyId], + ) + expect(rows[0].r.ok).toBe(true) + return (rows[0].r.transactions ?? []).map((t) => t.journal_entry_id) +} + +async function insertCustomerInvoice(params: { + userId: string + companyId: string + journalEntryId?: string | null + /** Defaults to 'sent' (issued). 'draft' / 'cancelled' are no document. */ + status?: string +}): Promise { + const customerId = randomUUID() + await getPool().query( + `INSERT INTO public.customers (id, user_id, company_id, name, customer_type) + VALUES ($1, $2, $3, 'EU Kund GmbH', 'eu_business')`, + [customerId, params.userId, params.companyId], + ) + const id = randomUUID() + await getPool().query( + `INSERT INTO public.invoices + (id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date, + currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status, + paid_amount, remaining_amount, journal_entry_id) + VALUES ($1, $2, $3, $4, $5, '2026-06-01', '2026-06-30', 'SEK', + 10000, 0, 10000, 'reverse_charge', 0, $7, 0, 10000, $6)`, + [ + id, + params.userId, + params.companyId, + customerId, + `F-${id.slice(0, 8)}`, + params.journalEntryId ?? null, + params.status ?? 'sent', + ], + ) + return id +} + +/** The row link_invoice_to_voucher writes: the voucher becomes the invoice's payment. */ +async function linkAsPayment(params: { + userId: string + companyId: string + invoiceId: string + journalEntryId: string +}): Promise { + await getPool().query( + `INSERT INTO public.invoice_payments + (user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id) + VALUES ($1, $2, $3, '2026-06-10', 10000, 'SEK', $4)`, + [params.userId, params.companyId, params.invoiceId, params.journalEntryId], + ) +} + +describe('customer-invoice hänvisning silences "Underlag saknas" (#2298)', () => { + let companyId: string + let jeImportLinked: string // SIE-imported sale, invoice matched to it → covered + let jeImportLoose: string // SIE-imported sale, nothing points at it → missing + let jeManualRegistered: string // manual booking the invoice register links directly → covered + let jeBankLinked: string // bank-driven entry, invoice matched to it → covered on BOTH surfaces + let jeImportForeignLink: string // linked only from ANOTHER company's invoice → still missing + let jeManualDraftLink: string // a DRAFT invoice points at it: no document yet → still missing + let jeImportCancelledPayment: string // payment row of a CANCELLED invoice → still missing + + beforeAll(async () => { + const s = await seedCompany() + companyId = s.companyId + const { userId, fiscalPeriodId } = s + + // The importer's shape for an EU service sale under kontantmetoden: + // debit bank, credit 3308. Source type 'import' is in the needs-doc list. + const mkJe = (n: number, sourceType: string) => + insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + voucherNumber: n, + entryDate: `2026-06-${String(n).padStart(2, '0')}`, + description: `${sourceType} ${n}`, + sourceType, + lines: [ + { accountNumber: '1930', debitAmount: 10000, creditAmount: 0 }, + { accountNumber: '3308', debitAmount: 0, creditAmount: 10000 }, + ], + }) + + jeImportLinked = await mkJe(1, 'import') + jeImportLoose = await mkJe(2, 'import') + jeManualRegistered = await mkJe(3, 'manual') + jeBankLinked = await mkJe(4, 'bank_transaction') + jeImportForeignLink = await mkJe(5, 'import') + jeManualDraftLink = await mkJe(6, 'manual') + jeImportCancelledPayment = await mkJe(7, 'import') + + const linkedInvoice = await insertCustomerInvoice({ userId, companyId }) + await linkAsPayment({ userId, companyId, invoiceId: linkedInvoice, journalEntryId: jeImportLinked }) + + await insertCustomerInvoice({ userId, companyId, journalEntryId: jeManualRegistered }) + + // Non-issued invoices: the link row exists but no document does, the + // counterpart of an unanchored supplier document. + await insertCustomerInvoice({ userId, companyId, journalEntryId: jeManualDraftLink, status: 'draft' }) + const cancelledInvoice = await insertCustomerInvoice({ userId, companyId, status: 'cancelled' }) + await linkAsPayment({ + userId, + companyId, + invoiceId: cancelledInvoice, + journalEntryId: jeImportCancelledPayment, + }) + + await insertTransaction({ userId, companyId, journalEntryId: jeBankLinked, date: '2026-06-04' }) + const bankInvoice = await insertCustomerInvoice({ userId, companyId }) + await linkAsPayment({ userId, companyId, invoiceId: bankInvoice, journalEntryId: jeBankLinked }) + + // Another tenant's invoice pointing at this company's entry: the FK + // allows it, the predicate must not honour it. + const other = await seedCompany() + const foreignInvoice = await insertCustomerInvoice({ userId: other.userId, companyId: other.companyId }) + await linkAsPayment({ + userId: other.userId, + companyId: other.companyId, + invoiceId: foreignInvoice, + journalEntryId: jeImportForeignLink, + }) + }) + + it('an imported verifikat matched to a register invoice through invoice_payments is not missing underlag', async () => { + const ids = await verifikatSurface(companyId) + expect(ids).not.toContain(jeImportLinked) + }) + + it('the same imported shape without a link still is (needs-doc source type, no hänvisning)', async () => { + const ids = await verifikatSurface(companyId) + expect(ids).toContain(jeImportLoose) + }) + + it('a manual verifikat the register points at through invoices.journal_entry_id is not missing underlag', async () => { + const ids = await verifikatSurface(companyId) + expect(ids).not.toContain(jeManualRegistered) + }) + + it('a link from another company does not silence the entry', async () => { + const ids = await verifikatSurface(companyId) + expect(ids).toContain(jeImportForeignLink) + }) + + it('a bank-driven entry matched to an invoice leaves both surfaces, so the subset invariant holds', async () => { + const [ver, tx] = await Promise.all([verifikatSurface(companyId), transactionsSurface(companyId)]) + expect(ver).not.toContain(jeBankLinked) + expect(tx).not.toContain(jeBankLinked) + for (const id of tx) expect(ver).toContain(id) + }) + + it('a DRAFT invoice pointing at the entry is no underlag: still missing', async () => { + const ids = await verifikatSurface(companyId) + expect(ids).toContain(jeManualDraftLink) + }) + + it('a payment row of a CANCELLED invoice is no underlag: still missing', async () => { + const ids = await verifikatSurface(companyId) + expect(ids).toContain(jeImportCancelledPayment) + }) + + it('the full verdict: exactly the unlinked, foreign-linked and non-issued-linked entries remain', async () => { + const ids = await verifikatSurface(companyId) + expect(ids.sort()).toEqual( + [jeImportLoose, jeImportForeignLink, jeManualDraftLink, jeImportCancelledPayment].sort(), + ) + }) +})