fix(import): SIE bulk-delete on service client + provider/reporting/b… (#724)
* fix(import): SIE bulk-delete on service client + provider/reporting/banking fixes Rebuilt branch onto main as a single commit. - import: run SIE bulk-delete RPCs on the service client to escape the 8s statement_timeout; undo_sie_import now takes an explicit actor (p_user_id) so its owner/admin gate works when auth.uid() is NULL on the service client (migration 20260624120000) + pg-real regression test - providers: distinguish missing Fortnox license from expired connection; provider_consent_tokens PK regression test - reports: include unmapped BAS expense groups in the income statement - enable-banking: reconnect closed/expired bank sessions in place - bookkeeping: surface linked invoices as underlag on the verifikat view - scripts: track BL cleanup/diagnostic tooling; data files (*.csv) are git-ignored and consentId is now a required arg with no silent default Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): add Cache-Control header to journal entry references response --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
db8983ba9e
commit
43925bc2d3
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { getJournalEntryUnderlagReferences } from '../journal-entry-references'
|
||||
|
||||
/**
|
||||
* The resolver issues its queries in a fixed `.from()` order, and the queued
|
||||
* mock consumes one enqueued result per `.from()` call:
|
||||
* 1. invoices (direct journal_entry_id link)
|
||||
* 2. invoice_payments (payment rows → invoice_id)
|
||||
* 3. invoices (by id — only when step 2 found new ids)
|
||||
* 4. supplier_invoices (registration_journal_entry_id)
|
||||
* 5. supplier_invoices (payment_journal_entry_id)
|
||||
* 6. supplier_invoice_payments (payment rows → supplier_invoice_id)
|
||||
* 7. supplier_invoices (by id — only when step 6 found new ids)
|
||||
*/
|
||||
describe('getJournalEntryUnderlagReferences', () => {
|
||||
const run = (results: { data: unknown }[]) => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany(results)
|
||||
return getJournalEntryUnderlagReferences(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'je-1',
|
||||
)
|
||||
}
|
||||
|
||||
it('surfaces a customer invoice linked only via a cash-method payment row', async () => {
|
||||
// The reported gap: debit 1930 / credit 3001, invoice linked through
|
||||
// invoice_payments, no document attached and no direct invoice link.
|
||||
const refs = await run([
|
||||
{ data: [] }, // 1. invoices direct — none
|
||||
{ data: [{ invoice_id: 'inv-x' }] }, // 2. invoice_payments
|
||||
{ data: [{ id: 'inv-x', invoice_number: '003' }] }, // 3. invoices by id
|
||||
{ data: [] }, // 4. supplier registration
|
||||
{ data: [] }, // 5. supplier payment
|
||||
{ data: [] }, // 6. supplier_invoice_payments
|
||||
])
|
||||
|
||||
expect(refs).toEqual([{ type: 'invoice', id: 'inv-x', number: '003' }])
|
||||
})
|
||||
|
||||
it('surfaces a supplier invoice linked via its registration booking', async () => {
|
||||
const refs = await run([
|
||||
{ data: [] }, // 1. invoices direct
|
||||
{ data: [] }, // 2. invoice_payments (empty → step 3 skipped)
|
||||
{ data: [{ id: 'si-1', supplier_invoice_number: 'LF-001' }] }, // 4. registration
|
||||
{ data: [] }, // 5. supplier payment
|
||||
{ data: [] }, // 6. supplier_invoice_payments
|
||||
])
|
||||
|
||||
expect(refs).toEqual([{ type: 'supplier_invoice', id: 'si-1', number: 'LF-001' }])
|
||||
})
|
||||
|
||||
it('returns nothing when no invoice is linked (warning legitimately stays)', async () => {
|
||||
const refs = await run([
|
||||
{ data: [] }, // 1. invoices direct
|
||||
{ data: [] }, // 2. invoice_payments
|
||||
{ data: [] }, // 4. supplier registration
|
||||
{ data: [] }, // 5. supplier payment
|
||||
{ data: [] }, // 6. supplier_invoice_payments
|
||||
])
|
||||
|
||||
expect(refs).toEqual([])
|
||||
})
|
||||
|
||||
it('deduplicates an invoice reachable via both the direct link and a payment row', async () => {
|
||||
const refs = await run([
|
||||
{ data: [{ id: 'inv-x', invoice_number: '003' }] }, // 1. invoices direct
|
||||
{ data: [{ invoice_id: 'inv-x' }] }, // 2. invoice_payments (already known → step 3 skipped)
|
||||
{ data: [] }, // 4. supplier registration
|
||||
{ data: [] }, // 5. supplier payment
|
||||
{ data: [] }, // 6. supplier_invoice_payments
|
||||
])
|
||||
|
||||
expect(refs).toEqual([{ type: 'invoice', id: 'inv-x', number: '003' }])
|
||||
})
|
||||
|
||||
it('returns both a customer and a supplier invoice, customer first', async () => {
|
||||
const refs = await run([
|
||||
{ data: [{ id: 'inv-a', invoice_number: 'A1' }] }, // 1. invoices direct
|
||||
{ data: [] }, // 2. invoice_payments (empty → step 3 skipped)
|
||||
{ data: [] }, // 4. supplier registration
|
||||
{ data: [] }, // 5. supplier payment
|
||||
{ data: [{ supplier_invoice_id: 'si-2' }] }, // 6. supplier_invoice_payments
|
||||
{ data: [{ id: 'si-2', supplier_invoice_number: 'LF-2' }] }, // 7. supplier by id
|
||||
])
|
||||
|
||||
expect(refs).toEqual([
|
||||
{ type: 'invoice', id: 'inv-a', number: 'A1' },
|
||||
{ type: 'supplier_invoice', id: 'si-2', number: 'LF-2' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* A followable reference from a verifikation back to its underlag — the customer
|
||||
* or supplier invoice that identifies what the affärshändelse avser and who the
|
||||
* motpart is.
|
||||
*
|
||||
* Surfacing these makes the verifieringskedja traceable from the verifikat side,
|
||||
* not only from the invoice side (BFL 5 kap 7§ — hänvisning till underlag;
|
||||
* BFNAR 2013:2 — the verification chain must be followable in both directions).
|
||||
*
|
||||
* Bank transactions are deliberately excluded: a bank line is the trace of the
|
||||
* affärshändelse, not its underlag. Counting it as underlag would wrongly silence
|
||||
* the "saknar underlag" warning for expenses that still genuinely need a kvitto.
|
||||
*/
|
||||
export type UnderlagReferenceType = 'invoice' | 'supplier_invoice'
|
||||
|
||||
export interface UnderlagReference {
|
||||
type: UnderlagReferenceType
|
||||
id: string
|
||||
/** invoice_number / supplier_invoice_number — the UI builds the label from this. */
|
||||
number: string
|
||||
}
|
||||
|
||||
interface InvoiceRow {
|
||||
id: string
|
||||
invoice_number: string
|
||||
}
|
||||
|
||||
interface SupplierInvoiceRow {
|
||||
id: string
|
||||
supplier_invoice_number: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every customer/supplier invoice linked to a verifikation, across all
|
||||
* the deterministic FK paths the engine uses to book one:
|
||||
* - invoices.journal_entry_id (faktureringsmetod registration / direct)
|
||||
* - invoice_payments.journal_entry_id (kontantmetod inbetalning / delbetalning)
|
||||
* - supplier_invoices.registration_journal_entry_id / payment_journal_entry_id
|
||||
* - supplier_invoice_payments.journal_entry_id (delbetalning)
|
||||
*
|
||||
* Every query is company-scoped (defense in depth alongside RLS). Results are
|
||||
* deduplicated by id, so an invoice reachable via several paths appears once.
|
||||
*/
|
||||
export async function getJournalEntryUnderlagReferences(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
journalEntryId: string,
|
||||
): Promise<UnderlagReference[]> {
|
||||
// --- Customer invoices ---------------------------------------------------
|
||||
const invoices = new Map<string, string>()
|
||||
|
||||
// Direct link (faktureringsmetod registration, or invoices.journal_entry_id).
|
||||
const { data: directInvoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
|
||||
for (const inv of (directInvoices ?? []) as InvoiceRow[]) {
|
||||
invoices.set(inv.id, inv.invoice_number)
|
||||
}
|
||||
|
||||
// Payment rows (kontantmetod inbetalning, partial payments) → invoice_payments.
|
||||
const { data: paymentRows } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
|
||||
const paymentInvoiceIds = new Set<string>()
|
||||
for (const row of (paymentRows ?? []) as { invoice_id: string | null }[]) {
|
||||
if (row.invoice_id && !invoices.has(row.invoice_id)) paymentInvoiceIds.add(row.invoice_id)
|
||||
}
|
||||
|
||||
if (paymentInvoiceIds.size > 0) {
|
||||
const { data: paidInvoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', Array.from(paymentInvoiceIds))
|
||||
|
||||
for (const inv of (paidInvoices ?? []) as InvoiceRow[]) {
|
||||
invoices.set(inv.id, inv.invoice_number)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Supplier invoices ---------------------------------------------------
|
||||
const supplierInvoices = new Map<string, string>()
|
||||
|
||||
// Registration booking (accrual) on the invoice itself.
|
||||
const { data: registrationLinks } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('registration_journal_entry_id', journalEntryId)
|
||||
|
||||
for (const si of (registrationLinks ?? []) as SupplierInvoiceRow[]) {
|
||||
supplierInvoices.set(si.id, si.supplier_invoice_number)
|
||||
}
|
||||
|
||||
// Payment booking on the invoice itself.
|
||||
const { data: paymentLinks } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('payment_journal_entry_id', journalEntryId)
|
||||
|
||||
for (const si of (paymentLinks ?? []) as SupplierInvoiceRow[]) {
|
||||
supplierInvoices.set(si.id, si.supplier_invoice_number)
|
||||
}
|
||||
|
||||
// Partial-payment rows → supplier_invoice_payments.
|
||||
const { data: supplierPaymentRows } = await supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('supplier_invoice_id')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
|
||||
const supplierPaymentIds = new Set<string>()
|
||||
for (const row of (supplierPaymentRows ?? []) as { supplier_invoice_id: string | null }[]) {
|
||||
if (row.supplier_invoice_id && !supplierInvoices.has(row.supplier_invoice_id)) {
|
||||
supplierPaymentIds.add(row.supplier_invoice_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (supplierPaymentIds.size > 0) {
|
||||
const { data: paidSupplierInvoices } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', Array.from(supplierPaymentIds))
|
||||
|
||||
for (const si of (paidSupplierInvoices ?? []) as SupplierInvoiceRow[]) {
|
||||
supplierInvoices.set(si.id, si.supplier_invoice_number)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Assemble ------------------------------------------------------------
|
||||
const references: UnderlagReference[] = []
|
||||
for (const [id, number] of invoices) references.push({ type: 'invoice', id, number })
|
||||
for (const [id, number] of supplierInvoices) references.push({ type: 'supplier_invoice', id, number })
|
||||
return references
|
||||
}
|
||||
Reference in New Issue
Block a user