Bug/year end numbers (#744)
* fix(bookkeeping): allow creating a fiscal year that fills an interior gap Fiscal-period creation only allowed chaining a new räkenskapsår before the earliest or after the latest existing period, so a company with a gap between years (e.g. 2024 + 2026 from an SIE import, missing 2025) could not create the missing year — it failed with "New period must chain before the earliest or after the latest existing period". Generalise forward chaining onto the new period's immediate predecessor, which covers both appending a new latest year and filling an interior gap. The "prior year must be locked" guard now applies only to true appends, not gap fills (a backfill, like backward chaining). previous_period_id is set to the predecessor and the successor is relinked so the BFNAR 2013:2 continuity chain stays intact. The create dialog suggests the missing year (capped so it never overlaps the next period), the settings page seeds the dialog at the earliest gap, and the default suggested name is now "Räkenskapsår <year>". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): omföra föregående års resultat (2099 → 2098) at year-end Year-end closing posts the result to 2099 "Årets resultat" and the opening balance carried it forward on 2099 every year, so 2099 accumulated across years and the prior result never moved off "Årets resultat". executeYearEndClosing now posts a separate "Omföring av föregående års resultat" verifikat (Dr 2099 / Cr 2098 for a profit, reversed for a loss) into the new period after the continuity check passes, so 2099 starts each year at zero. Kept as a standalone entry rather than folded into the opening balance so the IB stays a faithful mirror of the prior UB and IB/UB continuity still holds. Aktiebolag only; idempotent; no-op when 2099 is flat. The 2098 → 2091/2898 disposition (bolagsstämma decision) is intentionally left to a separate step. - new source_type 'result_appropriation' (migration + type + Zod enum) - generateResultAppropriation helper (planner + poster) wired as step 11 - ResultStep surfaces the omföring voucher - unit tests + pg-real invariant - scripts/repair-result-appropriation.ts: retroactive catch-up (dry-run default) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): shadow-detect date-drift duplicate bank transactions The content-dedup bridge buckets on exact (date, ore), so the same transaction re-imported with a booking date that drifted a day lands in a different bucket and slips past every dedup layer. Add a measure-only ("shadow") detector that flags would-be +/-1-day duplicates and counts them, without changing what is inserted - so the gap can be validated on real data before any enforcement, mirroring the scope-drift shadow. - shiftIsoDate(): pure, deterministic adjacent-date helper - ingest: DEDUP_DATE_DRIFT_MODE flag (default on), pre-loop bucket snapshot, per-row gate with desc-bridge + cross-channel-symmetry signals; logs shadow_date_drift_candidates, never alters inserts - fail-safe date guard so the measurement can never abort an import - regression tests for both signals, account/window/distinct guards, no-double-count, and the malformed-date fail-safe Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(bookkeeping): anonymize a customer reference in fiscal-period tests Remove a real customer name ("AXMD AB") from regression-test comments; no logic change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(workflows): enhance Docker image scanning and caching mechanisms * fix(bookkeeping): enhance year-end result appropriation handling and error reporting --------- 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
5c40fa9aeb
commit
2a8bf9b42e
@@ -89,6 +89,17 @@ describe('chat system prompt — always-on epistemics rules', () => {
|
||||
expect(out).toContain('träningsdata')
|
||||
})
|
||||
|
||||
it('addresses the user by their own tilltalsnamn, not owner/signatory names from the profile', () => {
|
||||
// Regression: the agent answered "vad heter jag" with the registered
|
||||
// firmatecknare's legal name from "Företagets profil" instead of the
|
||||
// user's own chosen name. The role block must name the user (firstName)
|
||||
// and explicitly demote company owner/signatory names.
|
||||
const out = block(null)
|
||||
expect(out).toContain('Jakob')
|
||||
expect(out).toMatch(/tilltalsnamn/i)
|
||||
expect(out).toContain('firmatecknare')
|
||||
})
|
||||
|
||||
it('lets the agent read a pre-loaded atom directly instead of re-loading it', () => {
|
||||
// Declarative intents pre-load swedish-vat etc. into Block 1, so the rule
|
||||
// must not force a redundant gnubok_load_skill when the owning atom is
|
||||
|
||||
@@ -196,6 +196,17 @@ export function buildIdentityBlock(args: BuildArgs): string {
|
||||
`Du är ${owner} specialiserade bokföringsassistent för ${companyName}. Du svarar alltid på svenska. Du är direkt, korrekt och kortfattad. Du föreslår — du beslutar inte. Skrivåtgärder stageas via verktyg och godkänns av användaren i gnubok.`,
|
||||
)
|
||||
lines.push('')
|
||||
if (firstName) {
|
||||
// Name disambiguation. The user sets their own tilltalsnamn in account
|
||||
// settings; that is who you are talking to. Owner/firmatecknare names that
|
||||
// show up under "Företagets profil" come from Bolagsverket and describe the
|
||||
// company — the model used to answer "vad heter jag" with the registered
|
||||
// signatory's legal name instead of the user's chosen name.
|
||||
lines.push(
|
||||
`Användaren du hjälper heter ${firstName} — det är hens eget tilltalsnamn. Tilltala hen så, och svara med det om hen frågar vad hen heter eller vad du kallar hen. Namn som dyker upp under "Företagets profil" nedan (verklig huvudman, firmatecknare, ägare) är fakta om bolaget, inte nödvändigtvis personen du pratar med — använd dem aldrig för att svara på "vad heter jag".`,
|
||||
)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Today's date. The model's training data has an earlier cutoff, so without
|
||||
// this it reasons about "förra månaden", "i år", overdue invoices and the
|
||||
|
||||
@@ -202,6 +202,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
|
||||
'currency_revaluation',
|
||||
'reminder_fee',
|
||||
'accrual',
|
||||
'result_appropriation',
|
||||
])
|
||||
|
||||
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeSuggestedPeriod, suggestSeedDate } from '../suggest-fiscal-period'
|
||||
|
||||
type Range = { period_start: string; period_end: string }
|
||||
|
||||
const FY2024: Range = { period_start: '2024-01-01', period_end: '2024-12-31' }
|
||||
const FY2026: Range = { period_start: '2026-01-01', period_end: '2026-12-31' }
|
||||
|
||||
describe('computeSuggestedPeriod', () => {
|
||||
it('suggests a calendar year around the entry date when there are no periods', () => {
|
||||
expect(computeSuggestedPeriod('2025-06-15', [])).toEqual({
|
||||
name: 'Räkenskapsår 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
})
|
||||
})
|
||||
|
||||
it('suggests a backfill year before the earliest period', () => {
|
||||
expect(computeSuggestedPeriod('2025-06-15', [FY2026])).toEqual({
|
||||
name: 'Räkenskapsår 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
})
|
||||
})
|
||||
|
||||
it('suggests the missing year when the entry date is in an interior gap', () => {
|
||||
// FY 2024 + FY 2026 exist, 2025 is the hole (interior-gap scenario).
|
||||
expect(computeSuggestedPeriod('2025-06-15', [FY2024, FY2026])).toEqual({
|
||||
name: 'Räkenskapsår 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
})
|
||||
})
|
||||
|
||||
it('caps the gap suggestion so it never overlaps the right neighbour', () => {
|
||||
// A six-month hole (Jan–Jun 2025) before a short FY 2025 H2 period.
|
||||
const fy2025h2: Range = { period_start: '2025-07-01', period_end: '2025-12-31' }
|
||||
expect(computeSuggestedPeriod('2025-03-15', [FY2024, fy2025h2])).toEqual({
|
||||
name: 'Räkenskapsår 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-06-30',
|
||||
})
|
||||
})
|
||||
|
||||
it('suggests the next forward year after the latest period', () => {
|
||||
expect(computeSuggestedPeriod('2025-06-15', [FY2024])).toEqual({
|
||||
name: 'Räkenskapsår 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestSeedDate', () => {
|
||||
const today = '2026-06-16'
|
||||
|
||||
it('returns today when there are no periods', () => {
|
||||
expect(suggestSeedDate([], today)).toBe('2026-06-16')
|
||||
})
|
||||
|
||||
it('returns the start of the earliest gap when one exists', () => {
|
||||
expect(suggestSeedDate([FY2024, FY2026], today)).toBe('2025-01-01')
|
||||
})
|
||||
|
||||
it('returns the day after the latest period when there is no gap', () => {
|
||||
const fy2025: Range = { period_start: '2025-01-01', period_end: '2025-12-31' }
|
||||
expect(suggestSeedDate([FY2024, fy2025], today)).toBe('2026-01-01')
|
||||
})
|
||||
|
||||
it('returns the day after the only period for a single period', () => {
|
||||
expect(suggestSeedDate([FY2024], today)).toBe('2025-01-01')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Pure helpers for proposing a new fiscal period (räkenskapsår) in the
|
||||
* "Skapa räkenskapsår" dialog. Kept framework-free so they can be unit-tested
|
||||
* in isolation (component code itself is not under test per the project's
|
||||
* lib/ + app/api/ test scope).
|
||||
*
|
||||
* All date math runs in UTC. Local-time `Date` arithmetic combined with
|
||||
* `toISOString()` shifts dates by the timezone offset (e.g. CET turns
|
||||
* 2024-12-31 into 2025-12-30), which silently corrupts period boundaries.
|
||||
*/
|
||||
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
/** Minimal shape needed for the date math — `FiscalPeriod` satisfies it. */
|
||||
type PeriodRange = Pick<FiscalPeriod, 'period_start' | 'period_end'>
|
||||
|
||||
export interface SuggestedPeriod {
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
/** Add `days` to a YYYY-MM-DD date string, returning a YYYY-MM-DD string. */
|
||||
function addDays(date: string, days: number): string {
|
||||
const d = new Date(date + 'T00:00:00Z')
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* A fiscal-year name: `Räkenskapsår 2025`, or `Räkenskapsår 2024/2025` when it
|
||||
* straddles two calendar years. Swedish by default to match the app's existing
|
||||
* fiscal-year naming (Swedish-first); the field stays editable in the dialog.
|
||||
*/
|
||||
function periodName(start: string, end: string): string {
|
||||
const startYear = Number(start.slice(0, 4))
|
||||
const endYear = Number(end.slice(0, 4))
|
||||
return startYear === endYear ? `Räkenskapsår ${startYear}` : `Räkenskapsår ${startYear}/${endYear}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a fiscal period for the create dialog, given the date the user is
|
||||
* trying to book and the company's existing periods. Three cases:
|
||||
* - No periods yet → a calendar year around the entry date.
|
||||
* - Entry date before the earliest period → a year ending the day before it (backfill).
|
||||
* - Entry date inside an interior gap → a year filling the hole, starting the day
|
||||
* after the left neighbour and capped so it never overlaps the right neighbour
|
||||
* (a clean one-year hole yields exactly that year, e.g. 2025 between 2024 and 2026).
|
||||
* - Otherwise → the next year chaining forward off the latest period.
|
||||
*/
|
||||
export function computeSuggestedPeriod(
|
||||
entryDate: string,
|
||||
periods: PeriodRange[],
|
||||
): SuggestedPeriod {
|
||||
if (periods.length === 0) {
|
||||
// No periods at all — suggest a calendar year period around the entry date.
|
||||
const year = entryDate.split('-')[0]
|
||||
return { name: `Räkenskapsår ${year}`, period_start: `${year}-01-01`, period_end: `${year}-12-31` }
|
||||
}
|
||||
|
||||
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
const earliest = sorted[0]
|
||||
const latest = sorted[sorted.length - 1]
|
||||
|
||||
if (entryDate < earliest.period_start) {
|
||||
// Backward: end = day before earliest start, start = 12 months back, 1st of month.
|
||||
const endStr = addDays(earliest.period_start, -1)
|
||||
const start = new Date(endStr + 'T00:00:00Z')
|
||||
start.setUTCMonth(start.getUTCMonth() - 11)
|
||||
start.setUTCDate(1)
|
||||
const startStr = start.toISOString().split('T')[0]
|
||||
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
|
||||
}
|
||||
|
||||
// Interior gap: the entry date sits between the earliest and latest period but is
|
||||
// not contained by any existing period.
|
||||
const containing = sorted.find((p) => p.period_start <= entryDate && entryDate <= p.period_end)
|
||||
if (!containing && entryDate <= latest.period_end) {
|
||||
const leftNeighbour = [...sorted].reverse().find((p) => p.period_end < entryDate)!
|
||||
const rightNeighbour = sorted.find((p) => p.period_start > entryDate)!
|
||||
|
||||
const startStr = addDays(leftNeighbour.period_end, 1)
|
||||
|
||||
// Tentative end: 12 months after start, last day of the prior month.
|
||||
const end = new Date(startStr + 'T00:00:00Z')
|
||||
end.setUTCMonth(end.getUTCMonth() + 12)
|
||||
end.setUTCDate(0)
|
||||
let endStr = end.toISOString().split('T')[0]
|
||||
|
||||
// Cap at the day before the right neighbour starts so we never overlap it.
|
||||
const gapEnd = addDays(rightNeighbour.period_start, -1)
|
||||
if (gapEnd < endStr) endStr = gapEnd
|
||||
|
||||
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
|
||||
}
|
||||
|
||||
// Forward: start = day after latest end, end = 12 months later (last day of month).
|
||||
const startStr = addDays(latest.period_end, 1)
|
||||
const end = new Date(startStr + 'T00:00:00Z')
|
||||
end.setUTCMonth(end.getUTCMonth() + 12)
|
||||
end.setUTCDate(0)
|
||||
const endStr = end.toISOString().split('T')[0]
|
||||
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed date for the settings "Skapa räkenskapsår" dialog. Prefers the start of the
|
||||
* earliest gap between consecutive periods so the dialog proposes a missing year
|
||||
* (e.g. 2025 between 2024 and 2026) instead of jumping to the next forward year.
|
||||
* Falls back to the day after the latest period ends, or today when there are none.
|
||||
*/
|
||||
export function suggestSeedDate(periods: PeriodRange[], today: string): string {
|
||||
if (periods.length === 0) return today
|
||||
|
||||
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const gapStart = addDays(sorted[i].period_end, 1)
|
||||
if (gapStart < sorted[i + 1].period_start) return gapStart
|
||||
}
|
||||
|
||||
return addDays(sorted[sorted.length - 1].period_end, 1)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ============================================================
|
||||
// Mock — client.from() returns a fresh chainable builder whose
|
||||
// terminal maybeSingle()/single() draw from a per-test results array
|
||||
// (same pattern as year-end-service.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', 'limit', 'order']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
return b
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
return { from: vi.fn().mockImplementation(() => makeBuilder()) }
|
||||
}
|
||||
|
||||
vi.mock('@/lib/reports/opening-balances', () => ({
|
||||
getOpeningBalances: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateResultAppropriation } from '../result-appropriation-service'
|
||||
import { getOpeningBalances } from '@/lib/reports/opening-balances'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
|
||||
const FAKE_ENTRY = { id: 'ra-1', voucher_series: 'A', voucher_number: 2 }
|
||||
|
||||
/**
|
||||
* Stub the period's ingående balans (IB) with the given per-account debit/credit
|
||||
* balances. The omföring reads 2099 from here, NOT the full trial balance, so
|
||||
* current-year period activity on 2099 can never skew the reclassified amount.
|
||||
*/
|
||||
function mockOpeningBalance(
|
||||
rows: Array<{ account_number: string; debit: number; credit: number }>
|
||||
) {
|
||||
vi.mocked(getOpeningBalances).mockResolvedValue({
|
||||
balances: new Map(rows.map((r) => [r.account_number, { debit: r.debit, credit: r.credit }])),
|
||||
obEntryId: 'ob-1',
|
||||
} as never)
|
||||
}
|
||||
|
||||
const AB = { data: { entity_type: 'aktiebolag' }, error: null }
|
||||
const NO_EXISTING = { data: null, error: null }
|
||||
const PERIOD = {
|
||||
data: { period_start: '2025-01-01', name: 'FY 2025', opening_balance_entry_id: 'ob-1' },
|
||||
error: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
vi.mocked(createJournalEntry).mockResolvedValue(FAKE_ENTRY as never)
|
||||
})
|
||||
|
||||
describe('generateResultAppropriation', () => {
|
||||
it('posts Dr 2099 / Cr 2098 for a profit (AB)', async () => {
|
||||
results = [AB, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 100000 }])
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toEqual(FAKE_ENTRY)
|
||||
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
|
||||
source_type: string
|
||||
entry_date: string
|
||||
voucher_series: string
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
expect(input.source_type).toBe('result_appropriation')
|
||||
expect(input.entry_date).toBe('2025-01-01')
|
||||
expect(input.voucher_series).toBe('A')
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2099', debit_amount: 100000, credit_amount: 0 })
|
||||
)
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2098', debit_amount: 0, credit_amount: 100000 })
|
||||
)
|
||||
})
|
||||
|
||||
it('posts Dr 2098 / Cr 2099 for a loss (AB)', async () => {
|
||||
results = [AB, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 40000, credit: 0 }])
|
||||
|
||||
await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2098', debit_amount: 40000, credit_amount: 0 })
|
||||
)
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2099', debit_amount: 0, credit_amount: 40000 })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for a non-aktiebolag (enskild firma) without posting', async () => {
|
||||
results = [{ data: { entity_type: 'enskild_firma' }, error: null }]
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toBeNull()
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
expect(getOpeningBalances).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is idempotent — returns null when an appropriation entry already exists', async () => {
|
||||
results = [AB, { data: { id: 'ra-existing' }, error: null }]
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toBeNull()
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
expect(getOpeningBalances).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when 2099 carries no IB balance', async () => {
|
||||
results = [AB, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '1930', debit: 5000, credit: 0 }])
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toBeNull()
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults missing company_settings to aktiebolag and posts', async () => {
|
||||
results = [NO_EXISTING /* settings missing */, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 5000 }])
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toEqual(FAKE_ENTRY)
|
||||
expect(createJournalEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reclassifies the IB 2099 amount only — current-year 2099 activity is excluded', async () => {
|
||||
// getOpeningBalances reads the IB entry (the carried-forward prior result),
|
||||
// not the trial balance, so any current-year postings to 2099 in this period
|
||||
// (e.g. when the catch-up script runs mid-year) cannot inflate the omföring.
|
||||
results = [AB, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 80000 }])
|
||||
|
||||
await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
// Exactly the IB amount (80000), regardless of any later 2099 activity.
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2099', debit_amount: 80000, credit_amount: 0 })
|
||||
)
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2098', debit_amount: 0, credit_amount: 80000 })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
|
||||
import { seedCompany, insertDraftJournalEntry, insertFiscalPeriod } from '@/tests/pg/fixtures'
|
||||
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
|
||||
|
||||
/**
|
||||
@@ -111,6 +111,103 @@ describe('year-end invariants (pg-real)', () => {
|
||||
expect(Math.abs(net)).toBeLessThanOrEqual(ORE_TOLERANCE)
|
||||
})
|
||||
|
||||
it('result appropriation omföring zeros the carried-forward 2099 in the NEW period', async () => {
|
||||
// This mirrors production: the closing entry lands in year N, and the
|
||||
// omföring (2099 → 2098) is posted in the SEPARATE next period (year N+1)
|
||||
// dated its first day — NOT back in the closing period. Posting both in one
|
||||
// period (as a naive test would) hides whether 2099 actually starts the new
|
||||
// year at zero, which is the whole invariant.
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
// Year N (2026): closing posts the result onto 2099 — a balanced 3001 → 2099
|
||||
// transfer leaving 2099 with a 5000 credit balance as that year's UB.
|
||||
const closeId = await insertDraftJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
entryDate: '2026-12-31',
|
||||
description: 'Årsbokslut',
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '3001', 5000.00, 0),
|
||||
($1, '2099', 0, 5000.00)`,
|
||||
[closeId],
|
||||
)
|
||||
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, closeId])
|
||||
|
||||
// Year N+1 (2027): a fresh open period. The omföring belongs here.
|
||||
const nextPeriodId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
name: '2027',
|
||||
periodStart: '2027-01-01',
|
||||
periodEnd: '2027-12-31',
|
||||
})
|
||||
|
||||
// Opening-balance entry mirrors year N's UB into the new period: 2099 is
|
||||
// carried forward verbatim (1930 IB balances it). This is what leaves 2099
|
||||
// non-zero at the start of the new year — exactly what the omföring fixes.
|
||||
const ibId = await insertDraftJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: nextPeriodId,
|
||||
entryDate: '2027-01-01',
|
||||
description: 'Ingående balans',
|
||||
sourceType: 'opening_balance',
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', 5000.00, 0),
|
||||
($1, '2099', 0, 5000.00)`,
|
||||
[ibId],
|
||||
)
|
||||
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, ibId])
|
||||
|
||||
// The year-open omföring: Dr 2099 / Cr 2098, dated the new period's first
|
||||
// day, posted in the new period. source_type must be accepted by the CHECK
|
||||
// constraint (see source-type-constraint.pg.test.ts) and the balance trigger
|
||||
// must pass.
|
||||
const omforId = await insertDraftJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: nextPeriodId,
|
||||
entryDate: '2027-01-01',
|
||||
description: 'Omföring av föregående års resultat (2099 → 2098)',
|
||||
sourceType: 'result_appropriation',
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '2099', 5000.00, 0),
|
||||
($1, '2098', 0, 5000.00)`,
|
||||
[omforId],
|
||||
)
|
||||
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, omforId])
|
||||
|
||||
// In the NEW period: 2099 net must be 0 (IB +5000 credit cancelled by the
|
||||
// omföring's 5000 debit); 2098 must hold the result (credit-normal, so
|
||||
// debit − credit = −5000). Scoping to nextPeriodId is the point — 2099 zeros
|
||||
// out in the period the result was carried into, not the closing period.
|
||||
const { rows } = await getPool().query<{ acct: string; net: string }>(
|
||||
`SELECT l.account_number AS acct,
|
||||
COALESCE(SUM(l.debit_amount - l.credit_amount), 0) AS net
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN public.journal_entries je ON je.id = l.journal_entry_id
|
||||
WHERE je.company_id = $1
|
||||
AND je.fiscal_period_id = $2
|
||||
AND je.status = 'posted'
|
||||
AND l.account_number IN ('2099', '2098')
|
||||
GROUP BY l.account_number`,
|
||||
[companyId, nextPeriodId],
|
||||
)
|
||||
const net = Object.fromEntries(rows.map((r) => [r.acct, roundOre(Number(r.net))]))
|
||||
expect(Math.abs(net['2099'] ?? 0)).toBeLessThanOrEqual(ORE_TOLERANCE)
|
||||
expect(net['2098']).toBe(-5000)
|
||||
})
|
||||
|
||||
it('rejects a one-öre IB/UB style discrepancy in opening balance lines', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { getOpeningBalances } from '@/lib/reports/opening-balances'
|
||||
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { JournalEntry, CreateJournalEntryLineInput } from '@/types'
|
||||
|
||||
const log = createLogger('result-appropriation-service')
|
||||
|
||||
/** Årets resultat (current-year result, aktiebolag). */
|
||||
export const RESULT_ACCOUNT = '2099'
|
||||
/** Vinst eller förlust från föregående år. */
|
||||
export const PRIOR_RESULT_ACCOUNT = '2098'
|
||||
|
||||
export interface ResultAppropriationPlan {
|
||||
periodId: string
|
||||
periodName: string
|
||||
/** entry_date for the omföring — the new period's first day. */
|
||||
periodStart: string
|
||||
/** Net 2099 balance, credit-positive (a profit is > 0, a loss is < 0). */
|
||||
net: number
|
||||
/** Absolute, öre-rounded amount that moves between 2099 and 2098. */
|
||||
amount: number
|
||||
direction: 'profit' | 'loss'
|
||||
/** Balanced lines for the omföring verifikat. */
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only computation of the year-open omföring (no writes). Returns the plan
|
||||
* to move 2099 "Årets resultat" onto 2098 "Vinst eller förlust från föregående
|
||||
* år", or null when there is nothing to do.
|
||||
*
|
||||
* Returns null when:
|
||||
* - the company is not an aktiebolag (enskild firma books to 2010, no 2099),
|
||||
* - the period already has a result_appropriation entry (idempotency), or
|
||||
* - 2099 carries no balance (within ORE_TOLERANCE).
|
||||
*
|
||||
* Shared by generateResultAppropriation (which posts the plan) and the
|
||||
* retroactive catch-up script (which previews it in dry-run) so the preview
|
||||
* and the committed entry can never diverge.
|
||||
*/
|
||||
export async function planResultAppropriation(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periodId: string,
|
||||
): Promise<ResultAppropriationPlan | null> {
|
||||
// Aktiebolag only. Same resolution as previewYearEndClosing's closing-account
|
||||
// decision, so the omföring runs exactly when the result was posted to 2099.
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const entityType = settings?.entity_type ?? 'aktiebolag'
|
||||
if (entityType !== 'aktiebolag') return null
|
||||
|
||||
// Idempotency: never plan a second omföring for a period that already has one.
|
||||
const { data: existing } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', periodId)
|
||||
.eq('source_type', 'result_appropriation')
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (existing) return null
|
||||
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, name, opening_balance_entry_id')
|
||||
.eq('id', periodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (!period) throw new Error('Fiscal period not found')
|
||||
|
||||
// Read 2099 from the period's INGÅENDE BALANS only — the carried-forward
|
||||
// prior result that the IB entry mirrored from last year's UB — NOT the full
|
||||
// trial balance. The omföring must reclassify exactly that carried amount;
|
||||
// scoping to IB makes it correct even when the period already has current-year
|
||||
// 2099 activity (e.g. the retroactive catch-up script running mid-year, where
|
||||
// closing = IB + activity would over/under-reclassify). getOpeningBalances
|
||||
// reads the committed opening_balance entry, falling back to a server-side
|
||||
// aggregate of prior posted lines when none is set. credit − debit is positive
|
||||
// for a profit (2099 is credit-normal).
|
||||
const { balances } = await getOpeningBalances(supabase, companyId, period)
|
||||
const ib2099 = balances.get(RESULT_ACCOUNT)
|
||||
const net = ib2099 ? roundOre(ib2099.credit - ib2099.debit) : 0
|
||||
if (Math.abs(net) < ORE_TOLERANCE) return null
|
||||
|
||||
const amount = roundOre(Math.abs(net))
|
||||
const lines: CreateJournalEntryLineInput[] =
|
||||
net > 0
|
||||
? [
|
||||
// Profit: move the credit balance off 2099 onto 2098.
|
||||
{
|
||||
account_number: RESULT_ACCOUNT,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: 'Omföring av föregående års resultat',
|
||||
},
|
||||
{
|
||||
account_number: PRIOR_RESULT_ACCOUNT,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: 'Föregående års resultat',
|
||||
},
|
||||
]
|
||||
: [
|
||||
// Loss: move the debit balance off 2099 onto 2098.
|
||||
{
|
||||
account_number: PRIOR_RESULT_ACCOUNT,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: 'Föregående års resultat',
|
||||
},
|
||||
{
|
||||
account_number: RESULT_ACCOUNT,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: 'Omföring av föregående års resultat',
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
periodId,
|
||||
periodName: period.name,
|
||||
periodStart: period.period_start,
|
||||
net,
|
||||
amount,
|
||||
direction: net > 0 ? 'profit' : 'loss',
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Omföring av föregående års resultat — reclassify 2099 at new-year open.
|
||||
*
|
||||
* After a new fiscal year's opening balances are generated, account 2099
|
||||
* "Årets resultat" carries the prior year's result forward (the IB entry is a
|
||||
* faithful mirror of the prior period's UB). Per BAS practice the prior result
|
||||
* must not remain on 2099: each year must start with 2099 = 0 so it only ever
|
||||
* holds the *current* year's result. This posts the year-open reclassification
|
||||
* as a SEPARATE verifikat in the new period:
|
||||
*
|
||||
* profit (2099 has a credit balance): Dr 2099 / Cr 2098
|
||||
* loss (2099 has a debit balance): Dr 2098 / Cr 2099
|
||||
*
|
||||
* It is deliberately NOT folded into the opening-balance entry. The IB entry
|
||||
* must stay a faithful mirror of the prior UB, or validateBalanceContinuity()
|
||||
* — which reads IB solely from the period's opening_balance entry — would flag
|
||||
* 2099 and 2098 as discrepancies and executeYearEndClosing would self-reverse.
|
||||
* A standalone entry is invisible to that check.
|
||||
*
|
||||
* The further disposition 2098 → 2091 (balanserat resultat) / 2898 (utdelning)
|
||||
* is the bolagsstämma's decision and is intentionally left to a separate step.
|
||||
*
|
||||
* Idempotent / AB-only — see planResultAppropriation for the no-op conditions.
|
||||
* Powers both executeYearEndClosing (steady state) and the retroactive
|
||||
* catch-up script (clears any accumulated 2099 in a company's open period).
|
||||
*/
|
||||
export async function generateResultAppropriation(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
periodId: string,
|
||||
): Promise<JournalEntry | null> {
|
||||
const plan = await planResultAppropriation(supabase, companyId, periodId)
|
||||
if (!plan) return null
|
||||
|
||||
const entry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: periodId,
|
||||
entry_date: plan.periodStart,
|
||||
description: `Omföring av föregående års resultat (${RESULT_ACCOUNT} → ${PRIOR_RESULT_ACCOUNT})`,
|
||||
source_type: 'result_appropriation',
|
||||
voucher_series: 'A',
|
||||
lines: plan.lines,
|
||||
})
|
||||
|
||||
log.info('Posted result appropriation omföring', {
|
||||
operation: 'result_appropriation.post',
|
||||
companyId,
|
||||
entityType: 'journal_entry',
|
||||
entityId: entry.id,
|
||||
amount: plan.amount,
|
||||
direction: plan.direction,
|
||||
})
|
||||
|
||||
return entry
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const log = createLogger('year-end-service')
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
|
||||
import { generateResultAppropriation } from './result-appropriation-service'
|
||||
import {
|
||||
previewCurrencyRevaluation,
|
||||
executeCurrencyRevaluation,
|
||||
@@ -425,6 +426,7 @@ export async function previewYearEndClosing(
|
||||
* 8. Close the period (irreversible — every guard must run before this)
|
||||
* 9. Generate opening balances in next period
|
||||
* 10. Validate IB/UB continuity
|
||||
* 11. Omföra föregående års resultat (2099 → 2098) in the new period (AB only)
|
||||
*/
|
||||
export async function executeYearEndClosing(
|
||||
supabase: SupabaseClient,
|
||||
@@ -610,6 +612,38 @@ export async function executeYearEndClosing(
|
||||
)
|
||||
}
|
||||
|
||||
// 11. Omföra föregående års resultat: move 2099 "Årets resultat" off onto
|
||||
// 2098 in the new period so it starts the year at zero (aktiebolag only).
|
||||
// This is a SEPARATE verifikat by design — folding it into the IB entry
|
||||
// would make the continuity check above fail, since that check reads IB
|
||||
// solely from the opening_balance entry. Non-fatal: the close and IB are
|
||||
// already valid and immutable; a failure here is logged and left for the
|
||||
// retroactive catch-up script (scripts/repair-result-appropriation.ts).
|
||||
let resultAppropriationEntry: JournalEntry | null = null
|
||||
let resultAppropriationFailed = false
|
||||
try {
|
||||
resultAppropriationEntry = await generateResultAppropriation(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
nextPeriod.id
|
||||
)
|
||||
} catch (err) {
|
||||
resultAppropriationFailed = true
|
||||
// alert:true marks this for out-of-band alerting (the log sink / Sentry
|
||||
// integration filters on it) — a silent accounting failure must not wait
|
||||
// for a manual audit. The new period now opens with 2099 still carrying the
|
||||
// prior result; resultAppropriationFailed below drives a UI warning and the
|
||||
// catch-up script (scripts/repair-result-appropriation.ts) posts the fix.
|
||||
log.error('year-end: result appropriation omföring failed (non-fatal)', err as Error, {
|
||||
operation: 'year_end.result_appropriation',
|
||||
alert: true,
|
||||
companyId,
|
||||
entityType: 'fiscal_period',
|
||||
entityId: nextPeriod.id,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch the now-closed period for the event payload
|
||||
const { data: closedPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
@@ -630,6 +664,8 @@ export async function executeYearEndClosing(
|
||||
nextPeriod,
|
||||
openingBalanceEntry,
|
||||
revaluationEntry: revaluationResult?.entry ?? null,
|
||||
resultAppropriationEntry,
|
||||
resultAppropriationFailed,
|
||||
continuity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
contentBucketKey,
|
||||
descriptionsBridge,
|
||||
normalizeImportedDescription,
|
||||
shiftIsoDate,
|
||||
FALLBACK_DESCRIPTION,
|
||||
} from '../external-id'
|
||||
|
||||
@@ -175,3 +176,24 @@ describe('normalizeImportedDescription', () => {
|
||||
expect(normalizeImportedDescription('Unknown Pizza AB')).toBe('Unknown Pizza AB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shiftIsoDate', () => {
|
||||
it('shifts a date forward and backward by whole days', () => {
|
||||
expect(shiftIsoDate('2024-06-15', 1)).toBe('2024-06-16')
|
||||
expect(shiftIsoDate('2024-06-15', -1)).toBe('2024-06-14')
|
||||
expect(shiftIsoDate('2024-06-15', 0)).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('crosses month and year boundaries', () => {
|
||||
expect(shiftIsoDate('2024-06-30', 1)).toBe('2024-07-01')
|
||||
expect(shiftIsoDate('2024-07-01', -1)).toBe('2024-06-30')
|
||||
expect(shiftIsoDate('2025-12-31', 1)).toBe('2026-01-01')
|
||||
expect(shiftIsoDate('2026-01-01', -1)).toBe('2025-12-31')
|
||||
})
|
||||
|
||||
it('handles the leap day deterministically (no wall-clock dependency)', () => {
|
||||
expect(shiftIsoDate('2024-02-28', 1)).toBe('2024-02-29') // 2024 is a leap year
|
||||
expect(shiftIsoDate('2024-03-01', -1)).toBe('2024-02-29')
|
||||
expect(shiftIsoDate('2025-02-28', 1)).toBe('2025-03-01') // 2025 is not
|
||||
})
|
||||
})
|
||||
|
||||
@@ -405,7 +405,7 @@ describe('ingestTransactions', () => {
|
||||
// 2c-bis. Cross-channel mirror: the SAME bank account imported via two feeds
|
||||
// (Nordea CSV payee text vs PSD2 OCR/message) — same date+amount, one row
|
||||
// per channel, descriptions that do NOT bridge — IS deduped on
|
||||
// (date, öre). This is the AXMD/Axel case: a CSV import landing on top of
|
||||
// (date, öre). The real-world trigger: a CSV import landing on top of
|
||||
// existing Enable Banking rows whose descriptions share no text.
|
||||
// -----------------------------------------------------------------------
|
||||
it('dedupes a cross-channel mirror (CSV vs PSD2) even when descriptions do not bridge', async () => {
|
||||
@@ -547,7 +547,7 @@ describe('ingestTransactions', () => {
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2024-06-15', amount: -250,
|
||||
original_description: 'LAN AXMD 19', description: 'LAN AXMD 19',
|
||||
original_description: 'LOAN PAYMENT 19', description: 'LOAN PAYMENT 19',
|
||||
import_source: 'enable_banking', bank_connection_id: 'conn-1',
|
||||
cash_account_id: 'ca-1930', external_id: 'eb_SE_OLD_2024-06-15_-25000_0',
|
||||
}],
|
||||
@@ -825,6 +825,236 @@ describe('ingestTransactions', () => {
|
||||
expect(result.imported).toBe(0)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2h-shadow. Date-drift (the residual gap behind the reported bank↔bank dupes).
|
||||
// Every dedup layer buckets on EXACT (date, öre), so a twin whose booking
|
||||
// date drifted a day is invisible to all of them. The date-drift shadow
|
||||
// MEASURES how often a ±1-day rule would fire — it logs/counts but NEVER
|
||||
// changes what is inserted. These pin both that it detects the real cases
|
||||
// and, crucially, that it never flags a genuine row.
|
||||
// -----------------------------------------------------------------------
|
||||
it('shadow-flags an EB↔EB twin one day apart with a bridging description, but still imports it', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
// Same hotel expense, booking date drifted 15→16 (a real date-drift case).
|
||||
const raw = makeRaw({
|
||||
date: '2024-06-16',
|
||||
amount: -1500,
|
||||
description: 'Hotel expense',
|
||||
external_id: 'eb_SE_2024-06-16_-150000_0',
|
||||
import_source: 'enable_banking',
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-drift', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map — none
|
||||
// Unbooked EB twin one day earlier — same amount/desc/account, OLD-scheme id.
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2024-06-15', amount: -1500,
|
||||
original_description: 'Hotel expense', description: 'Hotel expense',
|
||||
import_source: 'enable_banking', bank_connection_id: 'conn-1',
|
||||
cash_account_id: 'ca-1930', external_id: 'eb_SE_2024-06-15_-150000_0',
|
||||
}],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup — different date bucket, no match
|
||||
enqueue({ data: { id: 'ca-1930' }, error: null }) // cash_accounts — same account
|
||||
enqueue({ data: inserted, error: null }) // insert — STILL imported (shadow only logs)
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
|
||||
settlementAccount: '1930',
|
||||
})
|
||||
|
||||
// Detected, but NOT acted on: imports exactly as before.
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.duplicates).toBe(0)
|
||||
expect(result.shadow_date_drift_candidates).toBe(1)
|
||||
// A different (adjacent) bucket → this is date-drift, not same-bucket scope-drift.
|
||||
expect(result.shadow_scope_drift_candidates).toBe(0)
|
||||
})
|
||||
|
||||
it('shadow-flags a CSV↔EB twin one day apart via cross-channel symmetry when descriptions do not bridge', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
// Nordea CSV row (payee-only desc) and its PSD2 twin booked a day later
|
||||
// (OCR/message desc) — descriptions share no prefix, so only the
|
||||
// cross-channel mirror DISPLACED by a day can catch it (a real date-drift case).
|
||||
const raw = makeRaw({
|
||||
date: '2024-06-15',
|
||||
amount: -2500,
|
||||
description: 'Nordea',
|
||||
external_id: 'nordea_business_csvhash',
|
||||
import_source: 'csv_nordea_business',
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-cross', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map — none
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2024-06-16', amount: -2500,
|
||||
original_description: 'Reimbursement', description: 'Reimbursement',
|
||||
import_source: 'enable_banking', bank_connection_id: 'conn-1',
|
||||
cash_account_id: null, external_id: 'eb_SE_2024-06-16_-250000_0',
|
||||
}],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null }) // supplier invoices
|
||||
enqueue({ data: [], error: null }) // external_id dedup — no match
|
||||
enqueue({ data: inserted, error: null }) // insert — STILL imported
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.duplicates).toBe(0)
|
||||
expect(result.shadow_date_drift_candidates).toBe(1)
|
||||
})
|
||||
|
||||
it('does not shadow-flag a date-drift twin on a different known cash account', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2024-06-16', amount: -250, description: 'Hotel expense',
|
||||
external_id: 'eb_acctB_2024-06-16_-25000_0', import_source: 'enable_banking',
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-b', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2024-06-15', amount: -250,
|
||||
original_description: 'Hotel expense', description: 'Hotel expense',
|
||||
import_source: 'enable_banking', cash_account_id: 'acct-A',
|
||||
external_id: 'eb_acctA_2024-06-15_-25000_0',
|
||||
}],
|
||||
error: null,
|
||||
}) // bridging twin one day earlier, but on account A
|
||||
enqueue({ data: [], error: null }) // supplier
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: { id: 'acct-B' }, error: null }) // cash_accounts → batch on account B
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw], {
|
||||
settlementAccount: '1931',
|
||||
})
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.shadow_date_drift_candidates).toBe(0)
|
||||
})
|
||||
|
||||
it('does not shadow-flag a twin two days away (outside the ±1-day window)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2024-06-17', amount: -250, description: 'Hotel expense',
|
||||
external_id: 'eb_2024-06-17_-25000_0', import_source: 'enable_banking',
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-far', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2024-06-15', amount: -250,
|
||||
original_description: 'Hotel expense', description: 'Hotel expense',
|
||||
import_source: 'enable_banking', cash_account_id: null,
|
||||
external_id: 'eb_2024-06-15_-25000_0',
|
||||
}],
|
||||
error: null,
|
||||
}) // bridging twin TWO days earlier
|
||||
enqueue({ data: [], error: null }) // supplier
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.shadow_date_drift_candidates).toBe(0)
|
||||
})
|
||||
|
||||
it('does not shadow-flag two genuinely-distinct same-amount rows a day apart (non-bridging, same feed)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2024-06-16', amount: -250, description: 'LUNCH RESTAURANG',
|
||||
external_id: 'eb_2024-06-16_-25000_0', import_source: 'enable_banking',
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-lunch', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked
|
||||
enqueue({
|
||||
data: [{
|
||||
date: '2024-06-15', amount: -250,
|
||||
original_description: 'COFFEE STARBUCKS', description: 'COFFEE STARBUCKS',
|
||||
import_source: 'enable_banking', cash_account_id: null,
|
||||
external_id: 'eb_2024-06-15_-25000_0',
|
||||
}],
|
||||
error: null,
|
||||
}) // distinct same-amount neighbour, same feed, non-bridging desc
|
||||
enqueue({ data: [], error: null }) // supplier
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.shadow_date_drift_candidates).toBe(0)
|
||||
})
|
||||
|
||||
it('does not double-count: an exact-date Layer-2 dedupe is not also a date-drift candidate', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2024-06-15', amount: -250, description: 'KAFFE',
|
||||
external_id: 'eb_new_2024-06-15_-25000_0', import_source: 'enable_banking',
|
||||
})
|
||||
|
||||
enqueue({ data: [], error: null }) // booked
|
||||
// Two stored twins: one EXACT-date (Layer-2 dedupes it) and one a day later.
|
||||
// The row is consumed by Layer-2 and never reaches the date-drift gate.
|
||||
enqueue({
|
||||
data: [
|
||||
{ date: '2024-06-15', amount: -250, original_description: 'KAFFE', description: 'KAFFE',
|
||||
import_source: 'enable_banking', cash_account_id: null, external_id: 'eb_old_0615' },
|
||||
{ date: '2024-06-16', amount: -250, original_description: 'KAFFE', description: 'KAFFE',
|
||||
import_source: 'enable_banking', cash_account_id: null, external_id: 'eb_old_0616' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null }) // supplier
|
||||
enqueue({ data: [], error: null }) // external_id dedup — no match
|
||||
// No insert — deduped by Layer-2.
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.imported).toBe(0)
|
||||
expect(result.shadow_date_drift_candidates).toBe(0)
|
||||
})
|
||||
|
||||
it('never lets the date-drift measurement break an import (malformed date is fail-safe)', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
// A malformed date would make shiftIsoDate throw; the guard must skip
|
||||
// detection so the row imports exactly as before — measurement can never
|
||||
// abort a sync. (Without the guard this test throws instead of asserting.)
|
||||
const raw = makeRaw({
|
||||
date: 'not-a-date', amount: -250, description: 'Hotel expense',
|
||||
external_id: 'eb_bad_date_0', import_source: 'enable_banking',
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-baddate', external_id: raw.external_id })
|
||||
|
||||
enqueue({ data: [], error: null }) // booked
|
||||
enqueue({ data: [], error: null }) // unbooked
|
||||
enqueue({ data: [], error: null }) // supplier
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: inserted, error: null }) // insert — still happens
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.shadow_date_drift_candidates).toBe(0)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Counts errors when insert fails
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1205,6 +1435,7 @@ describe('ingestTransactions', () => {
|
||||
errors: 0,
|
||||
transaction_ids: [],
|
||||
shadow_scope_drift_candidates: 0,
|
||||
shadow_date_drift_candidates: 0,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -164,3 +164,22 @@ export function descriptionsBridge(
|
||||
if (x === '' || y === '') return x === y
|
||||
return x.startsWith(y) || y.startsWith(x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift an ISO `YYYY-MM-DD` date by a whole number of days, returning a new
|
||||
* `YYYY-MM-DD` string. Deterministic and INPUT-ONLY — it does the arithmetic
|
||||
* with `Date.UTC` on the parsed components and `new Date(ms)`, never the wall
|
||||
* clock (`Date.now()` / argless `new Date()`), so it is safe in dedup code that
|
||||
* must not depend on the current time. Correctly crosses month, year and
|
||||
* leap-day boundaries via UTC epoch math.
|
||||
*
|
||||
* Used to enumerate the adjacent date buckets the date-drift dedup shadow
|
||||
* inspects: a booking date that drifts a day between syncs lands its twin in
|
||||
* `contentBucketKey(shiftIsoDate(date, ±1), amount)`, which the exact-date
|
||||
* content bridge cannot see.
|
||||
*/
|
||||
export function shiftIsoDate(date: string, deltaDays: number): string {
|
||||
const [y, m, d] = date.split('-').map(Number)
|
||||
const ms = Date.UTC(y, m - 1, d) + deltaDays * 86_400_000
|
||||
return new Date(ms).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matchi
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription } from '@/lib/transactions/external-id'
|
||||
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription, shiftIsoDate } from '@/lib/transactions/external-id'
|
||||
import { isImportedTransaction } from '@/lib/transactions/origin'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
|
||||
@@ -206,6 +206,7 @@ export async function ingestTransactions(
|
||||
errors: 0,
|
||||
transaction_ids: [],
|
||||
shadow_scope_drift_candidates: 0,
|
||||
shadow_date_drift_candidates: 0,
|
||||
}
|
||||
|
||||
const log = createLogger('transactions.ingest', { companyId })
|
||||
@@ -218,6 +219,15 @@ export async function ingestTransactions(
|
||||
// DEDUP_SCOPE_DRIFT_MODE=off to silence. There is deliberately NO 'enforce'
|
||||
// branch yet: we validate on real fleet data first (see the plan).
|
||||
const scopeDriftShadow = process.env.DEDUP_SCOPE_DRIFT_MODE !== 'off'
|
||||
// SHADOW-ONLY instrumentation for the date-drift bridge: the content bridge
|
||||
// buckets on EXACT (date, öre), so a booking date that drifts a day between
|
||||
// syncs lands its twin in an ADJACENT bucket and every dedup layer misses it
|
||||
// (this produced the observed EB↔EB and CSV↔EB 1-day-apart duplicates). When
|
||||
// on, we LOG + COUNT which surviving rows a ±1-day-tolerant rule WOULD treat
|
||||
// as re-imports, but never change what is inserted. Default on; set
|
||||
// DEDUP_DATE_DRIFT_MODE=off to silence. No 'enforce' branch — same as
|
||||
// scope-drift, we validate on real fleet data first.
|
||||
const dateDriftShadow = process.env.DEDUP_DATE_DRIFT_MODE !== 'off'
|
||||
|
||||
// Pre-fetch existing transactions for content-based dedup (date+amount+
|
||||
// description prefix, plus the cross-channel mirror below). Booked rows catch
|
||||
@@ -397,6 +407,26 @@ export async function ingestTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shadow-mode date-drift precompute (measure only) ─────────────────────
|
||||
// The content bridge matches only the EXACT (date, öre) bucket, so a twin
|
||||
// whose booking date drifted a day is invisible to it. Snapshot the stored
|
||||
// buckets BEFORE the dedup loop — a COPY of each bucket's entries, so Layer-2's
|
||||
// splices don't mutate what the shadow reads — so each surviving row can look
|
||||
// one day to either side for an account-compatible twin without disturbing
|
||||
// real dedup. Window is ±1 day (the only gap observed); a named constant so
|
||||
// widening to ±2 is one line if fleet data shows it.
|
||||
const DATE_DRIFT_WINDOW_DAYS = 1
|
||||
const storedByBucketForDrift = new Map<string, BucketEntry[]>()
|
||||
if (batchIsImportFeed && dateDriftShadow) {
|
||||
for (const bucket of [existingMaps.booked, existingMaps.unbookedImported]) {
|
||||
for (const [k, entries] of bucket) {
|
||||
const snapshot = storedByBucketForDrift.get(k)
|
||||
if (snapshot) snapshot.push(...entries)
|
||||
else storedByBucketForDrift.set(k, [...entries])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track already-matched invoice IDs within this ingestion batch
|
||||
// to prevent suggesting the same invoice for multiple transactions
|
||||
const matchedInvoiceIds = new Set<string>()
|
||||
@@ -539,6 +569,74 @@ export async function ingestTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
// SHADOW-ONLY: date-drift. This row survived Layer-1 + Layer-2 and WILL
|
||||
// insert. The content bridge only matched its EXACT (date, öre) bucket, so a
|
||||
// twin whose booking date drifted a day is invisible to it. Look ±1 day for
|
||||
// an account-compatible stored twin that EITHER bridges by description
|
||||
// (same/enriched title — the EB↔EB hotel/fee case) OR is a cross-feed
|
||||
// count-symmetric mirror displaced by a day (the CSV↔EB case where the
|
||||
// descriptions don't bridge). Record it for fleet validation, then insert
|
||||
// unchanged — this block never affects result.imported/duplicates.
|
||||
//
|
||||
// Fail-safe date guard: measurement must NEVER abort a real import. raw.date
|
||||
// is always ISO in practice, but a malformed value would make shiftIsoDate
|
||||
// throw (new Date(NaN).toISOString()), so we skip detection rather than risk
|
||||
// it. Any /^\d{4}-\d{2}-\d{2}$/ value is safe — Date.UTC normalizes
|
||||
// out-of-range parts to a finite epoch, never NaN.
|
||||
if (dateDriftShadow && batchIsImportFeed && /^\d{4}-\d{2}-\d{2}$/.test(raw.date)) {
|
||||
let driftMatch: { entry: BucketEntry; gap: number; signal: 'desc' | 'cross-channel' } | undefined
|
||||
const incomingHere = incomingByBucket.get(bucketKey) ?? 0
|
||||
for (let delta = 1; delta <= DATE_DRIFT_WINDOW_DAYS && !driftMatch; delta++) {
|
||||
for (const sign of [-1, 1] as const) {
|
||||
const adjKey = contentBucketKey(shiftIsoDate(raw.date, sign * delta), raw.amount)
|
||||
const entries = storedByBucketForDrift.get(adjKey)
|
||||
if (!entries) continue
|
||||
// Cross-feed count-symmetry across the drift: equal counts of incoming
|
||||
// rows in THIS bucket and account-compatible cross-feed rows one day
|
||||
// over — the cross-channel mirror, displaced by a date drift.
|
||||
const adjCrossFeed = entries.filter(
|
||||
(e) =>
|
||||
e.isImportFeed &&
|
||||
e.source !== batchSource &&
|
||||
(cashAccountId === null || e.cashAccountId === null || e.cashAccountId === cashAccountId),
|
||||
).length
|
||||
const mirrorSymmetric = adjCrossFeed > 0 && incomingHere === adjCrossFeed
|
||||
for (const entry of entries) {
|
||||
const sameAccount =
|
||||
cashAccountId === null || entry.cashAccountId === null || entry.cashAccountId === cashAccountId
|
||||
if (!sameAccount) continue
|
||||
if (descriptionsBridge(description, entry.desc)) {
|
||||
driftMatch = { entry, gap: sign * delta, signal: 'desc' }
|
||||
break
|
||||
}
|
||||
if (mirrorSymmetric && entry.isImportFeed && entry.source !== batchSource) {
|
||||
driftMatch = { entry, gap: sign * delta, signal: 'cross-channel' }
|
||||
break
|
||||
}
|
||||
}
|
||||
if (driftMatch) break
|
||||
}
|
||||
}
|
||||
if (driftMatch) {
|
||||
result.shadow_date_drift_candidates = (result.shadow_date_drift_candidates ?? 0) + 1
|
||||
log.info('import dedup shadow: date-drift candidate', {
|
||||
decision: 'date-drift',
|
||||
mode: 'shadow',
|
||||
signal: driftMatch.signal,
|
||||
dayGap: driftMatch.gap,
|
||||
bucket: bucketKey,
|
||||
incomingExternalId: raw.external_id,
|
||||
incomingDescription: description,
|
||||
incomingAmount: raw.amount,
|
||||
incomingSource: raw.import_source ?? null,
|
||||
cashAccountId,
|
||||
matchedStoredExternalId: driftMatch.entry.externalId,
|
||||
matchedStoredDescription: driftMatch.entry.desc,
|
||||
matchedStoredCashAccountId: driftMatch.entry.cashAccountId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Insert new transaction (with SEK conversion for foreign currencies)
|
||||
const rateInfo = raw.currency && raw.currency !== 'SEK'
|
||||
? exchangeRatesByDate.get(`${raw.currency}|${raw.date}`)
|
||||
|
||||
Reference in New Issue
Block a user