diff --git a/.github/workflows/docker-image-scan.yml b/.github/workflows/docker-image-scan.yml new file mode 100644 index 00000000..3c5bba6f --- /dev/null +++ b/.github/workflows/docker-image-scan.yml @@ -0,0 +1,80 @@ +name: Scheduled Image Vulnerability Scan + +# The build pipeline (docker-publish.yml) builds and publishes the image but +# does NOT fail on CVEs — it stays green so deploys are deterministic. This +# workflow is the actual vulnerability gate: it re-scans the published `latest` +# image and fails (notifying repo admins) plus raises a Security-tab alert on a +# fixable CRITICAL/HIGH CVE, prompting a dependency or base-image bump. +# +# It fires on three triggers: +# 1. workflow_run — the moment "Build and Push Docker Image" completes, so a +# freshly published image is gated within the scan's own duration (minutes) +# rather than waiting up to 24h for the cron. This is what shrinks the +# vulnerable-image exposure window after every publish. +# 2. schedule (daily) — catches CVEs newly disclosed against an already- +# published image even when nothing was republished. +# 3. workflow_dispatch — run on demand from the Actions tab after a patch to +# confirm clean. +# +# NOTE: GitHub only runs `schedule` and `workflow_run` triggers from the default +# branch, so both start firing once this is merged to main. +on: + workflow_run: + workflows: ['Build and Push Docker Image'] + types: [completed] + schedule: + # 06:17 UTC daily — off the hour to dodge cron congestion on GitHub. + - cron: '17 6 * * *' + workflow_dispatch: {} + +env: + REGISTRY: ghcr.io + IMAGE_NAME: erp-mafia/gnubok + +jobs: + scan: + runs-on: ubuntu-latest + # workflow_run fires even when the publish FAILED — there's no new image to + # gate in that case, so skip. schedule/workflow_dispatch carry no + # workflow_run payload, so the `!= 'workflow_run'` arm lets them through. + if: >- + github.event_name != 'workflow_run' || + github.event.workflow_run.conclusion == 'success' + permissions: + contents: read + # Pull the published image from GHCR. + packages: read + # SARIF upload to the repo's "Security" tab. + security-events: write + + steps: + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Scan published image with Trivy + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + # Block on fixable CRITICAL/HIGH — the same policy the build pipeline + # used to enforce inline. It is safe to block here: a red scheduled + # run is a "go patch" notification, not a blocked deploy. ignore-unfixed + # keeps it actionable (only CVEs we can resolve by rebuilding fail). + severity: CRITICAL,HIGH + exit-code: '1' + ignore-unfixed: true + format: sarif + output: trivy-results.sarif + + - name: Upload Trivy results to GitHub Security tab + # if: always() so findings still reach the Security tab even though the + # scan step above failed the run. Same category as docker-publish.yml so + # the two analyses share one alert set instead of duplicating. + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results.sarif + category: trivy diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 31d005c5..7d70db35 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -81,26 +81,39 @@ jobs: run: | cosign sign --yes "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}" - - name: Scan image with Trivy + - name: Scan image with Trivy (report-only) id: trivy + # Decoupled from the publish gate on purpose: this pipeline must stay + # green so builds are deterministic. The image is already pushed and + # signed above, so failing here would only redden the run — it would not + # unship a vulnerable image. exit-code:0 + continue-on-error keep CVEs + # (and even a Trivy/DB outage) from failing the build; findings still + # flow to the Security tab below. The real blocking gate is + # docker-image-scan.yml, which re-scans the published image and fails + # (notifying admins) on a fixable CRITICAL/HIGH CVE. It runs on a + # workflow_run trigger the moment THIS workflow completes — so the gap + # between publish and the blocking scan is the scan's own duration + # (minutes), not a 24h cron window — plus a daily cron as a safety net. + # Accepted residual risk: an image is live for that short scan window + # before the gate fires; see SELF-HOSTING.md / the risk register. + continue-on-error: true uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} - # HIGH is blocking for an accounting application — Trivy's CRITICAL - # bucket is narrow (mostly RCE-class), and HIGH covers everything - # from auth bypass to crypto downgrades. ignore-unfixed keeps the - # gate actionable: only CVEs we can patch by rebuilding fail the - # pipeline, not upstream-pending issues we have no remediation for. severity: CRITICAL,HIGH - exit-code: '1' + exit-code: '0' ignore-unfixed: true format: sarif output: trivy-results.sarif - name: Upload Trivy results to GitHub Security tab - # if:always() so a CRITICAL finding still ends up in the Security tab - # even though the Trivy step above failed the workflow. + # if: always() — evidence must reach the Security tab regardless of the + # scan step's exit status. With the previous `outcome == 'success'` guard, + # a Trivy/DB outage that errored the scan would silently drop findings. + # Kept non-fatal (continue-on-error) so a missing SARIF or a Security-tab + # hiccup can't redden an otherwise-good publish. if: always() + continue-on-error: true uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results.sarif diff --git a/.gitignore b/.gitignore index 7a285ea9..e296d1f2 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,11 @@ scripts/delete-duplicate-transactions.ts # scripts read or emit (ledger dumps, reconciliation exports) is real customer # räkenskapsinformation — never commit it. Keep the .ts/.sql tooling, ignore the data. scripts/*.csv + +# Local-only DESTRUCTIVE support tool — reopens a closed räkenskapsår by deleting +# only its bokslut layer (dispositioner + closing entry + next-year IB). Run by +# hand against real räkenskapsinformation; bypasses BFL immutability triggers, so +# it must never live in the repo / CI / cron. +scripts/reopen-bokslut.sql + +.claude/plans/write-up-a-plan-streamed-fiddle.md diff --git a/Dockerfile b/Dockerfile index 56ac35d2..05eec0d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,8 +49,13 @@ WORKDIR /app # Patch OS packages (libssl3/libcrypto3, …) with fixes published after the # pinned base digest, so CI's Trivy scan doesn't flag fixable Alpine CVEs. No # su-exec or curl needed: the entrypoint runs unprivileged as nextjs and the -# healthcheck uses BusyBox wget. -RUN apk upgrade --no-cache +# healthcheck uses BusyBox wget. The runtime runs `node server.js` and never +# invokes npm, so we delete the base image's bundled npm CLI: its vendored deps +# (picomatch, tar, brace-expansion, ip-address) are the packages Trivy flags on +# this image — removing npm clears them at the source and shrinks the attack +# surface. +RUN apk upgrade --no-cache && \ + rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts index b5702c9b..64b8e900 100644 --- a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts @@ -284,18 +284,77 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { expect(res.status).toBe(200) }) - it('rejects period that is neither forward nor backward', async () => { + // Regression (2026-06-16): a company with FY 2024 + FY 2026 but no + // FY 2025 could not create the missing year — the old code only allowed + // chaining before the earliest or after the latest period. A period that + // exactly fills an interior gap must be allowed. + it('allows filling a gap between two existing periods', async () => { + buildMockSupabase({ + allPeriods: [ + { id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true }, + { id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }, + ], + overlapping: [], + }) + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toBeDefined() + }) + + it('rejects a gap-fill period that is not adjacent to the preceding period', async () => { buildMockSupabase({ allPeriods: [ { id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true }, { id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }, ], }) - const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + // Starts 2025-02-01 instead of 2025-01-01 — would leave a hole after FY 2024. + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-02-01', period_end: '2025-12-31' }) const res = await POST(req) expect(res.status).toBe(400) const body = await res.json() - expect(body.error).toMatch(/must chain before the earliest or after the latest/) + expect(body.error).toMatch(/must start on 2025-01-01/) + }) + + // Regression: the start check only constrains the predecessor side. A gap-fill + // period that starts correctly (day after FY 2024) but ends BEFORE the day + // before FY 2026 would leave a fresh sub-gap while still relinking FY 2026's + // previous_period_id onto it — a broken continuity chain. The end-adjacency + // guard must reject it. + it('rejects a gap-fill period that does not end the day before the successor', async () => { + buildMockSupabase({ + allPeriods: [ + { id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true }, + { id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }, + ], + }) + // Correct start (2025-01-01) but ends 2025-11-30 — leaves a hole before FY 2026. + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-11-30' }) + const res = await POST(req) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/must end on 2025-12-31/) + }) + + // A gap fill is a backfill (like backward chaining), so the "prior year must be + // locked" guard must NOT apply — otherwise the two open neighbours would block it. + it('allows a gap fill even when both neighbouring years are open', async () => { + buildMockSupabase({ + allPeriods: [ + { id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }, + { id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }, + ], + openPeriods: [ + { id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }, + { id: 'p2', name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' }, + ], + overlapping: [], + }) + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(200) }) it('rejects invalid period duration (> 18 months)', async () => { @@ -461,4 +520,80 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { expect(insertSpy).toHaveBeenCalledTimes(1) expect(insertSpy.mock.calls[0][0].previous_period_id).toBeNull() }) + + it('gap fill chains to the predecessor and relinks the successor', async () => { + const insertSpy = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: { id: 'new-2025', name: 'FY 2025' }, error: null }), + }), + }) + const updatedIds: string[] = [] + const updatePayloads: unknown[] = [] + const updateSpy = vi.fn().mockImplementation((payload: unknown) => { + updatePayloads.push(payload) + return { + eq: vi.fn().mockImplementation((col: string, val: string) => { + if (col === 'id') updatedIds.push(val) + return { eq: vi.fn().mockResolvedValue({ error: null }) } + }), + } + }) + + let fpCallIndex = 0 + const supabase = { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) }, + from: vi.fn().mockImplementation((table: string) => { + if (table === 'company_settings') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: { bookkeeping_locked_through: null }, error: null }), + }), + }), + } + } + fpCallIndex++ + const callNum = fpCallIndex + return { + select: vi.fn().mockImplementation(() => { + if (callNum === 1) { + // allPeriods: FY 2024 + FY 2026 with a hole at 2025 + return { + eq: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ + data: [ + { id: 'p-2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }, + { id: 'p-2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false }, + ], + error: null, + }), + }), + } + } + // overlap query + return { + eq: vi.fn().mockReturnValue({ + lte: vi.fn().mockReturnValue({ + gte: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue({ data: [], error: null }) }), + }), + }), + } + }), + insert: insertSpy, + update: updateSpy, + } + }), + } + ;(createClient as ReturnType).mockResolvedValue(supabase) + + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + + expect(res.status).toBe(200) + // New period chains onto FY 2024 (the predecessor). + expect(insertSpy.mock.calls[0][0].previous_period_id).toBe('p-2024') + // FY 2026 (the successor) is relinked to follow the new period. + expect(updatePayloads[0]).toEqual({ previous_period_id: 'new-2025' }) + expect(updatedIds[0]).toBe('p-2026') + }) }) diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index 4580738a..7464eef8 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -65,23 +65,26 @@ export async function POST(request: Request) { return NextResponse.json({ error: durationError }, { status: 400 }) } - if (allPeriods && allPeriods.length > 0) { - const earliest = allPeriods[0] - const latest = allPeriods[allPeriods.length - 1] + // Identify the new period's immediate neighbours. Fiscal periods never overlap + // (the no_overlapping_fiscal_periods DB exclusion constraint), so ordering by + // period_start is also ordering by period_end. + // predecessor = closest existing period ending before the new one starts + // successor = closest existing period starting after the new one ends + const sortedPeriods = allPeriods ?? [] + const predecessor = [...sortedPeriods].reverse().find((p) => p.period_end < body.period_start) ?? null + const successor = sortedPeriods.find((p) => p.period_start > body.period_end) ?? null - const isBackward = body.period_end < earliest.period_start - const isForward = body.period_start > latest.period_end + if (sortedPeriods.length > 0) { + const earliest = sortedPeriods[0] + const latest = sortedPeriods[sortedPeriods.length - 1] - if (!isBackward && !isForward) { - // Neither backward nor forward — must overlap or be in the middle - return NextResponse.json( - { error: 'New period must chain before the earliest or after the latest existing period' }, - { status: 400 } - ) - } + const isPrepend = body.period_end < earliest.period_start + const isAppend = body.period_start > latest.period_end - if (isBackward) { - // Backward chaining: new period_end must be day before earliest period_start + if (isPrepend) { + // Prepend before the earliest period: new period_end must be the day before + // the earliest period starts. Skip the "no open prior period" constraint — + // backfilling an earlier year needs that year to stay open. const expectedEnd = new Date(earliest.period_start + 'T12:00:00Z') expectedEnd.setUTCDate(expectedEnd.getUTCDate() - 1) const expectedEndStr = expectedEnd.toISOString().split('T')[0] @@ -91,58 +94,91 @@ export async function POST(request: Request) { { status: 400 } ) } - // Skip "no unclosed period" constraint for backward chaining (backfill needs the period open) } else { - // Forward chaining: keep existing constraints (contiguity + no unclosed periods) - const prev = new Date(latest.period_end + 'T12:00:00Z') - prev.setUTCDate(prev.getUTCDate() + 1) - const expectedStart = prev.toISOString().split('T')[0] - if (body.period_start !== expectedStart) { - return NextResponse.json( - { error: `Period must start on ${expectedStart} (day after latest period ends)` }, - { status: 400 } - ) + // Forward-like: either append a new latest year OR fill an interior gap + // between two existing years. Both must chain onto their immediate + // predecessor — i.e. start the day after it ends. When appending, the + // predecessor IS the latest period (original forward-chaining behaviour); + // when filling a gap, it's the year just before the hole. + if (predecessor) { + const next = new Date(predecessor.period_end + 'T12:00:00Z') + next.setUTCDate(next.getUTCDate() + 1) + const expectedStart = next.toISOString().split('T')[0] + if (body.period_start !== expectedStart) { + return NextResponse.json( + { error: `Period must start on ${expectedStart} (day after the preceding period ends)` }, + { status: 400 } + ) + } + } + // No predecessor here means the new period reaches back over the earliest + // existing period (an overlap) — the overlap check below returns 409. + + // Gap fill: the new period must also butt up against its SUCCESSOR — end + // exactly the day before the successor starts — so it fills the hole + // completely. The predecessor check above only constrains the start side. + // Without this end check a too-short period would leave a fresh sub-gap + // yet still get the successor's previous_period_id relinked onto it + // (below), silently breaking the BFNAR 2013:2 continuity chain; a too-long + // period that bleeds past the successor is separately caught as an overlap + // (409). Appends have no successor, so this is skipped. + if (successor) { + const prevDay = new Date(successor.period_start + 'T12:00:00Z') + prevDay.setUTCDate(prevDay.getUTCDate() - 1) + const expectedEnd = prevDay.toISOString().split('T')[0] + if (body.period_end !== expectedEnd) { + return NextResponse.json( + { error: `Period must end on ${expectedEnd} (day before the following period starts)` }, + { status: 400 } + ) + } } - // Enforce: max one editable prior period (no skipping ahead) — forward only. - // A period is "effectively locked" if EITHER its own locked_at is set, OR + // The "prior year must be locked" guard applies only when appending a new + // latest räkenskapsår, not when backfilling a gap between existing years. + // A gap fill is a backfill (like prepend) and must not be blocked by an + // open neighbouring year. + // + // A period counts as "effectively locked" if its own locked_at is set, OR // company_settings.bookkeeping_locked_through covers its end date (the // enforce_company_lock_date trigger blocks any entry on/before that date). // BFL 6 kap allows löpande bokföring of the new year in parallel with // bokslut work on the prior year, so locked-but-not-closed prior periods // must not block creating the next räkenskapsår. - const { data: openPeriods } = await supabase - .from('fiscal_periods') - .select('id, name, period_start, period_end') - .eq('company_id', companyId) - .eq('is_closed', false) - .is('locked_at', null) - .order('period_start', { ascending: true }) + if (isAppend) { + const { data: openPeriods } = await supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end') + .eq('company_id', companyId) + .eq('is_closed', false) + .is('locked_at', null) + .order('period_start', { ascending: true }) - const { data: settings } = await supabase - .from('company_settings') - .select('bookkeeping_locked_through') - .eq('company_id', companyId) - .maybeSingle() + const { data: settings } = await supabase + .from('company_settings') + .select('bookkeeping_locked_through') + .eq('company_id', companyId) + .maybeSingle() - const lockThrough = settings?.bookkeeping_locked_through ?? null - const trulyOpen = (openPeriods ?? []).filter( - (p) => !(lockThrough && p.period_end <= lockThrough) - ) + const lockThrough = settings?.bookkeeping_locked_through ?? null + const trulyOpen = (openPeriods ?? []).filter( + (p) => !(lockThrough && p.period_end <= lockThrough) + ) - if (trulyOpen.length > 0) { - // Hand the blocking periods (id + name + dates) to the client so the - // "Skapa räkenskapsår" dialog can offer to lock them inline and retry, - // instead of dead-ending the user on a message they can't act on. - const blockingPeriods = trulyOpen.map((p) => ({ - id: p.id, - name: p.name, - period_start: p.period_start, - period_end: p.period_end, - })) - return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, { - details: { blockingPeriods }, - }) + if (trulyOpen.length > 0) { + // Hand the blocking periods (id + name + dates) to the client so the + // "Skapa räkenskapsår" dialog can offer to lock them inline and retry, + // instead of dead-ending the user on a message they can't act on. + const blockingPeriods = trulyOpen.map((p) => ({ + id: p.id, + name: p.name, + period_start: p.period_start, + period_end: p.period_end, + })) + return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, { + details: { blockingPeriods }, + }) + } } } } @@ -163,18 +199,11 @@ export async function POST(request: Request) { ) } - // Resolve previous_period_id for forward chaining so the new period is - // linked to the period it follows. Without this, balance-sheet/trial-balance - // reports fall back to scanning every prior journal line (BFNAR 2013:2 - // continuity chain is broken). Backward chaining sets previous_period_id - // on the old earliest period instead (see below), not on the new one. - let previousPeriodId: string | null = null - if (allPeriods && allPeriods.length > 0) { - const latest = allPeriods[allPeriods.length - 1] - if (body.period_start > latest.period_end) { - previousPeriodId = latest.id - } - } + // Chain the new period onto its predecessor (append or gap fill) so reports + // can walk the BFNAR 2013:2 continuity chain instead of scanning every prior + // journal line. Prepend leaves this null and instead relinks the old earliest + // period to follow the new one (below). + const previousPeriodId = predecessor ? predecessor.id : null const { data, error } = await supabase .from('fiscal_periods') @@ -193,14 +222,19 @@ export async function POST(request: Request) { return NextResponse.json({ error: error.message }, { status: 500 }) } - // For backward chaining: update the old earliest period's previous_period_id - if (allPeriods && allPeriods.length > 0) { - const earliest = allPeriods[0] - if (body.period_end < earliest.period_start) { + // Keep the continuity chain intact for the period that now follows the new one: + // - Prepend: the old earliest period follows the new (earlier) period. + // - Gap fill: the successor period follows the new period. + // (Append has no successor, so nothing to relink.) + if (sortedPeriods.length > 0) { + const earliest = sortedPeriods[0] + const isPrepend = body.period_end < earliest.period_start + const periodToRelink = isPrepend ? earliest : successor + if (periodToRelink) { await supabase .from('fiscal_periods') .update({ previous_period_id: data.id }) - .eq('id', earliest.id) + .eq('id', periodToRelink.id) .eq('company_id', companyId) } } diff --git a/components/bookkeeping/CreatePeriodDialog.tsx b/components/bookkeeping/CreatePeriodDialog.tsx index 777f6ac4..f67384cf 100644 --- a/components/bookkeeping/CreatePeriodDialog.tsx +++ b/components/bookkeeping/CreatePeriodDialog.tsx @@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { Loader2, Lock } from 'lucide-react' +import { computeSuggestedPeriod } from '@/lib/bookkeeping/suggest-fiscal-period' import type { FiscalPeriod } from '@/types' interface Props { @@ -42,58 +43,6 @@ function errorMessage(err: unknown, fallback = 'Ett oväntat fel uppstod.'): str return fallback } -function computeSuggestedPeriod(entryDate: string, periods: FiscalPeriod[]) { - if (periods.length === 0) { - // No periods at all — suggest a calendar year period around the entry date - const year = entryDate.split('-')[0] - return { - name: `FY ${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 - // Use UTC throughout — local-time Date math + toISOString() shifts dates by - // the timezone offset (e.g. CET produces 2024-12-31 → 2025-12-30). - const end = new Date(earliest.period_start + 'T00:00:00Z') - end.setUTCDate(end.getUTCDate() - 1) - - const start = new Date(end) - start.setUTCMonth(start.getUTCMonth() - 11) - start.setUTCDate(1) - - const startStr = start.toISOString().split('T')[0] - const endStr = end.toISOString().split('T')[0] - const startYear = start.getUTCFullYear() - const endYear = end.getUTCFullYear() - const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}` - - return { name, period_start: startStr, period_end: endStr } - } - - // Forward: start = day after latest end, end = 12 months later (last day of month) - const start = new Date(latest.period_end + 'T00:00:00Z') - start.setUTCDate(start.getUTCDate() + 1) - - const end = new Date(start) - end.setUTCMonth(end.getUTCMonth() + 12) - end.setUTCDate(0) // Last day of previous month - - const startStr = start.toISOString().split('T')[0] - const endStr = end.toISOString().split('T')[0] - const startYear = start.getUTCFullYear() - const endYear = end.getUTCFullYear() - const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}` - - return { name, period_start: startStr, period_end: endStr } -} - export default function CreatePeriodDialog({ open, onOpenChange, entryDate, periods, onCreated }: Props) { const { toast } = useToast() const suggested = useMemo(() => computeSuggestedPeriod(entryDate, periods), [entryDate, periods]) diff --git a/components/bookkeeping/year-end/ResultStep.tsx b/components/bookkeeping/year-end/ResultStep.tsx index f86ebf32..4f854ad5 100644 --- a/components/bookkeeping/year-end/ResultStep.tsx +++ b/components/bookkeeping/year-end/ResultStep.tsx @@ -70,10 +70,34 @@ export function ResultStep({ result }: ResultStepProps) { value={formatVoucher(result.openingBalanceEntry)} href={`/bookkeeping/${result.openingBalanceEntry.id}`} /> + {result.resultAppropriationEntry && ( + + )} + {result.resultAppropriationFailed && ( + + + +

+ + Omföringen av föregående års resultat (2099 → 2098) kunde inte bokföras. + {' '} + Bokslutet och de ingående balanserna är klara, men konto 2099 “Årets + resultat” bär fortfarande föregående års resultat in i den nya perioden. + Det måste flyttas till 2098 innan balansräkningen stämmer. Kör om bokslutet + eller kontakta support — felet är loggat. +

+
+
+ )} + {continuity && ( locked > open. */ function periodStatus(p: FiscalPeriod): 'closed' | 'locked' | 'open' { @@ -23,22 +24,6 @@ const STATUS_VARIANT: Record<'closed' | 'locked' | 'open', 'secondary' | 'warnin open: 'success', } -/** ISO date one day after the latest period ends — seeds the create dialog so - * its suggestion chains forward onto the most recent year. UTC throughout to - * avoid timezone-offset date drift. */ -function nextEntryDate(periods: FiscalPeriod[]): string { - if (periods.length === 0) { - return new Date().toISOString().split('T')[0] - } - const latestEnd = periods - .map((p) => p.period_end) - .sort((a, b) => a.localeCompare(b)) - .at(-1)! - const d = new Date(latestEnd + 'T00:00:00Z') - d.setUTCDate(d.getUTCDate() + 1) - return d.toISOString().split('T')[0] -} - export function FiscalYearsManager() { const t = useTranslations('settings_bookkeeping') const [periods, setPeriods] = useState([]) @@ -115,7 +100,7 @@ export function FiscalYearsManager() { diff --git a/extensions/general/mcp-server/__tests__/agent-briefing.test.ts b/extensions/general/mcp-server/__tests__/agent-briefing.test.ts index 126675a8..4a84922d 100644 --- a/extensions/general/mcp-server/__tests__/agent-briefing.test.ts +++ b/extensions/general/mcp-server/__tests__/agent-briefing.test.ts @@ -44,6 +44,8 @@ function mockSupabase(opts: { title: string | null description: string }> + // profiles.full_name for the signed-in user. undefined → no profile row. + userFullName?: string | null errors?: { profile?: string; memory?: string; atoms?: string } }) { const profile = opts.profile === undefined ? null : opts.profile @@ -53,6 +55,19 @@ function mockSupabase(opts: { return { from: vi.fn((table: string) => { + if (table === 'profiles') { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + maybeSingle: vi.fn().mockResolvedValue({ + data: + opts.userFullName === undefined ? null : { full_name: opts.userFullName }, + error: null, + }), + })), + })), + } + } if (table === 'agent_profiles') { return { select: vi.fn(() => ({ @@ -120,7 +135,7 @@ describe('gnubok_get_agent_briefing tool', () => { expect(input.required).toBeUndefined() }) - it('returns null summary, empty atoms, empty memory when no profile exists', async () => { + it('returns null summary, empty atoms, empty memory, null user_name when nothing exists', async () => { const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')! const supabase = mockSupabase({ profile: null }) const result = (await tool.execute( @@ -130,6 +145,7 @@ describe('gnubok_get_agent_briefing tool', () => { supabase as never, { type: 'api_key' } )) as { + user_name: string | null profile_summary: string | null atoms: unknown[] memory: unknown[] @@ -137,6 +153,34 @@ describe('gnubok_get_agent_briefing tool', () => { expect(result.profile_summary).toBeNull() expect(result.atoms).toEqual([]) expect(result.memory).toEqual([]) + expect(result.user_name).toBeNull() + }) + + it('returns only the first name (tilltalsnamn) — data minimisation, not the full legal name', async () => { + const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')! + const supabase = mockSupabase({ profile: null, userFullName: 'Peter Bennet' }) + const result = (await tool.execute( + {}, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' } + )) as { user_name: string | null } + // GDPR Art.5(1)(c): the surname never enters the LLM prompt. + expect(result.user_name).toBe('Peter') + }) + + it('treats a blank full_name as no name (null)', async () => { + const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')! + const supabase = mockSupabase({ profile: null, userFullName: ' ' }) + const result = (await tool.execute( + {}, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' } + )) as { user_name: string | null } + expect(result.user_name).toBeNull() }) it('returns profile + atom metadata + memory when populated', async () => { diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 2aa725ff..b911287a 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -1616,7 +1616,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_skills', title: 'List Domain Skills', - description: 'List available domain-knowledge skills filtered to this company (entity type, VAT, payroll). Workflow guides + loaded specialty atoms. Pass include_all=true to see hidden skills. Call gnubok_load_skill(slug) for any body.', + description: 'List domain-knowledge skills for this company (entity type, VAT, payroll). Workflow guides + loaded specialty atoms. Pass include_all=true to see hidden skills. Call gnubok_load_skill(slug) for any body.', inputSchema: { type: 'object', additionalProperties: false, @@ -2062,7 +2062,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_get_agent_briefing', title: 'Get Agent Briefing', - description: 'Bootstrap this company\'s accountant context in one call: profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories. Call once at session start.', + description: 'Bootstrap this company\'s accountant context in one call: user_name, profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories. Call once at session start.', inputSchema: { type: 'object', additionalProperties: false, @@ -2072,6 +2072,11 @@ export const tools: McpTool[] = [ type: 'object', additionalProperties: false, properties: { + user_name: { + type: ['string', 'null'], + description: + 'Name of the person you are assisting — address them by it (their tilltalsnamn), not the owner in profile_summary. Null if unset.', + }, profile_summary: { type: ['string', 'null'], description: 'Composer-generated one-paragraph summary of the company. Null if no agent profile exists yet (composer has not run).', @@ -2107,7 +2112,7 @@ export const tools: McpTool[] = [ }, }, }, - required: ['profile_summary', 'atoms', 'memory'], + required: ['user_name', 'profile_summary', 'atoms', 'memory'], }, annotations: { readOnlyHint: true, @@ -2115,8 +2120,8 @@ export const tools: McpTool[] = [ idempotentHint: true, openWorldHint: false, }, - async execute(_args, companyId, _userId, supabase) { - const [profileRes, memoryRes] = await Promise.all([ + async execute(_args, companyId, userId, supabase) { + const [profileRes, memoryRes, userRes] = await Promise.all([ supabase .from('agent_profiles') .select('profile_summary, horizontal_atoms, vertical_atoms, modifier_atoms') @@ -2130,6 +2135,16 @@ export const tools: McpTool[] = [ .order('relevance_score', { ascending: false, nullsFirst: false }) .order('last_accessed_at', { ascending: false, nullsFirst: false }) .limit(30), + // The user's own preferred name (profiles.full_name) so the agent can + // address them correctly. Distinct from owner/signatory names that may + // appear in profile_summary — those come from Bolagsverket via TIC and + // describe the company, not necessarily the person chatting. Best-effort: + // a failed read yields a null name, never a thrown briefing. + supabase + .from('profiles') + .select('full_name') + .eq('id', userId) + .maybeSingle(), ]) if (profileRes.error) throw new Error(`Failed to load agent profile: ${profileRes.error.message}`) @@ -2150,6 +2165,16 @@ export const tools: McpTool[] = [ relevance_score: number | null }> + // profiles read is best-effort — ignore userRes.error so a missing name + // never blocks the briefing. Data minimisation (GDPR Art.5(1)(c)): the + // agent only needs the tilltalsnamn to address the user, so pass the first + // token only — never the full legal name — into the LLM prompt. Mirrors + // app/api/agent/invoke/route.ts, which also derives firstName via split. + const userName = + (((userRes.data as { full_name: string | null } | null)?.full_name ?? '') + .trim() + .split(/\s+/)[0] || null) + const atomIds = [ ...(profile?.horizontal_atoms ?? []), ...(profile?.vertical_atoms ?? []), @@ -2178,6 +2203,7 @@ export const tools: McpTool[] = [ } return { + user_name: userName, profile_summary: profile?.profile_summary ?? null, atoms, memory: memoryRows.map((m) => ({ @@ -2555,7 +2581,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_categorize_transaction', title: 'Categorize Bank Transaction', - description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. vat_amount overrides the computed moms; reverse_charge is rejected when the underlag shows the seller already charged VAT.', + description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. vat_amount overrides computed moms; reverse_charge is rejected when the underlag shows the seller charged VAT.', inputSchema: { type: 'object', additionalProperties: false, @@ -4242,7 +4268,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_get_general_ledger', title: 'General Ledger (Huvudbok)', - description: 'General ledger (huvudbok) for a fiscal period: per-account opening balance, entries, closing balance. Optional account range filter. For ad-hoc cross-account, amount, or free-text line queries use gnubok_query_journal.', + description: 'General ledger (huvudbok) for a fiscal period: per-account opening, entries, closing balances. Optional account range filter. For ad-hoc cross-account/amount/free-text queries use gnubok_query_journal.', inputSchema: { type: 'object', additionalProperties: false, @@ -4737,7 +4763,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_match_batch_allocate', title: 'Batch-Allocate Payment', - description: 'Allocate 1 bank tx across N customer OR N supplier invoices (samlingsbetalning, BFL 5 kap 6§). Use when one receipt covers many invoices or one transfer pays many bills. Customer needs income, supplier expense. Stages.', + description: 'Allocate 1 bank tx across N customer OR N supplier invoices (samlingsbetalning, BFL 5 kap 6§). Use when one receipt covers many invoices or one transfer pays many bills. Stages.', inputSchema: { type: 'object', additionalProperties: false, @@ -5333,7 +5359,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_link_invoice_to_voucher', title: 'Link Invoice to Voucher', - description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_invoice först.', + description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Kör gnubok_find_voucher_candidates_for_invoice först.', inputSchema: { type: 'object', additionalProperties: false, @@ -6310,7 +6336,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_unmatched_documents', title: 'List Unmatched Documents', - description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. The amount is in the invoice currency — FX-normalise before comparing to transactions.amount.', + description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. Amount is in the invoice currency; FX-normalise before comparing to transactions.amount.', inputSchema: { type: 'object', additionalProperties: false, @@ -6532,7 +6558,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_attach_document_to_transaction', title: 'Attach Document to Transaction', - description: 'Stage attaching a document to a bank transaction. Verify tx (date, amount, counterparty) and document (filename, vendor, amount) match first — the preview shown to the human reviewer mirrors what you pass here. Stages for approval.', + description: 'Stage attaching a document to a bank transaction. Verify tx (date, amount, counterparty) and document (filename, vendor, amount) match first — the reviewer\'s preview mirrors what you pass here. Stages for approval.', inputSchema: { type: 'object', additionalProperties: false, @@ -8408,7 +8434,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_create_voucher', title: 'Create Manual Voucher (Verifikation)', - description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Pass inbox_item_id to book a kvitto direct (links + attaches doc). HIGH risk.', + description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Pass inbox_item_id to book a kvitto direct. HIGH risk.', inputSchema: { type: 'object', additionalProperties: false, @@ -8809,7 +8835,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_reverse_journal_entry', title: 'Reverse Journal Entry (Storno)', - description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Only when the affärshändelse should never have been booked (duplicate, ghost, test). Booked wrong → gnubok_correct_entry; refund → gnubok_credit_invoice. HIGH risk.', + description: 'Stage a storno: inverts debits/credits, original stays visible (BFL 5 kap). Only when it should never have been booked (duplicate, ghost, test). Booked wrong → gnubok_correct_entry; refund → gnubok_credit_invoice. HIGH risk.', inputSchema: { type: 'object', additionalProperties: false, @@ -9546,7 +9572,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_set_inbox_extracted_data', title: 'Set Inbox Extracted Data', - description: 'Replace extracted_data on an inbox item with agent-supplied fields (bring-your-own-extraction). Use when your own pipeline parses the document better than Accounted\'s OCR. Follow with gnubok_create_supplier_invoice_from_inbox to stage.', + description: 'Replace extracted_data on an inbox item with agent-supplied fields. Use when your pipeline parses the document better than Accounted\'s OCR. Follow with gnubok_create_supplier_invoice_from_inbox to stage.', inputSchema: { type: 'object', additionalProperties: false, diff --git a/lib/agent/chat/__tests__/system-prompt.test.ts b/lib/agent/chat/__tests__/system-prompt.test.ts index e6f7c278..0a4f2926 100644 --- a/lib/agent/chat/__tests__/system-prompt.test.ts +++ b/lib/agent/chat/__tests__/system-prompt.test.ts @@ -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 diff --git a/lib/agent/chat/system-prompt.ts b/lib/agent/chat/system-prompt.ts index 04ca4374..1414dbf9 100644 --- a/lib/agent/chat/system-prompt.ts +++ b/lib/agent/chat/system-prompt.ts @@ -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 diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 053637b3..a140f3aa 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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. */ diff --git a/lib/bookkeeping/__tests__/suggest-fiscal-period.test.ts b/lib/bookkeeping/__tests__/suggest-fiscal-period.test.ts new file mode 100644 index 00000000..91878435 --- /dev/null +++ b/lib/bookkeeping/__tests__/suggest-fiscal-period.test.ts @@ -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') + }) +}) diff --git a/lib/bookkeeping/suggest-fiscal-period.ts b/lib/bookkeeping/suggest-fiscal-period.ts new file mode 100644 index 00000000..d83afa3d --- /dev/null +++ b/lib/bookkeeping/suggest-fiscal-period.ts @@ -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 + +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) +} diff --git a/lib/core/bookkeeping/__tests__/result-appropriation.test.ts b/lib/core/bookkeeping/__tests__/result-appropriation.test.ts new file mode 100644 index 00000000..ebe15e78 --- /dev/null +++ b/lib/core/bookkeeping/__tests__/result-appropriation.test.ts @@ -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 = {} + 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 }) + ) + }) +}) diff --git a/lib/core/bookkeeping/__tests__/year-end-invariants.pg.test.ts b/lib/core/bookkeeping/__tests__/year-end-invariants.pg.test.ts index 0c7297f2..2b6ac3e9 100644 --- a/lib/core/bookkeeping/__tests__/year-end-invariants.pg.test.ts +++ b/lib/core/bookkeeping/__tests__/year-end-invariants.pg.test.ts @@ -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() diff --git a/lib/core/bookkeeping/result-appropriation-service.ts b/lib/core/bookkeeping/result-appropriation-service.ts new file mode 100644 index 00000000..e2a5a613 --- /dev/null +++ b/lib/core/bookkeeping/result-appropriation-service.ts @@ -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 { + // 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 { + 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 +} diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts index 6abc91b8..e40682ed 100644 --- a/lib/core/bookkeeping/year-end-service.ts +++ b/lib/core/bookkeeping/year-end-service.ts @@ -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, } } diff --git a/lib/transactions/__tests__/external-id.test.ts b/lib/transactions/__tests__/external-id.test.ts index fb3016f0..acf2946e 100644 --- a/lib/transactions/__tests__/external-id.test.ts +++ b/lib/transactions/__tests__/external-id.test.ts @@ -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 + }) +}) diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts index af7e1c67..fe895145 100644 --- a/lib/transactions/__tests__/ingest.test.ts +++ b/lib/transactions/__tests__/ingest.test.ts @@ -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, }) }) diff --git a/lib/transactions/external-id.ts b/lib/transactions/external-id.ts index f9a1222b..7f149d54 100644 --- a/lib/transactions/external-id.ts +++ b/lib/transactions/external-id.ts @@ -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) +} diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index 861906be..610399f3 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -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() + 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() @@ -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}`) diff --git a/scripts/repair-result-appropriation.ts b/scripts/repair-result-appropriation.ts new file mode 100644 index 00000000..de2cb558 --- /dev/null +++ b/scripts/repair-result-appropriation.ts @@ -0,0 +1,236 @@ +#!/usr/bin/env npx tsx +/** + * Retroactive catch-up for the year-open result omföring (2099 → 2098). + * + * Problem: before generateResultAppropriation existed, year-end closing posted + * the result to 2099 "Årets resultat" and the opening-balance entry carried it + * forward verbatim. 2099 was therefore re-opened on 2099 every year and the + * prior result accumulated there instead of being moved off "Årets resultat". + * + * Fix (per affected aktiebolag): for EACH of the company's open (unlocked, + * unclosed) periods, post one balanced omföring verifikat that clears the 2099 + * balance the period's ingående balans carried forward (Dr 2099 / Cr 2098 for a + * profit, reversed for a loss). Each period is handled independently — this is + * NOT a single lump-sum across years. A period whose 2099 is already flat (or + * already has a result_appropriation entry) is skipped. No closed/locked years + * are touched — entries land in open periods and respect every BFL trigger. This + * corrects the balance sheet going forward; it does not reconstruct per-year + * history (which would require reopening closed years). + * + * The actual posting and all no-op gating (AB-only, idempotency, zero balance) + * are delegated to the SAME helper the year-end flow uses, so the catch-up and + * the steady-state behaviour can never diverge. + * + * Attribution (BFL 5 kap 6§): the omföring verifikat is attributed to a user. + * Pass --user-id to set it explicitly. Otherwise it defaults to the company + * owner; only if no owner row exists does it fall back to an arbitrary member, + * and that fallback prints a loud WARNING so a misattributed rättelse can't slip + * through unnoticed. + * + * Usage: + * # Preview every affected company (read-only) + * npx tsx scripts/repair-result-appropriation.ts + * + * # Preview a single company + * npx tsx scripts/repair-result-appropriation.ts --company-id + * + * # Apply (post the omföring entries), attributing to a specific user + * npx tsx scripts/repair-result-appropriation.ts --commit --user-id + * + * Run against staging first; only run against prod after reviewing the dry-run. + */ + +import { config } from 'dotenv' +config({ path: '.env.local' }) +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { + planResultAppropriation, + generateResultAppropriation, +} from '../lib/core/bookkeeping/result-appropriation-service' + +// ────────────────────────────────────────────────────────────────── +// Args + client +// ────────────────────────────────────────────────────────────────── + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : undefined +} + +const ONLY_COMPANY_ID = arg('company-id') +const USER_ID_OVERRIDE = arg('user-id') +const COMMIT = process.argv.includes('--commit') + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) as SupabaseClient + +console.log('─────────────────────────────────────────────────────────') +console.log('Result Appropriation Catch-up (2099 → 2098)') +console.log('─────────────────────────────────────────────────────────') +console.log('Supabase URL :', supabaseUrl) +console.log('Scope :', ONLY_COMPANY_ID ? `company ${ONLY_COMPANY_ID}` : 'ALL companies') +console.log('Attribution :', USER_ID_OVERRIDE ? `user ${USER_ID_OVERRIDE} (--user-id)` : 'company owner (fallback: any member)') +console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)') +console.log('─────────────────────────────────────────────────────────\n') + +// ────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────── + +async function listCompanyIds(): Promise { + if (ONLY_COMPANY_ID) return [ONLY_COMPANY_ID] + const { data, error } = await supabase + .from('companies') + .select('id') + .order('created_at', { ascending: true }) + if (error) throw new Error(`Failed to list companies: ${error.message}`) + return (data as { id: string }[]).map((c) => c.id) +} + +/** Open periods (not locked, not closed), earliest first. */ +async function listOpenPeriods(companyId: string): Promise<{ id: string; name: string }[]> { + const { data, error } = await supabase + .from('fiscal_periods') + .select('id, name') + .eq('company_id', companyId) + .eq('is_closed', false) + .is('locked_at', null) + .order('period_start', { ascending: true }) + if (error) throw new Error(`Failed to list open periods for ${companyId}: ${error.message}`) + return (data as { id: string; name: string }[]) ?? [] +} + +/** + * Resolve the user_id to attribute the verifikat to (BFL 5 kap 6§). Precedence: + * 1. --user-id override (caller takes responsibility for correctness), + * 2. the company owner, + * 3. any member — but this is an arbitrary attribution, so it prints a loud + * WARNING; a rättelse landing on the wrong person must never be silent. + * Returns null only when the company has no members at all. + */ +async function resolveAttributionUserId( + companyId: string, + companyLabel: string, +): Promise { + if (USER_ID_OVERRIDE) return USER_ID_OVERRIDE + + const { data: owner } = await supabase + .from('company_members') + .select('user_id') + .eq('company_id', companyId) + .eq('role', 'owner') + .order('created_at', { ascending: true }) + .limit(1) + .maybeSingle() + if (owner?.user_id) return owner.user_id as string + + // Fallback: any member (e.g. legacy data with no explicit owner row). + const { data: anyMember } = await supabase + .from('company_members') + .select('user_id') + .eq('company_id', companyId) + .limit(1) + .maybeSingle() + const fallbackId = (anyMember?.user_id as string) ?? null + if (fallbackId) { + console.warn( + ` ⚠ ${companyLabel}: no owner row — attributing the omföring to an ARBITRARY ` + + `member (${fallbackId}). Pass --user-id to attribute it deliberately.`, + ) + } + return fallbackId +} + +// ────────────────────────────────────────────────────────────────── +// Main +// ────────────────────────────────────────────────────────────────── + +async function main() { + let scanned = 0 + let planned = 0 + let posted = 0 + let skippedNoOwner = 0 + let failed = 0 + + const companyIds = await listCompanyIds() + console.log(`Scanning ${companyIds.length} company(ies)…\n`) + + for (const companyId of companyIds) { + scanned++ + let openPeriods: { id: string; name: string }[] + try { + openPeriods = await listOpenPeriods(companyId) + } catch (err) { + console.error(` · ${companyId}: FAILED to list periods:`, err instanceof Error ? err.message : err) + failed++ + continue + } + + for (const period of openPeriods) { + let plan + try { + plan = await planResultAppropriation(supabase, companyId, period.id) + } catch (err) { + console.error( + ` · ${companyId} / ${period.name}: FAILED to plan:`, + err instanceof Error ? err.message : err, + ) + failed++ + continue + } + if (!plan) continue // non-AB, already done, or 2099 flat + + planned++ + console.log( + ` · ${companyId} / ${period.name}: ${plan.direction} ${plan.amount} kr ` + + `— ${plan.lines.map((l) => `${l.account_number} ${l.debit_amount ? `D ${l.debit_amount}` : `K ${l.credit_amount}`}`).join(' / ')}`, + ) + + if (!COMMIT) continue + + const userId = await resolveAttributionUserId(companyId, `${companyId} / ${period.name}`) + if (!userId) { + console.error(` · ${companyId}: SKIPPED — no member to attribute the entry to`) + skippedNoOwner++ + continue + } + + try { + const entry = await generateResultAppropriation(supabase, companyId, userId, period.id) + if (entry) { + console.log(` → posted ${entry.voucher_series}${entry.voucher_number} (${entry.id})`) + posted++ + } + } catch (err) { + console.error( + ` · ${companyId} / ${period.name}: FAILED to post:`, + err instanceof Error ? err.message : err, + ) + failed++ + } + } + } + + console.log('\n─────────────────────────────────────────────────────────') + console.log('Summary') + console.log('─────────────────────────────────────────────────────────') + console.log(`Companies scanned : ${scanned}`) + console.log(`Omföringar planned: ${planned}`) + console.log(`Omföringar posted : ${posted}`) + console.log(`Skipped (no owner): ${skippedNoOwner}`) + console.log(`Failed : ${failed}`) + console.log(`Mode : ${COMMIT ? 'COMMIT' : 'DRY RUN'}`) + if (!COMMIT) console.log('\nRe-run with --commit to apply.') +} + +main().catch((err) => { + console.error('\nFATAL:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/supabase/migrations/20260627120000_journal_source_type_result_appropriation.sql b/supabase/migrations/20260627120000_journal_source_type_result_appropriation.sql new file mode 100644 index 00000000..fe56365a --- /dev/null +++ b/supabase/migrations/20260627120000_journal_source_type_result_appropriation.sql @@ -0,0 +1,37 @@ +-- Migration: add 'result_appropriation' to journal_entries.source_type CHECK +-- +-- Year-end closing posts the net result to 2099 "Årets resultat", and the +-- opening-balance entry carries every class 1-2 account forward verbatim — +-- so 2099 was re-opened on 2099 each year and the prior result accumulated +-- there instead of being moved off "Årets resultat". The new +-- generateResultAppropriation() helper posts a separate year-open omföring +-- (Dr 2099 / Cr 2098 for a profit) in the new period so 2099 starts each +-- year at zero. That verifikat uses source_type='result_appropriation'; +-- this migration adds the value to the DB allowlist so the insert is not +-- rejected with PG 23514. The TS type (JournalEntrySourceType) and the Zod +-- schema (JournalEntrySourceTypeSchema) are updated in the same change. +-- +-- See 20260623120000 for the previous expansion pattern. We preserve all +-- pre-existing source_type values and append the new one. + +ALTER TABLE public.journal_entries + DROP CONSTRAINT IF EXISTS journal_entries_source_type_check; + +ALTER TABLE public.journal_entries + ADD CONSTRAINT journal_entries_source_type_check + CHECK (source_type IN ( + 'manual', 'bank_transaction', 'invoice_created', + 'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment', + 'opening_balance', 'year_end', + 'storno', 'correction', 'import', 'system', + 'inbox_item', + 'supplier_invoice_registered', 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', 'supplier_credit_note', + 'currency_revaluation', + 'supplier_invoice_privately_paid', + 'reminder_fee', + 'accrual', + 'result_appropriation' + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index a43b9715..f4267d96 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1251,6 +1251,7 @@ export type JournalEntrySourceType = | 'currency_revaluation' | 'reminder_fee' | 'accrual' + | 'result_appropriation' // Journal entry status export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' | 'cancelled' @@ -2711,6 +2712,24 @@ export interface YearEndResult { nextPeriod: FiscalPeriod openingBalanceEntry: JournalEntry revaluationEntry: JournalEntry | null + /** + * Year-open omföring av föregående års resultat (Dr 2099 / Cr 2098) posted + * into the new period so 2099 "Årets resultat" starts the year at zero. + * Aktiebolag only; null for enskild firma or when 2099 carried no balance. + * The further disposition 2098 → 2091/2898 is the stämma's decision and is + * intentionally left to a separate step. + */ + resultAppropriationEntry: JournalEntry | null + /** + * True when the year-open omföring (2099 → 2098) was attempted but threw. + * The close + IB are already valid and immutable, so the failure is + * non-fatal to the year-end itself — but it leaves 2099 carrying the prior + * result into the new period, which is non-compliant. Surfaced so the UI can + * alert the user (and an alertable log line fires server-side); the + * retroactive catch-up script (scripts/repair-result-appropriation.ts) then + * posts the missing omföring. False on success or when there was nothing to do. + */ + resultAppropriationFailed: boolean /** * IB/UB reconciliation per balance sheet account, computed after the * opening balances are posted. Surfaced to the UI's ResultStep so the @@ -2995,6 +3014,14 @@ export interface IngestResult { * the rule would fire, so it can be validated on real data before enforcement. */ shadow_scope_drift_candidates?: number + /** + * SHADOW-MODE counter: rows that an enforcing date-drift dedup rule WOULD have + * treated as re-imports — a twin with the same öre and an account-compatible, + * bridging (or cross-channel count-symmetric) match one day away, which the + * exact-date content bridge misses. Still imported; the field only measures + * how often the rule would fire, for validation before any enforcement. + */ + shadow_date_drift_candidates?: number } // ── Invoice extraction (used by invoice-inbox extension and core utils) ──