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