Bug/year end numbers (#744)

* fix(bookkeeping): allow creating a fiscal year that fills an interior gap

Fiscal-period creation only allowed chaining a new räkenskapsår before the
earliest or after the latest existing period, so a company with a gap between
years (e.g. 2024 + 2026 from an SIE import, missing 2025) could not create the
missing year — it failed with "New period must chain before the earliest or
after the latest existing period".

Generalise forward chaining onto the new period's immediate predecessor, which
covers both appending a new latest year and filling an interior gap. The
"prior year must be locked" guard now applies only to true appends, not gap
fills (a backfill, like backward chaining). previous_period_id is set to the
predecessor and the successor is relinked so the BFNAR 2013:2 continuity chain
stays intact. The create dialog suggests the missing year (capped so it never
overlaps the next period), the settings page seeds the dialog at the earliest
gap, and the default suggested name is now "Räkenskapsår <year>".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): omföra föregående års resultat (2099 → 2098) at year-end

Year-end closing posts the result to 2099 "Årets resultat" and the opening
balance carried it forward on 2099 every year, so 2099 accumulated across
years and the prior result never moved off "Årets resultat".

executeYearEndClosing now posts a separate "Omföring av föregående års
resultat" verifikat (Dr 2099 / Cr 2098 for a profit, reversed for a loss)
into the new period after the continuity check passes, so 2099 starts each
year at zero. Kept as a standalone entry rather than folded into the opening
balance so the IB stays a faithful mirror of the prior UB and IB/UB
continuity still holds. Aktiebolag only; idempotent; no-op when 2099 is flat.
The 2098 → 2091/2898 disposition (bolagsstämma decision) is intentionally
left to a separate step.

- new source_type 'result_appropriation' (migration + type + Zod enum)
- generateResultAppropriation helper (planner + poster) wired as step 11
- ResultStep surfaces the omföring voucher
- unit tests + pg-real invariant
- scripts/repair-result-appropriation.ts: retroactive catch-up (dry-run default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): shadow-detect date-drift duplicate bank transactions

The content-dedup bridge buckets on exact (date, ore), so the same
transaction re-imported with a booking date that drifted a day lands in
a different bucket and slips past every dedup layer. Add a measure-only
("shadow") detector that flags would-be +/-1-day duplicates and counts
them, without changing what is inserted - so the gap can be validated on
real data before any enforcement, mirroring the scope-drift shadow.

- shiftIsoDate(): pure, deterministic adjacent-date helper
- ingest: DEDUP_DATE_DRIFT_MODE flag (default on), pre-loop bucket
  snapshot, per-row gate with desc-bridge + cross-channel-symmetry
  signals; logs shadow_date_drift_candidates, never alters inserts
- fail-safe date guard so the measurement can never abort an import
- regression tests for both signals, account/window/distinct guards,
  no-double-count, and the malformed-date fail-safe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(bookkeeping): anonymize a customer reference in fiscal-period tests

Remove a real customer name ("AXMD AB") from regression-test comments;
no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(workflows): enhance Docker image scanning and caching mechanisms

* fix(bookkeeping): enhance year-end result appropriation handling and error reporting

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-16 18:22:09 +02:00
committed by GitHub
parent 5c40fa9aeb
commit 2a8bf9b42e
27 changed files with 1860 additions and 174 deletions
+80
View File
@@ -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
+22 -9
View File
@@ -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
+8
View File
@@ -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
+7 -2
View File
@@ -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
@@ -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<typeof vi.fn>).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')
})
})
+106 -72
View File
@@ -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)
}
}
+1 -52
View File
@@ -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])
@@ -70,10 +70,34 @@ export function ResultStep({ result }: ResultStepProps) {
value={formatVoucher(result.openingBalanceEntry)}
href={`/bookkeeping/${result.openingBalanceEntry.id}`}
/>
{result.resultAppropriationEntry && (
<ResultRow
label="Omföring av föregående års resultat (2099 → 2098)"
value={formatVoucher(result.resultAppropriationEntry)}
href={`/bookkeeping/${result.resultAppropriationEntry.id}`}
/>
)}
<ResultRow label="Ny räkenskapsperiod" value={result.nextPeriod.name} />
</CardContent>
</Card>
{result.resultAppropriationFailed && (
<Card className="border-destructive/30 bg-destructive/5">
<CardContent className="p-4 flex items-start gap-3">
<AlertTriangle className="h-4 w-4 mt-0.5 text-destructive shrink-0" />
<p className="text-sm">
<span className="font-medium">
Omföringen av föregående års resultat (2099 2098) kunde inte bokföras.
</span>{' '}
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.
</p>
</CardContent>
</Card>
)}
{continuity && (
<ContinuityPanel
discrepancies={discrepancies}
+2 -17
View File
@@ -9,6 +9,7 @@ import { Plus } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import type { FiscalPeriod } from '@/types'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { suggestSeedDate } from '@/lib/bookkeeping/suggest-fiscal-period'
/** Status of a fiscal period, in legal precedence: closed > 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<FiscalPeriod[]>([])
@@ -115,7 +100,7 @@ export function FiscalYearsManager() {
<CreatePeriodDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
entryDate={nextEntryDate(periods)}
entryDate={suggestSeedDate(periods, new Date().toISOString().split('T')[0])}
periods={periods}
onCreated={fetchPeriods}
/>
@@ -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 () => {
+40 -14
View File
@@ -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,
@@ -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
+11
View File
@@ -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
+1
View File
@@ -202,6 +202,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
'currency_revaluation',
'reminder_fee',
'accrual',
'result_appropriation',
])
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import { computeSuggestedPeriod, suggestSeedDate } from '../suggest-fiscal-period'
type Range = { period_start: string; period_end: string }
const FY2024: Range = { period_start: '2024-01-01', period_end: '2024-12-31' }
const FY2026: Range = { period_start: '2026-01-01', period_end: '2026-12-31' }
describe('computeSuggestedPeriod', () => {
it('suggests a calendar year around the entry date when there are no periods', () => {
expect(computeSuggestedPeriod('2025-06-15', [])).toEqual({
name: 'Räkenskapsår 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
})
it('suggests a backfill year before the earliest period', () => {
expect(computeSuggestedPeriod('2025-06-15', [FY2026])).toEqual({
name: 'Räkenskapsår 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
})
it('suggests the missing year when the entry date is in an interior gap', () => {
// FY 2024 + FY 2026 exist, 2025 is the hole (interior-gap scenario).
expect(computeSuggestedPeriod('2025-06-15', [FY2024, FY2026])).toEqual({
name: 'Räkenskapsår 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
})
it('caps the gap suggestion so it never overlaps the right neighbour', () => {
// A six-month hole (JanJun 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')
})
})
+123
View File
@@ -0,0 +1,123 @@
/**
* Pure helpers for proposing a new fiscal period (räkenskapsår) in the
* "Skapa räkenskapsår" dialog. Kept framework-free so they can be unit-tested
* in isolation (component code itself is not under test per the project's
* lib/ + app/api/ test scope).
*
* All date math runs in UTC. Local-time `Date` arithmetic combined with
* `toISOString()` shifts dates by the timezone offset (e.g. CET turns
* 2024-12-31 into 2025-12-30), which silently corrupts period boundaries.
*/
import type { FiscalPeriod } from '@/types'
/** Minimal shape needed for the date math — `FiscalPeriod` satisfies it. */
type PeriodRange = Pick<FiscalPeriod, 'period_start' | 'period_end'>
export interface SuggestedPeriod {
name: string
period_start: string
period_end: string
}
/** Add `days` to a YYYY-MM-DD date string, returning a YYYY-MM-DD string. */
function addDays(date: string, days: number): string {
const d = new Date(date + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().split('T')[0]
}
/**
* A fiscal-year name: `Räkenskapsår 2025`, or `Räkenskapsår 2024/2025` when it
* straddles two calendar years. Swedish by default to match the app's existing
* fiscal-year naming (Swedish-first); the field stays editable in the dialog.
*/
function periodName(start: string, end: string): string {
const startYear = Number(start.slice(0, 4))
const endYear = Number(end.slice(0, 4))
return startYear === endYear ? `Räkenskapsår ${startYear}` : `Räkenskapsår ${startYear}/${endYear}`
}
/**
* Suggest a fiscal period for the create dialog, given the date the user is
* trying to book and the company's existing periods. Three cases:
* - No periods yet → a calendar year around the entry date.
* - Entry date before the earliest period → a year ending the day before it (backfill).
* - Entry date inside an interior gap → a year filling the hole, starting the day
* after the left neighbour and capped so it never overlaps the right neighbour
* (a clean one-year hole yields exactly that year, e.g. 2025 between 2024 and 2026).
* - Otherwise → the next year chaining forward off the latest period.
*/
export function computeSuggestedPeriod(
entryDate: string,
periods: PeriodRange[],
): SuggestedPeriod {
if (periods.length === 0) {
// No periods at all — suggest a calendar year period around the entry date.
const year = entryDate.split('-')[0]
return { name: `Räkenskapsår ${year}`, period_start: `${year}-01-01`, period_end: `${year}-12-31` }
}
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
const earliest = sorted[0]
const latest = sorted[sorted.length - 1]
if (entryDate < earliest.period_start) {
// Backward: end = day before earliest start, start = 12 months back, 1st of month.
const endStr = addDays(earliest.period_start, -1)
const start = new Date(endStr + 'T00:00:00Z')
start.setUTCMonth(start.getUTCMonth() - 11)
start.setUTCDate(1)
const startStr = start.toISOString().split('T')[0]
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
}
// Interior gap: the entry date sits between the earliest and latest period but is
// not contained by any existing period.
const containing = sorted.find((p) => p.period_start <= entryDate && entryDate <= p.period_end)
if (!containing && entryDate <= latest.period_end) {
const leftNeighbour = [...sorted].reverse().find((p) => p.period_end < entryDate)!
const rightNeighbour = sorted.find((p) => p.period_start > entryDate)!
const startStr = addDays(leftNeighbour.period_end, 1)
// Tentative end: 12 months after start, last day of the prior month.
const end = new Date(startStr + 'T00:00:00Z')
end.setUTCMonth(end.getUTCMonth() + 12)
end.setUTCDate(0)
let endStr = end.toISOString().split('T')[0]
// Cap at the day before the right neighbour starts so we never overlap it.
const gapEnd = addDays(rightNeighbour.period_start, -1)
if (gapEnd < endStr) endStr = gapEnd
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
}
// Forward: start = day after latest end, end = 12 months later (last day of month).
const startStr = addDays(latest.period_end, 1)
const end = new Date(startStr + 'T00:00:00Z')
end.setUTCMonth(end.getUTCMonth() + 12)
end.setUTCDate(0)
const endStr = end.toISOString().split('T')[0]
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
}
/**
* Seed date for the settings "Skapa räkenskapsår" dialog. Prefers the start of the
* earliest gap between consecutive periods so the dialog proposes a missing year
* (e.g. 2025 between 2024 and 2026) instead of jumping to the next forward year.
* Falls back to the day after the latest period ends, or today when there are none.
*/
export function suggestSeedDate(periods: PeriodRange[], today: string): string {
if (periods.length === 0) return today
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
for (let i = 0; i < sorted.length - 1; i++) {
const gapStart = addDays(sorted[i].period_end, 1)
if (gapStart < sorted[i + 1].period_start) return gapStart
}
return addDays(sorted[sorted.length - 1].period_end, 1)
}
@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — client.from() returns a fresh chainable builder whose
// terminal maybeSingle()/single() draw from a per-test results array
// (same pattern as year-end-service.test.ts).
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'limit', 'order']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return { from: vi.fn().mockImplementation(() => makeBuilder()) }
}
vi.mock('@/lib/reports/opening-balances', () => ({
getOpeningBalances: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
}))
import { generateResultAppropriation } from '../result-appropriation-service'
import { getOpeningBalances } from '@/lib/reports/opening-balances'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
const FAKE_ENTRY = { id: 'ra-1', voucher_series: 'A', voucher_number: 2 }
/**
* Stub the period's ingående balans (IB) with the given per-account debit/credit
* balances. The omföring reads 2099 from here, NOT the full trial balance, so
* current-year period activity on 2099 can never skew the reclassified amount.
*/
function mockOpeningBalance(
rows: Array<{ account_number: string; debit: number; credit: number }>
) {
vi.mocked(getOpeningBalances).mockResolvedValue({
balances: new Map(rows.map((r) => [r.account_number, { debit: r.debit, credit: r.credit }])),
obEntryId: 'ob-1',
} as never)
}
const AB = { data: { entity_type: 'aktiebolag' }, error: null }
const NO_EXISTING = { data: null, error: null }
const PERIOD = {
data: { period_start: '2025-01-01', name: 'FY 2025', opening_balance_entry_id: 'ob-1' },
error: null,
}
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
vi.mocked(createJournalEntry).mockResolvedValue(FAKE_ENTRY as never)
})
describe('generateResultAppropriation', () => {
it('posts Dr 2099 / Cr 2098 for a profit (AB)', async () => {
results = [AB, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 100000 }])
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
expect(entry).toEqual(FAKE_ENTRY)
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
source_type: string
entry_date: string
voucher_series: string
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.source_type).toBe('result_appropriation')
expect(input.entry_date).toBe('2025-01-01')
expect(input.voucher_series).toBe('A')
expect(input.lines).toContainEqual(
expect.objectContaining({ account_number: '2099', debit_amount: 100000, credit_amount: 0 })
)
expect(input.lines).toContainEqual(
expect.objectContaining({ account_number: '2098', debit_amount: 0, credit_amount: 100000 })
)
})
it('posts Dr 2098 / Cr 2099 for a loss (AB)', async () => {
results = [AB, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '2099', debit: 40000, credit: 0 }])
await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.lines).toContainEqual(
expect.objectContaining({ account_number: '2098', debit_amount: 40000, credit_amount: 0 })
)
expect(input.lines).toContainEqual(
expect.objectContaining({ account_number: '2099', debit_amount: 0, credit_amount: 40000 })
)
})
it('returns null for a non-aktiebolag (enskild firma) without posting', async () => {
results = [{ data: { entity_type: 'enskild_firma' }, error: null }]
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
expect(entry).toBeNull()
expect(createJournalEntry).not.toHaveBeenCalled()
expect(getOpeningBalances).not.toHaveBeenCalled()
})
it('is idempotent — returns null when an appropriation entry already exists', async () => {
results = [AB, { data: { id: 'ra-existing' }, error: null }]
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
expect(entry).toBeNull()
expect(createJournalEntry).not.toHaveBeenCalled()
expect(getOpeningBalances).not.toHaveBeenCalled()
})
it('returns null when 2099 carries no IB balance', async () => {
results = [AB, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '1930', debit: 5000, credit: 0 }])
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
expect(entry).toBeNull()
expect(createJournalEntry).not.toHaveBeenCalled()
})
it('defaults missing company_settings to aktiebolag and posts', async () => {
results = [NO_EXISTING /* settings missing */, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 5000 }])
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
expect(entry).toEqual(FAKE_ENTRY)
expect(createJournalEntry).toHaveBeenCalledTimes(1)
})
it('reclassifies the IB 2099 amount only — current-year 2099 activity is excluded', async () => {
// getOpeningBalances reads the IB entry (the carried-forward prior result),
// not the trial balance, so any current-year postings to 2099 in this period
// (e.g. when the catch-up script runs mid-year) cannot inflate the omföring.
results = [AB, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 80000 }])
await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
// Exactly the IB amount (80000), regardless of any later 2099 activity.
expect(input.lines).toContainEqual(
expect.objectContaining({ account_number: '2099', debit_amount: 80000, credit_amount: 0 })
)
expect(input.lines).toContainEqual(
expect.objectContaining({ account_number: '2098', debit_amount: 0, credit_amount: 80000 })
)
})
})
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from '@/tests/pg/setup'
import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
import { seedCompany, insertDraftJournalEntry, insertFiscalPeriod } from '@/tests/pg/fixtures'
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
/**
@@ -111,6 +111,103 @@ describe('year-end invariants (pg-real)', () => {
expect(Math.abs(net)).toBeLessThanOrEqual(ORE_TOLERANCE)
})
it('result appropriation omföring zeros the carried-forward 2099 in the NEW period', async () => {
// This mirrors production: the closing entry lands in year N, and the
// omföring (2099 → 2098) is posted in the SEPARATE next period (year N+1)
// dated its first day — NOT back in the closing period. Posting both in one
// period (as a naive test would) hides whether 2099 actually starts the new
// year at zero, which is the whole invariant.
const { userId, companyId, fiscalPeriodId } = await seedCompany()
// Year N (2026): closing posts the result onto 2099 — a balanced 3001 → 2099
// transfer leaving 2099 with a 5000 credit balance as that year's UB.
const closeId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-12-31',
description: 'Årsbokslut',
})
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '3001', 5000.00, 0),
($1, '2099', 0, 5000.00)`,
[closeId],
)
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, closeId])
// Year N+1 (2027): a fresh open period. The omföring belongs here.
const nextPeriodId = await insertFiscalPeriod({
userId,
companyId,
name: '2027',
periodStart: '2027-01-01',
periodEnd: '2027-12-31',
})
// Opening-balance entry mirrors year N's UB into the new period: 2099 is
// carried forward verbatim (1930 IB balances it). This is what leaves 2099
// non-zero at the start of the new year — exactly what the omföring fixes.
const ibId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId: nextPeriodId,
entryDate: '2027-01-01',
description: 'Ingående balans',
sourceType: 'opening_balance',
})
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 5000.00, 0),
($1, '2099', 0, 5000.00)`,
[ibId],
)
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, ibId])
// The year-open omföring: Dr 2099 / Cr 2098, dated the new period's first
// day, posted in the new period. source_type must be accepted by the CHECK
// constraint (see source-type-constraint.pg.test.ts) and the balance trigger
// must pass.
const omforId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId: nextPeriodId,
entryDate: '2027-01-01',
description: 'Omföring av föregående års resultat (2099 → 2098)',
sourceType: 'result_appropriation',
})
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '2099', 5000.00, 0),
($1, '2098', 0, 5000.00)`,
[omforId],
)
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, omforId])
// In the NEW period: 2099 net must be 0 (IB +5000 credit cancelled by the
// omföring's 5000 debit); 2098 must hold the result (credit-normal, so
// debit credit = 5000). Scoping to nextPeriodId is the point — 2099 zeros
// out in the period the result was carried into, not the closing period.
const { rows } = await getPool().query<{ acct: string; net: string }>(
`SELECT l.account_number AS acct,
COALESCE(SUM(l.debit_amount - l.credit_amount), 0) AS net
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = $1
AND je.fiscal_period_id = $2
AND je.status = 'posted'
AND l.account_number IN ('2099', '2098')
GROUP BY l.account_number`,
[companyId, nextPeriodId],
)
const net = Object.fromEntries(rows.map((r) => [r.acct, roundOre(Number(r.net))]))
expect(Math.abs(net['2099'] ?? 0)).toBeLessThanOrEqual(ORE_TOLERANCE)
expect(net['2098']).toBe(-5000)
})
it('rejects a one-öre IB/UB style discrepancy in opening balance lines', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
@@ -0,0 +1,191 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { getOpeningBalances } from '@/lib/reports/opening-balances'
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
import { createLogger } from '@/lib/logger'
import type { JournalEntry, CreateJournalEntryLineInput } from '@/types'
const log = createLogger('result-appropriation-service')
/** Årets resultat (current-year result, aktiebolag). */
export const RESULT_ACCOUNT = '2099'
/** Vinst eller förlust från föregående år. */
export const PRIOR_RESULT_ACCOUNT = '2098'
export interface ResultAppropriationPlan {
periodId: string
periodName: string
/** entry_date for the omföring — the new period's first day. */
periodStart: string
/** Net 2099 balance, credit-positive (a profit is > 0, a loss is < 0). */
net: number
/** Absolute, öre-rounded amount that moves between 2099 and 2098. */
amount: number
direction: 'profit' | 'loss'
/** Balanced lines for the omföring verifikat. */
lines: CreateJournalEntryLineInput[]
}
/**
* Read-only computation of the year-open omföring (no writes). Returns the plan
* to move 2099 "Årets resultat" onto 2098 "Vinst eller förlust från föregående
* år", or null when there is nothing to do.
*
* Returns null when:
* - the company is not an aktiebolag (enskild firma books to 2010, no 2099),
* - the period already has a result_appropriation entry (idempotency), or
* - 2099 carries no balance (within ORE_TOLERANCE).
*
* Shared by generateResultAppropriation (which posts the plan) and the
* retroactive catch-up script (which previews it in dry-run) so the preview
* and the committed entry can never diverge.
*/
export async function planResultAppropriation(
supabase: SupabaseClient,
companyId: string,
periodId: string,
): Promise<ResultAppropriationPlan | null> {
// Aktiebolag only. Same resolution as previewYearEndClosing's closing-account
// decision, so the omföring runs exactly when the result was posted to 2099.
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = settings?.entity_type ?? 'aktiebolag'
if (entityType !== 'aktiebolag') return null
// Idempotency: never plan a second omföring for a period that already has one.
const { data: existing } = await supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', periodId)
.eq('source_type', 'result_appropriation')
.in('status', ['posted', 'reversed'])
.limit(1)
.maybeSingle()
if (existing) return null
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, name, opening_balance_entry_id')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found')
// Read 2099 from the period's INGÅENDE BALANS only — the carried-forward
// prior result that the IB entry mirrored from last year's UB — NOT the full
// trial balance. The omföring must reclassify exactly that carried amount;
// scoping to IB makes it correct even when the period already has current-year
// 2099 activity (e.g. the retroactive catch-up script running mid-year, where
// closing = IB + activity would over/under-reclassify). getOpeningBalances
// reads the committed opening_balance entry, falling back to a server-side
// aggregate of prior posted lines when none is set. credit debit is positive
// for a profit (2099 is credit-normal).
const { balances } = await getOpeningBalances(supabase, companyId, period)
const ib2099 = balances.get(RESULT_ACCOUNT)
const net = ib2099 ? roundOre(ib2099.credit - ib2099.debit) : 0
if (Math.abs(net) < ORE_TOLERANCE) return null
const amount = roundOre(Math.abs(net))
const lines: CreateJournalEntryLineInput[] =
net > 0
? [
// Profit: move the credit balance off 2099 onto 2098.
{
account_number: RESULT_ACCOUNT,
debit_amount: amount,
credit_amount: 0,
line_description: 'Omföring av föregående års resultat',
},
{
account_number: PRIOR_RESULT_ACCOUNT,
debit_amount: 0,
credit_amount: amount,
line_description: 'Föregående års resultat',
},
]
: [
// Loss: move the debit balance off 2099 onto 2098.
{
account_number: PRIOR_RESULT_ACCOUNT,
debit_amount: amount,
credit_amount: 0,
line_description: 'Föregående års resultat',
},
{
account_number: RESULT_ACCOUNT,
debit_amount: 0,
credit_amount: amount,
line_description: 'Omföring av föregående års resultat',
},
]
return {
periodId,
periodName: period.name,
periodStart: period.period_start,
net,
amount,
direction: net > 0 ? 'profit' : 'loss',
lines,
}
}
/**
* Omföring av föregående års resultat — reclassify 2099 at new-year open.
*
* After a new fiscal year's opening balances are generated, account 2099
* "Årets resultat" carries the prior year's result forward (the IB entry is a
* faithful mirror of the prior period's UB). Per BAS practice the prior result
* must not remain on 2099: each year must start with 2099 = 0 so it only ever
* holds the *current* year's result. This posts the year-open reclassification
* as a SEPARATE verifikat in the new period:
*
* profit (2099 has a credit balance): Dr 2099 / Cr 2098
* loss (2099 has a debit balance): Dr 2098 / Cr 2099
*
* It is deliberately NOT folded into the opening-balance entry. The IB entry
* must stay a faithful mirror of the prior UB, or validateBalanceContinuity()
* — which reads IB solely from the period's opening_balance entry — would flag
* 2099 and 2098 as discrepancies and executeYearEndClosing would self-reverse.
* A standalone entry is invisible to that check.
*
* The further disposition 2098 → 2091 (balanserat resultat) / 2898 (utdelning)
* is the bolagsstämma's decision and is intentionally left to a separate step.
*
* Idempotent / AB-only — see planResultAppropriation for the no-op conditions.
* Powers both executeYearEndClosing (steady state) and the retroactive
* catch-up script (clears any accumulated 2099 in a company's open period).
*/
export async function generateResultAppropriation(
supabase: SupabaseClient,
companyId: string,
userId: string,
periodId: string,
): Promise<JournalEntry | null> {
const plan = await planResultAppropriation(supabase, companyId, periodId)
if (!plan) return null
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: periodId,
entry_date: plan.periodStart,
description: `Omföring av föregående års resultat (${RESULT_ACCOUNT}${PRIOR_RESULT_ACCOUNT})`,
source_type: 'result_appropriation',
voucher_series: 'A',
lines: plan.lines,
})
log.info('Posted result appropriation omföring', {
operation: 'result_appropriation.post',
companyId,
entityType: 'journal_entry',
entityId: entry.id,
amount: plan.amount,
direction: plan.direction,
})
return entry
}
+36
View File
@@ -8,6 +8,7 @@ const log = createLogger('year-end-service')
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
import { generateResultAppropriation } from './result-appropriation-service'
import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
@@ -425,6 +426,7 @@ export async function previewYearEndClosing(
* 8. Close the period (irreversible — every guard must run before this)
* 9. Generate opening balances in next period
* 10. Validate IB/UB continuity
* 11. Omföra föregående års resultat (2099 → 2098) in the new period (AB only)
*/
export async function executeYearEndClosing(
supabase: SupabaseClient,
@@ -610,6 +612,38 @@ export async function executeYearEndClosing(
)
}
// 11. Omföra föregående års resultat: move 2099 "Årets resultat" off onto
// 2098 in the new period so it starts the year at zero (aktiebolag only).
// This is a SEPARATE verifikat by design — folding it into the IB entry
// would make the continuity check above fail, since that check reads IB
// solely from the opening_balance entry. Non-fatal: the close and IB are
// already valid and immutable; a failure here is logged and left for the
// retroactive catch-up script (scripts/repair-result-appropriation.ts).
let resultAppropriationEntry: JournalEntry | null = null
let resultAppropriationFailed = false
try {
resultAppropriationEntry = await generateResultAppropriation(
supabase,
companyId,
userId,
nextPeriod.id
)
} catch (err) {
resultAppropriationFailed = true
// alert:true marks this for out-of-band alerting (the log sink / Sentry
// integration filters on it) — a silent accounting failure must not wait
// for a manual audit. The new period now opens with 2099 still carrying the
// prior result; resultAppropriationFailed below drives a UI warning and the
// catch-up script (scripts/repair-result-appropriation.ts) posts the fix.
log.error('year-end: result appropriation omföring failed (non-fatal)', err as Error, {
operation: 'year_end.result_appropriation',
alert: true,
companyId,
entityType: 'fiscal_period',
entityId: nextPeriod.id,
})
}
// Fetch the now-closed period for the event payload
const { data: closedPeriod } = await supabase
.from('fiscal_periods')
@@ -630,6 +664,8 @@ export async function executeYearEndClosing(
nextPeriod,
openingBalanceEntry,
revaluationEntry: revaluationResult?.entry ?? null,
resultAppropriationEntry,
resultAppropriationFailed,
continuity,
}
}
@@ -5,6 +5,7 @@ import {
contentBucketKey,
descriptionsBridge,
normalizeImportedDescription,
shiftIsoDate,
FALLBACK_DESCRIPTION,
} from '../external-id'
@@ -175,3 +176,24 @@ describe('normalizeImportedDescription', () => {
expect(normalizeImportedDescription('Unknown Pizza AB')).toBe('Unknown Pizza AB')
})
})
describe('shiftIsoDate', () => {
it('shifts a date forward and backward by whole days', () => {
expect(shiftIsoDate('2024-06-15', 1)).toBe('2024-06-16')
expect(shiftIsoDate('2024-06-15', -1)).toBe('2024-06-14')
expect(shiftIsoDate('2024-06-15', 0)).toBe('2024-06-15')
})
it('crosses month and year boundaries', () => {
expect(shiftIsoDate('2024-06-30', 1)).toBe('2024-07-01')
expect(shiftIsoDate('2024-07-01', -1)).toBe('2024-06-30')
expect(shiftIsoDate('2025-12-31', 1)).toBe('2026-01-01')
expect(shiftIsoDate('2026-01-01', -1)).toBe('2025-12-31')
})
it('handles the leap day deterministically (no wall-clock dependency)', () => {
expect(shiftIsoDate('2024-02-28', 1)).toBe('2024-02-29') // 2024 is a leap year
expect(shiftIsoDate('2024-03-01', -1)).toBe('2024-02-29')
expect(shiftIsoDate('2025-02-28', 1)).toBe('2025-03-01') // 2025 is not
})
})
+233 -2
View File
@@ -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,
})
})
+19
View File
@@ -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)
}
+99 -1
View File
@@ -7,7 +7,7 @@ import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matchi
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription } from '@/lib/transactions/external-id'
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription, shiftIsoDate } from '@/lib/transactions/external-id'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { createLogger } from '@/lib/logger'
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
@@ -206,6 +206,7 @@ export async function ingestTransactions(
errors: 0,
transaction_ids: [],
shadow_scope_drift_candidates: 0,
shadow_date_drift_candidates: 0,
}
const log = createLogger('transactions.ingest', { companyId })
@@ -218,6 +219,15 @@ export async function ingestTransactions(
// DEDUP_SCOPE_DRIFT_MODE=off to silence. There is deliberately NO 'enforce'
// branch yet: we validate on real fleet data first (see the plan).
const scopeDriftShadow = process.env.DEDUP_SCOPE_DRIFT_MODE !== 'off'
// SHADOW-ONLY instrumentation for the date-drift bridge: the content bridge
// buckets on EXACT (date, öre), so a booking date that drifts a day between
// syncs lands its twin in an ADJACENT bucket and every dedup layer misses it
// (this produced the observed EB↔EB and CSV↔EB 1-day-apart duplicates). When
// on, we LOG + COUNT which surviving rows a ±1-day-tolerant rule WOULD treat
// as re-imports, but never change what is inserted. Default on; set
// DEDUP_DATE_DRIFT_MODE=off to silence. No 'enforce' branch — same as
// scope-drift, we validate on real fleet data first.
const dateDriftShadow = process.env.DEDUP_DATE_DRIFT_MODE !== 'off'
// Pre-fetch existing transactions for content-based dedup (date+amount+
// description prefix, plus the cross-channel mirror below). Booked rows catch
@@ -397,6 +407,26 @@ export async function ingestTransactions(
}
}
// ── Shadow-mode date-drift precompute (measure only) ─────────────────────
// The content bridge matches only the EXACT (date, öre) bucket, so a twin
// whose booking date drifted a day is invisible to it. Snapshot the stored
// buckets BEFORE the dedup loop — a COPY of each bucket's entries, so Layer-2's
// splices don't mutate what the shadow reads — so each surviving row can look
// one day to either side for an account-compatible twin without disturbing
// real dedup. Window is ±1 day (the only gap observed); a named constant so
// widening to ±2 is one line if fleet data shows it.
const DATE_DRIFT_WINDOW_DAYS = 1
const storedByBucketForDrift = new Map<string, BucketEntry[]>()
if (batchIsImportFeed && dateDriftShadow) {
for (const bucket of [existingMaps.booked, existingMaps.unbookedImported]) {
for (const [k, entries] of bucket) {
const snapshot = storedByBucketForDrift.get(k)
if (snapshot) snapshot.push(...entries)
else storedByBucketForDrift.set(k, [...entries])
}
}
}
// Track already-matched invoice IDs within this ingestion batch
// to prevent suggesting the same invoice for multiple transactions
const matchedInvoiceIds = new Set<string>()
@@ -539,6 +569,74 @@ export async function ingestTransactions(
}
}
// SHADOW-ONLY: date-drift. This row survived Layer-1 + Layer-2 and WILL
// insert. The content bridge only matched its EXACT (date, öre) bucket, so a
// twin whose booking date drifted a day is invisible to it. Look ±1 day for
// an account-compatible stored twin that EITHER bridges by description
// (same/enriched title — the EB↔EB hotel/fee case) OR is a cross-feed
// count-symmetric mirror displaced by a day (the CSV↔EB case where the
// descriptions don't bridge). Record it for fleet validation, then insert
// unchanged — this block never affects result.imported/duplicates.
//
// Fail-safe date guard: measurement must NEVER abort a real import. raw.date
// is always ISO in practice, but a malformed value would make shiftIsoDate
// throw (new Date(NaN).toISOString()), so we skip detection rather than risk
// it. Any /^\d{4}-\d{2}-\d{2}$/ value is safe — Date.UTC normalizes
// out-of-range parts to a finite epoch, never NaN.
if (dateDriftShadow && batchIsImportFeed && /^\d{4}-\d{2}-\d{2}$/.test(raw.date)) {
let driftMatch: { entry: BucketEntry; gap: number; signal: 'desc' | 'cross-channel' } | undefined
const incomingHere = incomingByBucket.get(bucketKey) ?? 0
for (let delta = 1; delta <= DATE_DRIFT_WINDOW_DAYS && !driftMatch; delta++) {
for (const sign of [-1, 1] as const) {
const adjKey = contentBucketKey(shiftIsoDate(raw.date, sign * delta), raw.amount)
const entries = storedByBucketForDrift.get(adjKey)
if (!entries) continue
// Cross-feed count-symmetry across the drift: equal counts of incoming
// rows in THIS bucket and account-compatible cross-feed rows one day
// over — the cross-channel mirror, displaced by a date drift.
const adjCrossFeed = entries.filter(
(e) =>
e.isImportFeed &&
e.source !== batchSource &&
(cashAccountId === null || e.cashAccountId === null || e.cashAccountId === cashAccountId),
).length
const mirrorSymmetric = adjCrossFeed > 0 && incomingHere === adjCrossFeed
for (const entry of entries) {
const sameAccount =
cashAccountId === null || entry.cashAccountId === null || entry.cashAccountId === cashAccountId
if (!sameAccount) continue
if (descriptionsBridge(description, entry.desc)) {
driftMatch = { entry, gap: sign * delta, signal: 'desc' }
break
}
if (mirrorSymmetric && entry.isImportFeed && entry.source !== batchSource) {
driftMatch = { entry, gap: sign * delta, signal: 'cross-channel' }
break
}
}
if (driftMatch) break
}
}
if (driftMatch) {
result.shadow_date_drift_candidates = (result.shadow_date_drift_candidates ?? 0) + 1
log.info('import dedup shadow: date-drift candidate', {
decision: 'date-drift',
mode: 'shadow',
signal: driftMatch.signal,
dayGap: driftMatch.gap,
bucket: bucketKey,
incomingExternalId: raw.external_id,
incomingDescription: description,
incomingAmount: raw.amount,
incomingSource: raw.import_source ?? null,
cashAccountId,
matchedStoredExternalId: driftMatch.entry.externalId,
matchedStoredDescription: driftMatch.entry.desc,
matchedStoredCashAccountId: driftMatch.entry.cashAccountId,
})
}
}
// 2. Insert new transaction (with SEK conversion for foreign currencies)
const rateInfo = raw.currency && raw.currency !== 'SEK'
? exchangeRatesByDate.get(`${raw.currency}|${raw.date}`)
+236
View File
@@ -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 <uuid>
*
* # Apply (post the omföring entries), attributing to a specific user
* npx tsx scripts/repair-result-appropriation.ts --commit --user-id <uuid>
*
* 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<string[]> {
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<string | null> {
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 <uuid> 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)
})
@@ -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';
+27
View File
@@ -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) ──