* fix(reports): periodisk sammanställning nets a stornoed or corrected EU invoice voucher (#2351) The PS reads entries with status posted or reversed, so a stornoed EU invoice voucher kept its 3308/3108 credit while the storno that nets it was dropped: its source_type is 'storno' and no register row points at it. A makulerad EU sale was over-reported while the account-based ruta 39 was zero. Which invoice explains an entry is now resolved by one composed helper, getInvoicesExplainingJournalEntries (lib/core/bookkeeping/ journal-entry-references.ts): the engine's own source_id, the invoice-side rows, and the rättelse chain through correction_of_id / reverses_id, walked upwards under the MAX_CHAIN_WALK cap correction-chain.ts already uses. Parents outside the batch are fetched by id, company-scoped. The link columns are followed rather than the storno's copied source_id because correctEntry() copies none onto its storno or correction, and a storno's copied source_id is polymorphic (a bank row on a bank booking). getInvoiceReferencesForJournalEntries (the RPC mirror behind the underlag surfaces) is unchanged: storno and correction are not doc-requiring source types, so those surfaces have no such hole. - INVOICE_SOURCED_ENTRY_TYPES / LINK_LOOKUP_CHUNK move to the helper module (the set now names every engine type whose source_id is an invoice). - PS: one resolver call; reverses_id, correction_of_id in the select; the ZERO_NET_EXCLUDED text names makulering beside kreditfaktura. - Tests: resolver cases (chain inheritance, out-of-batch fetch, own link wins, mirror, cycle, cap) and PS cases (the issue's storno nets to ZERO_NET_EXCLUDED, rättelse chain, later-period storno, mirror, linked import, gone invoice). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * fix(bookkeeping): PR #2354 review: bound in-batch chain propagation at MAX_CHAIN_WALK getInvoicesExplainingJournalEntries attributed an in-batch chain of stornos/corrections all the way down through a recursive assign over waitingOn, while parents fetched from outside the batch stopped at MAX_CHAIN_WALK. The two paths now agree: every attribution carries its depth from the root (0 for an entry resolved by its own source_id or invoice-side link), propagation is an explicit queue instead of recursion, and a descendant more than MAX_CHAIN_WALK links below the root resolves to no invoice, both when it inherits from an already attributed parent and when it is reached by propagation. Test: an in-batch chain of MAX_CHAIN_WALK + 1 links, in both batch orders, attributes the links within the cap and not the one beyond it, with no parent fetch. 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>
893 lines
32 KiB
TypeScript
893 lines
32 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// ============================================================
|
|
// Mock: sequential result queue (mirrors vat-declaration.test.ts)
|
|
// ============================================================
|
|
|
|
let resultIdx: number
|
|
let results: Array<{ data?: unknown; error?: unknown }>
|
|
|
|
function makeBuilder() {
|
|
const b: Record<string, unknown> = {}
|
|
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range']) {
|
|
b[m] = vi.fn().mockReturnValue(b)
|
|
}
|
|
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
|
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
|
return b
|
|
}
|
|
|
|
function makeClient() {
|
|
return {
|
|
from: vi.fn().mockImplementation(() => makeBuilder()),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any
|
|
}
|
|
|
|
import {
|
|
generatePeriodiskSammanstallning,
|
|
normalizeVatNumber,
|
|
reconcilePsAgainstVatDeclaration,
|
|
} from '../periodisk-sammanstallning'
|
|
import { calculatePeriodDates, formatPeriodLabel } from '../period-dates'
|
|
|
|
let supabase: ReturnType<typeof makeClient>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
resultIdx = 0
|
|
results = []
|
|
supabase = makeClient()
|
|
})
|
|
|
|
// ============================================================
|
|
// Pure helpers
|
|
// ============================================================
|
|
|
|
describe('calculatePeriodDates', () => {
|
|
it('monthly January', () => {
|
|
expect(calculatePeriodDates('monthly', 2025, 1)).toEqual({ start: '2025-01-01', end: '2025-01-31' })
|
|
})
|
|
it('monthly December', () => {
|
|
expect(calculatePeriodDates('monthly', 2025, 12)).toEqual({ start: '2025-12-01', end: '2025-12-31' })
|
|
})
|
|
it('quarterly Q2', () => {
|
|
expect(calculatePeriodDates('quarterly', 2025, 2)).toEqual({ start: '2025-04-01', end: '2025-06-30' })
|
|
})
|
|
it('quarterly Q4', () => {
|
|
expect(calculatePeriodDates('quarterly', 2025, 4)).toEqual({ start: '2025-10-01', end: '2025-12-31' })
|
|
})
|
|
})
|
|
|
|
describe('formatPeriodLabel', () => {
|
|
it('monthly', () => expect(formatPeriodLabel('monthly', 2025, 5)).toBe('Maj 2025'))
|
|
it('quarterly', () => expect(formatPeriodLabel('quarterly', 2025, 2)).toBe('Kvartal 2 2025'))
|
|
})
|
|
|
|
describe('normalizeVatNumber', () => {
|
|
it('strips Swedish country prefix', () => {
|
|
expect(normalizeVatNumber('SE556677889901')).toBe('556677889901')
|
|
})
|
|
it('strips whitespace and uppercases', () => {
|
|
expect(normalizeVatNumber(' de 123456789 ')).toBe('123456789')
|
|
})
|
|
it('handles EL prefix', () => {
|
|
expect(normalizeVatNumber('EL123456789')).toBe('123456789')
|
|
})
|
|
it('handles already-stripped numbers', () => {
|
|
expect(normalizeVatNumber('556677889901')).toBe('556677889901')
|
|
})
|
|
it('handles null/empty', () => {
|
|
expect(normalizeVatNumber(null)).toBe('')
|
|
expect(normalizeVatNumber('')).toBe('')
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Generator
|
|
// ============================================================
|
|
|
|
interface InvoiceFx {
|
|
id: string
|
|
customer: {
|
|
id: string
|
|
name: string
|
|
country: string | null
|
|
vat_number: string | null
|
|
vat_number_validated?: boolean
|
|
vat_number_validated_at?: string | null
|
|
} | null
|
|
}
|
|
|
|
interface LineFx {
|
|
account_number: string
|
|
debit_amount: number
|
|
credit_amount: number
|
|
}
|
|
|
|
// Recent validation so VIES_UNVALIDATED warnings don't fire by default.
|
|
const RECENT = new Date().toISOString()
|
|
|
|
// The generator fetches the period's entries with their PS-account lines
|
|
// embedded (journal_entries + journal_entry_lines!inner, one page here), then
|
|
// resolves each entry's invoice: the engine's own entries by source_id,
|
|
// everything else through getInvoiceReferencesForJournalEntries (invoices by
|
|
// journal_entry_id, then invoice_payments), and finally loads the invoices
|
|
// with their customer. Queue order per test:
|
|
// 1. journal_entries page (with embedded lines)
|
|
// 2. invoices by journal_entry_id only when a non-engine entry exists
|
|
// 3. invoice_payments only when a non-engine entry exists
|
|
// 4. invoices by id only when some invoice id resolved
|
|
function je(sourceId: string) {
|
|
return `je-${sourceId}`
|
|
}
|
|
|
|
function entryEU(sourceId: string, lines: LineFx[] = []) {
|
|
return {
|
|
id: je(sourceId),
|
|
entry_date: '2025-05-15',
|
|
status: 'posted',
|
|
source_type: 'invoice_created',
|
|
source_id: sourceId,
|
|
journal_entry_lines: lines,
|
|
}
|
|
}
|
|
|
|
function entryCredit(sourceId: string, lines: LineFx[] = []) {
|
|
return {
|
|
id: je(sourceId),
|
|
entry_date: '2025-05-20',
|
|
status: 'posted',
|
|
source_type: 'credit_note',
|
|
source_id: sourceId,
|
|
journal_entry_lines: lines,
|
|
}
|
|
}
|
|
|
|
/** A verifikat that did not come from the invoice engine (SIE import, manual). */
|
|
function entryOther(id: string, sourceType: string, lines: LineFx[] = []) {
|
|
return {
|
|
id,
|
|
entry_date: '2025-05-15',
|
|
status: 'posted',
|
|
source_type: sourceType,
|
|
source_id: null as string | null,
|
|
journal_entry_lines: lines,
|
|
}
|
|
}
|
|
|
|
function lineEU(account: string, credit: number): LineFx {
|
|
return { account_number: account, debit_amount: 0, credit_amount: credit }
|
|
}
|
|
|
|
function lineCredit(account: string, debit: number): LineFx {
|
|
return { account_number: account, debit_amount: debit, credit_amount: 0 }
|
|
}
|
|
|
|
function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat = 'DE123456789'): InvoiceFx {
|
|
return {
|
|
id,
|
|
customer: {
|
|
id: customer,
|
|
name,
|
|
country: 'DE',
|
|
vat_number: vat,
|
|
vat_number_validated: true,
|
|
vat_number_validated_at: RECENT,
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('generatePeriodiskSammanstallning', () => {
|
|
it('empty period returns zero rows and zero warnings', async () => {
|
|
// journal_entries: none match → every lookup is skipped.
|
|
results = [{ data: [], error: null }]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows).toEqual([])
|
|
expect(report.warnings).toEqual([])
|
|
expect(report.totals.rowCount).toBe(0)
|
|
expect(report.totals.grand).toBe(0)
|
|
expect(report.period.label).toBe('Maj 2025')
|
|
expect(supabase.from).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('single EU service sale → 1 row, type 3 only', async () => {
|
|
results = [
|
|
{ data: [entryEU('inv-de', [lineEU('3308', 10000)])], error: null },
|
|
{ data: [invDE()], error: null },
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows).toHaveLength(1)
|
|
expect(report.rows[0]).toMatchObject({
|
|
country: 'DE',
|
|
vatNumber: '123456789',
|
|
services: 10000,
|
|
goods: 0,
|
|
triangulation: 0,
|
|
})
|
|
expect(report.totals).toMatchObject({ services: 10000, goods: 0, triangulation: 0, grand: 10000, rowCount: 1 })
|
|
expect(report.warnings).toEqual([])
|
|
// Engine entries resolve by source_id: no invoice-link round trips.
|
|
expect(supabase.from).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('aggregates multiple invoices to same customer', async () => {
|
|
results = [
|
|
{
|
|
data: [
|
|
entryEU('inv1', [lineEU('3308', 4000)]),
|
|
entryEU('inv2', [lineEU('3308', 3500)]),
|
|
entryEU('inv3', [lineEU('3308', 2500)]),
|
|
],
|
|
error: null,
|
|
},
|
|
{
|
|
data: [
|
|
{ ...invDE('inv1') },
|
|
{ ...invDE('inv2') },
|
|
{ ...invDE('inv3') },
|
|
],
|
|
error: null,
|
|
},
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows).toHaveLength(1)
|
|
expect(report.rows[0].services).toBe(10000)
|
|
})
|
|
|
|
it('one customer with both services and goods → 1 row with both filled', async () => {
|
|
results = [
|
|
{
|
|
data: [
|
|
entryEU('inv1', [lineEU('3308', 7000)]),
|
|
entryEU('inv2', [lineEU('3108', 5000)]),
|
|
],
|
|
error: null,
|
|
},
|
|
{
|
|
data: [invDE('inv1'), invDE('inv2')],
|
|
error: null,
|
|
},
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows).toHaveLength(1)
|
|
expect(report.rows[0]).toMatchObject({ services: 7000, goods: 5000, triangulation: 0 })
|
|
})
|
|
|
|
it('credit invoice nets against original in same period', async () => {
|
|
results = [
|
|
{
|
|
data: [
|
|
entryEU('inv1', [lineEU('3308', 10000)]),
|
|
entryCredit('cn1', [lineCredit('3308', 3000)]),
|
|
],
|
|
error: null,
|
|
},
|
|
{
|
|
data: [invDE('inv1'), invDE('cn1')],
|
|
error: null,
|
|
},
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows).toHaveLength(1)
|
|
expect(report.rows[0].services).toBe(7000)
|
|
})
|
|
|
|
it('credit fully cancels → row excluded with ZERO_NET_EXCLUDED warning', async () => {
|
|
results = [
|
|
{
|
|
data: [
|
|
entryEU('inv1', [lineEU('3308', 10000)]),
|
|
entryCredit('cn1', [lineCredit('3308', 10000)]),
|
|
],
|
|
error: null,
|
|
},
|
|
{ data: [invDE('inv1'), invDE('cn1')], error: null },
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows).toHaveLength(0)
|
|
expect(report.warnings.some(w => w.code === 'ZERO_NET_EXCLUDED')).toBe(true)
|
|
})
|
|
|
|
it('customer missing country → MISSING_COUNTRY error and row blocked', async () => {
|
|
results = [
|
|
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
|
{
|
|
data: [{
|
|
id: 'inv1',
|
|
customer: { id: 'c1', name: 'No Country', country: null, vat_number: 'DE123', vat_number_validated: true, vat_number_validated_at: RECENT },
|
|
}],
|
|
error: null,
|
|
},
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.warnings.some(w => w.code === 'MISSING_COUNTRY' && w.level === 'error')).toBe(true)
|
|
expect(report.rows[0]?.hasBlockingIssue).toBe(true)
|
|
})
|
|
|
|
it('customer missing vat_number → MISSING_VAT_NUMBER error', async () => {
|
|
results = [
|
|
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
|
{
|
|
data: [{
|
|
id: 'inv1',
|
|
customer: { id: 'c1', name: 'No VAT', country: 'DE', vat_number: null, vat_number_validated: false, vat_number_validated_at: null },
|
|
}],
|
|
error: null,
|
|
},
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.warnings.some(w => w.code === 'MISSING_VAT_NUMBER' && w.level === 'error')).toBe(true)
|
|
expect(report.rows[0]?.hasBlockingIssue).toBe(true)
|
|
})
|
|
|
|
it('VAT prefix mismatch surfaces COUNTRY_PREFIX_MISMATCH warning', async () => {
|
|
results = [
|
|
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
|
{
|
|
data: [{
|
|
id: 'inv1',
|
|
customer: { id: 'c1', name: 'Mixed', country: 'DE', vat_number: 'FR123456', vat_number_validated: true, vat_number_validated_at: RECENT },
|
|
}],
|
|
error: null,
|
|
},
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.warnings.some(w => w.code === 'COUNTRY_PREFIX_MISMATCH')).toBe(true)
|
|
expect(report.rows).toHaveLength(1)
|
|
})
|
|
|
|
it('non-EU country on EU account → NON_EU_COUNTRY_ON_EU_ACCOUNT and excluded from CSV', async () => {
|
|
results = [
|
|
{ data: [entryEU('inv1', [lineEU('3308', 5000)])], error: null },
|
|
{
|
|
data: [{
|
|
id: 'inv1',
|
|
customer: { id: 'c1', name: 'US Co', country: 'US', vat_number: 'US123', vat_number_validated: true, vat_number_validated_at: RECENT },
|
|
}],
|
|
error: null,
|
|
},
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.warnings.some(w => w.code === 'NON_EU_COUNTRY_ON_EU_ACCOUNT')).toBe(true)
|
|
expect(report.rows[0].hasBlockingIssue).toBe(true)
|
|
})
|
|
|
|
it('Greek customer → country code emitted as EL', async () => {
|
|
results = [
|
|
{ data: [entryEU('inv1', [lineEU('3308', 4200)])], error: null },
|
|
{
|
|
data: [{
|
|
id: 'inv1',
|
|
customer: { id: 'c1', name: 'Hellas', country: 'GR', vat_number: 'EL123456', vat_number_validated: true, vat_number_validated_at: RECENT },
|
|
}],
|
|
error: null,
|
|
},
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows[0].country).toBe('EL')
|
|
})
|
|
|
|
it('goods sold in quarterly period → GOODS_SOLD_WITH_QUARTERLY_PERIOD warning', async () => {
|
|
results = [
|
|
{ data: [entryEU('inv1', [lineEU('3108', 9000)])], error: null },
|
|
{ data: [{ ...invDE('inv1') }], error: null },
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'quarterly', 2025, 2)
|
|
|
|
expect(report.warnings.some(w => w.code === 'GOODS_SOLD_WITH_QUARTERLY_PERIOD')).toBe(true)
|
|
})
|
|
|
|
it('sorts rows by country then vat_number', async () => {
|
|
results = [
|
|
{
|
|
data: [
|
|
entryEU('inv-fr', [lineEU('3308', 1000)]),
|
|
entryEU('inv-de', [lineEU('3308', 2000)]),
|
|
entryEU('inv-at', [lineEU('3308', 3000)]),
|
|
],
|
|
error: null,
|
|
},
|
|
{
|
|
data: [
|
|
{ id: 'inv-fr', customer: { id: 'fr', name: 'FR', country: 'FR', vat_number: 'FR999', vat_number_validated: true, vat_number_validated_at: RECENT } },
|
|
{ id: 'inv-de', customer: { id: 'de', name: 'DE', country: 'DE', vat_number: 'DE888', vat_number_validated: true, vat_number_validated_at: RECENT } },
|
|
{ id: 'inv-at', customer: { id: 'at', name: 'AT', country: 'AT', vat_number: 'ATU111', vat_number_validated: true, vat_number_validated_at: RECENT } },
|
|
],
|
|
error: null,
|
|
},
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows.map(r => r.country)).toEqual(['AT', 'DE', 'FR'])
|
|
})
|
|
|
|
it('an engine entry whose invoice is gone → CUSTOMER_NOT_FOUND error (a data defect, never silence)', async () => {
|
|
results = [
|
|
{ data: [entryEU('inv-gone', [lineEU('3308', 5000)])], error: null },
|
|
{ data: [], error: null }, // invoices by id: nothing
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.warnings.some(w => w.code === 'CUSTOMER_NOT_FOUND' && w.level === 'error')).toBe(true)
|
|
expect(report.rows).toHaveLength(1)
|
|
expect(report.rows[0].hasBlockingIssue).toBe(true)
|
|
})
|
|
|
|
it('rejects yearly period type', async () => {
|
|
await expect(
|
|
generatePeriodiskSammanstallning(supabase, 'c1', 'yearly' as 'monthly', 2025, 1),
|
|
).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// 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
|
|
// ============================================================
|
|
|
|
describe('reconcilePsAgainstVatDeclaration', () => {
|
|
it('returns null matches when periods do not coincide', async () => {
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'quarterly', 2025, 2)
|
|
// No data calls expected: function bails before invoking calculateVatDeclaration.
|
|
results = []
|
|
const reconciled = await reconcilePsAgainstVatDeclaration(supabase, 'c1', report, 'monthly')
|
|
expect(reconciled.reconciliation.matches).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('legacy country names on customers (#2028)', () => {
|
|
it('reads a stored country name as its ISO code: no false warnings, correct CSV country', async () => {
|
|
const legacy = invDE()
|
|
legacy.customer!.country = 'Germany'
|
|
results = [
|
|
{ data: [entryEU('inv-de', [lineEU('3308', 15000)])], error: null },
|
|
{ data: [legacy], 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: 15000 })
|
|
})
|
|
|
|
it('still names an unmapped country in the warning', async () => {
|
|
const legacy = invDE()
|
|
legacy.customer!.country = 'Atlantis'
|
|
results = [
|
|
{ data: [entryEU('inv-de', [lineEU('3308', 15000)])], error: null },
|
|
{ data: [legacy], error: null },
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.warnings.map((w) => w.code)).toContain('NON_EU_COUNTRY_ON_EU_ACCOUNT')
|
|
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 })
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Storno and rättelse chains (#2351)
|
|
// ============================================================
|
|
|
|
describe('storno and rättelse chains (#2351)', () => {
|
|
/**
|
|
* What reverseEntry() / correctEntry() write for the storno: source_type
|
|
* 'storno' and reverses_id. reverseEntry() also copies the original's
|
|
* source_id; correctEntry() copies nothing. The resolver follows the link
|
|
* column either way, so the fixtures leave source_id null unless a test
|
|
* says otherwise.
|
|
*/
|
|
function entryStorno(id: string, reversesId: string, lines: LineFx[], extra: Record<string, unknown> = {}) {
|
|
return {
|
|
id,
|
|
entry_date: '2025-05-25',
|
|
status: 'posted',
|
|
source_type: 'storno',
|
|
source_id: null as string | null,
|
|
reverses_id: reversesId,
|
|
correction_of_id: null,
|
|
journal_entry_lines: lines,
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
/** The correction correctEntry() writes: source_type 'correction', correction_of_id. */
|
|
function entryCorrection(id: string, correctionOfId: string, lines: LineFx[]) {
|
|
return {
|
|
id,
|
|
entry_date: '2025-05-25',
|
|
status: 'posted',
|
|
source_type: 'correction',
|
|
source_id: null as string | null,
|
|
reverses_id: null,
|
|
correction_of_id: correctionOfId,
|
|
journal_entry_lines: lines,
|
|
}
|
|
}
|
|
|
|
/** Table of every `.from()` call, in order: pins which lookups ran. */
|
|
const fromTables = () => (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0])
|
|
|
|
it('a reversed EU invoice voucher and its storno in the same period net to ZERO_NET_EXCLUDED', async () => {
|
|
// The reported case: the registration voucher was stornoed with
|
|
// reverseEntry(). status 'reversed' keeps the original in the fetch; the
|
|
// storno (source_id copied, reverses_id set) must be filed under the same
|
|
// invoice or the makulerad sale is over-reported while ruta 39 nets it.
|
|
results = [
|
|
{
|
|
data: [
|
|
{ ...entryEU('inv-de', [lineEU('3308', 10000)]), status: 'reversed' },
|
|
entryStorno('je-storno', je('inv-de'), [lineCredit('3308', 10000)], { source_id: 'inv-de' }),
|
|
],
|
|
error: null,
|
|
},
|
|
{ data: [], error: null }, // invoices by journal_entry_id: the storno
|
|
{ data: [], error: null }, // invoice_payments: the storno
|
|
{ data: [invDE()], error: null },
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows).toEqual([])
|
|
expect(report.totals.grand).toBe(0)
|
|
expect(report.warnings.map((w) => w.code)).toEqual(['ZERO_NET_EXCLUDED'])
|
|
expect(report.warnings[0].message).toContain('makulering')
|
|
// The original is in the batch: no parent fetch.
|
|
expect(fromTables()).toEqual(['journal_entries', 'invoices', 'invoice_payments', 'invoices'])
|
|
})
|
|
|
|
it('a rättelse chain (original, storno, correction) is filed once, at the corrected amount', async () => {
|
|
// correctEntry() copies no source_id onto either new entry: only the
|
|
// link columns tie them to the invoice.
|
|
results = [
|
|
{
|
|
data: [
|
|
{ ...entryEU('inv-de', [lineEU('3308', 10000)]), status: 'reversed' },
|
|
entryStorno('je-storno', je('inv-de'), [lineCredit('3308', 10000)]),
|
|
entryCorrection('je-correction', je('inv-de'), [lineEU('3308', 12000)]),
|
|
],
|
|
error: null,
|
|
},
|
|
{ data: [], error: null },
|
|
{ data: [], 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 })
|
|
})
|
|
|
|
it('a storno booked in a later period is filed under the original\'s customer, like a later credit note', async () => {
|
|
// June holds only the storno; the May original is loaded by id so the
|
|
// PS for June nets the same way ruta 39 does for June.
|
|
results = [
|
|
{
|
|
data: [
|
|
entryStorno('je-storno', 'je-may', [lineCredit('3308', 10000)], { entry_date: '2025-06-03' }),
|
|
],
|
|
error: null,
|
|
},
|
|
{ data: [], error: null }, // invoices by journal_entry_id
|
|
{ data: [], error: null }, // invoice_payments
|
|
{
|
|
data: [{ id: 'je-may', source_type: 'invoice_created', source_id: 'inv-de', reverses_id: null, correction_of_id: null }],
|
|
error: null,
|
|
}, // journal_entries by id: the May original
|
|
{ data: [invDE()], error: null },
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 6)
|
|
|
|
expect(report.warnings).toEqual([])
|
|
expect(report.rows).toHaveLength(1)
|
|
expect(report.rows[0]).toMatchObject({ country: 'DE', vatNumber: '123456789', services: -10000 })
|
|
expect(fromTables()).toEqual(['journal_entries', 'invoices', 'invoice_payments', 'journal_entries', 'invoices'])
|
|
})
|
|
|
|
it('mirror: a storno of a manual 3308 posting no invoice points at stays out of the filing, silently', async () => {
|
|
results = [
|
|
{
|
|
data: [
|
|
entryOther('je-man', 'manual', [lineEU('3308', 5000)]),
|
|
entryStorno('je-storno', 'je-man', [lineCredit('3308', 5000)]),
|
|
],
|
|
error: null,
|
|
},
|
|
{ data: [], error: null }, // invoices by journal_entry_id: both
|
|
{ data: [], error: null }, // invoice_payments: both
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
expect(report.rows).toEqual([])
|
|
expect(report.warnings).toEqual([])
|
|
// The original is in the batch and unexplained: no parent fetch, no
|
|
// invoice load.
|
|
expect(fromTables()).toEqual(['journal_entries', 'invoices', 'invoice_payments'])
|
|
})
|
|
|
|
it('a storno of a linked import nets it: the link lives on the original', async () => {
|
|
results = [
|
|
{
|
|
data: [
|
|
entryOther('je-imp', 'import', [lineEU('3308', 12000)]),
|
|
entryStorno('je-storno', 'je-imp', [lineCredit('3308', 12000)]),
|
|
],
|
|
error: null,
|
|
},
|
|
{ data: [], error: null },
|
|
{ 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.rows).toEqual([])
|
|
expect(report.warnings.map((w) => w.code)).toEqual(['ZERO_NET_EXCLUDED'])
|
|
})
|
|
|
|
it('a storno of an engine entry whose invoice is gone is reported, never silenced', async () => {
|
|
results = [
|
|
{
|
|
data: [
|
|
{ ...entryEU('inv-gone', [lineEU('3308', 5000)]), status: 'reversed' },
|
|
entryStorno('je-storno', je('inv-gone'), [lineCredit('3308', 5000)]),
|
|
],
|
|
error: null,
|
|
},
|
|
{ data: [], error: null },
|
|
{ data: [], error: null },
|
|
{ data: [], error: null }, // invoices by id: nothing
|
|
]
|
|
|
|
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
|
|
|
|
// One blocking error per posting, the same verdict the original gets.
|
|
expect(report.warnings.filter((w) => w.code === 'CUSTOMER_NOT_FOUND' && w.level === 'error')).toHaveLength(2)
|
|
})
|
|
})
|