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:
Mattsson
2026-07-23 16:16:55 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 43f7ccab9e
commit 288915c152
65 changed files with 4357 additions and 451 deletions
@@ -35,7 +35,7 @@ interface CapturedCall {
args: unknown[]
}
/** Chainable builder recording calls; resolves queued {data,error,count} per from(). */
/** Chainable builder recording calls; resolves queued {data,error,count} per from()/rpc(). */
function createCapturingSupabase(
results: { data?: unknown; error?: unknown; count?: number | null }[]
) {
@@ -60,6 +60,11 @@ function createCapturingSupabase(
calls.push({ method: 'from', args: [table] })
return makeBuilder()
},
rpc: (...args: unknown[]) => {
calls.push({ method: 'rpc', args })
const result = results[idx++] ?? { data: null, error: null, count: null }
return Promise.resolve({ data: result.data ?? null, error: result.error ?? null })
},
}
return { supabase, calls }
}
@@ -95,7 +100,7 @@ describe('GET /api/bookkeeping/accounts', () => {
expect(status).toBe(400)
})
it('lists accounts for the company', async () => {
it('lists accounts via the single-round-trip RPC', async () => {
const { supabase, calls } = createCapturingSupabase([
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }] },
])
@@ -105,11 +110,67 @@ describe('GET /api/bookkeeping/accounts', () => {
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
const rpcCall = calls.find((c) => c.method === 'rpc')
expect(rpcCall?.args).toEqual([
'list_company_accounts',
{ p_company_id: 'company-1', p_active_only: true, p_account_class: null },
])
// The RPC path must not also hit the table: exactly one round trip.
expect(calls.some((c) => c.method === 'from')).toBe(false)
})
it('maps ?class=3&active=false onto the RPC arguments', async () => {
const { supabase, calls } = createCapturingSupabase([{ data: [] }])
auth(supabase)
const req = createMockRequest('/api/bookkeeping/accounts', {
searchParams: { class: '3', active: 'false' },
})
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await listGET(req, routeParams)
)
expect(status).toBe(200)
expect(body.data).toEqual([])
const rpcCall = calls.find((c) => c.method === 'rpc')
expect(rpcCall?.args[1]).toEqual({
p_company_id: 'company-1',
p_active_only: false,
p_account_class: 3,
})
})
it('falls back to the paged fetch when the RPC is not deployed (PGRST202)', async () => {
const { supabase, calls } = createCapturingSupabase([
{ error: { code: 'PGRST202', message: 'function not found in schema cache' } },
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }] },
])
auth(supabase)
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(
await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
expect(calls.filter((c) => c.method === 'from').map((c) => c.args)).toContainEqual([
'chart_of_accounts',
])
expect(calls.filter((c) => c.method === 'eq').map((c) => c.args)).toContainEqual([
'company_id',
'company-1',
])
})
it('returns the legacy 500 { error: string } on a non-fallback RPC error', async () => {
const { supabase, calls } = createCapturingSupabase([
{ error: { code: 'XX000', message: 'boom' } },
])
auth(supabase)
const { status, body } = await parseJsonResponse<{ error: string }>(
await listGET(createMockRequest('/api/bookkeeping/accounts'), routeParams)
)
expect(status).toBe(500)
expect(typeof body.error).toBe('string')
// A non-deploy error must NOT silently fall back to the paged fetch.
expect(calls.some((c) => c.method === 'from')).toBe(false)
})
})
describe('POST /api/bookkeeping/accounts', () => {
+22
View File
@@ -26,6 +26,28 @@ export const GET = withRouteContext('bookkeeping.accounts.list', async (request,
const activeOnly = validated.data.active !== 'false'
try {
// Single-round-trip path: the RPC aggregates the whole list into one json
// scalar server-side, bypassing PostgREST's 1000-row page cap. Large
// charts (95/1250 prod companies exceed 1000 active accounts) previously
// paid 2-5 sequential cross-region round trips through fetchAllRows.
const rpc = await supabase.rpc('list_company_accounts', {
p_company_id: companyId,
p_active_only: activeOnly,
p_account_class: accountClass ?? null,
})
if (!rpc.error) return NextResponse.json({ data: rpc.data ?? [] })
if (rpc.error.code === 'PGRST202' || rpc.error.code === '42883' || rpc.error.code === '42501') {
// Function not deployed yet (self-hosted instance not migrated, or the
// deploy-ordering window before the branching merge applies the
// migration) or EXECUTE not granted: fall back to the paged fetch.
// Mirrors the load-bearing fallback in lib/company/context.ts.
log.warn('list_company_accounts RPC unavailable, falling back to paged fetch', {
code: rpc.error.code,
})
} else {
throw new Error(rpc.error.message)
}
const data = await fetchAllRows(({ from, to }) => {
let query = supabase
.from('chart_of_accounts')
@@ -84,14 +84,23 @@ describe('annual report compliance route', () => {
})
it('returns 404 for another company period', async () => {
const { enqueue } = setup()
enqueue({ data: null })
// GET has no periodExists preflight: the 404 comes from the report
// builder throwing 'Fiscal period not found' (same company_id filter).
setup()
vi.mocked(buildCanonicalAnnualReport).mockRejectedValue(
new Error('Fiscal period not found'),
)
expect((await GET(createMockRequest('/x'), params)).status).toBe(404)
})
it('maps unexpected builder failures to the generic error envelope', async () => {
setup()
vi.mocked(buildCanonicalAnnualReport).mockRejectedValue(new Error('boom'))
expect((await GET(createMockRequest('/x'), params)).status).toBe(500)
})
it('returns the canonical compliance result', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
setup()
const { status, body } = await parseJsonResponse<{ data: typeof model }>(
await GET(createMockRequest('/x'), params),
)
@@ -99,6 +108,20 @@ describe('annual report compliance route', () => {
expect(body.data.validation.ok).toBe(true)
})
it('keeps the periodExists preflight on PATCH (404 before any write)', async () => {
const { enqueue } = setup()
enqueue({ data: null })
const response = await PATCH(
createMockRequest('/x', {
method: 'PATCH',
body: { is_public_limited_company: false },
}),
params,
)
expect(response.status).toBe(404)
expect(upsertAnnualReportProfile).not.toHaveBeenCalled()
})
it('persists legal facts and confirmation timestamps', async () => {
const { enqueue } = setup()
enqueue({ data: { id: 'period-1' } })
@@ -70,15 +70,22 @@ export const GET = withRouteContext(
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
if (!(await periodExists(supabase, companyId, id))) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
const model = await buildCanonicalAnnualReport(supabase, companyId, id, {
stage: 'draft',
includeIxbrl: false,
})
return NextResponse.json({ data: responseData(model) })
} catch (err) {
// No periodExists preflight here: buildArsredovisningData applies the
// same company_id filter and throws 'Fiscal period not found' for
// missing/foreign periods, so mapping that message (mirroring the data
// route) yields the identical 404 envelope without the extra round
// trip. PATCH keeps the preflight since it guards the profile upsert
// before any write.
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
return errorResponse(err, log, { requestId })
}
},
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
// Exercised through the real withRouteContext wrapper: mock its auth/company
// dependencies and inject the Supabase client via requireAuth.
const { supabase: mockSupabase } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn(),
readCachedRate: vi.fn(),
}))
vi.mock('@/lib/sandbox/guard', () => ({
guardSandbox: vi.fn(),
}))
// Sentinel service client: the route must pass THIS client (not the user
// client) to the cache read and to fetchExchangeRate, so the first live
// fetch can warm the shared exchange_rates cache (INSERT is service-role
// only since migration 20260710100000).
const serviceSentinel = {}
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => serviceSentinel),
}))
import { GET } from '../route'
import { fetchExchangeRate, readCachedRate } from '@/lib/currency/riksbanken'
import { guardSandbox } from '@/lib/sandbox/guard'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function makeReq(query: string) {
return new Request(`http://localhost/api/currency/rate${query}`)
}
const noParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
vi.mocked(guardSandbox).mockResolvedValue(null)
vi.mocked(readCachedRate).mockResolvedValue(null)
vi.mocked(fetchExchangeRate).mockResolvedValue(null)
})
describe('GET /api/currency/rate', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(makeReq('?currency=EUR'), noParams)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 for an invalid currency', async () => {
const res = await GET(makeReq('?currency=CHF'), noParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('Invalid currency')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('returns 400 for a malformed date', async () => {
const res = await GET(makeReq('?currency=EUR&date=2025-1-5'), noParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('Invalid date (expected YYYY-MM-DD)')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('returns 400 for a shape-valid but impossible date', async () => {
// Passes the YYYY-MM-DD regex but parses to an Invalid Date.
const res = await GET(makeReq('?currency=EUR&date=2025-13-45'), noParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('Invalid date (expected YYYY-MM-DD)')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('returns 403 for sandbox companies without any Riksbanken traffic', async () => {
vi.mocked(guardSandbox).mockResolvedValue(
NextResponse.json({ error: 'Inte tillgängligt i sandlådan.' }, { status: 403 }),
)
// Even with a cache miss the external fetch must never fire.
vi.mocked(readCachedRate).mockResolvedValue(null)
const res = await GET(makeReq('?currency=EUR&date=2025-01-15'), noParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(403)
expect(guardSandbox).toHaveBeenCalledWith(mockSupabase, 'company-1')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('serves a cache hit without calling fetchExchangeRate', async () => {
vi.mocked(readCachedRate).mockResolvedValue({
currency: 'EUR',
rate: 11.11,
date: '2025-01-15',
})
const res = await GET(makeReq('?currency=EUR&date=2025-01-15'), noParams)
const { status, body } = await parseJsonResponse<{
data: { currency: string; rate: number; date: string }
}>(res)
expect(status).toBe(200)
expect(body).toEqual({ data: { currency: 'EUR', rate: 11.11, date: '2025-01-15' } })
expect(readCachedRate).toHaveBeenCalledWith(serviceSentinel, 'EUR', '2025-01-15')
expect(fetchExchangeRate).not.toHaveBeenCalled()
})
it('falls through to fetchExchangeRate with the service client on cache miss', async () => {
vi.mocked(readCachedRate).mockResolvedValue(null)
vi.mocked(fetchExchangeRate).mockResolvedValue({
currency: 'EUR',
rate: 11.42,
date: '2025-01-15',
})
const res = await GET(makeReq('?currency=EUR&date=2025-01-15'), noParams)
const { status, body } = await parseJsonResponse<{
data: { currency: string; rate: number; date: string }
}>(res)
expect(status).toBe(200)
expect(body).toEqual({ data: { currency: 'EUR', rate: 11.42, date: '2025-01-15' } })
expect(readCachedRate).toHaveBeenCalledWith(serviceSentinel, 'EUR', '2025-01-15')
expect(fetchExchangeRate).toHaveBeenCalledWith('EUR', expect.any(Date), serviceSentinel)
})
it('returns 502 when both the cache and Riksbanken come up empty', async () => {
vi.mocked(readCachedRate).mockResolvedValue(null)
vi.mocked(fetchExchangeRate).mockResolvedValue(null)
const res = await GET(makeReq('?currency=EUR&date=2025-01-15'), noParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(502)
expect(body.error).toBe('Could not fetch exchange rate')
})
})
+27 -7
View File
@@ -1,20 +1,18 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { fetchExchangeRate, readCachedRate } from '@/lib/currency/riksbanken'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { guardSandbox } from '@/lib/sandbox/guard'
import type { Currency } from '@/types'
const VALID_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
// Riksbanken's open API is IP rate-limited — the sandbox guard keeps demo
// Riksbanken's open API is IP rate-limited: the sandbox guard keeps demo
// traffic from eating that budget (withRouteContext already refuses
// sessions without an active company).
export const GET = withRouteContext('currency.rate', async (request, ctx) => {
const { supabase, companyId } = ctx
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
const { searchParams } = new URL(request.url)
const currency = searchParams.get('currency') as Currency | null
const dateStr = searchParams.get('date')
@@ -23,14 +21,36 @@ export const GET = withRouteContext('currency.rate', async (request, ctx) => {
return NextResponse.json({ error: 'Invalid currency' }, { status: 400 })
}
// Reject malformed dates up front — an Invalid Date would otherwise reach
// Reject malformed dates up front: an Invalid Date would otherwise reach
// the Riksbanken request as "NaN-NaN-NaN".
if (dateStr && !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
return NextResponse.json({ error: 'Invalid date (expected YYYY-MM-DD)' }, { status: 400 })
}
const date = dateStr ? new Date(dateStr) : undefined
const rate = await fetchExchangeRate(currency, date)
// The regex accepts shapes like 2025-13-45 that still parse to an Invalid
// Date; catch those here so toISOString below cannot throw.
if (date && Number.isNaN(date.getTime())) {
return NextResponse.json({ error: 'Invalid date (expected YYYY-MM-DD)' }, { status: 400 })
}
// Same cache key fetchExchangeRate computes internally.
const formattedDate = (date ?? new Date()).toISOString().split('T')[0]
// exchange_rates is tenant-free public reference data (no company_id,
// SELECT policy USING(true)), so a service-role read is safe here, and it
// is the only client that can also WARM the cache: INSERT is service-role
// only since migration 20260710100000. The cache read runs in parallel
// with the sandbox guard (both are DB-only round trips); no Riksbanken
// traffic happens until the guard has resolved false, and only on a miss.
const service = createServiceClientNoCookies()
const [blocked, cached] = await Promise.all([
guardSandbox(supabase, companyId),
readCachedRate(service, currency, formattedDate),
])
if (blocked) return blocked
const rate = cached ?? (await fetchExchangeRate(currency, date, service))
if (!rate) {
return NextResponse.json({ error: 'Could not fetch exchange rate' }, { status: 502 })
+73 -3
View File
@@ -4,6 +4,7 @@ import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeDocumentAttachment,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
@@ -26,7 +27,7 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
import { DELETE } from '../route'
import { GET, DELETE } from '../route'
import { requireWritePermission } from '@/lib/auth/require-write'
import { NextResponse } from 'next/server'
@@ -41,10 +42,79 @@ beforeEach(() => {
vi.mocked(requireWritePermission).mockResolvedValue({ ok: true })
})
function makeReq() {
return new Request('http://localhost/api/documents/doc-1', { method: 'DELETE' })
function makeReq(method: 'GET' | 'DELETE' = 'DELETE') {
return new Request('http://localhost/api/documents/doc-1', { method })
}
describe('GET /api/documents/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when the document is not found in the company', async () => {
enqueue({ data: null, error: null }) // doc lookup
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(404)
expect(body.error).toBe('Document not found')
})
it('returns 500 when the signed URL cannot be created', async () => {
enqueue({ data: makeDocumentAttachment({ id: 'doc-1' }), error: null })
mockSupabase.storage.from.mockReturnValueOnce({
createSignedUrl: vi.fn().mockResolvedValue({ data: null, error: { message: 'boom' } }),
} as never)
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(500)
expect(body.error).toContain('Failed to create download URL')
})
it('returns the document with a signed download URL and emits document.accessed', async () => {
const row = makeDocumentAttachment({
id: 'doc-1',
file_name: 'kvitto.pdf',
storage_path: 'documents/user-1/kvitto.pdf',
})
enqueue({ data: row, error: null })
const handler = vi.fn()
eventBus.on('document.accessed', handler)
const res = await GET(makeReq('GET'), createMockRouteParams({ id: 'doc-1' }))
const { status, body } = await parseJsonResponse<{
data: { id: string; download_url: string }
}>(res)
expect(status).toBe(200)
expect(body.data.id).toBe('doc-1')
expect(body.data.download_url).toBe('https://example.com/signed')
expect(mockSupabase.storage.from).toHaveBeenCalledWith('documents')
const storageBucket = mockSupabase.storage.from.mock.results[0]?.value
expect(storageBucket.createSignedUrl).toHaveBeenCalledWith('documents/user-1/kvitto.pdf', 3600)
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
document: expect.objectContaining({ id: 'doc-1', file_name: 'kvitto.pdf' }),
userId: 'user-1',
companyId: 'company-1',
}),
)
})
})
describe('DELETE /api/documents/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
+18 -13
View File
@@ -28,10 +28,24 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Create signed download URL (60 minutes)
const { data: signedUrl, error: signError } = await supabase.storage
.from('documents')
.createSignedUrl(doc.storage_path, 3600)
// Sign the download URL (60 minutes) and persist the access event in
// parallel: both depend only on the row fetch and are independent of
// each other. The emit stays awaited (event-log-handler's insert must
// not race Vercel function suspension) and never rejects (the bus
// settles handlers via Promise.allSettled), so it cannot fail this
// Promise.all.
const [signResult] = await Promise.all([
supabase.storage.from('documents').createSignedUrl(doc.storage_path, 3600),
eventBus.emit({
type: 'document.accessed',
payload: {
document: { id: doc.id, file_name: doc.file_name },
userId: user.id,
companyId,
},
}),
])
const { data: signedUrl, error: signError } = signResult
if (signError) {
return NextResponse.json(
@@ -40,15 +54,6 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
)
}
await eventBus.emit({
type: 'document.accessed',
payload: {
document: { id: doc.id, file_name: doc.file_name },
userId: user.id,
companyId,
},
})
return NextResponse.json({
data: {
...doc,
@@ -107,6 +107,7 @@ describe('GET /api/invoices/[id]/deliveries', () => {
provider: 'resend',
error_code: null,
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
sent_at: '2026-07-22T10:30:00.000Z',
failed_at: null,
created_at: '2026-07-22T10:29:59.000Z',
@@ -118,7 +119,6 @@ describe('GET /api/invoices/[id]/deliveries', () => {
expect(body.data[0]).not.toHaveProperty('body_text')
expect(body.data[0]).not.toHaveProperty('body_html')
expect(body.data[0]).not.toHaveProperty('provider_message_id')
expect(body.data[0]).not.toHaveProperty('attachment_filename')
expect(body.data[0]).not.toHaveProperty('attachment_content_type')
expect(body.data[0]).not.toHaveProperty('attachment_sha256')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
+7 -3
View File
@@ -13,6 +13,7 @@ interface InvoiceDeliverySummaryRow {
provider: string | null
error_code: string | null
document_attachment_id: string | null
attachment_filename: string | null
sent_at: string | null
failed_at: string | null
created_at: string
@@ -31,9 +32,11 @@ interface MaskedInvoiceDeliverySummaryRow
*
* Returns minimized delivery metadata for an invoice. Exact message content,
* BCC recipients, provider identifiers, checksums, and full recipient
* addresses stay server-side. The database allow-list and masking boundary is
* defined by list_invoice_delivery_summaries in migration 20260723003000; this
* route masks returned addresses again as defense in depth.
* addresses stay server-side. The attachment filename passes through: it is
* derived from data the invoice already exposes to every company member. The
* database allow-list and masking boundary is defined by
* list_invoice_delivery_summaries in migration 20260723150000; this route
* masks returned addresses again as defense in depth.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.deliveries.list',
@@ -78,6 +81,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
provider: delivery.provider,
error_code: delivery.error_code,
document_attachment_id: delivery.document_attachment_id,
attachment_filename: delivery.attachment_filename,
sent_at: delivery.sent_at,
failed_at: delivery.failed_at,
created_at: delivery.created_at,
+288
View File
@@ -0,0 +1,288 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { KPIReport } from '@/types'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
// Legacy generators are mocked as spies: the no-dimension hot path must never
// call them, the dimension path must still route through them. The pure
// builders (buildIncomeStatementFromRows, assembleMonthlyBreakdown) stay real
// so the happy path asserts the full KPI JSON end to end.
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('@/lib/reports/ar-ledger', () => ({
generateARLedger: vi.fn(),
}))
vi.mock('@/lib/reports/income-statement', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/reports/income-statement')>()
return { ...actual, generateIncomeStatement: vi.fn() }
})
vi.mock('@/lib/reports/monthly-breakdown', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/reports/monthly-breakdown')>()
return { ...actual, generateMonthlyBreakdown: vi.fn() }
})
import { GET } from '../route'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const mockARLedger = vi.mocked(generateARLedger)
const mockIncomeStatement = vi.mocked(generateIncomeStatement)
const mockMonthlyBreakdown = vi.mocked(generateMonthlyBreakdown)
const noParams = { params: Promise.resolve({}) }
function authed() {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
function unauthed() {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
function makePeriod(overrides: Record<string, unknown> = {}) {
return {
id: 'period-1',
company_id: 'company-1',
period_start: '2026-01-01',
period_end: '2026-03-31',
is_closed: false,
opening_balance_entry_id: null,
...overrides,
}
}
/** Wire payload as returned by the get_kpi_report_aggregates RPC. */
function aggPayload() {
return {
tb: [
{ account_number: '1930', debit: 12500, credit: 0 },
{ account_number: '2611', debit: 0, credit: 2500 },
{ account_number: '3001', debit: 0, credit: 10000 },
{ account_number: '5010', debit: 3000, credit: 0 },
],
tb_ex_year_end: [
{ account_number: '1930', debit: 12500, credit: 0 },
{ account_number: '2611', debit: 0, credit: 2500 },
{ account_number: '3001', debit: 0, credit: 10000 },
{ account_number: '5010', debit: 3000, credit: 0 },
],
ob: [],
monthly: [
{ year: 2026, month: 1, income: 10000, expenses: 0 },
{ year: 2026, month: 2, income: 0, expenses: 3000 },
],
}
}
const CHART = [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
{ account_number: '5010', account_name: 'Lokalhyra', account_class: 5 },
]
const PAID_INVOICES = Array.from({ length: 5 }, () => ({
invoice_date: '2026-01-01',
paid_at: '2026-01-11',
}))
const SUPPLIER_ROWS = [
{ supplier_id: 'sup-1', total_sek: 400, total: 400, supplier: { id: 'sup-1', name: 'Leverantören AB' } },
{ supplier_id: 'sup-1', total_sek: 100, total: 100, supplier: { id: 'sup-1', name: 'Leverantören AB' } },
{ supplier_id: 'sup-2', total_sek: 200, total: 200, supplier: { id: 'sup-2', name: 'Andra AB' } },
]
function kpiRequest(searchParams: Record<string, string> = { period_id: 'period-1' }) {
return createMockRequest('/api/reports/kpi', { searchParams })
}
beforeEach(() => {
vi.clearAllMocks()
reset()
authed()
mockARLedger.mockResolvedValue({ total_outstanding: 1500, total_overdue: 500 } as never)
})
describe('GET /api/reports/kpi', () => {
it('returns 401 when not authenticated', async () => {
unauthed()
const res = await GET(kpiRequest(), noParams)
expect(res.status).toBe(401)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns 400 when period_id is missing', async () => {
const res = await GET(kpiRequest({}), noParams)
expect(res.status).toBe(400)
})
it('returns 404 for an unknown fiscal period', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const res = await GET(kpiRequest(), noParams)
expect(res.status).toBe(404)
})
it('returns 400 for a half-provided dimension pair', async () => {
enqueue({ data: makePeriod() })
const res = await GET(kpiRequest({ period_id: 'period-1', dim_no: '6' }), noParams)
expect(res.status).toBe(400)
})
it('happy path: builds the full KPI report from one aggregate round trip', async () => {
enqueue({ data: makePeriod() }) // fiscal_periods
enqueue({ data: aggPayload() }) // rpc get_kpi_report_aggregates
enqueue({ data: [{ account_number: '1930', debit: 5000, credit: 0 }] }) // rpc compute_prior_opening_balances
enqueue({ data: CHART }) // chart_of_accounts
enqueue({ data: null }) // extension_data prefs (no row)
enqueue({ data: PAID_INVOICES }) // invoices
enqueue({ data: SUPPLIER_ROWS }) // supplier_invoices
const res = await GET(kpiRequest(), noParams)
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({
netResult: 7000,
cashPosition: 17500, // 5000 IB + 12500 period debit on 1930
outstandingReceivables: 1500,
overdueReceivables: 500,
vatLiability: 2500,
totalRevenue: 10000,
totalExpenses: 3000,
grossMargin: 100,
expenseRatio: 30,
avgPaymentDays: 10,
periodComplete: false,
months: [
{ label: 'Jan', income: 10000, expenses: 0, net: 10000 },
{ label: 'Feb', income: 0, expenses: 3000, net: -3000 },
{ label: 'Mar', income: 0, expenses: 0, net: 0 },
],
period: { start: '2026-01-01', end: '2026-03-31' },
expenseComposition: { class4: 0, class5: 3000, class6: 0, class7: 0 },
topSuppliers: [
{ supplier_id: 'sup-1', supplier_name: 'Leverantören AB', total: 500 },
{ supplier_id: 'sup-2', supplier_name: 'Andra AB', total: 200 },
],
})
expect(supabase.rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
p_company_id: 'company-1',
p_fiscal_period_id: 'period-1',
p_ob_entry_id: null,
})
expect(supabase.rpc).toHaveBeenCalledWith('compute_prior_opening_balances', {
p_company_id: 'company-1',
p_period_start: '2026-01-01',
})
// The hot path must not touch the legacy line-scanning generators.
expect(mockTrialBalance).not.toHaveBeenCalled()
expect(mockIncomeStatement).not.toHaveBeenCalled()
expect(mockMonthlyBreakdown).not.toHaveBeenCalled()
})
it('skips the prior-balance RPC when the period has an opening balance entry', async () => {
enqueue({ data: makePeriod({ opening_balance_entry_id: 'ob-1' }) }) // fiscal_periods
enqueue({
data: {
...aggPayload(),
ob: [
{ account_number: '1930', debit: 5000, credit: 0 },
{ account_number: '2081', debit: 0, credit: 5000 },
],
},
}) // rpc get_kpi_report_aggregates (no prior RPC follows)
enqueue({ data: CHART }) // chart_of_accounts
enqueue({ data: null }) // extension_data prefs
enqueue({ data: [] }) // invoices
enqueue({ data: [] }) // supplier_invoices
const res = await GET(kpiRequest(), noParams)
const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res)
expect(status).toBe(200)
expect(body.data.cashPosition).toBe(17500) // OB 5000 + period 12500 on 1930
expect(supabase.rpc).toHaveBeenCalledTimes(1)
expect(supabase.rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', {
p_company_id: 'company-1',
p_fiscal_period_id: 'period-1',
p_ob_entry_id: 'ob-1',
})
})
it('dimension-filtered path still uses the legacy generators and never the RPC', async () => {
enqueue({ data: makePeriod() }) // fiscal_periods
enqueue({ data: null }) // extension_data prefs
enqueue({ data: [] }) // invoices
enqueue({ data: [] }) // supplier_invoices
mockIncomeStatement.mockResolvedValue({
revenue_sections: [],
total_revenue: 0,
expense_sections: [],
total_expenses: 0,
financial_sections: [],
total_financial: 0,
net_result: 0,
period: { start: '', end: '' },
})
mockTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
mockMonthlyBreakdown.mockResolvedValue({ months: [] })
const res = await GET(
kpiRequest({ period_id: 'period-1', dim_no: '6', dim_code: 'P001' }),
noParams
)
expect(res.status).toBe(200)
const dimensions = { '6': 'P001' }
expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'period-1', {
dimensions,
})
expect(mockMonthlyBreakdown).toHaveBeenCalledWith(supabase, 'company-1', 'period-1', {
dimensions,
})
// Unfiltered TB for balance-side KPIs + dimension-scoped TB for the
// expense composition.
expect(mockTrialBalance).toHaveBeenCalledTimes(2)
expect(mockTrialBalance).toHaveBeenNthCalledWith(1, supabase, 'company-1', 'period-1')
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, supabase, 'company-1', 'period-1', {
dimensions,
})
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('returns 500 when the aggregates RPC fails', async () => {
enqueue({ data: makePeriod() }) // fiscal_periods
enqueue({ data: null, error: { message: 'connection reset' } }) // rpc get_kpi_report_aggregates
const res = await GET(kpiRequest(), noParams)
expect(res.status).toBe(500)
const body = (await res.json()) as { error: { code: string } }
expect(body.error).toBeDefined()
})
})
+142 -39
View File
@@ -1,9 +1,22 @@
import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import {
generateIncomeStatement,
buildIncomeStatementFromRows,
} from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
import { generateARLedger, type ARLedgerReport } from '@/lib/reports/ar-ledger'
import {
generateMonthlyBreakdown,
assembleMonthlyBreakdown,
type MonthlyBreakdown,
} from '@/lib/reports/monthly-breakdown'
import {
fetchKpiAggregates,
buildOpeningBalances,
buildTrialBalanceRows,
} from '@/lib/reports/kpi-aggregates'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
calculateCashPosition,
calculateGrossMargin,
@@ -13,7 +26,12 @@ import {
} from '@/lib/reports/kpi'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
import type { KPIReport, KPIPreferences } from '@/types'
import type {
KPIReport,
KPIPreferences,
IncomeStatementReport,
TrialBalanceRow,
} from '@/types'
export const GET = withRouteContext('report.kpi', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
@@ -44,52 +62,137 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co
}
const dimensions = dimFilter.dimensions
// Load user preferences for account overrides
const { data: prefsData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'core/kpi')
.eq('key', 'preferences')
.single()
const preferences = mergeWithDefaults(
(prefsData?.value as Partial<KPIPreferences>) ?? {}
)
const [
incomeStatement,
trialBalanceResult,
arLedger,
monthlyBreakdown,
paidInvoicesResult,
topSuppliersResult,
filteredTrialBalance,
] = await Promise.all([
generateIncomeStatement(supabase, companyId, periodId, { dimensions }),
generateTrialBalance(supabase, companyId, periodId),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId, { dimensions }),
// The company-wide queries both paths share. Factories, not promises, so
// each Promise.all issues them inside its own single round-trip wave.
const prefsQuery = () =>
supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'core/kpi')
.eq('key', 'preferences')
.single()
const paidInvoicesQuery = () =>
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null),
.not('paid_at', 'is', null)
const topSuppliersQuery = () =>
supabase
.from('supplier_invoices')
.select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
.eq('company_id', companyId)
.gte('invoice_date', period.period_start)
.lte('invoice_date', period.period_end)
.neq('status', 'credited'),
// Second, dimension-scoped TB only when filtered: feeds the expense
// composition (classes 4-7, P&L) without touching the unfiltered TB the
// balance-side KPIs read.
dimensions
? generateTrialBalance(supabase, companyId, periodId, { dimensions })
: Promise.resolve(null),
])
.neq('status', 'credited')
let prefsValue: unknown
let incomeStatement: IncomeStatementReport
let trialBalanceResult: { rows: TrialBalanceRow[] }
let arLedger: ARLedgerReport
let monthlyBreakdown: MonthlyBreakdown
let paidInvoicesResult: { data: Array<{ invoice_date: string; paid_at: string }> | null }
let topSuppliersResult: { data: unknown[] | null; error: unknown }
let filteredTrialBalance: { rows: TrialBalanceRow[] } | null
if (dimensions) {
// Dimension-filtered path: the legacy generators, unchanged. The second,
// dimension-scoped TB feeds the expense composition (classes 4-7, P&L)
// without touching the unfiltered TB the balance-side KPIs read.
const [prefsRes, is, tb, ar, mb, paid, sup, filteredTb] = await Promise.all([
prefsQuery(),
generateIncomeStatement(supabase, companyId, periodId, { dimensions }),
generateTrialBalance(supabase, companyId, periodId),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId, { dimensions }),
paidInvoicesQuery(),
topSuppliersQuery(),
generateTrialBalance(supabase, companyId, periodId, { dimensions }),
])
prefsValue = prefsRes.data?.value
incomeStatement = is
trialBalanceResult = tb
arLedger = ar
monthlyBreakdown = mb
paidInvoicesResult = paid
topSuppliersResult = sup
filteredTrialBalance = filteredTb
} else {
// Hot path (no dimension filter): one Promise.all round trip. The
// get_kpi_report_aggregates RPC replaces three full journal-line scans
// (unfiltered TB, income-statement TB, monthly breakdown) with a single
// SQL pass; the pure builders below reproduce the legacy merge/rounding.
const obEntryId: string | null = period.opening_balance_entry_id ?? null
const [agg, priorResult, accounts, prefsRes, ar, paid, sup] = await Promise.all([
fetchKpiAggregates(supabase, companyId, periodId, obEntryId),
// Opening balances without an OB entry fall back to the server-side
// prior-period aggregate, exactly like getOpeningBalances.
obEntryId
? Promise.resolve(null)
: supabase.rpc('compute_prior_opening_balances', {
p_company_id: companyId,
p_period_start: period.period_start,
}),
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)
),
prefsQuery(),
generateARLedger(supabase, companyId),
paidInvoicesQuery(),
topSuppliersQuery(),
])
if (priorResult?.error) {
// Mirrors the fallback branch of lib/reports/opening-balances.ts.
throw new Error(priorResult.error.message)
}
const accountMap = new Map<string, { name: string; class: number }>()
for (const acc of accounts) {
accountMap.set(acc.account_number, {
name: acc.account_name,
class: acc.account_class,
})
}
const openingBalances = buildOpeningBalances(
agg,
obEntryId ? null : (priorResult?.data ?? [])
)
trialBalanceResult = { rows: buildTrialBalanceRows(openingBalances, agg.tb, accountMap) }
const rowsExYearEnd = buildTrialBalanceRows(openingBalances, agg.tb_ex_year_end, accountMap)
incomeStatement = buildIncomeStatementFromRows(rowsExYearEnd)
monthlyBreakdown = assembleMonthlyBreakdown(
period.period_start,
period.period_end,
agg.monthly.map((m) => ({
year: m.year,
month0: m.month - 1,
income: m.income,
expenses: m.expenses,
}))
)
prefsValue = prefsRes.data?.value
arLedger = ar
paidInvoicesResult = paid
topSuppliersResult = sup
filteredTrialBalance = null
}
const preferences = mergeWithDefaults(
(prefsValue as Partial<KPIPreferences>) ?? {}
)
// Cash position: use account overrides if set
const cashOverrides = preferences.accountOverrides['cashPosition']
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
const mockSupabase = {
auth: { getUser: vi.fn() },
from: vi.fn(),
rpc: vi.fn(),
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
// Deliberately NOT mocking @/lib/reports/vat-declaration: the test drives the
// real calculateVatDeclaration -> fetchVatAccountTotals path off the rpc mock,
// so the ruta assertions below prove the öre-exact projection end to end.
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
const mockUser = { id: 'user-1', email: 'test@test.se' }
/** Wire payload as returned by the get_vat_declaration_totals RPC. */
function rpcPayload() {
return {
totals: [
{ account_number: '3001', debit: 0, credit: 100000 },
{ account_number: '2611', debit: 0, credit: 25000 },
{ account_number: '2641', debit: 3200, credit: 0 },
],
settlement_shaped_entries: [],
source_type_counts: { invoice_created: 2, bank_transaction: 3, manual: 1 },
}
}
function makeRequest(query: string) {
return new Request(`http://localhost/api/reports/vat-declaration${query}`)
}
describe('GET /api/reports/vat-declaration', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
mockSupabase.rpc.mockResolvedValue({ data: rpcPayload(), error: null })
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(
makeRequest('?periodType=quarterly&year=2026&period=3'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(401)
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('returns 400 when required params are missing', async () => {
const res = await GET(makeRequest(''), { params: Promise.resolve({}) })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_MISSING_PARAMS')
})
it('returns 400 for an invalid periodType', async () => {
const res = await GET(
makeRequest('?periodType=weekly&year=2026&period=1'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_INVALID_PERIOD_TYPE')
})
it('returns 400 for an invalid year', async () => {
const res = await GET(
makeRequest('?periodType=monthly&year=1999&period=1'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_INVALID_YEAR')
})
it('returns 400 for out-of-range periods per period type', async () => {
for (const query of [
'?periodType=monthly&year=2026&period=13',
'?periodType=quarterly&year=2026&period=5',
'?periodType=yearly&year=2026&period=2',
]) {
const res = await GET(makeRequest(query), { params: Promise.resolve({}) })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_INVALID_PERIOD')
}
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('happy path quarterly: öre-exact rutor from a single RPC round trip', async () => {
const res = await GET(
makeRequest('?periodType=quarterly&year=2026&period=3'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.rutor.ruta05).toBe(100000)
expect(body.data.rutor.ruta10).toBe(25000)
expect(body.data.rutor.ruta48).toBe(3200)
expect(body.data.rutor.ruta49).toBe(21800)
expect(body.data.breakdown.invoices.base25).toBe(100000)
expect(body.data.invoiceCount).toBe(2)
expect(body.data.transactionCount).toBe(3)
expect(body.data.periodLabel).toBe('Kvartal 3 2026')
// Regression guard: the dead company_settings round trip is gone and
// resolvePeriodDates makes no DB call for calendar quarters, so the
// handler issues exactly one PostgREST call: the totals RPC.
expect(mockSupabase.from).not.toHaveBeenCalled()
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'get_vat_declaration_totals',
expect.objectContaining({
p_company_id: 'company-1',
p_start: '2026-07-01',
p_end: '2026-09-30',
}),
)
})
it('happy path monthly: no table queries, one RPC', async () => {
const res = await GET(
makeRequest('?periodType=monthly&year=2026&period=7'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.rutor.ruta49).toBe(21800)
expect(body.data.periodLabel).toBe('Juli 2026')
expect(mockSupabase.from).not.toHaveBeenCalled()
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'get_vat_declaration_totals',
expect.objectContaining({ p_start: '2026-07-01', p_end: '2026-07-31' }),
)
})
it('returns the VAT_REPORT_GENERATION_FAILED envelope when the RPC errors', async () => {
mockSupabase.rpc.mockResolvedValue({
data: null,
error: { message: 'connection reset' },
})
const res = await GET(
makeRequest('?periodType=quarterly&year=2026&period=3'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error.code).toBe('VAT_REPORT_GENERATION_FAILED')
})
})
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import { buildESkdFile } from '@/lib/reports/vat-eskd-file'
import type { VatPeriodType, AccountingMethod } from '@/types'
import type { VatPeriodType } from '@/types'
/**
* Momsdeklaration eSKDUpload (v6.0) XML file for filing at skatteverket.se via
@@ -64,15 +64,12 @@ export const GET = withRouteContext(
)
}
const accountingMethod = (companyRow.accounting_method as AccountingMethod) || 'accrual'
const declaration = await calculateVatDeclaration(
supabase,
companyId,
periodType,
year,
period,
accountingMethod,
{ fiscalPeriodId },
)
+1 -4
View File
@@ -7,7 +7,7 @@ import {
} from '@/lib/reports/vat-declaration'
import { buildManualFilingRows } from '@/lib/reports/vat-manual-filing'
import { VatDeclarationPDF } from '@/lib/reports/vat-declaration-pdf-template'
import type { VatPeriodType, AccountingMethod, CompanySettings } from '@/types'
import type { VatPeriodType, CompanySettings } from '@/types'
/**
* Momsdeklaration PDF for manual filing at skatteverket.se. The declaration is
@@ -51,15 +51,12 @@ export const GET = withRouteContext(
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
const accountingMethod = (companyRow.accounting_method as AccountingMethod) || 'accrual'
const declaration = await calculateVatDeclaration(
supabase,
companyId,
periodType,
year,
period,
accountingMethod,
{ fiscalPeriodId },
)
+5 -10
View File
@@ -5,7 +5,7 @@ import {
} from '@/lib/reports/vat-declaration'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { VatPeriodType, AccountingMethod } from '@/types'
import type { VatPeriodType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
@@ -77,17 +77,12 @@ export const GET = withRouteContext(
})
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.single()
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
try {
// No accounting-method argument: the method is baked into journal entry
// timing (see the invariant note on calculateVatDeclaration), so no
// company_settings round trip is needed here.
const declaration = await calculateVatDeclaration(
supabase, companyId!, periodType, year, period, accountingMethod,
supabase, companyId!, periodType, year, period,
{ fiscalPeriodId },
)
+6 -16
View File
@@ -14,7 +14,6 @@ import {
VAT_RUTA_LABELS,
type VatPeriodType,
type VatDeclarationRutor,
type AccountingMethod,
} from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -48,24 +47,15 @@ export const GET = withRouteContext('report.vat_declaration.xlsx', async (reques
return NextResponse.json({ error: 'Invalid year or period' }, { status: 400 })
}
const [{ data: settings }, { data: companyRow }] = await Promise.all([
supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.single(),
supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.single(),
])
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
const { data: companyRow } = await supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.single()
try {
const declaration = await calculateVatDeclaration(
supabase, companyId, periodType, year, period, accountingMethod,
supabase, companyId, periodType, year, period,
{ fiscalPeriodId },
)
@@ -431,7 +431,6 @@ describe('GET /reports/vat-declaration', () => {
'monthly',
2026,
4,
undefined,
)
})
})
@@ -13,7 +13,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import type { AccountingMethod, VatPeriodType } from '@/types'
import type { VatPeriodType } from '@/types'
const VatPeriodTypeEnum = z.enum(['monthly', 'quarterly', 'yearly'])
const AccountingMethodEnum = z.enum(['accrual', 'cash'])
@@ -32,7 +32,7 @@ registerEndpoint({
pitfalls: [
'`period_type` (monthly|quarterly|yearly), `year`, and `period` are all required.',
'For monthly: period is 1-12. For quarterly: period is 1-4. For yearly: period is 1.',
'`accounting_method` defaults to accrual (faktureringsmetoden); pass cash for kontantmetoden to honor the VAT-on-payment rule per ML 15 kap 8-11 §§ (ML 2023:200, which replaced ML 1994:200 on 1 July 2023: the prior ML 13 kap reference is outdated).',
'`accounting_method` is accepted for backward compatibility but has no effect on the figures: the declaration is a pure ledger projection, and the method (faktureringsmetoden vs kontantmetoden per ML 15 kap 8-11 §§, ML 2023:200) is already reflected in when VAT-bearing journal entries are posted.',
'Output ruta 49 = (10+11+12+30+31+32+60+61+62) − 48. Positive = pay; negative = refund.',
],
example: {
@@ -123,7 +123,9 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
},
})
}
const { period_type, year, period, accounting_method } = filters.data
// accounting_method is still accepted (public API back-compat) but has no
// effect: see the invariant note on calculateVatDeclaration.
const { period_type, year, period } = filters.data
const gen = await safeGenerate(
() =>
@@ -133,7 +135,6 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
period_type as VatPeriodType,
year,
period,
accounting_method as AccountingMethod | undefined,
),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'vat-declaration' },
)