Fix/fdb fr usrs (#1125)
* fix(invoices): return attachment filename in delivery history summaries The 20260723003000 hardening dropped attachment_filename from list_invoice_delivery_summaries, so the delivery history UI always fell back to the generic "faktura.pdf" label. Recreate the RPC with the filename included: it is derived from company name, customer name, invoice number, and date, all already visible to every company member, so the minimization boundary is unchanged. Addresses stay masked and message content, BCC, and checksums stay server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): surface own-account transfer legs in match-to-voucher by default The second (incoming) leg of a transfer between two of the company's own bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog because the voucher counted as 'already matched' once its outgoing leg was linked, even though the incoming account's line had no settling transaction. Users read the empty default list as 'the app won't let me link this'. get_account_gl_lines_for_matching now counts links per settlement account: a transaction provably on another cash account no longer marks the voucher as matched for the requested account, so the unsettled transfer leg surfaces by default (and auto-selects on an exact match). Same-account N:1 stays behind the 'Visa aven matchade verifikationer' opt-in, and transactions without a resolvable cash account conservatively keep counting everywhere. get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile). Companion guard: mark_entry_as_opening_balance now refuses entries with linked bank transactions, since half-settled transfer vouchers became reachable in the reconciliation view's unmatched table where 'Mark som IB' renders; re-tagging one would strand its transaction against a movement- excluded entry. getReconciliationStatus counts unmatched GL lines with the account-scoped RPC so the status card agrees with the table. Fixes #1026 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of requests over 300ms. Target: p95 under 300ms. - requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of a second network getUser per request; getUser fallback keeps HS256 self-hosted and existing test mocks working; middleware still revocation-checks every /api request - resolve_active_company RPC (20260723161000): one round trip replaces 2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall back to the legacy query path - arsredovisning build-data: ~33 sequential round trips down to ~7, output byte-identical (snapshot-proven) - currency rate route: stop bypassing the exchange_rates cache (missing supabase arg caused an external Riksbanken call on every request) - document.get: parallelize row fetch, signed URL and audit event - list_company_accounts RPC (20260723170000): accounts list in one round trip instead of paging past PostgREST's 1000-row cap - vat-declaration route: drop a dead sequential company_settings query - get_kpi_report_aggregates RPC (20260723180000): KPI report's three full-period line scans collapsed into one aggregate call; dimension- filtered path unchanged - lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to warn, zero the eslint baseline ratchet All four gates green: lint 0 errors, 9163 tests, check:guards, build. Migrations applied idempotently to staging only; prod receives them via Supabase branching on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): resolve PR review findings across auth, VAT declaration, and IB retag - requireAuth getClaims fast path: pin iss (project URL) and aud ('authenticated'), log every fallback to getUser (ASVS V9.1 finding) - remove the ignored accountingMethod parameter from calculateVatDeclaration and the dead company_settings.accounting_method reads in xlsx/pdf/eskd routes; v1 API keeps accepting the query param but documents it as a no-op - close the mark_entry_as_opening_balance TOCTOU race with a transactions trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests; applied to staging and smoke-verified both directions - re-add the 42501 tenant guard to branch-local migration 20260723160000 (function body had silently reverted to the pre-20260619130100 definition) - document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt opening balance per BFNAR 2012:1 ch.29) - add KPI VAT-liability test covering reduced-rate output accounts 2621/2631 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard The re-added tenant guard carried the pre-20260703180000 raw NOT IN (SELECT user_company_ids()) pattern, which the null-safe-tenant-guards ratchet blocks. Staging re-synced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
43f7ccab9e
commit
288915c152
@@ -1,9 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createTestLogger } from '../logger'
|
||||
|
||||
// The sink element type is the (unexported) LogRecord from lib/logger.ts,
|
||||
// recovered via Parameters<> so the test stays in sync with the real signature.
|
||||
type Sink = Parameters<typeof createTestLogger>[1]
|
||||
|
||||
describe('logger', () => {
|
||||
it('emits records with module + msg + level + ts', () => {
|
||||
const sink: any[] = []
|
||||
const sink: Sink = []
|
||||
const log = createTestLogger('test/module', sink)
|
||||
log.info('hello')
|
||||
|
||||
@@ -17,14 +21,14 @@ describe('logger', () => {
|
||||
})
|
||||
|
||||
it('merges base context into every record', () => {
|
||||
const sink: any[] = []
|
||||
const sink: Sink = []
|
||||
const log = createTestLogger('m', sink, { requestId: 'req_1' })
|
||||
log.info('hi')
|
||||
expect(sink[0].requestId).toBe('req_1')
|
||||
})
|
||||
|
||||
it('child() returns a logger that merges extra context', () => {
|
||||
const sink: any[] = []
|
||||
const sink: Sink = []
|
||||
const log = createTestLogger('m', sink, { requestId: 'req_1' })
|
||||
const child = log.child({ companyId: 'co_1', userId: 'u_1' })
|
||||
child.warn('oops')
|
||||
@@ -36,31 +40,35 @@ describe('logger', () => {
|
||||
})
|
||||
|
||||
it('treats Error args as the err field with name/message/code', () => {
|
||||
const sink: any[] = []
|
||||
const sink: Sink = []
|
||||
const log = createTestLogger('m', sink)
|
||||
const err = new Error('boom')
|
||||
;(err as any).code = '23505'
|
||||
;(err as Error & { code?: string }).code = '23505'
|
||||
log.error('insert failed', err)
|
||||
|
||||
expect(sink[0].err).toMatchObject({ name: 'Error', message: 'boom', code: '23505' })
|
||||
})
|
||||
|
||||
it('merges plain-object args into context', () => {
|
||||
const sink: any[] = []
|
||||
const sink: Sink = []
|
||||
const log = createTestLogger('m', sink)
|
||||
log.info('done', { durationMs: 42, status: 200 })
|
||||
expect(sink[0]).toMatchObject({ durationMs: 42, status: 200 })
|
||||
})
|
||||
|
||||
it('redacts sensitive keys recursively', () => {
|
||||
const sink: any[] = []
|
||||
const sink: Sink = []
|
||||
const log = createTestLogger('m', sink)
|
||||
log.info('login', {
|
||||
user: 'alice',
|
||||
headers: { authorization: 'Bearer secret', cookie: 'sess=xxx' },
|
||||
payload: { password: 'hunter2', token: 'tok' },
|
||||
})
|
||||
const rec = sink[0]
|
||||
const rec = sink[0] as {
|
||||
user: string
|
||||
headers: { authorization: string; cookie: string }
|
||||
payload: { password: string; token: string }
|
||||
}
|
||||
expect(rec.headers.authorization).toBe('[REDACTED]')
|
||||
expect(rec.headers.cookie).toBe('[REDACTED]')
|
||||
expect(rec.payload.password).toBe('[REDACTED]')
|
||||
@@ -69,7 +77,7 @@ describe('logger', () => {
|
||||
})
|
||||
|
||||
it('redacts personnummer-shaped strings while preserving UUIDs', () => {
|
||||
const sink: any[] = []
|
||||
const sink: Sink = []
|
||||
const log = createTestLogger('m', sink)
|
||||
log.info('processing for 800101-1234', { uuid: '57484518-3409-4b29-9d23-5d22f08bda63' })
|
||||
expect(sink[0].msg).toBe('[REDACTED]')
|
||||
@@ -77,7 +85,7 @@ describe('logger', () => {
|
||||
})
|
||||
|
||||
it('routes non-object, non-Error args into details', () => {
|
||||
const sink: any[] = []
|
||||
const sink: Sink = []
|
||||
const log = createTestLogger('m', sink)
|
||||
log.warn('legacy', 'string arg', 42)
|
||||
expect(sink[0].details).toEqual(['string arg', 42])
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
import { requireAuth } from '../require-auth'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
const CLAIMS = {
|
||||
iss: 'https://test.supabase.co/auth/v1',
|
||||
sub: 'user-1',
|
||||
aud: 'authenticated',
|
||||
exp: 9999999999,
|
||||
iat: 0,
|
||||
role: 'authenticated',
|
||||
aal: 'aal1',
|
||||
session_id: 'sess-1',
|
||||
email: 'test@test.se',
|
||||
is_anonymous: false,
|
||||
app_metadata: { provider: 'email' },
|
||||
user_metadata: {},
|
||||
}
|
||||
|
||||
const MOCK_USER = {
|
||||
id: 'user-1',
|
||||
aud: 'authenticated',
|
||||
email: 'test@test.se',
|
||||
app_metadata: { provider: 'email' },
|
||||
user_metadata: {},
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
type MockAuth = Record<string, unknown>
|
||||
|
||||
function useSupabase(auth: MockAuth) {
|
||||
const supabase = { auth }
|
||||
vi.mocked(createClient).mockResolvedValue(supabase as never)
|
||||
return supabase
|
||||
}
|
||||
|
||||
describe('requireAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Deterministic baseline: MFA off unless a test stubs it on.
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'false')
|
||||
// Matches CLAIMS.iss so the fast path passes the issuer pinning.
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://test.supabase.co')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('uses locally verified claims without calling getUser (fast path)', async () => {
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const getUser = vi.fn()
|
||||
useSupabase({ getClaims, getUser })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(result.user?.email).toBe('test@test.se')
|
||||
expect(result.user?.app_metadata).toEqual({ provider: 'email' })
|
||||
expect(getUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to getUser when the client has no getClaims (legacy mocks)', async () => {
|
||||
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
|
||||
useSupabase({ getUser })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(getUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns 401 when neither claims nor getUser yield a user', async () => {
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: null, error: null })
|
||||
const getUser = vi.fn().mockResolvedValue({ data: { user: null }, error: null })
|
||||
useSupabase({ getClaims, getUser })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.user).toBeNull()
|
||||
expect(result.error?.status).toBe(401)
|
||||
const body = await result.error?.json()
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
expect(getUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to getUser when getClaims throws (JWKS outage)', async () => {
|
||||
const getClaims = vi.fn().mockRejectedValue(new Error('jwks fetch failed'))
|
||||
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
|
||||
useSupabase({ getClaims, getUser })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(getUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to getUser when the claims issuer does not match the project URL', async () => {
|
||||
const claims = { ...CLAIMS, iss: 'https://evil.example.com/auth/v1' }
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
|
||||
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
|
||||
useSupabase({ getClaims, getUser })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(getUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to getUser when the claims audience is not authenticated', async () => {
|
||||
const claims = { ...CLAIMS, aud: 'something-else' }
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
|
||||
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
|
||||
useSupabase({ getClaims, getUser })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(getUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts an array audience containing authenticated', async () => {
|
||||
const claims = { ...CLAIMS, aud: ['authenticated', 'other'] }
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
|
||||
const getUser = vi.fn()
|
||||
useSupabase({ getClaims, getUser })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(getUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 when MFA is required and AAL2 is not verified', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
|
||||
data: { currentLevel: 'aal1', nextLevel: 'aal2' },
|
||||
error: null,
|
||||
})
|
||||
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.user).toBeNull()
|
||||
expect(result.error?.status).toBe(403)
|
||||
const body = await result.error?.json()
|
||||
expect(body).toEqual({ error: 'MFA verification required' })
|
||||
})
|
||||
|
||||
it('skips the MFA check for bankid_linked users', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
|
||||
const claims = { ...CLAIMS, app_metadata: { provider: 'email', bankid_linked: true } }
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
|
||||
const getAuthenticatorAssuranceLevel = vi.fn()
|
||||
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(getAuthenticatorAssuranceLevel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes when MFA is required and the session is already AAL2', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
|
||||
data: { currentLevel: 'aal2', nextLevel: 'aal2' },
|
||||
error: null,
|
||||
})
|
||||
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(getAuthenticatorAssuranceLevel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,98 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { shouldEnforceMfa } from './mfa'
|
||||
import type { User, SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { User, SupabaseClient, JwtPayload } from '@supabase/supabase-js'
|
||||
|
||||
type AuthResult =
|
||||
| { user: User; supabase: SupabaseClient; error: null }
|
||||
| { user: null; supabase: SupabaseClient; error: NextResponse }
|
||||
|
||||
/**
|
||||
* Maps verified JWT claims onto the User subset routes actually consume
|
||||
* (id, email, is_anonymous, app_metadata, user_metadata, role, phone).
|
||||
*
|
||||
* Server-only fields (identities, factors, created_at timestamps) are absent
|
||||
* from the token and verified unused by any route (2026-07-23 audit);
|
||||
* created_at is set to '' only to satisfy the type.
|
||||
*/
|
||||
/**
|
||||
* Defense-in-depth pinning on top of getClaims' signature/expiry verification:
|
||||
* the token must come from THIS project's auth server (iss) and be an
|
||||
* end-user access token (aud 'authenticated'; anonymous sign-ins share it).
|
||||
* A mismatch is not treated as unauthenticated: we fall back to the
|
||||
* server-side getUser() check, which is authoritative.
|
||||
*/
|
||||
function claimsPinned(claims: JwtPayload): boolean {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/+$/, '')
|
||||
// Without a configured URL (unit tests) there is nothing to pin against.
|
||||
const issOk = !supabaseUrl || claims.iss === `${supabaseUrl}/auth/v1`
|
||||
const aud = claims.aud
|
||||
const audOk = Array.isArray(aud) ? aud.includes('authenticated') : aud === 'authenticated'
|
||||
return issOk && audOk
|
||||
}
|
||||
|
||||
function userFromClaims(claims: JwtPayload): User {
|
||||
return {
|
||||
id: claims.sub,
|
||||
aud: Array.isArray(claims.aud) ? (claims.aud[0] ?? 'authenticated') : (claims.aud ?? 'authenticated'),
|
||||
role: claims.role,
|
||||
email: claims.email,
|
||||
phone: claims.phone,
|
||||
app_metadata: claims.app_metadata ?? {},
|
||||
user_metadata: claims.user_metadata ?? {},
|
||||
is_anonymous: claims.is_anonymous ?? false,
|
||||
created_at: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth + MFA guard for API routes.
|
||||
*
|
||||
* Returns the authenticated user and Supabase client, or a JSON error response.
|
||||
* When MFA is required (hosted deployment), verifies AAL2 assurance level.
|
||||
*
|
||||
* Fast path: getClaims() performs local WebCrypto verification against the
|
||||
* shared 10-minute JWKS cache instead of a per-request network getUser()
|
||||
* round trip. HS256/self-hosted projects fall back to a server call inside
|
||||
* getClaims itself (identical semantics; NEXT_PUBLIC_SELF_HOSTED needs no
|
||||
* special-casing). Revocation is still checked on every request by proxy.ts
|
||||
* middleware getUser() before any route runs. Claims-sourced metadata
|
||||
* (email, app_metadata, is_anonymous) can be up to one access-token TTL
|
||||
* stale, which is acceptable for all current consumers: bankid_linked
|
||||
* staleness is covered because the middleware MFA gate
|
||||
* (lib/supabase/middleware.ts) uses the FRESH getUser result.
|
||||
*/
|
||||
export async function requireAuth(): Promise<AuthResult> {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
let user: User | null = null
|
||||
try {
|
||||
// The typeof guard keeps legacy test mocks (auth object with only
|
||||
// getUser) on the old path.
|
||||
if (typeof supabase.auth.getClaims === 'function') {
|
||||
const { data } = await supabase.auth.getClaims()
|
||||
const claims = data?.claims
|
||||
if (claims?.sub) {
|
||||
if (claimsPinned(claims)) {
|
||||
user = userFromClaims(claims)
|
||||
} else {
|
||||
console.error('requireAuth: getClaims iss/aud pinning failed; falling back to getUser', {
|
||||
iss: claims.iss,
|
||||
aud: claims.aud,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// JWKS outage or malformed token: fall through to the server-side check.
|
||||
// Logged because every hit here degrades the request to the slower
|
||||
// getUser round trip; a spike must be visible in production.
|
||||
console.error('requireAuth: getClaims failed; falling back to getUser', err)
|
||||
}
|
||||
if (!user) {
|
||||
const { data } = await supabase.auth.getUser()
|
||||
user = data?.user ?? null
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"flerarsoversikt": [
|
||||
{
|
||||
"year": "2022",
|
||||
"net_revenue": 0,
|
||||
"result_after_financial": 0,
|
||||
"soliditet_pct": null
|
||||
},
|
||||
{
|
||||
"year": "2023",
|
||||
"net_revenue": 0,
|
||||
"result_after_financial": 0,
|
||||
"soliditet_pct": null
|
||||
},
|
||||
{
|
||||
"year": "2024",
|
||||
"net_revenue": 0,
|
||||
"result_after_financial": 0,
|
||||
"soliditet_pct": null
|
||||
},
|
||||
{
|
||||
"year": "2025",
|
||||
"net_revenue": 0,
|
||||
"result_after_financial": 0,
|
||||
"soliditet_pct": null
|
||||
}
|
||||
],
|
||||
"noter": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Redovisnings- och värderingsprinciper",
|
||||
"body": "Årsredovisningen är upprättad i enlighet med Årsredovisningslagen (1995:1554) och Bokföringsnämndens allmänna råd BFNAR 2012:1 Årsredovisning och koncernredovisning (K3).\n\nVärderingsprinciper: Tillgångar och skulder värderas till anskaffningsvärde om inget annat anges. Materiella anläggningstillgångar redovisas till anskaffningsvärde med avdrag för ackumulerade avskrivningar och eventuella nedskrivningar. Avskrivning sker linjärt över tillgångens bedömda nyttjandeperiod.\n\nUppskjuten skatt: Uppskjuten skatt redovisas enligt balansräkningsmetoden för temporära skillnader mellan redovisade och skattemässiga värden på tillgångar och skulder. Uppskjuten skatt värderas till nominellt belopp utan diskontering och beräknas utifrån den skattesats som är beslutad på balansdagen.\n\nIntäktsredovisning: Intäkter redovisas till det verkliga värdet av det som erhållits eller kommer att erhållas och redovisas när väsentliga risker och förmåner har överförts till köparen, beloppet kan mätas tillförlitligt och det är sannolikt att de ekonomiska fördelarna tillfaller företaget.\n\nLeasing: Leasingavtal klassificeras som finansiell eller operationell leasing. Operationella leasingavgifter redovisas linjärt i resultaträkningen under leasingperioden. Finansiella leasingavtal redovisas som anläggningstillgång med motsvarande skuld i balansräkningen.\n\nFinansiella instrument: Finansiella instrument redovisas initialt till anskaffningsvärde inklusive transaktionskostnader. Kundfordringar värderas till det belopp som beräknas inflyta. Övriga finansiella tillgångar och skulder redovisas till upplupet anskaffningsvärde."
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"title": "Uppskjutna skatter",
|
||||
"body": "Uppskjuten skatteskuld avser i huvudsak temporära skillnader på obeskattade reserver (periodiseringsfonder och överavskrivningar), beräknad med skattesatsen 20,6 %.\n\nIngående saldo (2240): 50 000 kr\nÅrets förändring (8940): 20 600 kr\nUtgående saldo (2240): 70 600 kr"
|
||||
},
|
||||
{
|
||||
"number": 3,
|
||||
"title": "Medelantal anställda",
|
||||
"body": "Bolaget har inte haft några anställda under räkenskapsåret."
|
||||
},
|
||||
{
|
||||
"number": 4,
|
||||
"title": "Långfristiga skulder",
|
||||
"body": "Inga skulder förfaller till betalning senare än fem år efter balansdagen."
|
||||
},
|
||||
{
|
||||
"number": 5,
|
||||
"title": "Eventualförpliktelser",
|
||||
"body": "Inga."
|
||||
},
|
||||
{
|
||||
"number": 6,
|
||||
"title": "Ställda säkerheter",
|
||||
"body": "Inga."
|
||||
},
|
||||
{
|
||||
"number": 7,
|
||||
"title": "Väsentliga händelser efter balansdagen",
|
||||
"body": "Inga väsentliga händelser har inträffat efter räkenskapsårets utgång som påverkar bedömningen av företagets ställning och resultat."
|
||||
}
|
||||
],
|
||||
"warnings": [
|
||||
"Årets resultat enligt resultaträkningen (-20600 kr) stämmer inte med konto 2099 (0 kr). Kontrollera att bokslutet är genomfört (resultatdisposition bokad).",
|
||||
"Balansräkningen balanserar inte: Summa tillgångar 0 kr ≠ Summa eget kapital och skulder 70600 kr (kontrollera-kod 3005).",
|
||||
"Aktiekapitalnoten saknas eftersom uppgifter om aktiekapital inte finns i Inställningar → Företag. K3 / ÅRL kräver att noten innehåller registrerat belopp innan inlämning till Bolagsverket.",
|
||||
"Bolaget redovisar enligt K3 (BFNAR 2012:1). Soliditeten är beräknad med 79,4 % av obeskattade reserver inräknat i eget kapital. PDF:en innehåller kassaflödesanalys, förändring av eget kapital och utökade noter: granska innehållet mot er specifika redovisning innan inlämning.",
|
||||
"Datum för årsstämma saknas. Fastställelseintyget i PDF:en lämnas tomt på datumraden tills det fylls i nedan."
|
||||
]
|
||||
}
|
||||
@@ -37,6 +37,9 @@ import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
|
||||
import { listAssets } from '@/lib/bokslut/assets/asset-service'
|
||||
// Captured from the sequential (pre-dedupe) implementation: the parallel
|
||||
// TB-pair fetch must reproduce it byte for byte.
|
||||
import multiYearSnapshot from './arsredovisning-k3-multiyear-snapshot.json'
|
||||
|
||||
interface ChainableMock {
|
||||
from: ReturnType<typeof vi.fn>
|
||||
@@ -48,6 +51,7 @@ function makeSupabase(opts: {
|
||||
aktiekapital?: number | null
|
||||
antalAktier?: number | null
|
||||
agmDate?: string | null
|
||||
previousPeriodId?: string | null
|
||||
}): ChainableMock {
|
||||
const from = vi.fn((table: string) => {
|
||||
if (table === 'fiscal_periods') {
|
||||
@@ -62,7 +66,7 @@ function makeSupabase(opts: {
|
||||
name: '2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
previous_period_id: null,
|
||||
previous_period_id: opts.previousPeriodId ?? null,
|
||||
closing_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
@@ -486,3 +490,53 @@ describe('buildArsredovisningData: K2 byte-equivalence', () => {
|
||||
expect(mockedKassaflode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildArsredovisningData: prior-period TB dedupe (multi-year)', () => {
|
||||
// Current period + previous year + 2 older years: exercises both the
|
||||
// comparative pair and the full flerårsöversikt window at once.
|
||||
const FOUR_PERIODS = [
|
||||
{ id: 'fp1', name: '2025', period_start: '2025-01-01', period_end: '2025-12-31' },
|
||||
{ id: 'fp0', name: '2024', period_start: '2024-01-01', period_end: '2024-12-31' },
|
||||
{ id: 'fpA', name: '2023', period_start: '2023-01-01', period_end: '2023-12-31' },
|
||||
{ id: 'fpB', name: '2022', period_start: '2022-01-01', period_end: '2022-12-31' },
|
||||
]
|
||||
|
||||
it('fetches each prior-period TB pair exactly once (previous year is no longer fetched twice)', async () => {
|
||||
mockFetchAllRows.mockResolvedValue(FOUR_PERIODS)
|
||||
const supabase = makeSupabase({ accountingFramework: 'k2', previousPeriodId: 'fp0' })
|
||||
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
|
||||
await buildArsredovisningData(supabase, 'co1', 'fp1')
|
||||
|
||||
const callsFor = (periodId: string) =>
|
||||
mockedTrialBalance.mock.calls.filter((call) => call[2] === periodId)
|
||||
// Current period: full + statutory pre-closing from the statement batch.
|
||||
expect(callsFor('fp1')).toHaveLength(2)
|
||||
// Previous year: ONE pair, shared by the comparatives and the overview
|
||||
// (the sequential version fetched it twice: 4 calls).
|
||||
expect(callsFor('fp0')).toHaveLength(2)
|
||||
// Each older overview year: one pair.
|
||||
expect(callsFor('fpA')).toHaveLength(2)
|
||||
expect(callsFor('fpB')).toHaveLength(2)
|
||||
expect(mockedTrialBalance).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
|
||||
it('K3: drops the duplicate noter TB fetch and keeps output byte-identical', async () => {
|
||||
mockFetchAllRows.mockResolvedValue(FOUR_PERIODS)
|
||||
const supabase = makeSupabase({ accountingFramework: 'k3', previousPeriodId: 'fp0' })
|
||||
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
|
||||
const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
|
||||
|
||||
// buildK3Noter used to fetch the current-period full TB a third time;
|
||||
// it now reuses the statement batch's rows.
|
||||
const currentPeriodCalls = mockedTrialBalance.mock.calls.filter((call) => call[2] === 'fp1')
|
||||
expect(currentPeriodCalls).toHaveLength(2)
|
||||
|
||||
// Deep-equality against the output captured from the sequential
|
||||
// implementation: same rows, same note numbering, same warning order.
|
||||
expect(data.forvaltningsberattelse.flerarsoversikt).toEqual(
|
||||
multiYearSnapshot.flerarsoversikt,
|
||||
)
|
||||
expect(data.noter).toEqual(multiYearSnapshot.noter)
|
||||
expect(data.warnings).toEqual(multiYearSnapshot.warnings)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
NoteEntry,
|
||||
KassaflodesAnalysisSummary,
|
||||
} from './types'
|
||||
import type { AccountingFramework, Asset } from '@/types'
|
||||
import type { AccountingFramework, Asset, TrialBalanceRow } from '@/types'
|
||||
|
||||
/**
|
||||
* Pre-populate the K2 årsredovisning data for a fiscal period. Loads:
|
||||
@@ -129,24 +129,51 @@ export async function buildArsredovisningData(
|
||||
const prevPeriodRow = period.previous_period_id
|
||||
? ((periodList ?? []) as PeriodRow[]).find((p) => p.id === period.previous_period_id) ?? null
|
||||
: null
|
||||
let previousTb: TrialBalancePair | null = null
|
||||
if (prevPeriodRow) {
|
||||
try {
|
||||
const [prevFull, prevPreClosing] = await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, prevPeriodRow.id),
|
||||
// Comparative RR figures need the same statutory view as the current
|
||||
// year: keep booked depreciation, appropriations, and tax, excluding
|
||||
// only the linked final result-closing entry.
|
||||
generateTrialBalance(supabase, companyId, prevPeriodRow.id, {
|
||||
excludeFinalClosingEntry: true,
|
||||
}),
|
||||
])
|
||||
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
|
||||
} catch {
|
||||
statementWarnings.push(
|
||||
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår, balans- och resultaträkningen visas utan jämförelseår. Kontrollera det föregående årets bokföring.',
|
||||
)
|
||||
}
|
||||
|
||||
// Flerårsöversikt window: the current period + up to 3 prior (oldest
|
||||
// first). Resolved here so the prior-period trial balances it needs can
|
||||
// share one parallel wave with the comparative-year pair instead of being
|
||||
// fetched sequentially (and, for the previous year, twice).
|
||||
const sortedPeriods = [...((periodList ?? []) as PeriodRow[])].sort((a, b) =>
|
||||
a.period_start.localeCompare(b.period_start),
|
||||
)
|
||||
const currentIdx = sortedPeriods.findIndex((p) => p.id === fiscalPeriodId)
|
||||
const overviewSlice =
|
||||
currentIdx === -1 ? [] : sortedPeriods.slice(Math.max(0, currentIdx - 3), currentIdx + 1)
|
||||
|
||||
// Every prior period needed by the comparatives and/or the flerårsöversikt
|
||||
// gets its TB pair fetched exactly once. Comparative RR figures need the
|
||||
// same statutory view as the current year: keep booked depreciation,
|
||||
// appropriations, and tax, excluding only the linked final result-closing
|
||||
// entry. A failed pair downgrades to null so a broken prior year (e.g. a
|
||||
// partial SIE import without IB continuity) never blocks the document.
|
||||
const tbTargets = new Map<string, PeriodRow>()
|
||||
if (prevPeriodRow) tbTargets.set(prevPeriodRow.id, prevPeriodRow)
|
||||
for (const p of overviewSlice) {
|
||||
if (p.id !== fiscalPeriodId) tbTargets.set(p.id, p)
|
||||
}
|
||||
const tbPairs = new Map<string, TrialBalancePair | null>()
|
||||
await Promise.all(
|
||||
[...tbTargets.values()].map(async (p) => {
|
||||
try {
|
||||
const [full, preClosing] = await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, p.id),
|
||||
generateTrialBalance(supabase, companyId, p.id, { excludeFinalClosingEntry: true }),
|
||||
])
|
||||
tbPairs.set(p.id, { full: full.rows, preClosing: preClosing.rows })
|
||||
} catch {
|
||||
tbPairs.set(p.id, null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Previous fiscal year comparison (jämförelsesiffror): a TB failure
|
||||
// downgrades to "no comparison year" with a warning instead of blocking.
|
||||
const previousTb = prevPeriodRow ? tbPairs.get(prevPeriodRow.id) ?? null : null
|
||||
if (prevPeriodRow && !previousTb) {
|
||||
statementWarnings.push(
|
||||
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår, balans- och resultaträkningen visas utan jämförelseår. Kontrollera det föregående årets bokföring.',
|
||||
)
|
||||
}
|
||||
const mapping = mapTrialBalancesToK2(
|
||||
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
|
||||
@@ -167,13 +194,7 @@ export async function buildArsredovisningData(
|
||||
const persistedRd = narrative?.resultatdisposition ?? undefined
|
||||
const persistedAgmDate = narrative?.agm_date ?? null
|
||||
|
||||
const flerarsoversikt = await buildFlerarsoversikt(
|
||||
supabase,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
(periodList ?? []) as Array<{ id: string; name: string; period_start: string; period_end: string }>,
|
||||
mapping,
|
||||
)
|
||||
const flerarsoversikt = buildFlerarsoversikt(overviewSlice, fiscalPeriodId, mapping, tbPairs)
|
||||
|
||||
const egen_kapital_changes = buildEquityChanges(mapping)
|
||||
const proposedDividend = narrative?.proposed_dividend ?? 0
|
||||
@@ -206,40 +227,39 @@ export async function buildArsredovisningData(
|
||||
// K3 vs K2 split: K3 has a richer note set + a kassaflöde + a separate
|
||||
// equity-changes statement. The 18a/b warning that flagged "K3 noter not
|
||||
// yet emitted" is removed below now that we actually emit them.
|
||||
const { notes: noter, warnings: noterWarnings } =
|
||||
accountingFramework === 'k3'
|
||||
? await buildK3Noter(
|
||||
supabase,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
entityType,
|
||||
period.period_start,
|
||||
period.period_end,
|
||||
narrative,
|
||||
)
|
||||
: await buildK2Noter(
|
||||
supabase,
|
||||
companyId,
|
||||
entityType,
|
||||
period.period_start,
|
||||
period.period_end,
|
||||
narrative,
|
||||
)
|
||||
|
||||
//
|
||||
// Kassaflödesanalys + separate equity-changes statement, K3 only. K2
|
||||
// mindre företag is exempt from kassaflödesanalys (BFNAR 2016:10 punkt
|
||||
// 5.2) and keeps equity changes inside förvaltningsberättelsen.
|
||||
// 5.2) and keeps equity changes inside förvaltningsberättelsen. The K3
|
||||
// noter and the kassaflödesanalys are independent reads, so they share
|
||||
// one round trip; the kassaflöde failure warning still lands AFTER the
|
||||
// noter warnings so the warnings array order is unchanged.
|
||||
let noter: NoteEntry[]
|
||||
let noterWarnings: string[]
|
||||
let kassaflodesanalys: KassaflodesAnalysisSummary | undefined
|
||||
let equity_changes_statement:
|
||||
| { rows: EgenKapitalRow[]; closing_total: number }
|
||||
| undefined
|
||||
if (accountingFramework === 'k3') {
|
||||
try {
|
||||
const cashFlow = await generateKassaflodesanalys(
|
||||
const [noterResult, cashFlowSettled] = await Promise.all([
|
||||
buildK3Noter(
|
||||
supabase,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
)
|
||||
entityType,
|
||||
period.period_start,
|
||||
period.period_end,
|
||||
narrative,
|
||||
tbFull.rows,
|
||||
),
|
||||
generateKassaflodesanalys(supabase, companyId, fiscalPeriodId).then(
|
||||
(cashFlow) => ({ ok: true as const, cashFlow }),
|
||||
() => ({ ok: false as const }),
|
||||
),
|
||||
])
|
||||
noter = noterResult.notes
|
||||
noterWarnings = noterResult.warnings
|
||||
if (cashFlowSettled.ok) {
|
||||
const { cashFlow } = cashFlowSettled
|
||||
// Strip fiscal_period_id from the embedded report: period info is
|
||||
// already on ArsredovisningData.fiscal_period; carrying it twice in
|
||||
// the payload would be redundant.
|
||||
@@ -252,7 +272,7 @@ export async function buildArsredovisningData(
|
||||
total_cash_flow: cashFlow.total_cash_flow,
|
||||
reconciliation: cashFlow.reconciliation,
|
||||
}
|
||||
} catch {
|
||||
} else {
|
||||
// A partial SIE import can leave 1xxx without an IB row: the report
|
||||
// throws. Surface as a warning instead of blocking the whole ÅR.
|
||||
noterWarnings.push(
|
||||
@@ -264,6 +284,17 @@ export async function buildArsredovisningData(
|
||||
// reuse buildEquityChangesNote's roll-forward to keep one source of
|
||||
// truth for the closing total.
|
||||
equity_changes_statement = buildK3EquityChangesStatement(mapping)
|
||||
} else {
|
||||
const k2Noter = await buildK2Noter(
|
||||
supabase,
|
||||
companyId,
|
||||
entityType,
|
||||
period.period_start,
|
||||
period.period_end,
|
||||
narrative,
|
||||
)
|
||||
noter = k2Noter.notes
|
||||
noterWarnings = k2Noter.warnings
|
||||
}
|
||||
|
||||
const resultatrakning = buildRrRows(mapping)
|
||||
@@ -414,32 +445,26 @@ export function calculateSoliditet(mapping: K2MappingResult): number | null {
|
||||
return Math.round((adjustedEquity / totalAssets) * 1000) / 10
|
||||
}
|
||||
|
||||
async function buildFlerarsoversikt(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
/**
|
||||
* Flerårsöversikt from pre-fetched trial-balance pairs. `overviewSlice` is
|
||||
* the current period + up to 3 prior, oldest first (resolved by the caller
|
||||
* so the pairs could be fetched in one parallel wave); `tbPairs` holds the
|
||||
* prior-period pairs, with null marking a period whose TB fetch failed.
|
||||
*/
|
||||
function buildFlerarsoversikt(
|
||||
overviewSlice: PeriodRow[],
|
||||
currentPeriodId: string,
|
||||
allPeriods: PeriodRow[],
|
||||
currentMapping: K2MappingResult,
|
||||
): Promise<FlerarsoversiktRow[]> {
|
||||
// Take the current period + 3 prior (oldest first).
|
||||
const sorted = [...allPeriods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
const currentIdx = sorted.findIndex((p) => p.id === currentPeriodId)
|
||||
if (currentIdx === -1) return []
|
||||
const slice = sorted.slice(Math.max(0, currentIdx - 3), currentIdx + 1)
|
||||
|
||||
tbPairs: Map<string, TrialBalancePair | null>,
|
||||
): FlerarsoversiktRow[] {
|
||||
const rows: FlerarsoversiktRow[] = []
|
||||
for (const p of slice) {
|
||||
for (const p of overviewSlice) {
|
||||
try {
|
||||
let mapping = currentMapping
|
||||
if (p.id !== currentPeriodId) {
|
||||
const [tbFull, tbPreClosing] = await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, p.id),
|
||||
generateTrialBalance(supabase, companyId, p.id, { excludeFinalClosingEntry: true }),
|
||||
])
|
||||
mapping = mapTrialBalancesToK2(
|
||||
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
|
||||
null,
|
||||
)
|
||||
const pair = tbPairs.get(p.id)
|
||||
if (!pair) throw new Error('trial balance unavailable')
|
||||
mapping = mapTrialBalancesToK2(pair, null)
|
||||
}
|
||||
const netRevenue = mapping.rr['Nettoomsattning']?.current ?? 0
|
||||
const resultAfterFinancial = mapping.totals.resultatEfterFinansiellaPoster.current
|
||||
@@ -524,14 +549,29 @@ async function buildK2Noter(
|
||||
// an AB the user just hasn't configured yet: staying silent would let
|
||||
// them download an incomplete K2 ÅR without realising.
|
||||
const maybeAb = isAbK2 || entityType === 'unknown'
|
||||
|
||||
// The three reads feeding the notes below (aktiekapital settings, asset
|
||||
// register, employee windows) are independent, so they share one parallel
|
||||
// round trip instead of three sequential ones. Note bodies, push order,
|
||||
// and numbering (notes.length + 1) are unchanged.
|
||||
const [settingsResult, assets, employeesResult] = await Promise.all([
|
||||
maybeAb
|
||||
? supabase
|
||||
.from('company_settings')
|
||||
.select('aktiekapital, antal_aktier')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
: Promise.resolve({ data: null }),
|
||||
listAssets(supabase, companyId),
|
||||
supabase
|
||||
.from('employees')
|
||||
.select('employment_start, employment_end, employment_degree')
|
||||
.eq('company_id', companyId),
|
||||
])
|
||||
|
||||
if (maybeAb) {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('aktiekapital, antal_aktier')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
type AktiekapitalShape = { aktiekapital?: number | null; antal_aktier?: number | null }
|
||||
const ak = settings as AktiekapitalShape | null
|
||||
const ak = (settingsResult.data ?? null) as AktiekapitalShape | null
|
||||
const aktiekapital = ak?.aktiekapital ?? null
|
||||
const antalAktier = ak?.antal_aktier ?? null
|
||||
// Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier;
|
||||
@@ -563,7 +603,6 @@ async function buildK2Noter(
|
||||
|
||||
// Avskrivningstider: derive from asset register (supplementary
|
||||
// disclosure; the statutory ÅRL 5:8 § roll-forward follows below).
|
||||
const assets = await listAssets(supabase, companyId)
|
||||
if (assets.length > 0) {
|
||||
const byCategory = new Map<string, Set<number>>()
|
||||
for (const a of assets) {
|
||||
@@ -622,12 +661,8 @@ async function buildK2Noter(
|
||||
// ÅRL 5:20 § requires the note for AB regardless of value: "0" must be
|
||||
// disclosed as "Inga anställda". For enskild firma the disclosure is
|
||||
// discretionary, so we still skip when medelantal === 0 there.
|
||||
const { data: employeeRows } = await supabase
|
||||
.from('employees')
|
||||
.select('employment_start, employment_end, employment_degree')
|
||||
.eq('company_id', companyId)
|
||||
const medelantal = computeMedelantalAnstallda(
|
||||
(employeeRows ?? []) as Array<{
|
||||
(employeesResult.data ?? []) as Array<{
|
||||
employment_start: string
|
||||
employment_end: string | null
|
||||
employment_degree: number
|
||||
@@ -706,19 +741,49 @@ async function buildK2Noter(
|
||||
*
|
||||
* The aktiekapital note is shared with K2 logic: K3 punkt 18.x also
|
||||
* mandates the share-capital disclosure for AB.
|
||||
*
|
||||
* tbFullRows MUST be the FULL current-period trial balance (tbFull.rows:
|
||||
* opening balances included, year-end closing entries NOT excluded). The
|
||||
* uppskjutna-skatter note derives its BFNAR 2012:1 ch.29 opening balance,
|
||||
* movement, and closing balance for 2240/8940 from these rows; passing
|
||||
* tbPreClosing.rows would zero the opening balance and misstate the note.
|
||||
* The K3 multiyear snapshot test pins a non-zero 2240 opening balance to
|
||||
* guard this contract.
|
||||
*/
|
||||
async function buildK3Noter(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
entityType: string,
|
||||
periodStartIso: string,
|
||||
periodEndIso: string,
|
||||
narrative: NarrativeRow | null,
|
||||
tbFullRows: TrialBalanceRow[],
|
||||
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
|
||||
const notes: NoteEntry[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
const isAb = entityType === 'aktiebolag'
|
||||
const maybeAb = isAb || entityType === 'unknown'
|
||||
|
||||
// The three reads feeding the notes below (asset register, aktiekapital
|
||||
// settings, employee windows) are independent, so they share one parallel
|
||||
// round trip instead of three sequential ones. Note bodies, push order,
|
||||
// and numbering (notes.length + 1) are unchanged.
|
||||
const [assetsResult, settingsResult, employeesResult] = await Promise.all([
|
||||
listAssets(supabase, companyId),
|
||||
maybeAb
|
||||
? supabase
|
||||
.from('company_settings')
|
||||
.select('aktiekapital, antal_aktier')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
: Promise.resolve({ data: null }),
|
||||
supabase
|
||||
.from('employees')
|
||||
.select('employment_start, employment_end, employment_degree')
|
||||
.eq('company_id', companyId),
|
||||
])
|
||||
|
||||
// 1. Redovisningsprinciper. We check whether any asset has K3 components
|
||||
// configured so the principles paragraph only mentions komponentavskrivning
|
||||
// when it's actually in use.
|
||||
@@ -732,7 +797,7 @@ async function buildK3Noter(
|
||||
// (months elapsed / useful life) which matches what the per-component
|
||||
// depreciation engine (computeComponentDepreciation) produces over a year.
|
||||
// The fiscal period end is the as-of date for the depreciation snapshot.
|
||||
const assets = (await listAssets(supabase, companyId)) as Asset[]
|
||||
const assets = assetsResult as Asset[]
|
||||
const monthsBetween = (fromIso: string, toIso: string): number => {
|
||||
const from = new Date(`${fromIso}T00:00:00Z`)
|
||||
const to = new Date(`${toIso}T00:00:00Z`)
|
||||
@@ -778,19 +843,12 @@ async function buildK3Noter(
|
||||
|
||||
// 2. Aktiekapital (shared with K2 logic: K3 punkt 18.x mandates the same
|
||||
// disclosure for AB).
|
||||
const isAb = entityType === 'aktiebolag'
|
||||
const maybeAb = isAb || entityType === 'unknown'
|
||||
if (maybeAb) {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('aktiekapital, antal_aktier')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
type AktiekapitalShape = {
|
||||
aktiekapital?: number | null
|
||||
antal_aktier?: number | null
|
||||
}
|
||||
const ak = settings as AktiekapitalShape | null
|
||||
const ak = (settingsResult.data ?? null) as AktiekapitalShape | null
|
||||
const aktiekapital = ak?.aktiekapital ?? null
|
||||
const antalAktier = ak?.antal_aktier ?? null
|
||||
// Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier;
|
||||
@@ -846,10 +904,11 @@ async function buildK3Noter(
|
||||
|
||||
// 4. Uppskjutna skatter. K3 ch.29 requires disclosure of opening,
|
||||
// movement, and closing balance of uppskjuten skatteskuld. We derive
|
||||
// these from the trial balance for 2240 (latent tax liability) and
|
||||
// 8940 (latent tax expense).
|
||||
// these from the current-period full trial balance (passed in by the
|
||||
// caller, which already fetched it for the statements) for 2240 (latent
|
||||
// tax liability) and 8940 (latent tax expense).
|
||||
try {
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||||
const rows = tbFullRows
|
||||
const row2240 = rows.find((r) => r.account_number === '2240')
|
||||
const row8940 = rows.find((r) => r.account_number === '8940')
|
||||
// 2240 is credit-normal liability: opening = opening_credit - opening_debit
|
||||
@@ -886,12 +945,8 @@ async function buildK3Noter(
|
||||
// 5. Medelantal anställda: FTE-weighted average per ÅRL 5:20 §. The note is
|
||||
// statutory for AB regardless of value (disclose "0" explicitly); for non-AB
|
||||
// entities we still skip when there are no employees.
|
||||
const { data: employeeRows } = await supabase
|
||||
.from('employees')
|
||||
.select('employment_start, employment_end, employment_degree')
|
||||
.eq('company_id', companyId)
|
||||
const medelantal = computeMedelantalAnstallda(
|
||||
(employeeRows ?? []) as Array<{
|
||||
(employeesResult.data ?? []) as Array<{
|
||||
employment_start: string
|
||||
employment_end: string | null
|
||||
employment_degree: number
|
||||
|
||||
@@ -20,8 +20,19 @@ type TerminalResult = { data?: unknown; error?: unknown }
|
||||
* A terminal seeded as an ARRAY is consumed in call order, for functions
|
||||
* that query the same table twice (getActiveCompanyId's fallback fetch +
|
||||
* preference validation both end on company_members.maybeSingle()).
|
||||
*
|
||||
* `rpcResult` seeds supabase.rpc('resolve_active_company'). The default is a
|
||||
* PGRST202 "function not found" error so every pre-RPC test keeps passing
|
||||
* unchanged: they now exercise the query fallback path, which is exactly the
|
||||
* behavior on a not-yet-migrated database.
|
||||
*/
|
||||
function buildSupabase(results: Record<string, Record<string, TerminalResult | TerminalResult[]>>) {
|
||||
function buildSupabase(
|
||||
results: Record<string, Record<string, TerminalResult | TerminalResult[]>>,
|
||||
rpcResult: TerminalResult = {
|
||||
data: null,
|
||||
error: { code: 'PGRST202', message: 'Could not find the function' },
|
||||
},
|
||||
) {
|
||||
const calls: CapturedCall[] = []
|
||||
|
||||
function makeChain(table: string) {
|
||||
@@ -44,6 +55,10 @@ function buildSupabase(results: Record<string, Record<string, TerminalResult | T
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
|
||||
rpc: vi.fn(async () => ({
|
||||
data: rpcResult.data ?? null,
|
||||
error: rpcResult.error ?? null,
|
||||
})),
|
||||
}
|
||||
|
||||
return { supabase, calls }
|
||||
@@ -231,6 +246,73 @@ describe('getActiveCompanyId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveCompanyId via resolve_active_company RPC', () => {
|
||||
it('resolves from the RPC in one call without touching any table', async () => {
|
||||
const { supabase } = buildSupabase(
|
||||
{},
|
||||
{ data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }] },
|
||||
)
|
||||
|
||||
const id = await getActiveCompanyId(supabase as never, 'user-1')
|
||||
|
||||
expect(id).toBe('company-1')
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('resolve_active_company')
|
||||
// The whole point of the RPC: zero PostgREST table round trips.
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null from an RPC row with a null company_id (no companies) without table queries', async () => {
|
||||
const { supabase } = buildSupabase(
|
||||
{},
|
||||
{ data: [{ company_id: null, locale: 'en', used_fallback: true }] },
|
||||
)
|
||||
|
||||
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws resolution_failed on a non-fallback RPC error instead of masking it', async () => {
|
||||
const { supabase } = buildSupabase(
|
||||
{},
|
||||
{ data: null, error: { code: '57014', message: 'statement timeout' } },
|
||||
)
|
||||
|
||||
const err = await getActiveCompanyId(supabase as never, 'user-1').catch((e) => e)
|
||||
|
||||
expect(err).toBeInstanceOf(CompanyContextError)
|
||||
expect(err.code).toBe('resolution_failed')
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the query path on 42501 (service-role client lacks EXECUTE)', async () => {
|
||||
// The mcp-oauth token route and the events route (API-key branch) call
|
||||
// requireCompanyId with createServiceClientNoCookies(): EXECUTE is
|
||||
// granted to `authenticated` only, so the RPC refuses with 42501 and the
|
||||
// query path (filtered by the explicit userId param) must take over.
|
||||
const { supabase } = buildSupabase(
|
||||
{
|
||||
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
|
||||
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
||||
},
|
||||
{ data: null, error: { code: '42501', message: 'permission denied for function' } },
|
||||
)
|
||||
|
||||
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
||||
})
|
||||
|
||||
it('falls back to the query path on zero RPC rows (NULL auth.uid(), service client)', async () => {
|
||||
const { supabase } = buildSupabase(
|
||||
{
|
||||
user_preferences: { maybeSingle: { data: null } },
|
||||
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
||||
},
|
||||
{ data: [] },
|
||||
)
|
||||
|
||||
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompanyDisplayName', () => {
|
||||
it('returns company_settings.company_name and never reads companies when set', async () => {
|
||||
const { supabase, calls } = buildSupabase({
|
||||
|
||||
@@ -34,6 +34,12 @@ export class CompanyContextError extends Error {
|
||||
* Having Next.js and RLS both read from `user_preferences` keeps them
|
||||
* perfectly in sync.
|
||||
*
|
||||
* RPC-first: tries `resolve_active_company()` (one round trip, semantically
|
||||
* identical to the query path and to `current_active_company_id()`), falling
|
||||
* back to the original query path when the function is not deployed
|
||||
* (PGRST202), the caller lacks EXECUTE (42501: service-role clients), or the
|
||||
* RPC returns zero rows (NULL auth.uid(), also service-role clients).
|
||||
*
|
||||
* Returns null only when the user positively has no non-archived companies.
|
||||
* Throws CompanyContextError('resolution_failed') when a query fails: a
|
||||
* transient failure must never read as "no companies", because callers
|
||||
@@ -42,6 +48,48 @@ export class CompanyContextError extends Error {
|
||||
export async function getActiveCompanyId(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const { data, error } = await supabase.rpc('resolve_active_company')
|
||||
|
||||
if (error) {
|
||||
// PGRST202: function not in the schema cache (self-hosted instance not
|
||||
// migrated yet, or a deploy racing the branch merge).
|
||||
// 42501: EXECUTE is granted to `authenticated` only, so a service-role
|
||||
// client is refused. These fallbacks are LOAD-BEARING, not defensive:
|
||||
// app/api/mcp-oauth/token/route.ts and app/api/events/route.ts (API-key
|
||||
// branch) call requireCompanyId with createServiceClientNoCookies(), and
|
||||
// must silently resolve via the query path or the OAuth token flow breaks.
|
||||
if (error.code === 'PGRST202' || error.code === '42501') {
|
||||
return getActiveCompanyIdViaQueries(supabase, userId)
|
||||
}
|
||||
throw new CompanyContextError(
|
||||
`Active company resolution failed: ${error.message}`,
|
||||
'resolution_failed'
|
||||
)
|
||||
}
|
||||
|
||||
const row = (Array.isArray(data) ? data[0] : data) as
|
||||
| { company_id: string | null; locale: string | null; used_fallback: boolean }
|
||||
| undefined
|
||||
| null
|
||||
|
||||
if (!row) {
|
||||
// Zero rows = NULL auth.uid() inside the RPC, i.e. a service-role client
|
||||
// (same call sites as the 42501 branch above). The query path filters by
|
||||
// the explicit userId param and still resolves correctly.
|
||||
return getActiveCompanyIdViaQueries(supabase, userId)
|
||||
}
|
||||
|
||||
return row.company_id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
|
||||
* fallback for getActiveCompanyId (see the fallback conditions there).
|
||||
*/
|
||||
async function getActiveCompanyIdViaQueries(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
// user_preferences (authoritative) + first membership, fetched in parallel:
|
||||
// the fallback query result doubles as validation when the preferred
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
fetchMultipleRates,
|
||||
fetchRateRange,
|
||||
fetchLatestRate,
|
||||
readCachedRate,
|
||||
convertToSEK,
|
||||
formatCurrencyAmount,
|
||||
} from '../riksbanken'
|
||||
@@ -223,6 +224,56 @@ describe('fetchExchangeRate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Exported so the currency.rate route can hit the exchange_rates cache in
|
||||
// parallel with its sandbox guard instead of always going through
|
||||
// fetchExchangeRate's sequential path.
|
||||
describe('readCachedRate', () => {
|
||||
it('maps an exact-date cache row to an ExchangeRate', async () => {
|
||||
const maybeSingle = vi.fn().mockResolvedValue({
|
||||
// rate arrives as a numeric string from PostgREST: must be Number()ed.
|
||||
data: { rate: '11.25', observation_date: '2025-01-14' },
|
||||
error: null,
|
||||
})
|
||||
const supabase = {
|
||||
from: vi.fn(() => ({
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
maybeSingle,
|
||||
})),
|
||||
} as never
|
||||
|
||||
const result = await readCachedRate(supabase, 'EUR', '2025-01-15')
|
||||
|
||||
expect(result).toEqual({ currency: 'EUR', rate: 11.25, date: '2025-01-14' })
|
||||
})
|
||||
|
||||
it('returns null when the cache has no row for the date', async () => {
|
||||
const supabase = {
|
||||
from: vi.fn(() => ({
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
})),
|
||||
} as never
|
||||
|
||||
const result = await readCachedRate(supabase, 'EUR', '2025-01-15')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('swallows thrown client errors and returns null (best-effort cache)', async () => {
|
||||
const supabase = {
|
||||
from: vi.fn(() => {
|
||||
throw new Error('connection refused')
|
||||
}),
|
||||
} as never
|
||||
|
||||
const result = await readCachedRate(supabase, 'EUR', '2025-01-15')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMultipleRates', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
||||
@@ -32,8 +32,8 @@ async function fetchWithRetry(url: string): Promise<Response> {
|
||||
return fetch(url, { headers: RIKSBANKEN_HEADERS, next: { revalidate: 3600 } })
|
||||
}
|
||||
|
||||
/** Cache reads/writes are best-effort — a cache failure must never block a rate. */
|
||||
async function readCachedRate(
|
||||
/** Cache reads/writes are best-effort: a cache failure must never block a rate. */
|
||||
export async function readCachedRate(
|
||||
supabase: SupabaseClient,
|
||||
currency: Currency,
|
||||
rateDate: string,
|
||||
|
||||
@@ -300,6 +300,7 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
|
||||
id: deliveryId,
|
||||
to_addresses: ['***@example.com'],
|
||||
cc_addresses: ['***@example.com'],
|
||||
attachment_filename: 'invoice.pdf',
|
||||
}),
|
||||
])
|
||||
expect(summary.rows[0]).not.toHaveProperty('bcc_addresses')
|
||||
|
||||
@@ -573,9 +573,13 @@ export async function getReconciliationStatus(
|
||||
(tx) => tx.journal_entry_id === null && tx.is_ignored !== true
|
||||
).length
|
||||
|
||||
// Unlinked GL lines count (RPC excludes opening_balance, storno and correction
|
||||
// since 20260601120000_unlinked_gl_lines_exclude_storno_correction.sql)
|
||||
const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, bankAccount, dateFrom, dateTo)
|
||||
// Unmatched GL lines count (RPC excludes opening_balance, storno and correction
|
||||
// since 20260601120000_unlinked_gl_lines_exclude_storno_correction.sql).
|
||||
// Account-scoped since 20260723160000: a voucher whose links all sit on another
|
||||
// cash account (a transfer's other leg) counts as unmatched HERE, keeping this
|
||||
// number in agreement with the "Omatchade verifikationer" table the
|
||||
// reconciliation view derives from the same RPC.
|
||||
const unlinkedLines = await fetchGLLinesForMatching(supabase, companyId, bankAccount, dateFrom, dateTo)
|
||||
|
||||
const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100
|
||||
|
||||
@@ -684,9 +688,11 @@ export async function manualLink(
|
||||
// links net to zero and any mis-link surfaces as a non-zero difference on the
|
||||
// status card: there's no need to forbid a second link here. (A given
|
||||
// transaction still can't be double-linked: the tx.journal_entry_id guard
|
||||
// above already blocks that.) The candidate list only surfaces an
|
||||
// already-matched voucher when the user opts in via "Visa även matchade
|
||||
// verifikationer", so this can't happen by accident.
|
||||
// above already blocks that.) The candidate list surfaces a voucher already
|
||||
// settled on THIS account only when the user opts in via "Visa även matchade
|
||||
// verifikationer"; a voucher whose links all sit on another cash account (the
|
||||
// second leg of an own-account transfer, issue #1026) surfaces by default,
|
||||
// which is exactly the N:1-across-accounts case this permits.
|
||||
|
||||
// Apply link. The write re-checks the pointer we validated inside the write
|
||||
// itself (the read above is advisory): null for a free row, or the exact
|
||||
@@ -1033,17 +1039,25 @@ export async function fetchUnlinkedGLLines(
|
||||
|
||||
/** A match candidate that carries how many transactions already point at it. */
|
||||
export interface GLLineForMatching extends UnlinkedGLLine {
|
||||
/** Transactions settling this entry ON THE REQUESTED ACCOUNT (plus legacy
|
||||
* rows with no cash_account_id, which count everywhere). A transaction on
|
||||
* another cash account, e.g. the outgoing leg of an own-account transfer,
|
||||
* does not mark the voucher as matched here (issue #1026). */
|
||||
linked_transaction_count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch GL lines on a settlement account as match candidates. With
|
||||
* `includeMatched=false` this is parity with fetchUnlinkedGLLines (unmatched
|
||||
* only); with `includeMatched=true` it also returns already-matched vouchers,
|
||||
* each carrying `linked_transaction_count`, so a second/third bank transaction
|
||||
* can be attached to the same verifikat (N:1, a salary run paid in several
|
||||
* transfers, a supplier invoice paid in instalments). Server-only: like the rest
|
||||
* of this module it must never reach the client bundle.
|
||||
* `includeMatched=false` this returns vouchers not yet settled on the requested
|
||||
* account: unlike fetchUnlinkedGLLines, a voucher whose only links are
|
||||
* transactions on ANOTHER cash account (the second leg of an own-account
|
||||
* transfer) still surfaces, since from this account's perspective it is
|
||||
* unmatched (issue #1026). With `includeMatched=true` it also returns vouchers
|
||||
* already settled on this account, each carrying `linked_transaction_count`, so
|
||||
* a second/third bank transaction can be attached to the same verifikat (N:1,
|
||||
* a salary run paid in several transfers, a supplier invoice paid in
|
||||
* instalments). Server-only: like the rest of this module it must never reach
|
||||
* the client bundle.
|
||||
*/
|
||||
export async function fetchGLLinesForMatching(
|
||||
supabase: SupabaseClient,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
fetchKpiAggregates,
|
||||
buildOpeningBalances,
|
||||
buildTrialBalanceRows,
|
||||
type KpiAggregates,
|
||||
} from '../kpi-aggregates'
|
||||
|
||||
function emptyAgg(overrides: Partial<KpiAggregates> = {}): KpiAggregates {
|
||||
return { tb: [], tb_ex_year_end: [], ob: [], monthly: [], ...overrides }
|
||||
}
|
||||
|
||||
describe('fetchKpiAggregates', () => {
|
||||
it('calls the RPC with the expected args and coerces numbers', async () => {
|
||||
const rpc = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
tb: [{ account_number: '1930', debit: '125.5', credit: 0 }],
|
||||
tb_ex_year_end: [{ account_number: '3001', debit: null, credit: 100 }],
|
||||
ob: [],
|
||||
monthly: [{ year: 2026, month: '2', income: '10.25', expenses: undefined }],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const supabase = { rpc } as never
|
||||
|
||||
const agg = await fetchKpiAggregates(supabase, 'company-1', 'period-1', 'ob-1')
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
|
||||
p_company_id: 'company-1',
|
||||
p_fiscal_period_id: 'period-1',
|
||||
p_ob_entry_id: 'ob-1',
|
||||
})
|
||||
expect(agg.tb).toEqual([{ account_number: '1930', debit: 125.5, credit: 0 }])
|
||||
expect(agg.tb_ex_year_end).toEqual([{ account_number: '3001', debit: 0, credit: 100 }])
|
||||
expect(agg.ob).toEqual([])
|
||||
expect(agg.monthly).toEqual([{ year: 2026, month: 2, income: 10.25, expenses: 0 }])
|
||||
})
|
||||
|
||||
it('defaults missing sections to empty arrays', async () => {
|
||||
const rpc = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const supabase = { rpc } as never
|
||||
|
||||
const agg = await fetchKpiAggregates(supabase, 'company-1', 'period-1', null)
|
||||
expect(agg).toEqual(emptyAgg())
|
||||
expect(rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
|
||||
p_company_id: 'company-1',
|
||||
p_fiscal_period_id: 'period-1',
|
||||
p_ob_entry_id: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws a prefixed error when the RPC fails', async () => {
|
||||
const rpc = vi.fn().mockResolvedValue({ data: null, error: { message: 'boom' } })
|
||||
const supabase = { rpc } as never
|
||||
|
||||
await expect(
|
||||
fetchKpiAggregates(supabase, 'company-1', 'period-1', null)
|
||||
).rejects.toThrow('get_kpi_report_aggregates failed: boom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOpeningBalances', () => {
|
||||
it('OB-entry path (priorRows null): additive accumulation with Number coercion', () => {
|
||||
const agg = emptyAgg({
|
||||
ob: [
|
||||
{ account_number: '1930', debit: 5000, credit: 0 },
|
||||
// A duplicate account accumulates additively, mirroring the
|
||||
// per-line loop in opening-balances.ts lines 57-62.
|
||||
{ account_number: '1930', debit: 250.5, credit: 0 },
|
||||
{ account_number: '2010', debit: 0, credit: 5250.5 },
|
||||
],
|
||||
})
|
||||
|
||||
const balances = buildOpeningBalances(agg, null)
|
||||
expect(balances.get('1930')).toEqual({ debit: 5250.5, credit: 0 })
|
||||
expect(balances.get('2010')).toEqual({ debit: 0, credit: 5250.5 })
|
||||
expect(balances.size).toBe(2)
|
||||
})
|
||||
|
||||
it('fallback path: uses priorRows with Number()||0 coercion, ignoring the ob section', () => {
|
||||
const agg = emptyAgg({
|
||||
ob: [{ account_number: '9999', debit: 1, credit: 1 }],
|
||||
})
|
||||
// compute_prior_opening_balances returns numerics that may arrive as
|
||||
// strings through PostgREST: mirrors opening-balances.ts lines 77-86.
|
||||
const balances = buildOpeningBalances(agg, [
|
||||
{ account_number: '1930', debit: '1500.25', credit: '0' },
|
||||
{ account_number: '2440', debit: 'not-a-number', credit: 300 },
|
||||
])
|
||||
|
||||
expect(balances.get('1930')).toEqual({ debit: 1500.25, credit: 0 })
|
||||
expect(balances.get('2440')).toEqual({ debit: 0, credit: 300 })
|
||||
expect(balances.has('9999')).toBe(false)
|
||||
})
|
||||
|
||||
it('empty inputs produce an empty map in both shapes', () => {
|
||||
expect(buildOpeningBalances(emptyAgg(), null).size).toBe(0)
|
||||
expect(buildOpeningBalances(emptyAgg(), []).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTrialBalanceRows', () => {
|
||||
const accountMap = new Map<string, { name: string; class: number }>([
|
||||
['1930', { name: 'Företagskonto', class: 1 }],
|
||||
['3001', { name: 'Försäljning 25%', class: 3 }],
|
||||
])
|
||||
|
||||
it('merges opening and period accounts and computes IB + period = UB', () => {
|
||||
const opening = new Map([
|
||||
['1930', { debit: 5000, credit: 0 }],
|
||||
// Account only in opening: must still get a row.
|
||||
['2081', { debit: 0, credit: 25000 }],
|
||||
])
|
||||
const periodSums = [
|
||||
{ account_number: '1930', debit: 12500, credit: 3000 },
|
||||
// Account only in period: must still get a row.
|
||||
{ account_number: '3001', debit: 0, credit: 10000 },
|
||||
]
|
||||
|
||||
const rows = buildTrialBalanceRows(opening, periodSums, accountMap)
|
||||
|
||||
expect(rows.map((r) => r.account_number)).toEqual(['1930', '2081', '3001'])
|
||||
expect(rows[0]).toEqual({
|
||||
account_number: '1930',
|
||||
account_name: 'Företagskonto',
|
||||
account_class: 1,
|
||||
opening_debit: 5000,
|
||||
opening_credit: 0,
|
||||
period_debit: 12500,
|
||||
period_credit: 3000,
|
||||
closing_debit: 17500,
|
||||
closing_credit: 3000,
|
||||
})
|
||||
expect(rows[1]).toMatchObject({
|
||||
account_number: '2081',
|
||||
opening_credit: 25000,
|
||||
period_debit: 0,
|
||||
period_credit: 0,
|
||||
closing_credit: 25000,
|
||||
})
|
||||
expect(rows[2]).toMatchObject({
|
||||
account_number: '3001',
|
||||
account_name: 'Försäljning 25%',
|
||||
account_class: 3,
|
||||
opening_debit: 0,
|
||||
period_credit: 10000,
|
||||
closing_credit: 10000,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to "Konto <n>" naming and first-digit class for unknown accounts', () => {
|
||||
const rows = buildTrialBalanceRows(
|
||||
new Map(),
|
||||
[
|
||||
{ account_number: '2611', debit: 0, credit: 2500 },
|
||||
{ account_number: 'X99', debit: 1, credit: 0 },
|
||||
],
|
||||
accountMap
|
||||
)
|
||||
|
||||
const unknown = rows.find((r) => r.account_number === '2611')!
|
||||
expect(unknown.account_name).toBe('Konto 2611')
|
||||
expect(unknown.account_class).toBe(2)
|
||||
|
||||
// parseInt(n[0]) || 0 fallback: non-numeric first char lands class 0,
|
||||
// same as trial-balance.ts line 306.
|
||||
const weird = rows.find((r) => r.account_number === 'X99')!
|
||||
expect(weird.account_name).toBe('Konto X99')
|
||||
expect(weird.account_class).toBe(0)
|
||||
})
|
||||
|
||||
it('rounds all six amount fields with Math.round(x * 100) / 100', () => {
|
||||
const opening = new Map([['1930', { debit: 0.1, credit: 0 }]])
|
||||
const rows = buildTrialBalanceRows(
|
||||
opening,
|
||||
[{ account_number: '1930', debit: 0.2, credit: 0.005 }],
|
||||
accountMap
|
||||
)
|
||||
|
||||
// 0.1 + 0.2 = 0.30000000000000004 raw: closing must land on 0.3 exactly.
|
||||
expect(rows[0].opening_debit).toBe(0.1)
|
||||
expect(rows[0].period_debit).toBe(0.2)
|
||||
expect(rows[0].closing_debit).toBe(0.3)
|
||||
expect(rows[0].period_credit).toBe(0.01)
|
||||
expect(rows[0].closing_credit).toBe(0.01)
|
||||
})
|
||||
|
||||
it('sorts rows by account_number with localeCompare', () => {
|
||||
const rows = buildTrialBalanceRows(
|
||||
new Map(),
|
||||
[
|
||||
{ account_number: '8999', debit: 1, credit: 0 },
|
||||
{ account_number: '1510', debit: 1, credit: 0 },
|
||||
{ account_number: '2440', debit: 1, credit: 0 },
|
||||
],
|
||||
accountMap
|
||||
)
|
||||
expect(rows.map((r) => r.account_number)).toEqual(['1510', '2440', '8999'])
|
||||
})
|
||||
|
||||
it('returns no rows when both inputs are empty', () => {
|
||||
expect(buildTrialBalanceRows(new Map(), [], accountMap)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -116,6 +116,16 @@ describe('calculateVatLiability', () => {
|
||||
expect(calculateVatLiability(rows)).toBe(15000)
|
||||
})
|
||||
|
||||
it('includes reduced-rate output VAT (12% and 6%) in the liability', () => {
|
||||
const rows = [
|
||||
makeTrialBalanceRow({ account_number: '2611', closing_credit: 25000 }),
|
||||
makeTrialBalanceRow({ account_number: '2621', closing_credit: 1200 }),
|
||||
makeTrialBalanceRow({ account_number: '2631', closing_credit: 600 }),
|
||||
makeTrialBalanceRow({ account_number: '2641', closing_debit: 10000 }),
|
||||
]
|
||||
expect(calculateVatLiability(rows)).toBe(16800)
|
||||
})
|
||||
|
||||
it('nets EU reverse charge (2614 + 2645) to zero: issue #715', () => {
|
||||
const rows = [
|
||||
makeTrialBalanceRow({ account_number: '2614', closing_credit: 2500 }),
|
||||
|
||||
@@ -608,7 +608,12 @@ describe('generateTrialBalance', () => {
|
||||
}),
|
||||
).rejects.toThrow(/missing closing_entry_id/i)
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
// The guard throws before any journal data is read. The chart of
|
||||
// accounts is part of the first parallel wave (alongside the period
|
||||
// fetch), so it may have been queried; the entry/line tables must not be.
|
||||
const tables = supabase.from.mock.calls.map((c: unknown[]) => c[0])
|
||||
expect(tables).not.toContain('journal_entries')
|
||||
expect(tables).not.toContain('journal_entry_lines')
|
||||
})
|
||||
|
||||
it('keeps year-end adjustments for an open period without a final closing entry', async () => {
|
||||
|
||||
@@ -371,12 +371,6 @@ describe('calculateVatDeclaration', () => {
|
||||
expect(result.rutor.ruta49).toBe(-2500) // 500 - 3000
|
||||
})
|
||||
|
||||
it('accepts accountingMethod parameter for backward compatibility', async () => {
|
||||
seedLedger([])
|
||||
|
||||
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1, 'cash')
|
||||
expect(result.rutor.ruta49).toBe(0)
|
||||
})
|
||||
|
||||
it('throws a labelled error when the RPC fails', async () => {
|
||||
results = [{ data: null, error: { message: 'permission denied' } }]
|
||||
|
||||
@@ -375,7 +375,6 @@ async function generatePeriodReports(
|
||||
'yearly',
|
||||
startDate.getFullYear(),
|
||||
1,
|
||||
'accrual',
|
||||
{ fiscalPeriodId: period.id }
|
||||
)
|
||||
} catch {
|
||||
|
||||
@@ -33,6 +33,20 @@ export async function generateIncomeStatement(
|
||||
dimensions: options?.dimensions,
|
||||
})
|
||||
|
||||
return buildIncomeStatementFromRows(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure income-statement assembly from trial balance rows. Extracted so
|
||||
* callers that already hold pre-computed rows (e.g. the KPI route's
|
||||
* single-round-trip aggregate path) can reuse the section/rounding logic
|
||||
* without re-fetching journal lines. The rows must come from a trial
|
||||
* balance generated with excludeYearEndClosing (see generateIncomeStatement
|
||||
* above for why).
|
||||
*/
|
||||
export function buildIncomeStatementFromRows(
|
||||
rows: TrialBalanceRow[]
|
||||
): IncomeStatementReport {
|
||||
// Filter to income/expense accounts (class 3-8)
|
||||
const incomeExpenseRows = rows.filter(
|
||||
(r) => r.account_class >= 3 && r.account_class <= 8
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
|
||||
/**
|
||||
* Client-side companion for the get_kpi_report_aggregates RPC
|
||||
* (supabase/migrations/20260723180000_kpi_report_aggregates_rpc.sql).
|
||||
*
|
||||
* The KPI route used to scan every journal line of the fiscal period three
|
||||
* times through PostgREST (unfiltered trial balance, income-statement trial
|
||||
* balance with excludeYearEndClosing, monthly breakdown) and aggregate in
|
||||
* JS. The RPC returns the three pre-summed shapes in one round trip; the
|
||||
* builders here reproduce the exact merge/rounding semantics of the legacy
|
||||
* generators so the report JSON stays identical.
|
||||
*/
|
||||
|
||||
export interface AccountSums {
|
||||
account_number: string
|
||||
debit: number
|
||||
credit: number
|
||||
}
|
||||
|
||||
export interface KpiMonthlyBucket {
|
||||
year: number
|
||||
/** Calendar month 1-12 (SQL EXTRACT). Callers convert to 0-based before
|
||||
* handing buckets to assembleMonthlyBreakdown. */
|
||||
month: number
|
||||
income: number
|
||||
expenses: number
|
||||
}
|
||||
|
||||
export interface KpiAggregates {
|
||||
tb: AccountSums[]
|
||||
tb_ex_year_end: AccountSums[]
|
||||
ob: AccountSums[]
|
||||
monthly: KpiMonthlyBucket[]
|
||||
}
|
||||
|
||||
function toAccountSums(rows: unknown[] | null | undefined): AccountSums[] {
|
||||
return (rows ?? []).map((r) => {
|
||||
const row = r as { account_number?: unknown; debit?: unknown; credit?: unknown }
|
||||
return {
|
||||
account_number: String(row.account_number ?? ''),
|
||||
debit: Number(row.debit) || 0,
|
||||
credit: Number(row.credit) || 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the KPI aggregates in one round trip. Throws on RPC failure (the
|
||||
* route surfaces it as a 500 via withRouteContext; matches the
|
||||
* get_vat_declaration_totals precedent in lib/reports/vat-declaration.ts).
|
||||
*/
|
||||
export async function fetchKpiAggregates(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
obEntryId: string | null
|
||||
): Promise<KpiAggregates> {
|
||||
const { data, error } = await supabase.rpc('get_kpi_report_aggregates', {
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: fiscalPeriodId,
|
||||
p_ob_entry_id: obEntryId,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(`get_kpi_report_aggregates failed: ${error.message}`)
|
||||
}
|
||||
|
||||
const payload = (data ?? {}) as {
|
||||
tb?: unknown[]
|
||||
tb_ex_year_end?: unknown[]
|
||||
ob?: unknown[]
|
||||
monthly?: unknown[]
|
||||
}
|
||||
|
||||
return {
|
||||
tb: toAccountSums(payload.tb),
|
||||
tb_ex_year_end: toAccountSums(payload.tb_ex_year_end),
|
||||
ob: toAccountSums(payload.ob),
|
||||
monthly: (payload.monthly ?? []).map((r) => {
|
||||
const row = r as { year?: unknown; month?: unknown; income?: unknown; expenses?: unknown }
|
||||
return {
|
||||
year: Number(row.year) || 0,
|
||||
month: Number(row.month) || 0,
|
||||
income: Number(row.income) || 0,
|
||||
expenses: Number(row.expenses) || 0,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the opening-balance map for the fiscal period.
|
||||
*
|
||||
* Pass `priorRows: null` when the period has an opening-balance entry: the
|
||||
* balances then come from the RPC's `ob` section, mirroring the OB-entry
|
||||
* branch of getOpeningBalances (lib/reports/opening-balances.ts lines
|
||||
* 57-62: additive accumulation with Number()||0 coercion). Otherwise pass
|
||||
* the rows returned by the compute_prior_opening_balances RPC, mirroring
|
||||
* the fallback branch (lines 77-86: per-row set with Number()||0).
|
||||
*/
|
||||
export function buildOpeningBalances(
|
||||
agg: KpiAggregates,
|
||||
priorRows:
|
||||
| Array<{ account_number: string; debit: number | string; credit: number | string }>
|
||||
| null
|
||||
): Map<string, { debit: number; credit: number }> {
|
||||
const balances = new Map<string, { debit: number; credit: number }>()
|
||||
|
||||
if (priorRows === null) {
|
||||
for (const line of agg.ob) {
|
||||
const existing = balances.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
existing.debit += Number(line.debit) || 0
|
||||
existing.credit += Number(line.credit) || 0
|
||||
balances.set(line.account_number, existing)
|
||||
}
|
||||
} else {
|
||||
for (const row of priorRows) {
|
||||
balances.set(row.account_number, {
|
||||
debit: Number(row.debit) || 0,
|
||||
credit: Number(row.credit) || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return balances
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble TrialBalanceRow[] from pre-summed per-account period activity.
|
||||
*
|
||||
* Pinned to the row-building tail of generateTrialBalance
|
||||
* (lib/reports/trial-balance.ts lines 287-322, which is read-only): merged
|
||||
* key set of opening + period accounts, `Konto <n>` /
|
||||
* `parseInt(n[0]) || 0` fallback for accounts missing from the chart,
|
||||
* öre rounding on all six amount fields, and a final localeCompare sort.
|
||||
* Rounding goes through roundOre (the antipattern guard forbids new raw
|
||||
* Math.round(x * 100) / 100): identical to the source expression except
|
||||
* within float epsilon of half-öre boundaries. Keep the two in sync if
|
||||
* the source ever changes.
|
||||
*/
|
||||
export function buildTrialBalanceRows(
|
||||
openingBalances: Map<string, { debit: number; credit: number }>,
|
||||
periodSums: AccountSums[],
|
||||
accountMap: Map<string, { name: string; class: number }>
|
||||
): TrialBalanceRow[] {
|
||||
const periodBalances = new Map<string, { debit: number; credit: number }>()
|
||||
for (const sums of periodSums) {
|
||||
const existing = periodBalances.get(sums.account_number) || { debit: 0, credit: 0 }
|
||||
existing.debit += Number(sums.debit) || 0
|
||||
existing.credit += Number(sums.credit) || 0
|
||||
periodBalances.set(sums.account_number, existing)
|
||||
}
|
||||
|
||||
// Merge account numbers from both opening and period
|
||||
const allAccountNumbers = new Set([...openingBalances.keys(), ...periodBalances.keys()])
|
||||
|
||||
// Build rows: IB + period = UB
|
||||
const rows: TrialBalanceRow[] = []
|
||||
for (const accountNumber of allAccountNumbers) {
|
||||
const opening = openingBalances.get(accountNumber) || { debit: 0, credit: 0 }
|
||||
const periodActivity = periodBalances.get(accountNumber) || { debit: 0, credit: 0 }
|
||||
const accountInfo = accountMap.get(accountNumber) || {
|
||||
name: `Konto ${accountNumber}`,
|
||||
class: parseInt(accountNumber[0]) || 0,
|
||||
}
|
||||
|
||||
rows.push({
|
||||
account_number: accountNumber,
|
||||
account_name: accountInfo.name,
|
||||
account_class: accountInfo.class,
|
||||
opening_debit: roundOre(opening.debit),
|
||||
opening_credit: roundOre(opening.credit),
|
||||
period_debit: roundOre(periodActivity.debit),
|
||||
period_credit: roundOre(periodActivity.credit),
|
||||
closing_debit: roundOre(opening.debit + periodActivity.debit),
|
||||
closing_credit: roundOre(opening.credit + periodActivity.credit),
|
||||
})
|
||||
}
|
||||
|
||||
rows.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
export interface MonthlyBreakdownMonth {
|
||||
label: string
|
||||
@@ -17,6 +18,77 @@ const MONTH_LABELS = [
|
||||
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec',
|
||||
]
|
||||
|
||||
/** Pre-summed month bucket for assembleMonthlyBreakdown. */
|
||||
export interface MonthlyBucket {
|
||||
year: number
|
||||
/** 0-based month (JS Date convention, indexes MONTH_LABELS). */
|
||||
month0: number
|
||||
income: number
|
||||
expenses: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure assembly of the monthly breakdown from pre-summed buckets: month
|
||||
* range initialization, bucket fill, natural "YYYY-MM" sort, and Swedish
|
||||
* month labels. Extracted from generateMonthlyBreakdown so callers that
|
||||
* already hold per-month sums (e.g. the KPI route's single-round-trip
|
||||
* aggregate path) can reuse the assembly without re-scanning lines.
|
||||
*
|
||||
* Rounding happens once per bucket (income, expenses, then net over the
|
||||
* rounded pair) instead of the old incremental per-line rounding: equal
|
||||
* within float epsilon for real öre-denominated amounts.
|
||||
*/
|
||||
export function assembleMonthlyBreakdown(
|
||||
periodStart: string,
|
||||
periodEnd: string,
|
||||
buckets: MonthlyBucket[]
|
||||
): MonthlyBreakdown {
|
||||
// Build monthly aggregates using year-aware keys ("2024-03", "2024-04",
|
||||
// etc.) to avoid data corruption for non-calendar fiscal years (Apr-Mar).
|
||||
const monthMap = new Map<string, { year: number; month: number; income: number; expenses: number }>()
|
||||
|
||||
// Initialize all months in the period range
|
||||
const startDate = new Date(periodStart)
|
||||
const endDate = new Date(periodEnd)
|
||||
|
||||
for (
|
||||
let y = startDate.getFullYear(), m = startDate.getMonth();
|
||||
y < endDate.getFullYear() || (y === endDate.getFullYear() && m <= endDate.getMonth());
|
||||
m === 11 ? (y++, m = 0) : m++
|
||||
) {
|
||||
const key = `${y}-${String(m).padStart(2, '0')}`
|
||||
monthMap.set(key, { year: y, month: m, income: 0, expenses: 0 })
|
||||
}
|
||||
|
||||
for (const bucket of buckets) {
|
||||
const key = `${bucket.year}-${String(bucket.month0).padStart(2, '0')}`
|
||||
if (!monthMap.has(key)) {
|
||||
monthMap.set(key, { year: bucket.year, month: bucket.month0, income: 0, expenses: 0 })
|
||||
}
|
||||
const target = monthMap.get(key)!
|
||||
target.income += bucket.income
|
||||
target.expenses += bucket.expenses
|
||||
}
|
||||
|
||||
// Convert to sorted array (keys sort naturally as "YYYY-MM")
|
||||
const months: MonthlyBreakdownMonth[] = []
|
||||
const sortedKeys = Array.from(monthMap.keys()).sort()
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const data = monthMap.get(key)!
|
||||
const income = roundOre(data.income)
|
||||
const expenses = roundOre(data.expenses)
|
||||
months.push({
|
||||
label: MONTH_LABELS[data.month],
|
||||
income,
|
||||
expenses,
|
||||
net: roundOre(income - expenses),
|
||||
})
|
||||
}
|
||||
|
||||
return { months }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate monthly income vs expenses breakdown for a fiscal period.
|
||||
*
|
||||
@@ -73,22 +145,9 @@ export async function generateMonthlyBreakdown(
|
||||
return { months: [] }
|
||||
}
|
||||
|
||||
// Build monthly aggregates using year-aware keys ("2024-03", "2024-04", etc.)
|
||||
// to avoid data corruption for non-calendar fiscal years (e.g., Apr-Mar)
|
||||
const monthMap = new Map<string, { year: number; month: number; income: number; expenses: number }>()
|
||||
|
||||
// Initialize all months in the period range
|
||||
const startDate = new Date(period.period_start)
|
||||
const endDate = new Date(period.period_end)
|
||||
|
||||
for (
|
||||
let y = startDate.getFullYear(), m = startDate.getMonth();
|
||||
y < endDate.getFullYear() || (y === endDate.getFullYear() && m <= endDate.getMonth());
|
||||
m === 11 ? (y++, m = 0) : m++
|
||||
) {
|
||||
const key = `${y}-${String(m).padStart(2, '0')}`
|
||||
monthMap.set(key, { year: y, month: m, income: 0, expenses: 0 })
|
||||
}
|
||||
// Sum lines into per-month buckets (raw sums; assembleMonthlyBreakdown
|
||||
// rounds once per bucket), keyed year-aware for non-calendar fiscal years.
|
||||
const bucketMap = new Map<string, MonthlyBucket>()
|
||||
|
||||
for (const line of lines) {
|
||||
const entry = line.journal_entry as {
|
||||
@@ -101,18 +160,18 @@ export async function generateMonthlyBreakdown(
|
||||
const entryDate = new Date(entry.entry_date)
|
||||
const key = `${entryDate.getFullYear()}-${String(entryDate.getMonth()).padStart(2, '0')}`
|
||||
|
||||
if (!monthMap.has(key)) {
|
||||
monthMap.set(key, { year: entryDate.getFullYear(), month: entryDate.getMonth(), income: 0, expenses: 0 })
|
||||
let bucket = bucketMap.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { year: entryDate.getFullYear(), month0: entryDate.getMonth(), income: 0, expenses: 0 }
|
||||
bucketMap.set(key, bucket)
|
||||
}
|
||||
|
||||
const bucket = monthMap.get(key)!
|
||||
|
||||
if (accountClass === 3) {
|
||||
// Revenue accounts: credit side represents revenue
|
||||
bucket.income = Math.round((bucket.income + line.credit_amount - line.debit_amount) * 100) / 100
|
||||
bucket.income += line.credit_amount - line.debit_amount
|
||||
} else if (accountClass >= 4 && accountClass <= 7) {
|
||||
// Expense accounts: debit side represents expenses
|
||||
bucket.expenses = Math.round((bucket.expenses + line.debit_amount - line.credit_amount) * 100) / 100
|
||||
bucket.expenses += line.debit_amount - line.credit_amount
|
||||
} else if (accountClass === 8 && line.account_number !== '8999') {
|
||||
// Financial items (class 8): interest, exchange gains/losses, etc.
|
||||
// 8999 "Årets resultat" is a year-end closing account: its debit/credit
|
||||
@@ -120,26 +179,16 @@ export async function generateMonthlyBreakdown(
|
||||
// period's income-vs-expense signal on the month of closing.
|
||||
const amount = line.credit_amount - line.debit_amount
|
||||
if (amount >= 0) {
|
||||
bucket.income = Math.round((bucket.income + amount) * 100) / 100
|
||||
bucket.income += amount
|
||||
} else {
|
||||
bucket.expenses = Math.round((bucket.expenses + Math.abs(amount)) * 100) / 100
|
||||
bucket.expenses += Math.abs(amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted array (keys sort naturally as "YYYY-MM")
|
||||
const months: MonthlyBreakdownMonth[] = []
|
||||
const sortedKeys = Array.from(monthMap.keys()).sort()
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const data = monthMap.get(key)!
|
||||
months.push({
|
||||
label: MONTH_LABELS[data.month],
|
||||
income: data.income,
|
||||
expenses: data.expenses,
|
||||
net: Math.round((data.income - data.expenses) * 100) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
return { months }
|
||||
return assembleMonthlyBreakdown(
|
||||
period.period_start,
|
||||
period.period_end,
|
||||
Array.from(bucketMap.values())
|
||||
)
|
||||
}
|
||||
|
||||
+145
-121
@@ -49,18 +49,53 @@ export async function generateTrialBalance(
|
||||
isBalanced: boolean
|
||||
}> {
|
||||
|
||||
// Fetch period for opening balance computation
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const dimensionFilter =
|
||||
options?.dimensions && Object.keys(options.dimensions).length > 0
|
||||
? options.dimensions
|
||||
: undefined
|
||||
const excludeAllYearEndEntries = options?.excludeYearEndClosing
|
||||
|
||||
// Wave 1: the period row (for opening balance computation), the reversed
|
||||
// year-end entry ids (only needed for excludeYearEndClosing), and the
|
||||
// chart of accounts are mutually independent, so they share one parallel
|
||||
// round trip instead of three sequential ones. The accounts list is now
|
||||
// also fetched for reports that turn out empty or fail the closed-period
|
||||
// guard below; that occasional extra read-only query is the price of a
|
||||
// short critical path, and the returned data is unchanged.
|
||||
const [periodResult, yearEndIdRows, accounts] = await Promise.all([
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
excludeAllYearEndEntries
|
||||
? fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_type', 'year_end')
|
||||
.eq('status', 'reversed')
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
: Promise.resolve([] as Array<{ id: string }>),
|
||||
// Account names for row labelling.
|
||||
fetchAllRows<{
|
||||
account_number: string
|
||||
account_name: string
|
||||
account_class: number
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class')
|
||||
.eq('company_id', companyId)
|
||||
.order('account_number', { ascending: true })
|
||||
.range(from, to)
|
||||
),
|
||||
])
|
||||
const { data: period } = periodResult
|
||||
|
||||
// Existing operational reports intentionally exclude every year_end entry.
|
||||
// Statutory annual reports must exclude only the linked final closing entry:
|
||||
@@ -76,22 +111,7 @@ export async function generateTrialBalance(
|
||||
'Closed fiscal period is missing closing_entry_id; statutory pre-closing balances cannot be generated safely',
|
||||
)
|
||||
}
|
||||
const excludeAllYearEndEntries = options?.excludeYearEndClosing
|
||||
let yearEndEntryIds: string[] = []
|
||||
if (excludeAllYearEndEntries) {
|
||||
yearEndEntryIds = (
|
||||
await fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source_type', 'year_end')
|
||||
.eq('status', 'reversed')
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
).map((r) => r.id)
|
||||
}
|
||||
const yearEndEntryIds: string[] = yearEndIdRows.map((r) => r.id)
|
||||
const excludeYearEndChain = (query: EntryLinesQuery): EntryLinesQuery => {
|
||||
let q = query.neq('source_type', 'year_end')
|
||||
if (yearEndEntryIds.length > 0) {
|
||||
@@ -114,30 +134,79 @@ export async function generateTrialBalance(
|
||||
? query.or(`id.neq.${closingEntryId},status.neq.posted`)
|
||||
: query
|
||||
|
||||
// ── Opening balances (IB) at period_start ──────────────────────
|
||||
const { balances: obBalances, obEntryId } = await getOpeningBalances(
|
||||
supabase, companyId, period
|
||||
)
|
||||
// A dimension-filtered view cannot use company-wide opening balances (the
|
||||
// OB entry and the prior-period RPC are not dimension-aware). Drop them so
|
||||
// every reported amount is dimension-scoped activity: correct for the P&L
|
||||
// reports the filter is whitelisted for, and never fabricates balances if
|
||||
// misapplied. obEntryId is still needed to exclude the OB entry from lines.
|
||||
const openingBalances = dimensionFilter
|
||||
? new Map<string, { debit: number; credit: number }>()
|
||||
: obBalances
|
||||
// getOpeningBalances always reports the period's opening_balance_entry_id
|
||||
// back as obEntryId (see lib/reports/opening-balances.ts), so the id is
|
||||
// known before that fetch resolves and the line queries below can run in
|
||||
// the same round trip as the opening-balance read.
|
||||
const obEntryId = period?.opening_balance_entry_id ?? null
|
||||
|
||||
// ── Roll IB forward from period_start up to fromDate ───────────
|
||||
// When the caller requests a sub-range starting after period_start, the
|
||||
// "opening" of that window must include all activity since the period
|
||||
// started. We additively fold those lines into openingBalances so the
|
||||
// downstream IB/period split stays correct without changing call sites.
|
||||
if (
|
||||
options?.fromDate &&
|
||||
period?.period_start &&
|
||||
options.fromDate > period.period_start
|
||||
) {
|
||||
const priorLines = await fetchEntryLines<{
|
||||
// started (rolled forward below).
|
||||
const rollForwardWindow =
|
||||
options?.fromDate && period?.period_start && options.fromDate > period.period_start
|
||||
? { periodStart: period.period_start, fromDate: options.fromDate }
|
||||
: null
|
||||
|
||||
// Wave 2: opening balances (IB) at period_start, the IB roll-forward
|
||||
// slice, and the period lines are independent reads. The array order
|
||||
// [OB, roll-forward, lines] keeps each table's queries in the same order
|
||||
// the sequential version issued them.
|
||||
const [obResult, priorLines, lines] = await Promise.all([
|
||||
// ── Opening balances (IB) at period_start ──────────────────────
|
||||
getOpeningBalances(supabase, companyId, period),
|
||||
// ── Roll IB forward from period_start up to fromDate ───────────
|
||||
rollForwardWindow
|
||||
? fetchEntryLines<{
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>({
|
||||
supabase,
|
||||
lineColumns: 'id, account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) => {
|
||||
let query = q
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.gte('entry_date', rollForwardWindow.periodStart)
|
||||
.lt('entry_date', rollForwardWindow.fromDate)
|
||||
|
||||
if (obEntryId) {
|
||||
query = query.neq('id', obEntryId)
|
||||
}
|
||||
|
||||
if (excludeAllYearEndEntries) {
|
||||
query = excludeYearEndChain(query)
|
||||
}
|
||||
if (options?.excludeFinalClosingEntry) {
|
||||
query = excludeClosingEntry(query)
|
||||
}
|
||||
|
||||
return query
|
||||
},
|
||||
filterLines: dimensionFilter
|
||||
? // jsonb containment (@>): served by idx_jel_dimensions_gin.
|
||||
(q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter)
|
||||
: undefined,
|
||||
})
|
||||
: Promise.resolve(
|
||||
[] as Array<{
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>
|
||||
),
|
||||
// ── Period lines (excluding opening balance entry) ─────────────
|
||||
// If year-end closing set an OB entry, exclude it from period lines so
|
||||
// its values aren't double-counted (they're already captured as IB).
|
||||
// Race condition note: if year-end closing runs concurrently and sets
|
||||
// obEntryId between the period query and this query, the OB entry could
|
||||
// be missed from both IB and period. The window is sub-second and the
|
||||
// consequence is a single stale report: acceptable.
|
||||
fetchEntryLines<{
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
@@ -150,8 +219,19 @@ export async function generateTrialBalance(
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.gte('entry_date', period.period_start)
|
||||
.lt('entry_date', options.fromDate)
|
||||
|
||||
// Date filters are only applied when the caller explicitly asks. The
|
||||
// period itself is already enforced via fiscal_period_id, so adding
|
||||
// redundant entry_date bounds for the default case would just
|
||||
// increase query complexity (and break older mocks that don't stub gte
|
||||
// /lte). The fiscal_period_id constraint plus a CHECK on entry_date in
|
||||
// the engine keep activity inside the period.
|
||||
if (options?.fromDate) {
|
||||
query = query.gte('entry_date', options.fromDate)
|
||||
}
|
||||
if (options?.toDate) {
|
||||
query = query.lte('entry_date', options.toDate)
|
||||
}
|
||||
|
||||
if (obEntryId) {
|
||||
query = query.neq('id', obEntryId)
|
||||
@@ -170,87 +250,31 @@ export async function generateTrialBalance(
|
||||
? // jsonb containment (@>): served by idx_jel_dimensions_gin.
|
||||
(q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter)
|
||||
: undefined,
|
||||
})
|
||||
}),
|
||||
])
|
||||
|
||||
for (const line of priorLines) {
|
||||
const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
existing.debit += Number(line.debit_amount) || 0
|
||||
existing.credit += Number(line.credit_amount) || 0
|
||||
openingBalances.set(line.account_number, existing)
|
||||
}
|
||||
// A dimension-filtered view cannot use company-wide opening balances (the
|
||||
// OB entry and the prior-period RPC are not dimension-aware). Drop them so
|
||||
// every reported amount is dimension-scoped activity: correct for the P&L
|
||||
// reports the filter is whitelisted for, and never fabricates balances if
|
||||
// misapplied. obEntryId is still needed to exclude the OB entry from lines.
|
||||
const openingBalances = dimensionFilter
|
||||
? new Map<string, { debit: number; credit: number }>()
|
||||
: obResult.balances
|
||||
|
||||
// Additively fold the roll-forward lines into openingBalances so the
|
||||
// downstream IB/period split stays correct without changing call sites.
|
||||
for (const line of priorLines) {
|
||||
const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
existing.debit += Number(line.debit_amount) || 0
|
||||
existing.credit += Number(line.credit_amount) || 0
|
||||
openingBalances.set(line.account_number, existing)
|
||||
}
|
||||
|
||||
// ── Period lines (excluding opening balance entry) ─────────────
|
||||
// If year-end closing set an OB entry, exclude it from period lines so
|
||||
// its values aren't double-counted (they're already captured as IB).
|
||||
// Race condition note: if year-end closing runs concurrently and sets
|
||||
// obEntryId between the period query and this query, the OB entry could
|
||||
// be missed from both IB and period. The window is sub-second and the
|
||||
// consequence is a single stale report: acceptable.
|
||||
const lines = await fetchEntryLines<{
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>({
|
||||
supabase,
|
||||
lineColumns: 'id, account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) => {
|
||||
let query = q
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
|
||||
// Date filters are only applied when the caller explicitly asks. The
|
||||
// period itself is already enforced via fiscal_period_id, so adding
|
||||
// redundant entry_date bounds for the default case would just
|
||||
// increase query complexity (and break older mocks that don't stub gte
|
||||
// /lte). The fiscal_period_id constraint plus a CHECK on entry_date in
|
||||
// the engine keep activity inside the period.
|
||||
if (options?.fromDate) {
|
||||
query = query.gte('entry_date', options.fromDate)
|
||||
}
|
||||
if (options?.toDate) {
|
||||
query = query.lte('entry_date', options.toDate)
|
||||
}
|
||||
|
||||
if (obEntryId) {
|
||||
query = query.neq('id', obEntryId)
|
||||
}
|
||||
|
||||
if (excludeAllYearEndEntries) {
|
||||
query = excludeYearEndChain(query)
|
||||
}
|
||||
if (options?.excludeFinalClosingEntry) {
|
||||
query = excludeClosingEntry(query)
|
||||
}
|
||||
|
||||
return query
|
||||
},
|
||||
filterLines: dimensionFilter
|
||||
? // jsonb containment (@>): served by idx_jel_dimensions_gin.
|
||||
(q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (lines.length === 0 && openingBalances.size === 0) {
|
||||
return { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true }
|
||||
}
|
||||
|
||||
// Get account names
|
||||
const accounts = await fetchAllRows<{
|
||||
account_number: string
|
||||
account_name: string
|
||||
account_class: number
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class')
|
||||
.eq('company_id', companyId)
|
||||
.order('account_number', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const accountMap = new Map<string, { name: string; class: number }>()
|
||||
for (const acc of accounts) {
|
||||
accountMap.set(acc.account_number, {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
VatDeclaration,
|
||||
VatDeclarationRutor,
|
||||
VatPeriodType,
|
||||
AccountingMethod,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -410,8 +409,13 @@ export function rutorFromTotals(
|
||||
*
|
||||
* - ruta 49 = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
|
||||
*
|
||||
* The accounting method parameter is accepted for backward compatibility
|
||||
* but not used: the method is already baked into journal entry timing.
|
||||
* INVARIANT: the company's accounting method (faktureringsmetoden vs
|
||||
* kontantmetoden) needs no parameter here and must not become one. The method
|
||||
* is already baked into journal entry TIMING: kontantmetod companies post
|
||||
* VAT-bearing entries at payment date, faktureringsmetod companies at invoice
|
||||
* date, so summing posted lines per period is correct for both. A method
|
||||
* parameter existed until 2026-07-23 and was silently ignored; it was removed
|
||||
* so no future code path can branch on a value that callers hard-code.
|
||||
*/
|
||||
export async function calculateVatDeclaration(
|
||||
supabase: SupabaseClient,
|
||||
@@ -419,7 +423,6 @@ export async function calculateVatDeclaration(
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number,
|
||||
_accountingMethod: AccountingMethod = 'accrual',
|
||||
options: { fiscalPeriodId?: string } = {}
|
||||
): Promise<VatDeclaration> {
|
||||
// For yearly VAT this resolves to the räkenskapsår bounds (when a fiscal
|
||||
|
||||
@@ -307,12 +307,76 @@ export async function updateSession(request: NextRequest) {
|
||||
* we also upsert user_preferences so subsequent RLS lookups agree with
|
||||
* us without needing the fallback scan.
|
||||
*
|
||||
* RPC-first: `resolve_active_company()` collapses the whole resolution into
|
||||
* one round trip and is semantically identical to both the query path below
|
||||
* and `current_active_company_id()` (what RLS reads). `used_fallback` is
|
||||
* true exactly when the preference was missing, null, or stale, which is
|
||||
* exactly the condition under which the query path writes the resolved
|
||||
* company back to user_preferences: the write-back behavior is preserved.
|
||||
* Falls back to the query path on PGRST202 (self-hosted instance not
|
||||
* migrated yet, or a deploy racing the branch merge).
|
||||
*
|
||||
* Cannot use lib/company/context.ts because middleware runs on Edge.
|
||||
*/
|
||||
async function resolveCompanyForMiddleware(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
userId: string,
|
||||
_request: NextRequest
|
||||
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
|
||||
const { data, error } = await supabase.rpc('resolve_active_company')
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST202') {
|
||||
// Function not deployed here: use the query path.
|
||||
return resolveCompanyForMiddlewareViaQueries(supabase, userId, _request)
|
||||
}
|
||||
// Issue #1053: a FAILED call degrades (fail open), never reads as "no
|
||||
// companies". locale null is fine because the degraded flag already
|
||||
// suppresses the locale-cookie sync at the call site.
|
||||
console.error('[middleware] resolve_active_company rpc failed', error)
|
||||
return { companyId: null, locale: null, degraded: true }
|
||||
}
|
||||
|
||||
const row = Array.isArray(data) ? data[0] : data
|
||||
if (!row) {
|
||||
// Zero rows = NULL auth.uid(); impossible for the cookie-auth middleware
|
||||
// client, so treat as degraded rather than redirecting to onboarding.
|
||||
console.error('[middleware] resolve_active_company returned no row for authenticated user')
|
||||
return { companyId: null, locale: null, degraded: true }
|
||||
}
|
||||
|
||||
if (row.company_id && row.used_fallback) {
|
||||
// Write the fallback back to user_preferences so future RLS lookups see
|
||||
// the same active company without needing the fallback scan. Non-fatal
|
||||
// on failure: resolution already succeeded, but log it so silent
|
||||
// persistence failures (#701) are observable.
|
||||
const { error: writeBackError } = await supabase
|
||||
.from('user_preferences')
|
||||
.upsert(
|
||||
{ user_id: userId, active_company_id: row.company_id },
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
if (writeBackError) {
|
||||
console.error('[middleware] active company write-back failed', writeBackError)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
companyId: row.company_id ?? null,
|
||||
locale: row.locale ?? null,
|
||||
degraded: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
|
||||
* fallback for resolveCompanyForMiddleware (see the fallback conditions
|
||||
* there).
|
||||
*/
|
||||
async function resolveCompanyForMiddlewareViaQueries(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
userId: string,
|
||||
_request: NextRequest
|
||||
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
|
||||
// 1. user_preferences (authoritative) + first membership, fetched in
|
||||
// parallel: the fallback query result doubles as validation when the
|
||||
|
||||
Reference in New Issue
Block a user