Fix/invoice delivery and payment accounts (#1116)

* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
This commit is contained in:
Mattsson
2026-07-23 09:54:02 +02:00
committed by GitHub
parent 321e684523
commit 466e55a015
83 changed files with 6619 additions and 671 deletions
@@ -593,6 +593,10 @@ describe('generateFullArchive', () => {
expect(items).toEqual([
{ id: 'item-1', invoice_id: 'inv-1', description: 'Konsulttid' },
])
expect(supabase.rpc).toHaveBeenCalledWith(
'export_invoice_delivery_evidence',
{ p_company_id: 'company-1' },
)
})
it('skips raw SIE blobs when include_documents is false but keeps metadata', async () => {
+122 -33
View File
@@ -539,28 +539,24 @@ describe('generateTrialBalance', () => {
expect(result.isBalanced).toBe(true)
})
// ── excludeYearEndClosing symmetry ───────────────────────────────
// A reversed year_end entry keeps status='reversed' and stays in the
// ledger; its storno carries source_type='storno'. Excluding on
// source_type alone drops the original but keeps the counter-entry,
// inflating the P&L by exactly the reversed amount. The filter must also
// exclude entries chained to year_end entries via reverses_id /
// correction_of_id.
// ── final-closing precision ──────────────────────────────────────
// Tax and appropriations are also source_type='year_end'. The statutory
// pre-closing report must exclude only fiscal_periods.closing_entry_id.
it('excludes stornos and corrections chained to year_end entries', async () => {
it('excludes only the fiscal period closing entry', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null },
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: 'closing-1',
},
error: null,
},
],
journal_entries: [
// 1st: the year_end entry-id fetch added for chain exclusion
{ data: [{ id: 'ye-1' }, { id: 'ye-2' }], error: null },
// 2nd: the entries step of the period-lines fetch
{ data: [{ id: 'entry-1' }], error: null },
],
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
journal_entry_lines: [
{
data: [
@@ -574,34 +570,124 @@ describe('generateTrialBalance', () => {
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeYearEndClosing: true,
excludeFinalClosingEntry: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
const orCalls = builders.flatMap(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(b: any) => (b.or ? b.or.mock.calls.map((c: unknown[]) => c[0]) : []),
)
expect(orCalls).toContain('reverses_id.is.null,reverses_id.not.in.(ye-1,ye-2)')
expect(orCalls).toContain('correction_of_id.is.null,correction_of_id.not.in.(ye-1,ye-2)')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
})
it('skips the chain filters when the company has no year_end entries', async () => {
it('fails closed when a closed period has no linked final closing entry', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null },
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: null,
is_closed: true,
},
error: null,
},
],
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await expect(
generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeFinalClosingEntry: true,
}),
).rejects.toThrow(/missing closing_entry_id/i)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('keeps year-end adjustments for an open period without a final closing entry', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: null,
is_closed: false,
},
error: null,
},
],
journal_entries: [{ data: [{ id: 'tax-1' }], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeFinalClosingEntry: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
})
it('keeps a linked reversed closing together with its storno', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: 'closing-1',
},
error: null,
},
],
journal_entries: [{ data: [{ id: 'closing-1' }, { id: 'storno-1' }], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeFinalClosingEntry: true,
})
// The OR excludes closing-1 only while status is posted. If it is
// reversed during an administrative undo, both it and its storno remain.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
})
it('preserves the broad year-end exclusion for operational reports', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: 'closing-1',
},
error: null,
},
],
journal_entries: [
// year_end id fetch: none exist
{ data: [], error: null },
{ data: [{ id: 'entry-1' }], error: null },
{ data: [{ id: 'reversed-year-end-1' }], error: null },
{ data: [{ id: 'ordinary-1' }], error: null },
],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
@@ -613,14 +699,17 @@ describe('generateTrialBalance', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
const orCalls = builders.flatMap(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(b: any) => (b.or ? b.or.mock.calls : []),
)
expect(orCalls).toHaveLength(0)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
expect(orCalls).toContainEqual([
'reverses_id.is.null,reverses_id.not.in.(reversed-year-end-1)',
])
expect(orCalls).toContainEqual([
'correction_of_id.is.null,correction_of_id.not.in.(reversed-year-end-1)',
])
})
it('returns empty period activity when the range matches no lines', async () => {
+12 -3
View File
@@ -981,9 +981,17 @@ async function writeMasterData(
for (const t of MASTER_DATA_DUMP_TABLES) {
const pageKey = t.pageKey ?? 'id'
try {
const rows = t.via
? await fetchChildTableRows(supabase, companyId, t)
: await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
const rows = t.name === 'invoice_deliveries'
? await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
supabase
.rpc('export_invoice_delivery_evidence', { p_company_id: companyId })
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
{ dedupeBy: (row) => String(row.id) })
: t.via
? await fetchChildTableRows(supabase, companyId, t)
: await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
let q = supabase.from(t.name).select('*').eq('company_id', companyId)
if (t.orderBy) {
q = q.order(t.orderBy, { ascending: true })
@@ -997,6 +1005,7 @@ async function writeMasterData(
}, { dedupeBy: (r) => String(r[pageKey]) })
data.file(t.file, JSON.stringify(rows, null, 2))
} catch (err) {
if (t.name === 'invoice_deliveries') throw err
data.file(
t.file,
JSON.stringify(
+38 -17
View File
@@ -37,6 +37,7 @@ export async function generateTrialBalance(
fiscalPeriodId: string,
options?: {
excludeYearEndClosing?: boolean
excludeFinalClosingEntry?: boolean
fromDate?: string
toDate?: string
dimensions?: Record<string, string>
@@ -51,7 +52,7 @@ export async function generateTrialBalance(
// Fetch period for opening balance computation
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end, opening_balance_entry_id')
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
@@ -61,19 +62,23 @@ export async function generateTrialBalance(
? options.dimensions
: undefined
// Year-end exclusion must be symmetric: a reversed year_end entry stays in
// the ledger (status='reversed') together with its storno, but the storno
// carries source_type='storno' (and a correction carries 'correction'), so
// filtering on source_type alone drops the original while keeping its
// counter-entry. That inflates the P&L by exactly the reversed amount.
// Fetch the reversed year_end entry ids (company-wide: a storno may land in
// a later period than the entry it reverses) and exclude anything chained
// to them via reverses_id / correction_of_id. Only status='reversed'
// originals can be storno/correction targets (reverseEntry flips the
// original's status atomically), which keeps the id list short: in the
// common no-reversal case the chain filters are skipped entirely.
// Existing operational reports intentionally exclude every year_end entry.
// Statutory annual reports must exclude only the linked final closing entry:
// tax, depreciation, and appropriations also use source_type year_end. A
// closed period without the link is ambiguous, so fail instead of silently
// understating the statutory report.
if (
options?.excludeFinalClosingEntry
&& period?.is_closed === true
&& !period.closing_entry_id
) {
throw new Error(
'Closed fiscal period is missing closing_entry_id; statutory pre-closing balances cannot be generated safely',
)
}
const excludeAllYearEndEntries = options?.excludeYearEndClosing
let yearEndEntryIds: string[] = []
if (options?.excludeYearEndClosing) {
if (excludeAllYearEndEntries) {
yearEndEntryIds = (
await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
@@ -91,14 +96,24 @@ export async function generateTrialBalance(
let q = query.neq('source_type', 'year_end')
if (yearEndEntryIds.length > 0) {
const idList = `(${yearEndEntryIds.join(',')})`
// `.not('col','in',...)` alone would also drop NULL rows (NULL NOT IN
// (...) is NULL), i.e. every normal entry: OR in the null branch.
q = q.or(`reverses_id.is.null,reverses_id.not.in.${idList}`)
q = q.or(`correction_of_id.is.null,correction_of_id.not.in.${idList}`)
}
return q
}
const closingEntryId = options?.excludeFinalClosingEntry
? period?.closing_entry_id ?? null
: null
// The base query already admits only posted and reversed entries. Exclude a
// posted final closing entry, but retain a reversed one together with its
// storno so the two continue to net to zero. Draft entries never enter the
// base query.
const excludeClosingEntry = (query: EntryLinesQuery): EntryLinesQuery =>
closingEntryId
? query.or(`id.neq.${closingEntryId},status.neq.posted`)
: query
// ── Opening balances (IB) at period_start ──────────────────────
const { balances: obBalances, obEntryId } = await getOpeningBalances(
supabase, companyId, period
@@ -142,9 +157,12 @@ export async function generateTrialBalance(
query = query.neq('id', obEntryId)
}
if (options?.excludeYearEndClosing) {
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},
@@ -200,9 +218,12 @@ export async function generateTrialBalance(
query = query.neq('id', obEntryId)
}
if (options?.excludeYearEndClosing) {
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},