fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) (#2347)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
cce0de5704
commit
7448490fb7
@@ -89,6 +89,7 @@ import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
|||||||
import { fetchMigrationCoverageEnd } from '@/lib/transactions/migration-coverage'
|
import { fetchMigrationCoverageEnd } from '@/lib/transactions/migration-coverage'
|
||||||
import { isImportedTransaction } from '@/lib/transactions/origin'
|
import { isImportedTransaction } from '@/lib/transactions/origin'
|
||||||
import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status'
|
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 { isWithinBounds, resolvePeriodBounds } from '@/lib/transactions/period-filter'
|
||||||
import type { FiscalPeriod } from '@/types'
|
import type { FiscalPeriod } from '@/types'
|
||||||
|
|
||||||
@@ -1301,7 +1302,8 @@ export default function TransactionsPage() {
|
|||||||
// current-version document, which are covered by a supplier invoice's
|
// current-version document, which are covered by a supplier invoice's
|
||||||
// retained document (BFL 5 kap 7 § hänvisning: registration/payment FK or a
|
// retained document (BFL 5 kap 7 § hänvisning: registration/payment FK or a
|
||||||
// supplier_invoice_payments row: mirrors the verifikat_without_documents
|
// 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
|
// Incremental: only fetches JE ids not yet requested, so
|
||||||
// loadMoreTransactions pages are covered without refetching.
|
// loadMoreTransactions pages are covered without refetching.
|
||||||
// Soft-fails to "no badges" on error.
|
// Soft-fails to "no badges" on error.
|
||||||
@@ -1395,6 +1397,19 @@ export default function TransactionsPage() {
|
|||||||
jeIdsWithDocs.add(sip.journal_entry_id)
|
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<string, string[]>
|
||||||
|
try {
|
||||||
|
invoiceRefs = await getInvoiceReferencesForJournalEntries(supabase, companyId, chunk)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const journalEntryId of invoiceRefs.keys()) jeIdsWithDocs.add(journalEntryId)
|
||||||
const exemptIds = new Set(
|
const exemptIds = new Set(
|
||||||
(exemptRes.data ?? []).map((e) => e.journal_entry_id as string),
|
(exemptRes.data ?? []).map((e) => e.journal_entry_id as string),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -377,6 +377,8 @@ describe('GET /api/bookkeeping/journal-entries', () => {
|
|||||||
enqueue({ data: [], error: null }) // no SI references
|
enqueue({ data: [], error: null }) // no SI references
|
||||||
enqueue({ data: [], error: null }) // no SI payment-row references
|
enqueue({ data: [], error: null }) // no SI payment-row references
|
||||||
enqueue({ data: [{ journal_entry_id: E3 }], error: null }) // E3 exempt
|
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 })
|
const fullRow = makeJournalEntry({ id: E2 })
|
||||||
enqueue({ data: [fullRow], error: null }) // page rows
|
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 references
|
||||||
enqueue({ data: [], error: null }) // no SI payment-row references
|
enqueue({ data: [], error: null }) // no SI payment-row references
|
||||||
enqueue({ data: [], error: null }) // no exemptions
|
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
|
enqueue({ data: [makeJournalEntry({ id: E2 })], error: null }) // page rows
|
||||||
|
|
||||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
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 SI payment-row references
|
||||||
enqueue({ data: [], error: null }) // no exemptions
|
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
|
enqueue({ data: [makeJournalEntry({ id: E2 })], error: null }) // page rows
|
||||||
|
|
||||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||||
@@ -464,6 +470,30 @@ describe('GET /api/bookkeeping/journal-entries', () => {
|
|||||||
expect(body.count).toBe(1)
|
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 () => {
|
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
|
// A voucher-shaped needle fans out to TWO candidate queries (description
|
||||||
// ilike, then series+number), unioned by id: a runtime-built .or()
|
// 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 references
|
||||||
enqueue({ data: [], error: null }) // no SI payment-row references
|
enqueue({ data: [], error: null }) // no SI payment-row references
|
||||||
enqueue({ data: [], error: null }) // no exemptions
|
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
|
enqueue({ data: [makeJournalEntry({ id: E1 })], error: null }) // page rows
|
||||||
|
|
||||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||||
|
|||||||
@@ -64,12 +64,17 @@ describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => {
|
|||||||
expect((await parseJsonResponse(res)).status).toBe(400)
|
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 () => {
|
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: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates
|
||||||
enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a has a document
|
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 references with docs
|
||||||
enqueue({ data: [], error: null }) // no SI payment-row references
|
enqueue({ data: [], error: null }) // no SI payment-row references
|
||||||
enqueue({ data: [{ journal_entry_id: 'b' }], error: null }) // b already exempt
|
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 res = await POST(makeReq({ dry_run: true }))
|
||||||
const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res)
|
const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res)
|
||||||
expect(status).toBe(200)
|
expect(status).toBe(200)
|
||||||
@@ -108,18 +113,39 @@ describe('POST /api/bookkeeping/no-doc-required/bulk-missing', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
enqueue({ data: [], error: null }) // no exemptions
|
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 res = await POST(makeReq({ dry_run: true }))
|
||||||
const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res)
|
const { status, body } = await parseJsonResponse<{ data: { count: number } }>(res)
|
||||||
expect(status).toBe(200)
|
expect(status).toBe(200)
|
||||||
expect(body.data.count).toBe(2) // c and d
|
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 () => {
|
it('marks the missing entries and returns the count', async () => {
|
||||||
enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates
|
enqueue({ data: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], error: null }) // candidates
|
||||||
enqueue({ data: [], error: null }) // no documents
|
enqueue({ data: [], error: null }) // no documents
|
||||||
enqueue({ data: [], error: null }) // no SI references with docs
|
enqueue({ data: [], error: null }) // no SI references with docs
|
||||||
enqueue({ data: [], error: null }) // no SI payment-row references
|
enqueue({ data: [], error: null }) // no SI payment-row references
|
||||||
enqueue({ data: [{ journal_entry_id: 'a' }], error: null }) // a already exempt
|
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
|
enqueue({ error: null }) // helper upsert
|
||||||
const res = await POST(makeReq({ period_id: null, reason: 'Importerad' }))
|
const res = await POST(makeReq({ period_id: null, reason: 'Importerad' }))
|
||||||
const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res)
|
const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res)
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ beforeEach(() => {
|
|||||||
;(getActiveCompanyId as ReturnType<typeof vi.fn>).mockResolvedValue('company-1')
|
;(getActiveCompanyId as ReturnType<typeof vi.fn>).mockResolvedValue('company-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Queue order mirrors the route's Promise.all: direct docs, supplier_invoices
|
// Queue order mirrors the route: the Promise.all (direct docs, supplier_invoices
|
||||||
// references, supplier_invoice_payments references. The `document` embed
|
// references, supplier_invoice_payments references), then the customer-invoice
|
||||||
// carries the anchor state (journal_entry_id) of the SI's retained doc.
|
// 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: {
|
function enqueueAll(opts: {
|
||||||
direct?: Array<{ id: string; journal_entry_id: string }>
|
direct?: Array<{ id: string; journal_entry_id: string }>
|
||||||
si?: Array<{
|
si?: Array<{
|
||||||
@@ -51,10 +53,14 @@ function enqueueAll(opts: {
|
|||||||
document: { journal_entry_id: string | null } | null
|
document: { journal_entry_id: string | null } | 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.direct ?? [], error: null })
|
||||||
enqueue({ data: opts.si ?? [], error: null })
|
enqueue({ data: opts.si ?? [], error: null })
|
||||||
enqueue({ data: opts.sip ?? [], 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', () => {
|
describe('GET /api/documents/counts', () => {
|
||||||
@@ -190,4 +196,45 @@ describe('GET /api/documents/counts', () => {
|
|||||||
const res = await GET(makeReq([JE_A]))
|
const res = await GET(makeReq([JE_A]))
|
||||||
expect((await parseJsonResponse(res)).status).toBe(500)
|
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<string, number>
|
||||||
|
invoice_references: Record<string, number>
|
||||||
|
}>(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<string, number> }>(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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||||
|
import { getInvoiceReferencesForJournalEntries } from '@/lib/core/bookkeeping/journal-entry-references'
|
||||||
|
|
||||||
const uuidSchema = z.string().uuid()
|
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
|
* into a PostgREST .or() filter string, so validation doubles as injection
|
||||||
* protection).
|
* protection).
|
||||||
*
|
*
|
||||||
* Counts BOTH direct attachments (document_attachments.journal_entry_id) and
|
* `data` counts BOTH direct attachments (document_attachments.journal_entry_id)
|
||||||
* documents retained on a supplier invoice that references the entry
|
* and documents retained on a supplier invoice that references the entry
|
||||||
* (registration/payment FK or a supplier_invoice_payments row). BFL 5 kap 7 §
|
* (registration/payment FK or a supplier_invoice_payments row). BFL 5 kap 7 §
|
||||||
* accepts underlag via hänvisning, and the expanded-row view
|
* accepts underlag via hänvisning, and the expanded-row view
|
||||||
* (JournalEntryAttachments) already lists referenced docs: counting only
|
* (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
|
* guards, so they must not silence the missing-underlag warning (mirrors the
|
||||||
* verifikat_without_documents RPC). Documents are deduplicated per entry so a
|
* verifikat_without_documents RPC). Documents are deduplicated per entry so a
|
||||||
* doc that is both directly linked and referenced counts once.
|
* 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) => {
|
export const GET = withRouteContext('document.counts', async (request, ctx) => {
|
||||||
const { supabase, companyId } = 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)
|
add(row.journal_entry_id, row.supplier_invoice.document_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let invoiceRefs: Map<string, string[]>
|
||||||
|
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
|
// 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.
|
// entry the caller didn't ask about) must not leak into the response.
|
||||||
const requested = new Set(ids)
|
const requested = new Set(ids)
|
||||||
@@ -132,6 +148,10 @@ export const GET = withRouteContext('document.counts', async (request, ctx) => {
|
|||||||
for (const [journalEntryId, docIds] of docsByEntry) {
|
for (const [journalEntryId, docIds] of docsByEntry) {
|
||||||
if (requested.has(journalEntryId)) counts[journalEntryId] = docIds.size
|
if (requested.has(journalEntryId)) counts[journalEntryId] = docIds.size
|
||||||
}
|
}
|
||||||
|
const invoiceReferences: Record<string, number> = {}
|
||||||
|
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 })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -246,6 +246,11 @@ export default function JournalEntryList({
|
|||||||
const [count, setCount] = useState(0)
|
const [count, setCount] = useState(0)
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
|
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
|
||||||
|
// 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<Set<string>>(new Set())
|
||||||
// Counts arrive in a second request, after the rows are already painted.
|
// 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
|
// 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
|
// 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) => {
|
const fetchAttachmentCounts = useCallback(async (entryIds: string[], isCurrent: () => boolean = () => true) => {
|
||||||
if (entryIds.length === 0) {
|
if (entryIds.length === 0) {
|
||||||
setAttachmentCounts({})
|
setAttachmentCounts({})
|
||||||
|
setInvoiceReferenced(new Set())
|
||||||
setAttachmentCountsLoaded(true)
|
setAttachmentCountsLoaded(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -370,18 +376,23 @@ export default function JournalEntryList({
|
|||||||
batches.push(entryIds.slice(i, i + COUNTS_BATCH_SIZE))
|
batches.push(entryIds.slice(i, i + COUNTS_BATCH_SIZE))
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const empty = { counts: {} as Record<string, number>, referenced: [] as string[] }
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
batches.map(async (batch) => {
|
batches.map(async (batch) => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/documents/counts?journal_entry_ids=${batch.join(',')}`
|
`/api/documents/counts?journal_entry_ids=${batch.join(',')}`
|
||||||
)
|
)
|
||||||
if (!res.ok) return {} as Record<string, number>
|
if (!res.ok) return empty
|
||||||
const { data } = await res.json()
|
const { data, invoice_references } = await res.json()
|
||||||
return (data || {}) as Record<string, number>
|
return {
|
||||||
|
counts: (data || {}) as Record<string, number>,
|
||||||
|
referenced: Object.keys((invoice_references || {}) as Record<string, number>),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
if (!isCurrent()) return
|
if (!isCurrent()) return
|
||||||
setAttachmentCounts(Object.assign({}, ...results))
|
setAttachmentCounts(Object.assign({}, ...results.map((r) => r.counts)))
|
||||||
|
setInvoiceReferenced(new Set(results.flatMap((r) => r.referenced)))
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical: silently ignore
|
// Non-critical: silently ignore
|
||||||
} finally {
|
} finally {
|
||||||
@@ -810,8 +821,9 @@ export default function JournalEntryList({
|
|||||||
entry.status === 'posted' &&
|
entry.status === 'posted' &&
|
||||||
NEEDS_ATTACHMENT.has(entry.source_type) &&
|
NEEDS_ATTACHMENT.has(entry.source_type) &&
|
||||||
!attachmentCounts[entry.id] &&
|
!attachmentCounts[entry.id] &&
|
||||||
|
!invoiceReferenced.has(entry.id) &&
|
||||||
!noDocRequired.has(entry.id),
|
!noDocRequired.has(entry.id),
|
||||||
[attachmentCounts, noDocRequired],
|
[attachmentCounts, invoiceReferenced, noDocRequired],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleBatchExempt = async () => {
|
const handleBatchExempt = async () => {
|
||||||
@@ -1631,7 +1643,7 @@ export default function JournalEntryList({
|
|||||||
<span className="text-xs tabular-nums">{attachmentCounts[entry.id]}</span>
|
<span className="text-xs tabular-nums">{attachmentCounts[entry.id]}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
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) ? (
|
noDocRequired.has(entry.id) ? (
|
||||||
<span title={t('no_doc_required_indicator_tooltip')}>
|
<span title={t('no_doc_required_indicator_tooltip')}>
|
||||||
<CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
|
<CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
|||||||
import type { NotificationType } from '@/types'
|
import type { NotificationType } from '@/types'
|
||||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||||
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/types'
|
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 { sendNotificationToUser, readNotificationSettings } from './notification-sender'
|
||||||
import {
|
import {
|
||||||
createTaxDeadlinePayload,
|
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
|
// Entries the user has explicitly flagged as "no underlag required" (bank
|
||||||
// fees, interest, internal transfers, salary, tax payments). Treated as
|
// fees, interest, internal transfers, salary, tax payments). Treated as
|
||||||
// satisfied so we don't nag the user about them.
|
// satisfied so we don't nag the user about them.
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
|
|||||||
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
|
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
|
||||||
import { parseVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
import { parseVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
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
|
* Shared resolution of "posted verifikat that lack underlag", scoped by the
|
||||||
* journal list's filters. Single TS mirror of the verifikat_without_documents
|
* journal list's filters. Single TS mirror of the verifikat_without_documents
|
||||||
* RPC predicate (posted + document-requiring source type, no current-version
|
* 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, 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
|
* document is anchored to a journal entry or via a customer invoice that
|
||||||
* exemption). Used by the bulk "Inget underlag krävs" route and the journal
|
* points at the entry, no journal_entry_no_doc_required exemption). Used by
|
||||||
* list's missing_underlag filter so the two can never disagree.
|
* the bulk "Inget underlag krävs" route and the journal list's
|
||||||
|
* missing_underlag filter so the two can never disagree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MissingUnderlagFilters {
|
export interface MissingUnderlagFilters {
|
||||||
@@ -233,6 +235,17 @@ export async function resolveMissingUnderlagEntries(
|
|||||||
for (const r of (exemptRes.data ?? []) as { journal_entry_id: string }[]) {
|
for (const r of (exemptRes.data ?? []) as { journal_entry_id: string }[]) {
|
||||||
exempt.add(r.journal_entry_id)
|
exempt.add(r.journal_entry_id)
|
||||||
}
|
}
|
||||||
|
// BFL 5 kap 7 § hänvisning, customer side (#2298): an entry a register
|
||||||
|
// invoice points at (registration link or invoice_payments row, e.g. a
|
||||||
|
// SIE-imported sale matched to its invoice afterwards) is backed by that
|
||||||
|
// invoice. Mirrors the verifikat_without_documents RPC's customer arm.
|
||||||
|
let invoiceRefs: Map<string, string[]>
|
||||||
|
try {
|
||||||
|
invoiceRefs = await getInvoiceReferencesForJournalEntries(supabase, companyId, chunk)
|
||||||
|
} catch (err) {
|
||||||
|
throw new MissingUnderlagQueryError(getUserErrorMessage(err))
|
||||||
|
}
|
||||||
|
for (const journalEntryId of invoiceRefs.keys()) withDoc.add(journalEntryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates.filter((e) => !withDoc.has(e.id) && !exempt.has(e.id))
|
return candidates.filter((e) => !withDoc.has(e.id) && !exempt.has(e.id))
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
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
|
* 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' },
|
{ 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")',
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
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
|
* A followable reference from a verifikation back to its underlag: the customer
|
||||||
@@ -87,9 +88,13 @@ export async function getJournalEntryUnderlagReferences(
|
|||||||
const invoices = new Map<string, string>()
|
const invoices = new Map<string, string>()
|
||||||
|
|
||||||
// Direct link (faktureringsmetod registration, or invoices.journal_entry_id).
|
// 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 }) =>
|
const directInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
||||||
supabase.from('invoices').select('id, invoice_number')
|
supabase.from('invoices').select('id, invoice_number')
|
||||||
.eq('company_id', companyId).eq('journal_entry_id', journalEntryId)
|
.eq('company_id', companyId).eq('journal_entry_id', journalEntryId)
|
||||||
|
.not('status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
||||||
.order('id', { ascending: true }).range(from, to),
|
.order('id', { ascending: true }).range(from, to),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,6 +117,7 @@ export async function getJournalEntryUnderlagReferences(
|
|||||||
const paidInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
const paidInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
||||||
supabase.from('invoices').select('id, invoice_number')
|
supabase.from('invoices').select('id, invoice_number')
|
||||||
.eq('company_id', companyId).in('id', Array.from(paymentInvoiceIds))
|
.eq('company_id', companyId).in('id', Array.from(paymentInvoiceIds))
|
||||||
|
.not('status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
||||||
.order('id', { ascending: true }).range(from, to),
|
.order('id', { ascending: true }).range(from, to),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -195,3 +201,72 @@ export async function getJournalEntryUnderlagReferences(
|
|||||||
}
|
}
|
||||||
return references
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,3 +71,18 @@ export function isMatchableSupplierInvoice(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return getSupplierInvoiceMatchTargetState(candidate) === 'matchable'
|
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(',') + ')'
|
||||||
|
|||||||
@@ -99,55 +99,69 @@ interface InvoiceFx {
|
|||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LineFx {
|
||||||
|
account_number: string
|
||||||
|
debit_amount: number
|
||||||
|
credit_amount: number
|
||||||
|
}
|
||||||
|
|
||||||
// Recent validation so VIES_UNVALIDATED warnings don't fire by default.
|
// Recent validation so VIES_UNVALIDATED warnings don't fire by default.
|
||||||
const RECENT = new Date().toISOString()
|
const RECENT = new Date().toISOString()
|
||||||
|
|
||||||
// The generator fetches lines via the two-step entry-lines helper
|
// The generator fetches the period's entries with their PS-account lines
|
||||||
// (lib/bookkeeping/entry-lines.ts): journal_entries first, then
|
// embedded (journal_entries + journal_entry_lines!inner, one page here), then
|
||||||
// journal_entry_lines by entry id with the parent reattached under
|
// resolves each entry's invoice: the engine's own entries by source_id,
|
||||||
// `journal_entries`. Each fixture invoice gets one entry (je-<sourceId>).
|
// 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) {
|
function je(sourceId: string) {
|
||||||
return `je-${sourceId}`
|
return `je-${sourceId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryEU(sourceId: string) {
|
function entryEU(sourceId: string, lines: LineFx[] = []) {
|
||||||
return {
|
return {
|
||||||
id: je(sourceId),
|
id: je(sourceId),
|
||||||
company_id: 'c1',
|
|
||||||
entry_date: '2025-05-15',
|
entry_date: '2025-05-15',
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source_type: 'invoice_created',
|
source_type: 'invoice_created',
|
||||||
source_id: sourceId,
|
source_id: sourceId,
|
||||||
|
journal_entry_lines: lines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryCredit(sourceId: string) {
|
function entryCredit(sourceId: string, lines: LineFx[] = []) {
|
||||||
return {
|
return {
|
||||||
id: je(sourceId),
|
id: je(sourceId),
|
||||||
company_id: 'c1',
|
|
||||||
entry_date: '2025-05-20',
|
entry_date: '2025-05-20',
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source_type: 'credit_note',
|
source_type: 'credit_note',
|
||||||
source_id: sourceId,
|
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 {
|
return {
|
||||||
account_number: account,
|
id,
|
||||||
debit_amount: 0,
|
entry_date: '2025-05-15',
|
||||||
credit_amount: credit,
|
status: 'posted',
|
||||||
journal_entry_id: je(sourceId),
|
source_type: sourceType,
|
||||||
|
source_id: null as string | null,
|
||||||
|
journal_entry_lines: lines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineCredit(account: string, debit: number, sourceId: string) {
|
function lineEU(account: string, credit: number): LineFx {
|
||||||
return {
|
return { account_number: account, debit_amount: 0, credit_amount: credit }
|
||||||
account_number: account,
|
}
|
||||||
debit_amount: debit,
|
|
||||||
credit_amount: 0,
|
function lineCredit(account: string, debit: number): LineFx {
|
||||||
journal_entry_id: je(sourceId),
|
return { account_number: account, debit_amount: debit, credit_amount: 0 }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat = 'DE123456789'): InvoiceFx {
|
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', () => {
|
describe('generatePeriodiskSammanstallning', () => {
|
||||||
it('empty period returns zero rows and zero warnings', async () => {
|
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 }]
|
results = [{ data: [], error: null }]
|
||||||
|
|
||||||
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
||||||
@@ -176,13 +190,12 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
expect(report.totals.rowCount).toBe(0)
|
expect(report.totals.rowCount).toBe(0)
|
||||||
expect(report.totals.grand).toBe(0)
|
expect(report.totals.grand).toBe(0)
|
||||||
expect(report.period.label).toBe('Maj 2025')
|
expect(report.period.label).toBe('Maj 2025')
|
||||||
|
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('single EU service sale → 1 row, type 3 only', async () => {
|
it('single EU service sale → 1 row, type 3 only', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
{ data: [entryEU('inv-de', [lineEU('3308', 10000)])], error: null },
|
||||||
{ data: [entryEU('inv-de')], error: null },
|
|
||||||
{ data: [lineEU('3308', 10000, 'inv-de')], error: null },
|
|
||||||
{ data: [invDE()], 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.totals).toMatchObject({ services: 10000, goods: 0, triangulation: 0, grand: 10000, rowCount: 1 })
|
||||||
expect(report.warnings).toEqual([])
|
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 () => {
|
it('aggregates multiple invoices to same customer', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
|
||||||
{ data: [entryEU('inv1'), entryEU('inv2'), entryEU('inv3')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [
|
data: [
|
||||||
lineEU('3308', 4000, 'inv1'),
|
entryEU('inv1', [lineEU('3308', 4000)]),
|
||||||
lineEU('3308', 3500, 'inv2'),
|
entryEU('inv2', [lineEU('3308', 3500)]),
|
||||||
lineEU('3308', 2500, 'inv3'),
|
entryEU('inv3', [lineEU('3308', 2500)]),
|
||||||
],
|
],
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -230,12 +243,10 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
|
|
||||||
it('one customer with both services and goods → 1 row with both filled', async () => {
|
it('one customer with both services and goods → 1 row with both filled', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
|
||||||
{ data: [entryEU('inv1'), entryEU('inv2')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [
|
data: [
|
||||||
lineEU('3308', 7000, 'inv1'),
|
entryEU('inv1', [lineEU('3308', 7000)]),
|
||||||
lineEU('3108', 5000, 'inv2'),
|
entryEU('inv2', [lineEU('3108', 5000)]),
|
||||||
],
|
],
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -253,12 +264,10 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
|
|
||||||
it('credit invoice nets against original in same period', async () => {
|
it('credit invoice nets against original in same period', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
|
||||||
{ data: [entryEU('inv1'), entryCredit('cn1')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [
|
data: [
|
||||||
lineEU('3308', 10000, 'inv1'),
|
entryEU('inv1', [lineEU('3308', 10000)]),
|
||||||
lineCredit('3308', 3000, 'cn1'),
|
entryCredit('cn1', [lineCredit('3308', 3000)]),
|
||||||
],
|
],
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -276,12 +285,10 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
|
|
||||||
it('credit fully cancels → row excluded with ZERO_NET_EXCLUDED warning', async () => {
|
it('credit fully cancels → row excluded with ZERO_NET_EXCLUDED warning', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
|
||||||
{ data: [entryEU('inv1'), entryCredit('cn1')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [
|
data: [
|
||||||
lineEU('3308', 10000, 'inv1'),
|
entryEU('inv1', [lineEU('3308', 10000)]),
|
||||||
lineCredit('3308', 10000, 'cn1'),
|
entryCredit('cn1', [lineCredit('3308', 10000)]),
|
||||||
],
|
],
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -296,9 +303,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
|
|
||||||
it('customer missing country → MISSING_COUNTRY error and row blocked', async () => {
|
it('customer missing country → MISSING_COUNTRY error and row blocked', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
||||||
{ data: [entryEU('inv1')], error: null },
|
|
||||||
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [{
|
data: [{
|
||||||
id: 'inv1',
|
id: 'inv1',
|
||||||
@@ -316,9 +321,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
|
|
||||||
it('customer missing vat_number → MISSING_VAT_NUMBER error', async () => {
|
it('customer missing vat_number → MISSING_VAT_NUMBER error', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
||||||
{ data: [entryEU('inv1')], error: null },
|
|
||||||
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [{
|
data: [{
|
||||||
id: 'inv1',
|
id: 'inv1',
|
||||||
@@ -336,9 +339,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
|
|
||||||
it('VAT prefix mismatch surfaces COUNTRY_PREFIX_MISMATCH warning', async () => {
|
it('VAT prefix mismatch surfaces COUNTRY_PREFIX_MISMATCH warning', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
||||||
{ data: [entryEU('inv1')], error: null },
|
|
||||||
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [{
|
data: [{
|
||||||
id: 'inv1',
|
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 () => {
|
it('non-EU country on EU account → NON_EU_COUNTRY_ON_EU_ACCOUNT and excluded from CSV', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
||||||
{ data: [entryEU('inv1')], error: null },
|
|
||||||
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [{
|
data: [{
|
||||||
id: 'inv1',
|
id: 'inv1',
|
||||||
@@ -376,9 +375,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
|
|
||||||
it('Greek customer → country code emitted as EL', async () => {
|
it('Greek customer → country code emitted as EL', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
{ data: [entryEU('inv1', [lineEU('3308', 4200)])], error: null },
|
||||||
{ data: [entryEU('inv1')], error: null },
|
|
||||||
{ data: [lineEU('3308', 4200, 'inv1')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [{
|
data: [{
|
||||||
id: 'inv1',
|
id: 'inv1',
|
||||||
@@ -395,9 +392,7 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
|
|
||||||
it('goods sold in quarterly period → GOODS_SOLD_WITH_QUARTERLY_PERIOD warning', async () => {
|
it('goods sold in quarterly period → GOODS_SOLD_WITH_QUARTERLY_PERIOD warning', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
{ data: [entryEU('inv1', [lineEU('3108', 9000)])], error: null },
|
||||||
{ data: [entryEU('inv1')], error: null },
|
|
||||||
{ data: [lineEU('3108', 9000, 'inv1')], error: null },
|
|
||||||
{ data: [{ ...invDE('inv1') }], error: null },
|
{ data: [{ ...invDE('inv1') }], error: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -408,13 +403,11 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
|
|
||||||
it('sorts rows by country then vat_number', async () => {
|
it('sorts rows by country then vat_number', async () => {
|
||||||
results = [
|
results = [
|
||||||
// journal_entries page for the two-step entry-lines fetch
|
|
||||||
{ data: [entryEU('inv-fr'), entryEU('inv-de'), entryEU('inv-at')], error: null },
|
|
||||||
{
|
{
|
||||||
data: [
|
data: [
|
||||||
lineEU('3308', 1000, 'inv-fr'),
|
entryEU('inv-fr', [lineEU('3308', 1000)]),
|
||||||
lineEU('3308', 2000, 'inv-de'),
|
entryEU('inv-de', [lineEU('3308', 2000)]),
|
||||||
lineEU('3308', 3000, 'inv-at'),
|
entryEU('inv-at', [lineEU('3308', 3000)]),
|
||||||
],
|
],
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -433,6 +426,19 @@ describe('generatePeriodiskSammanstallning', () => {
|
|||||||
expect(report.rows.map(r => r.country)).toEqual(['AT', 'DE', 'FR'])
|
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 () => {
|
it('rejects yearly period type', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
generatePeriodiskSammanstallning(supabase, 'c1', 'yearly' as 'monthly', 2025, 1),
|
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
|
// Reconciliation
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -459,8 +578,7 @@ describe('legacy country names on customers (#2028)', () => {
|
|||||||
const legacy = invDE()
|
const legacy = invDE()
|
||||||
legacy.customer!.country = 'Germany'
|
legacy.customer!.country = 'Germany'
|
||||||
results = [
|
results = [
|
||||||
{ data: [entryEU('inv-de')], error: null },
|
{ data: [entryEU('inv-de', [lineEU('3308', 15000)])], error: null },
|
||||||
{ data: [lineEU('3308', 15000, 'inv-de')], error: null },
|
|
||||||
{ data: [legacy], error: null },
|
{ data: [legacy], error: null },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -475,8 +593,7 @@ describe('legacy country names on customers (#2028)', () => {
|
|||||||
const legacy = invDE()
|
const legacy = invDE()
|
||||||
legacy.customer!.country = 'Atlantis'
|
legacy.customer!.country = 'Atlantis'
|
||||||
results = [
|
results = [
|
||||||
{ data: [entryEU('inv-de')], error: null },
|
{ data: [entryEU('inv-de', [lineEU('3308', 15000)])], error: null },
|
||||||
{ data: [lineEU('3308', 15000, 'inv-de')], error: null },
|
|
||||||
{ data: [legacy], 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')
|
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
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 { calculatePeriodDates, formatPeriodLabel } from './period-dates'
|
||||||
import { calculateVatDeclaration } from './vat-declaration'
|
import { calculateVatDeclaration } from './vat-declaration'
|
||||||
import { normalizeCountryCode } from '@/lib/vat/country-codes'
|
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.
|
* momsdeklaration Ruta 35/38/39 can never drift. See §1.2 of the plan.
|
||||||
*
|
*
|
||||||
* Notes:
|
* 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
|
* - Account 3305/3105 (non-EU export) are NOT in this report: they go to
|
||||||
* Ruta 36/40 only.
|
* Ruta 36/40 only.
|
||||||
* - Trepartshandel (3107) is included so the report works if someone posts
|
* - Trepartshandel (3107) is included so the report works if someone posts
|
||||||
@@ -47,6 +55,8 @@ export type PsWarningCode =
|
|||||||
| 'CUSTOMER_NOT_FOUND'
|
| 'CUSTOMER_NOT_FOUND'
|
||||||
| 'ZERO_NET_EXCLUDED'
|
| 'ZERO_NET_EXCLUDED'
|
||||||
| 'GOODS_SOLD_WITH_QUARTERLY_PERIOD'
|
| 'GOODS_SOLD_WITH_QUARTERLY_PERIOD'
|
||||||
|
/** One verifikat linked to invoices of different customers: cannot be split per customer. */
|
||||||
|
| 'MIXED_CUSTOMER_SETTLEMENT'
|
||||||
|
|
||||||
export interface PsWarning {
|
export interface PsWarning {
|
||||||
level: 'error' | 'warning'
|
level: 'error' | 'warning'
|
||||||
@@ -55,6 +65,8 @@ export interface PsWarning {
|
|||||||
customerId?: string
|
customerId?: string
|
||||||
customerName?: string
|
customerName?: string
|
||||||
invoiceId?: string
|
invoiceId?: string
|
||||||
|
/** The verifikat a MIXED_CUSTOMER_SETTLEMENT warning is about. */
|
||||||
|
journalEntryId?: string
|
||||||
amount?: number
|
amount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,19 +117,37 @@ const ACCOUNT_TO_BUCKET: Record<string, 'services' | 'goods' | 'triangulation'>
|
|||||||
|
|
||||||
const PS_ACCOUNTS = Object.keys(ACCOUNT_TO_BUCKET)
|
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
|
account_number: string
|
||||||
debit_amount: number | string
|
debit_amount: number | string
|
||||||
credit_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 {
|
interface RawInvoice {
|
||||||
id: string
|
id: string
|
||||||
customer_id: string | null
|
customer_id: string | null
|
||||||
@@ -151,6 +181,34 @@ function round(value: number): number {
|
|||||||
return Math.round(value)
|
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 {
|
interface Accumulator {
|
||||||
country: string
|
country: string
|
||||||
vatNumber: string
|
vatNumber: string
|
||||||
@@ -183,33 +241,58 @@ export async function generatePeriodiskSammanstallning(
|
|||||||
|
|
||||||
const { start, end } = calculatePeriodDates(periodType, year, period)
|
const { start, end } = calculatePeriodDates(periodType, year, period)
|
||||||
|
|
||||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
// Driven from journal_entries (company + date indexed) with the EU-revenue
|
||||||
const lines = await fetchEntryLines<RawLine>({
|
// condition as an inner embed: the planner probes journal_entry_lines per
|
||||||
supabase,
|
// entry, so only entries carrying a posting on a PS account come back, with
|
||||||
entryColumns: 'company_id, entry_date, status, source_type, source_id',
|
// just those lines. Never the inverse shape (lines with an entries embed):
|
||||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
// see lib/bookkeeping/entry-lines.ts. No source_type filter: which register
|
||||||
filterEntries: (q: EntryLinesQuery) =>
|
// invoice a posting belongs to is resolved below through every link the
|
||||||
q
|
// register keeps, not only the engine's own source columns.
|
||||||
.eq('company_id', companyId)
|
const entries = await fetchAllRows<RawEntry>(({ from, to }) =>
|
||||||
.in('status', ['posted', 'reversed'])
|
supabase
|
||||||
// Cash sales on 3308/3108 are not a real flow (EU reverse-charge sales
|
.from('journal_entries')
|
||||||
// always go through AR); excluded to avoid phantom rows.
|
.select('id, voucher_series, voucher_number, entry_date, status, source_type, source_id, journal_entry_lines!inner(account_number, debit_amount, credit_amount)')
|
||||||
.in('source_type', ['invoice_created', 'credit_note'])
|
.eq('company_id', companyId)
|
||||||
.gte('entry_date', start)
|
.in('status', ['posted', 'reversed'])
|
||||||
.lte('entry_date', end),
|
.gte('entry_date', start)
|
||||||
filterLines: (q: EntryLinesQuery) => q.in('account_number', PS_ACCOUNTS),
|
.lte('entry_date', end)
|
||||||
})
|
.in('journal_entry_lines.account_number', PS_ACCOUNTS)
|
||||||
|
// Stable total order for correct paging (see fetch-all.ts).
|
||||||
const invoiceIds = Array.from(
|
.order('id', { ascending: true })
|
||||||
new Set(
|
.range(from, to) as unknown as PromiseLike<{ data: RawEntry[] | null; error: { message: string } | null }>,
|
||||||
lines
|
|
||||||
.map(l => l.journal_entries?.source_id)
|
|
||||||
.filter((id): id is string => typeof id === 'string'),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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<string, string[]>()
|
||||||
|
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<string>()
|
||||||
|
for (const ids of invoiceIdsByEntry.values()) for (const id of ids) allInvoiceIds.add(id)
|
||||||
|
|
||||||
const invoiceMap = new Map<string, RawInvoice>()
|
const invoiceMap = new Map<string, RawInvoice>()
|
||||||
if (invoiceIds.length > 0) {
|
for (const ids of chunk(Array.from(allInvoiceIds), LINK_LOOKUP_CHUNK)) {
|
||||||
const invoices = await fetchAllRows<RawInvoice>(({ from, to }) =>
|
const invoices = await fetchAllRows<RawInvoice>(({ from, to }) =>
|
||||||
supabase
|
supabase
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
@@ -225,7 +308,8 @@ export async function generatePeriodiskSammanstallning(
|
|||||||
vat_number_validated_at
|
vat_number_validated_at
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.in('id', invoiceIds)
|
.eq('company_id', companyId)
|
||||||
|
.in('id', ids)
|
||||||
// Stable total order for correct paging (see fetch-all.ts).
|
// Stable total order for correct paging (see fetch-all.ts).
|
||||||
.order('id', { ascending: true })
|
.order('id', { ascending: true })
|
||||||
.range(from, to) as unknown as PromiseLike<{ data: RawInvoice[] | null; error: { message: string } | null }>,
|
.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)
|
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<string, Accumulator>()
|
const accumulators = new Map<string, Accumulator>()
|
||||||
const warnings: PsWarning[] = []
|
const warnings: PsWarning[] = []
|
||||||
let goodsLineSeen = false
|
let goodsLineSeen = false
|
||||||
|
// Verifikat already reported as MIXED_CUSTOMER_SETTLEMENT: one warning per
|
||||||
|
// verifikat, not one per line.
|
||||||
|
const mixedReported = new Set<string>()
|
||||||
|
|
||||||
for (const line of lines) {
|
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]
|
const bucket = ACCOUNT_TO_BUCKET[line.account_number]
|
||||||
if (!bucket) continue
|
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
|
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 debit = Number(line.debit_amount) || 0
|
||||||
const credit = Number(line.credit_amount) || 0
|
const credit = Number(line.credit_amount) || 0
|
||||||
const net = credit - debit
|
const net = credit - debit
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
|
|||||||
*
|
*
|
||||||
* - 'has' : the verifikation has at least one current-version document,
|
* - 'has' : the verifikation has at least one current-version document,
|
||||||
* or is referenced by a supplier invoice whose source document
|
* or is referenced by a supplier invoice whose source document
|
||||||
* is retained (BFL 5 kap 7 §: hänvisning till underlag);
|
* is retained, or a customer invoice points at it (BFL 5 kap
|
||||||
* callers merge both kinds of ids into jeIdsWithDocs
|
* 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
|
* - 'missing': the verifikation's source type requires underlag (BFL 5 kap
|
||||||
* 7§), has none, and is not exempted via journal_entry_no_doc_required
|
* 7§), has none, and is not exempted via journal_entry_no_doc_required
|
||||||
* - 'none' : no statement either way (system-generated source types,
|
* - 'none' : no statement either way (system-generated source types,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user