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:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user