Bug/open banking flow (#854)

* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects

We never sent auth_method to Enable Banking, so it fell back to the ASPSP's
visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate*
PSUs the redirect flow does not support Mobile BankID, so authorization failed
right after the user approved in the BankID app. Mobile BankID at Handelsbanken
is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses
when requested explicitly.

Resolve the bank's preferred auth method before /auth: query the ASPSP's
auth_methods and pick the DECOUPLED (Mobile BankID) method when present,
otherwise leave auth_method unset so banks that already work are untouched.
The method name is read dynamically per psu_type, so it is robust across
sandbox/production naming.

- api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods
  field name (was available_auth_methods, never populated), add
  getPreferredAuthMethod(), thread optional authMethod through startAuthorization
- index: resolve authMethod in /connect and pass it on both fresh + reconnect
- tests: cover method selection and request-body shaping

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

* refactor(invoice-inbox): clean up bulk-selection toolbar UI

Redesign the selection toolbar shown when inbox items are checked:
one solid primary "Bokför valda" button with outlined secondary
actions ("Fråga assistenten", "Ta bort") and a plain selection
count. Removes the redundant "Avmarkera" button (users uncheck the
still-visible box), fixes label clipping, and gives the toolbar more
breathing room.

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

* chore(entitlements): bypass paywall in local development

Add isPaywallBypassed() so all gated capabilities are testable locally
without a subscription. Fires only on NODE_ENV=development (npm run dev)
or an explicit DISABLE_PAYWALL=true escape hatch — production builds run
under NODE_ENV=production and the entitlement suite runs under 'test',
so both keep exercising the real gate.

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

* fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer

TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand.

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

* feat(transactions): implement categorize core for bank transaction categorization

- Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations.
- Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing.
- Implemented fiscal period validation and duplicate booking detection.
- Enhanced logging and error handling for transaction categorization.

feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata

- Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken.
- Outputs metadata for business and personal PSU types, including default authentication methods.

fix(migrations): increase statement timeout for SIE bulk delete operations

- Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports.

feat(migrations): add bulk book inbox items to pending operations

- Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`.
- Supports bulk booking of matched inbox items against bank transactions.

test(pg): add tests for replace_period_opening_balance_link RPC

- Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow.
- Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries.

* fix(sie-export): update journal entries and lines handling in SIE export tests

* fix(migrations): resolve version collision on 20260629160000

The SIE bulk-delete statement_timeout migration shared version
20260629160000 with journal_entries_list_series_filter (merged from
main via #798/#823), causing a schema_migrations_pkey duplicate key
error on apply. Rename the branch's migration to 20260629160100.

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

* fix(compliance): resolve compliance-swarm + review findings

- opening-balance/correct: compensating rollback for the non-atomic
  storno+rebook so a mid-sequence failure never leaves two posted OB
  entries (ASVS V2.3); durable audit event on every failure path
  (V16); reference the original verifikationsnummer in the corrected
  entry per BFL 5 kap 5§; document that requireWrite already enforces
  write-role + membership (V8.2.1 was a false positive)
- reports sources routes: validate the cursor date component as ISO
  (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2),
  applied to both the VAT-declaration and trial-balance routes
- AgentSessionList: await the rename PATCH, revert the optimistic
  title and toast on failure (ASVS V4.5)
- bank booking: exclude same-batch siblings from the booking-time
  duplicate guard so bulk-booking distinct same-(date,amount)
  transactions no longer false-positives; pre-existing duplicate
  detection is preserved
- BulkBookInboxDialog: drop the unsafe currency-based reverse_charge
  default, add an omvänd skattskyldighet advisory, and type VAT
  options to the backend VatTreatment union
- OpeningBalanceRowEditor: hold onChange in a ref (synced in effect,
  not during render) so an unstable callback can't cause a render loop

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-01 18:13:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2da9c71eb3
commit f63d3e3100
83 changed files with 6769 additions and 1360 deletions
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockAuth = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue({
from: vi.fn(),
auth: { getUser: () => mockAuth() },
}),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
function mkReq() {
return new Request('http://localhost/api/bookkeeping/accounts/bas-catalog')
}
function mkParams() {
return { params: Promise.resolve({}) }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/bookkeeping/accounts/bas-catalog', () => {
it('returns 401 when not authenticated', async () => {
mockAuth.mockResolvedValue({ data: { user: null } })
const res = await GET(mkReq(), mkParams())
expect(res.status).toBe(401)
})
it('returns the full BAS catalogue with the projected fields', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const res = await GET(mkReq(), mkParams())
const body = await res.json()
expect(res.status).toBe(200)
expect(Array.isArray(body.data)).toBe(true)
// The real BAS 2026 chart is ~1,276 accounts.
expect(body.data.length).toBeGreaterThan(1000)
const it = body.data.find((a: { account_number: string }) => a.account_number === '6540')
expect(it).toMatchObject({
account_number: '6540',
account_name: 'IT-tjänster',
account_class: 6,
account_group: '65',
})
expect(typeof it.description).toBe('string')
})
it('sets a client cache header (static reference data)', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const res = await GET(mkReq(), mkParams())
expect(res.headers.get('Cache-Control')).toContain('max-age=')
})
})
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
/**
* GET /api/bookkeeping/accounts/bas-catalog
*
* The full BAS 2026 catalogue (~1,276 accounts), projected to the fields the
* AccountCombobox needs to search and render. This lets the manual bookkeeping
* flow surface accounts by name even when they aren't in the company's chart
* yet — selecting one routes through the existing activate-on-commit rail
* (ACCOUNTS_NOT_IN_CHART → ActivateAccountsDialog → /accounts/activate).
*
* The payload is static reference data for the deploy and identical for every
* company, so it's cached hard on the client. Wrapped in withRouteContext so it
* stays behind auth (MFA on hosted) like every other bookkeeping route.
*/
export const GET = withRouteContext('bookkeeping.accounts.bas_catalog', async () => {
const data = BAS_REFERENCE.map((a) => ({
account_number: a.account_number,
account_name: a.account_name,
account_class: a.account_class,
account_group: a.account_group,
description: a.description,
}))
return NextResponse.json(
{ data },
{ headers: { 'Cache-Control': 'private, max-age=86400' } },
)
})
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, createMockRouteParams } from '@/tests/helpers'
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
unlockPeriod: vi.fn(),
}))
import { requireAuth } from '@/lib/auth/require-auth'
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
import { POST } from '../route'
function unlockRequest(): Request {
return createMockRequest('/api/bookkeeping/fiscal-periods/p1/unlock', { method: 'POST' })
}
function mockAuth() {
;(requireAuth as ReturnType<typeof vi.fn>).mockResolvedValue({
user: { id: 'user-1' },
supabase: {},
error: null,
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('POST /api/bookkeeping/fiscal-periods/[id]/unlock', () => {
it('unlocks the period and returns it on success', async () => {
mockAuth()
;(unlockPeriod as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'p1', locked_at: null })
const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.id).toBe('p1')
})
it('maps a not-locked period to a 409', async () => {
mockAuth()
;(unlockPeriod as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Period is not locked'))
const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_UNLOCK_NOT_LOCKED')
})
it('maps a closed period to a 409', async () => {
mockAuth()
;(unlockPeriod as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Cannot unlock a closed period'),
)
const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_UNLOCK_CLOSED')
})
it('maps a missing period to a 404', async () => {
mockAuth()
;(unlockPeriod as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Fiscal period not found'))
const res = await POST(unlockRequest(), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_NOT_FOUND')
})
})
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
export const POST = withRouteContext(
'period.unlock',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ periodId: id })
try {
const period = await unlockPeriod(supabase, companyId!, user.id, id)
return NextResponse.json({ data: period })
} catch (err) {
opLog.error('failed to unlock period', err as Error)
// unlockPeriod() throws plain Error with messages like "Fiscal period not
// found", "Cannot unlock a closed period" or "Period is not locked" —
// translate to envelope codes, mirroring the sibling lock route.
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
if (/closed/i.test(message)) {
return errorResponseFromCode('PERIOD_UNLOCK_CLOSED', opLog, { requestId })
}
if (/not locked/i.test(message)) {
return errorResponseFromCode('PERIOD_UNLOCK_NOT_LOCKED', opLog, { requestId })
}
return errorResponse(err, opLog, { requestId })
}
},
{ requireWrite: true },
)
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateJournalEntry = vi.fn()
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
vi.mock('@/lib/bookkeeping/bas-reference', () => ({
getBASReference: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({
// All referenced accounts already exist → no chart activation insert.
fetchAllRows: vi.fn().mockResolvedValue([
{ account_number: '1930' },
{ account_number: '2099' },
]),
}))
import { POST } from '../correct/route'
const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000'
const BALANCED_LINES = [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
]
function makeRequest(body: unknown) {
return createMockRequest('/api/import/opening-balance/correct', {
method: 'POST',
body,
})
}
function openPeriodWithOB(overrides: Record<string, unknown> = {}) {
return {
id: PERIOD_ID,
company_id: 'company-1',
is_closed: false,
locked_at: null,
opening_balances_set: true,
opening_balance_entry_id: 'entry-old',
period_start: '2026-01-01',
...overrides,
}
}
describe('POST /api/import/opening-balance/correct', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 for unauthenticated requests', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body.error).toBe('Unauthorized')
})
it('returns 400 for invalid body', async () => {
const res = await POST(makeRequest({ fiscal_period_id: 'not-a-uuid', lines: [] }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 for non-existent fiscal period', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(404)
expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_NOT_FOUND')
})
it('returns 400 when the period is closed', async () => {
enqueue({ data: openPeriodWithOB({ is_closed: true }) })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_CLOSED')
})
it('returns 400 when the period is locked', async () => {
enqueue({ data: openPeriodWithOB({ locked_at: '2026-06-28T00:00:00Z' }) })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('OB_PERIOD_LOCKED')
})
it('returns 409 when the period has no opening balances to correct', async () => {
enqueue({ data: openPeriodWithOB({ opening_balances_set: false, opening_balance_entry_id: null }) })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(409)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_NO_EXISTING')
})
it('returns 409 when a year-end close exists on the period', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 1 }) // year-end entry count
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(409)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_YEAR_END_EXISTS')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('returns 400 for unbalanced corrected lines', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
const res = await POST(makeRequest({
fiscal_period_id: PERIOD_ID,
lines: [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 40000 },
],
}))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('OB_UNBALANCED')
})
it('books a corrected IB, stornoes the old one, and relinks on success', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: null }) // replace_period_opening_balance_link RPC
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 5 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.journal_entry_id).toBe('entry-new')
expect(body.data.reversed_entry_id).toBe('entry-old')
expect(body.data.lines_created).toBe(2)
// New IB created before the old one is reversed.
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ source_type: 'opening_balance', voucher_series: 'A' }),
)
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'entry-old',
)
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'replace_period_opening_balance_link',
expect.objectContaining({ p_period_id: PERIOD_ID, p_new_entry_id: 'entry-new' }),
)
})
it('returns 500 OB_CORRECT_FAILED if the relink RPC fails', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: { message: 'relink boom' } }) // RPC failure
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 5 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_FAILED')
})
})
@@ -0,0 +1,216 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateJournalEntry = vi.fn()
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
vi.mock('@/lib/bookkeeping/bas-reference', () => ({
getBASReference: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({
// All referenced accounts already exist → no chart activation insert (and no
// extra supabase.from() call that would shift the queued-mock cursor).
fetchAllRows: vi.fn().mockResolvedValue([
{ account_number: '1930' },
{ account_number: '2099' },
]),
}))
import { POST } from '../route'
type SpyInstance = ReturnType<typeof vi.spyOn>
const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000'
const BALANCED_LINES = [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
]
function makeRequest(body: unknown) {
return createMockRequest('/api/import/opening-balance/correct', {
method: 'POST',
body,
})
}
function openPeriodWithOB(overrides: Record<string, unknown> = {}) {
return {
id: PERIOD_ID,
company_id: 'company-1',
is_closed: false,
locked_at: null,
opening_balances_set: true,
opening_balance_entry_id: 'entry-old',
period_start: '2026-01-01',
// Embedded resource from the period fetch — the original IB verifikat's
// voucher label, used to build the BFL 5 kap 5§ reference.
opening_balance_entry: { voucher_series: 'A', voucher_number: 123 },
...overrides,
}
}
/** Flatten every console.error call into one searchable string. */
function auditLines(spy: SpyInstance): string {
return spy.mock.calls
.map((call) => call.map((a: unknown) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '))
.filter((line) => line.includes('opening balance correction failed'))
.join('\n')
}
describe('POST /api/import/opening-balance/correct — atomicity, audit, BFL reference', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
let errorSpy: SpyInstance
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
// The structured logger writes error-level records to console.error even in
// the test env; spy on it so we can assert the durable audit line.
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
errorSpy.mockRestore()
})
// FIX 3 (BFL 5 kap 5§) — the corrected entry references the original voucher.
it('references the original verifikationsnummer in the corrected entry description', async () => {
enqueue({ data: openPeriodWithOB({ opening_balance_entry: { voucher_series: 'B', voucher_number: 7 } }) }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: null }) // replace_period_opening_balance_link RPC
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' })
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
description: 'Ingående balanser (korrigerade, rättelse av B7)',
source_type: 'opening_balance',
}),
)
// Happy path stornoes ONLY the old entry — no compensating reverse.
expect(mockReverseEntry).toHaveBeenCalledTimes(1)
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-old')
})
// FIX 1 (ASVS V2.3) — compensation when the storno of the OLD entry throws
// after the new entry was already created.
it('compensates by stornoing the new entry when reverseEntry throws, returning OB_CORRECT_FAILED', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
// No RPC enqueue: step B throws before the relink is reached.
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry
.mockRejectedValueOnce(new Error('storno of old failed')) // step B (oldEntryId)
.mockResolvedValueOnce({ id: 'entry-storno-new' }) // compensation (newEntry.id)
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_FAILED')
// First the failed storno of the old entry, then the compensating storno of
// the new entry.
expect(mockReverseEntry).toHaveBeenCalledTimes(2)
expect(mockReverseEntry).toHaveBeenNthCalledWith(1, expect.anything(), 'company-1', 'user-1', 'entry-old')
expect(mockReverseEntry).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'user-1', 'entry-new')
// Durable audit carries both ids for manual recovery.
const audit = auditLines(errorSpy)
expect(audit).toContain('entry-new')
expect(audit).toContain('entry-old')
})
// FIX 1 + FIX 2 — relink RPC error triggers compensation and a durable audit.
it('compensates and emits a durable audit when the relink RPC returns an error', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: { message: 'relink boom' } }) // RPC failure
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' }) // step B + compensation both succeed
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(500)
const err = body.error as unknown as { code: string; details?: { newEntryId?: string; oldEntryId?: string } }
expect(err.code).toBe('OB_CORRECT_FAILED')
expect(err.details?.newEntryId).toBe('entry-new')
expect(err.details?.oldEntryId).toBe('entry-old')
// Compensation: old entry stornoed (step B) then the new entry stornoed.
expect(mockReverseEntry).toHaveBeenCalledTimes(2)
expect(mockReverseEntry).toHaveBeenNthCalledWith(1, expect.anything(), 'company-1', 'user-1', 'entry-old')
expect(mockReverseEntry).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'user-1', 'entry-new')
// Durable audit event payload contains newEntryId + oldEntryId.
const audit = auditLines(errorSpy)
expect(audit).toContain('opening_balance.correction_failed')
expect(audit).toContain('entry-new')
expect(audit).toContain('entry-old')
})
// FIX 2 — the compensating storno may itself fail; the handler must still
// return the envelope and audit the compensation failure (never rethrow).
it('audits a compensation failure and still returns OB_CORRECT_FAILED', async () => {
enqueue({ data: openPeriodWithOB() }) // period
enqueue({ count: 0 }) // year-end check
enqueue({ error: { message: 'relink boom' } }) // RPC failure
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry
.mockResolvedValueOnce({ id: 'entry-storno' }) // step B ok
.mockRejectedValueOnce(new Error('compensation storno failed')) // compensation throws
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: BALANCED_LINES }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('OB_CORRECT_FAILED')
const audit = auditLines(errorSpy)
expect(audit).toContain('compensation_failed')
expect(audit).toContain('entry-new')
expect(audit).toContain('entry-old')
})
})
@@ -0,0 +1,254 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import {
validateOpeningBalanceLines,
activateMissingAccounts,
buildOpeningBalanceEntryLines,
} from '@/lib/import/opening-balance/execute-helpers'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
ensureInitialized()
/**
* POST /api/import/opening-balance/correct
*
* Correct a period's existing opening balances the BFL-compliant way: the
* current IB verifikat (immutable, posted) is stornoed and a corrected IB is
* booked, then fiscal_periods.opening_balance_entry_id is relinked to the new
* entry via the replace_period_opening_balance_link RPC.
*
* Because getOpeningBalances reads the linked entry directly and the
* trial-balance / general-ledger movement queries include both `posted` and
* `reversed` lines (excluding only the linked OB entry), the stornoed old IB
* and its storno mirror cancel out in period movement — so the Balansrapport
* IB column shows the corrected figures and UB stays correct.
*
* Gated to the safe case only: the period must be open, unlocked, already have
* opening balances, and have no year-end close on top. Locked/closed periods or
* periods with a bokslut must be unwound first (assisted) — we refuse here.
*/
export const POST = withRouteContext(
'opening_balance.correct',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const result = await validateBody(request, OpeningBalanceExecuteSchema, {
log,
operation: 'opening_balance.correct',
})
if (!result.success) return result.response
const { fiscal_period_id, lines } = result.data
const opLog = log.child({ fiscalPeriodId: fiscal_period_id })
try {
// 1. Verify the fiscal period belongs to the company and is correctable.
// Write-role (non-viewer) + company membership are already enforced by
// withRouteContext({ requireWrite: true }) before this handler runs
// (requireWritePermission + getActiveCompanyId), and this fetch is scoped
// by that verified companyId — no redundant authz here (ASVS V8.2.1).
// The embedded opening_balance_entry pulls the original IB verifikat's
// voucher label so the corrected entry can reference it (BFL 5 kap 5§).
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select(
'*, opening_balance_entry:journal_entries!opening_balance_entry_id(voucher_series, voucher_number)',
)
.eq('id', fiscal_period_id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('OB_PERIOD_NOT_FOUND', opLog, { requestId })
}
if (period.is_closed) {
return errorResponseFromCode('OB_PERIOD_CLOSED', opLog, { requestId })
}
if (period.locked_at) {
return errorResponseFromCode('OB_PERIOD_LOCKED', opLog, { requestId })
}
if (!period.opening_balances_set || !period.opening_balance_entry_id) {
return errorResponseFromCode('OB_CORRECT_NO_EXISTING', opLog, { requestId })
}
// Refuse if a year-end close was built on top — correcting the IB without
// unwinding the bokslut would leave the period (and the next period's
// carried-forward IB) internally inconsistent.
const { count: yearEndCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscal_period_id)
.eq('source_type', 'year_end')
.eq('status', 'posted')
if ((yearEndCount ?? 0) > 0) {
return errorResponseFromCode('OB_CORRECT_YEAR_END_EXISTS', opLog, { requestId })
}
const oldEntryId = period.opening_balance_entry_id
// 2. Validate the corrected lines (drop zeros, ≥2 rows, no P&L, must balance).
const validation = validateOpeningBalanceLines(lines)
if (!validation.ok) {
return errorResponseFromCode(validation.code, opLog, {
requestId,
details:
validation.code === 'OB_PNL_ACCOUNT'
? { accounts: validation.accounts }
: validation.code === 'OB_UNBALANCED'
? { totalDebit: validation.totalDebit, totalCredit: validation.totalCredit, diff: validation.diff }
: undefined,
})
}
const { validLines, totalDebit, totalCredit } = validation
// 3. Auto-activate BAS accounts the corrected file references but the chart lacks.
const accountNumbers = [...new Set(validLines.map((l) => l.account_number))]
const activation = await activateMissingAccounts(supabase, companyId!, user.id, accountNumbers)
if (!activation.ok) {
opLog.error('opening balance account activation failed', new Error(activation.reason))
return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, {
requestId,
details: { reason: activation.reason },
})
}
// BFL 5 kap 5§ — reference the original verifikat so the correction is
// traceable to the entry it rättar. The embed above gave us the old IB's
// voucher label (e.g. "A123"). CreateJournalEntryInput exposes no dedicated
// correction-linkage field (corrects_entry_id / correction_of / metadata),
// so the description reference IS the linkage; we deliberately leave the
// generic source_id unset rather than overload it for an opening_balance.
const originalRef = (
period as {
opening_balance_entry?: {
voucher_series?: string | null
voucher_number?: number | null
} | null
}
).opening_balance_entry
const originalVoucherLabel =
originalRef?.voucher_series && originalRef?.voucher_number
? `${originalRef.voucher_series}${originalRef.voucher_number}`
: null
const correctedDescription = originalVoucherLabel
? `Ingående balanser (korrigerade, rättelse av ${originalVoucherLabel})`
: 'Ingående balanser (korrigerade)'
// 4. Book the corrected IB, storno the old one, then relink the period.
// Order matters: create the replacement BEFORE reversing the original so a
// mid-failure never leaves the period without an opening balance.
const newEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id,
entry_date: period.period_start,
description: correctedDescription,
source_type: 'opening_balance',
voucher_series: 'A',
lines: buildOpeningBalanceEntryLines(validLines),
})
// ASVS V16 — durable audit sink for a failed correction. The core event bus
// has no opening_balance.* correction event type and lib/events/types.ts is
// outside the scope of this change, so the failure is recorded via the
// structured logger: it lands in the JSON log sink (Vercel/Sentry), tagged
// `audit: true` + both entry ids so an operator can reconcile the period by
// hand. (Follow-up: promote to a typed event persisted to event_log.)
const auditCorrectionFailure = (fields: Record<string, unknown>) => {
opLog.error('audit: opening balance correction failed', {
audit: true,
event: 'opening_balance.correction_failed',
companyId,
userId: user.id,
fiscalPeriodId: fiscal_period_id,
newEntryId: newEntry.id,
oldEntryId,
...fields,
})
}
// FIX (ASVS V2.3 — atomicity via compensation): steps B (storno old) and
// C (relink) are NOT atomic with A (create new). A already produced a second
// posted opening_balance entry for the period; if B or C fails, that entry is
// orphaned and the Balansrapport would show two OB entries. Wrap B+C so that
// on ANY failure below we compensate by stornoing the NEW entry, restoring the
// period to its original consistent state (original OB still linked, new entry
// cancelled by its own storno).
try {
// B: storno the original IB.
await reverseEntry(supabase, companyId!, user.id, oldEntryId)
// C: point the period at the corrected IB (single atomic RPC).
const { error: relinkError } = await supabase.rpc('replace_period_opening_balance_link', {
p_company_id: companyId,
p_period_id: fiscal_period_id,
p_new_entry_id: newEntry.id,
})
if (relinkError) {
// Funnel the RPC error into the single compensation path below.
throw new Error(`replace_period_opening_balance_link failed: ${relinkError.message}`)
}
} catch (seqErr) {
const reason = seqErr instanceof Error ? seqErr.message : 'unknown'
// Durable audit BEFORE compensation so the ids survive even if the
// compensating storno also throws.
//
// Residual edge (documented): if B succeeded but C failed, the old entry is
// now reversed yet still linked to the period. We still compensate the new
// entry; the audit payload carries newEntryId + oldEntryId so an operator can
// finish recovery (re-link or re-book) manually.
auditCorrectionFailure({ phase: 'sequence_failed', reason })
// Compensating rollback. This may itself throw (e.g. the period was locked
// between A and here) — catch + audit and never let it propagate past the
// handler, so the caller always gets the OB_CORRECT_FAILED envelope.
try {
await reverseEntry(supabase, companyId!, user.id, newEntry.id)
auditCorrectionFailure({ phase: 'compensated', reason })
} catch (compErr) {
auditCorrectionFailure({
phase: 'compensation_failed',
reason,
compensationError: compErr instanceof Error ? compErr.message : 'unknown',
})
}
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
details: { reason, newEntryId: newEntry.id, oldEntryId },
})
}
return NextResponse.json({
data: {
success: true,
journal_entry_id: newEntry.id,
reversed_entry_id: oldEntryId,
fiscal_period_id,
lines_created: validLines.length,
total_debit: totalDebit,
total_credit: totalCredit,
},
})
} catch (err) {
if (isBookkeepingError(err)) {
return errorResponse(err, opLog, { requestId })
}
opLog.error('opening balance correct failed', err as Error)
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
{ requireWrite: true },
)
+26 -117
View File
@@ -4,11 +4,13 @@ import { validateBody } from '@/lib/api/validate'
import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
validateOpeningBalanceLines,
activateMissingAccounts,
buildOpeningBalanceEntryLines,
} from '@/lib/import/opening-balance/execute-helpers'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { CreateJournalEntryLineInput } from '@/types'
ensureInitialized()
@@ -60,127 +62,34 @@ export const POST = withRouteContext(
})
}
// 2. Filter zero-amount lines and reject P&L accounts.
const validLines = lines.filter((l) => l.debit_amount > 0 || l.credit_amount > 0)
if (validLines.length < 2) {
return errorResponseFromCode('OB_TOO_FEW_LINES', opLog, { requestId })
}
const pnlAccounts = validLines
.map((l) => l.account_number)
.filter((num) => {
const cls = parseInt(num.charAt(0), 10)
return cls >= 3 && cls <= 8
})
if (pnlAccounts.length > 0) {
return errorResponseFromCode('OB_PNL_ACCOUNT', opLog, {
// 2. Validate lines (drop zeros, ≥2 rows, no P&L accounts, must balance).
const validation = validateOpeningBalanceLines(lines)
if (!validation.ok) {
return errorResponseFromCode(validation.code, opLog, {
requestId,
details: { accounts: pnlAccounts.slice(0, 5) },
details:
validation.code === 'OB_PNL_ACCOUNT'
? { accounts: validation.accounts }
: validation.code === 'OB_UNBALANCED'
? { totalDebit: validation.totalDebit, totalCredit: validation.totalCredit, diff: validation.diff }
: undefined,
})
}
const { validLines, totalDebit, totalCredit } = validation
// 3. Verify balance.
let totalDebit = 0
let totalCredit = 0
for (const line of validLines) {
totalDebit = Math.round((totalDebit + line.debit_amount) * 100) / 100
totalCredit = Math.round((totalCredit + line.credit_amount) * 100) / 100
}
const diff = Math.round((totalDebit - totalCredit) * 100) / 100
if (Math.abs(diff) >= 0.01) {
return errorResponseFromCode('OB_UNBALANCED', opLog, {
requestId,
details: { totalDebit, totalCredit, diff },
})
}
// 4. Auto-activate BAS accounts not in the company's chart.
// 3. Auto-activate BAS accounts not in the company's chart.
const accountNumbers = [...new Set(validLines.map((l) => l.account_number))]
const existingAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.range(from, to),
)
const existingNumbers = new Set(existingAccounts.map((a) => a.account_number))
const accountsToActivate = accountNumbers
.filter((num) => !existingNumbers.has(num))
.map((num) => {
const ref = getBASReference(num)
if (ref) {
return {
user_id: user.id,
company_id: companyId,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
account_group: ref.account_group,
account_type: ref.account_type,
normal_balance: ref.normal_balance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: ref.description,
sru_code: ref.sru_code,
sort_order: parseInt(ref.account_number),
}
}
const accountClass = parseInt(num.charAt(0), 10)
const accountGroup = num.substring(0, 2)
const accountType =
accountClass === 1 ? 'asset'
: accountClass === 2 ? 'liability'
: accountClass === 3 ? 'revenue'
: 'expense'
const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit'
return {
user_id: user.id,
company_id: companyId,
account_number: num,
account_name: `Konto ${num}`,
account_class: accountClass,
account_group: accountGroup,
account_type: accountType,
normal_balance: normalBalance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: `Konto ${num}`,
sru_code: null,
sort_order: parseInt(num),
}
const activation = await activateMissingAccounts(supabase, companyId!, user.id, accountNumbers)
if (!activation.ok) {
opLog.error('opening balance account activation failed', new Error(activation.reason))
return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, {
requestId,
details: { reason: activation.reason },
})
if (accountsToActivate.length > 0) {
const { error: activateError } = await supabase
.from('chart_of_accounts')
.insert(accountsToActivate)
if (activateError) {
opLog.error('opening balance account activation failed', activateError)
return errorResponseFromCode('OB_ACCOUNT_ACTIVATION_FAILED', opLog, {
requestId,
details: { reason: activateError.message },
})
}
}
// 5. Create the opening balance journal entry.
const entryLines: CreateJournalEntryLineInput[] = validLines.map((line) => ({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
line_description: `IB ${line.account_number}`,
}))
// 4. Create the opening balance journal entry.
const entryLines = buildOpeningBalanceEntryLines(validLines)
const entry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id,
@@ -191,7 +100,7 @@ export const POST = withRouteContext(
lines: entryLines,
})
// 6. Mark the fiscal period.
// 5. Mark the fiscal period.
await supabase
.from('fiscal_periods')
.update({
+5
View File
@@ -3,6 +3,11 @@ import { replaceSIEImport } from '@/lib/import/sie-import'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
// Hard-deleting a large import (thousands of audit-logged journal entries +
// cascading lines) can take well over the default function timeout. Match the
// SIE execute route so the serverless function doesn't kill the request first.
export const maxDuration = 300
/**
* POST /api/import/sie/[id]/replace
*
+5
View File
@@ -3,6 +3,11 @@ import { undoSIEImport } from '@/lib/import/sie-import'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
// Hard-deleting a large import (thousands of audit-logged journal entries +
// cascading lines) can take well over the default function timeout. Match the
// SIE execute route so the serverless function doesn't kill the request first.
export const maxDuration = 300
/**
* DELETE /api/import/sie/[id]/undo
*
@@ -37,7 +37,9 @@ function buildSupabase(
}
return chain
}
// journal_entry_lines
// journal_entry_lines — terminates on `.range()` (fetchAllRows), which
// resolves to the line result. `data.length < PAGE_SIZE` so a single
// page is fetched.
const chain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
@@ -47,6 +49,7 @@ function buildSupabase(
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
or: vi.fn().mockReturnThis(),
range: vi.fn().mockResolvedValue(linesResult),
then: (resolve: (v: unknown) => void) => resolve(linesResult),
}
return chain
@@ -94,6 +97,24 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () =>
expect(res.status).toBe(404)
})
it('returns 400 when the cursor date component is not a structural ISO date', async () => {
// Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a
// malformed date component must still be rejected structurally.
mockCreateClient.mockResolvedValue(
buildSupabase(
{ id: 'user-1' },
{ account_number: '1930', account_name: 'Företagskonto' },
{ data: [], error: null }
) as never
)
const req = createMockRequest(
'/api/reports/trial-balance/account/1930/sources',
{ searchParams: { fiscal_period_id: 'period-1', cursor: 'notadate|5' } }
)
const res = await GET(req, createMockRouteParams({ accountNumber: '1930' }))
expect(res.status).toBe(400)
})
it('happy path: returns mapped lines for an account', async () => {
const linesData = [
{
@@ -293,4 +314,69 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () =>
expect(body.data.lines[0].journal_entry_id).toBe('je-low') // voucher 5 first
expect(body.data.lines[1].journal_entry_id).toBe('je-high') // voucher 20 second
})
it('paginates a >500-line account deterministically regardless of DB return order', async () => {
// Regression: with no stable parent ORDER BY, a raw `.limit(500)` returned
// an arbitrary subset that varied between identical requests — the
// "different rows on every reload" bug for high-volume accounts. We now
// fetch the full set and sort/slice in JS, so the first page is always the
// 500 chronologically-earliest lines.
const total = 600
const ordered = Array.from({ length: total }, (_, i) => {
const day = String((i % 28) + 1).padStart(2, '0')
return {
debit_amount: i + 1,
credit_amount: 0,
journal_entry_id: `je-${String(i).padStart(4, '0')}`,
journal_entries: {
id: `je-${String(i).padStart(4, '0')}`,
voucher_number: i + 1, // unique, monotonic with intended order
voucher_series: 'A',
entry_date: `2026-${String((i % 12) + 1).padStart(2, '0')}-${day}`,
description: `Row ${i}`,
status: 'posted',
company_id: 'company-1',
fiscal_period_id: 'period-1',
},
}
})
// Shuffle deterministically so the DB "return order" is not the sorted one.
const shuffled = [...ordered].sort((a, b) =>
a.journal_entry_id < b.journal_entry_id ? 1 : -1
)
mockCreateClient.mockResolvedValue(
buildSupabase(
{ id: 'user-1' },
{ account_number: '3001', account_name: 'Försäljning' },
{ data: shuffled, error: null }
) as never
)
const req = createMockRequest(
'/api/reports/trial-balance/account/3001/sources',
{ searchParams: { fiscal_period_id: 'period-1' } }
)
const res = await GET(req, createMockRouteParams({ accountNumber: '3001' }))
expect(res.status).toBe(200)
const body = (await res.json()) as {
data: { lines: Array<{ voucher_number: number; date: string }>; next_cursor: string | null }
}
// First page is exactly PAGE_LIMIT rows, fully sorted (date ASC, then
// voucher_number ASC — numeric, not lexicographic).
expect(body.data.lines).toHaveLength(500)
const lines = body.data.lines
for (let i = 1; i < lines.length; i++) {
const prev = lines[i - 1]
const cur = lines[i]
const ordered =
prev.date < cur.date ||
(prev.date === cur.date && prev.voucher_number <= cur.voucher_number)
expect(ordered).toBe(true)
}
// More rows remain → a cursor is returned pointing at the last delivered row.
expect(body.data.next_cursor).toBe(`${lines[499].date}|${lines[499].voucher_number}`)
})
})
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
/**
@@ -55,60 +56,66 @@ export async function GET(
)
}
// Pull all lines on this account in this period. We rely on the same
// join+filter pattern as `generateTrialBalance`. Pagination is server-side
// via cursor so even an account with tens of thousands of rows stays cheap.
let query = supabase
.from('journal_entry_lines')
.select(`
debit_amount,
credit_amount,
journal_entry_id,
journal_entries!inner(
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', accountNumber)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
.limit(PAGE_LIMIT + 1)
// Parse the optional cursor up front (format: <iso-date>|<voucher_number>).
// Pagination is applied in JS after a full, deterministically-ordered fetch.
let cursorDate: string | null = null
let cursorVoucherNum = 0
if (cursor) {
// Cursor format: <iso-date>|<voucher_number>
const [cursorDate, cursorVoucher] = cursor.split('|')
const cursorVoucherNum = parseInt(cursorVoucher, 10)
if (!cursorDate || isNaN(cursorVoucherNum)) {
const [cd, cv] = cursor.split('|')
cursorVoucherNum = parseInt(cv, 10)
// The cursor is applied in JS (string compare); structurally validating the
// date component here is defense-in-depth against malformed/injection cursors.
if (!cd || !/^\d{4}-\d{2}-\d{2}$/.test(cd) || isNaN(cursorVoucherNum)) {
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
}
// Filter for rows strictly after the cursor (date>cur OR same date & voucher>cur).
// Supabase doesn't expose tuple compare, so use an `or()` clause.
query = query.or(
`entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`,
{ foreignTable: 'journal_entries' }
)
cursorDate = cd
}
const { data, error } = await query
// Pull ALL lines on this account in this period, then sort + paginate in JS.
//
// Why not order/limit in SQL: `.order(col, { foreignTable })` in PostgREST
// sorts the *embedded* resource's rows, not the parent result set, so it
// cannot give us a chronological parent order. Without a stable parent order
// a raw `.limit()` returns an arbitrary subset that varies between identical
// requests — which surfaced as the trial-balance drill-down showing
// "different rows on every reload" for high-volume accounts. We instead page
// on the line PK (`id`) for a stable total order (see fetch-all.ts) and do
// the chronological sort here, mirroring `generateGeneralLedger`.
const rows = await fetchAllRows<{
id: string
debit_amount: number
credit_amount: number
// eslint-disable-next-line @typescript-eslint/no-explicit-any
journal_entries: any
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
id,
debit_amount,
credit_amount,
journal_entry_id,
journal_entries!inner(
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', accountNumber)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to), { dedupeBy: (r) => r.id })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = (data || []) as any[]
// Map all rows then sort in JS (date ASC, voucher_number ASC).
// .order({ foreignTable }) in Supabase sorts the embedded resource's rows,
// not the parent result set, so we cannot rely on DB ordering here.
// This mirrors the sort in generateGeneralLedger.
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
// a final deterministic tiebreak for lines sharing a date and voucher number
// across series).
const allMapped: ReportSourceLine[] = rows.map((row) => ({
journal_entry_id: row.journal_entries.id,
voucher_number: row.journal_entries.voucher_number,
@@ -120,14 +127,26 @@ export async function GET(
}))
allMapped.sort((a, b) => {
const dateComp = a.date.localeCompare(b.date)
return dateComp !== 0 ? dateComp : a.voucher_number - b.voucher_number
if (dateComp !== 0) return dateComp
if (a.voucher_number !== b.voucher_number) return a.voucher_number - b.voucher_number
return a.journal_entry_id.localeCompare(b.journal_entry_id)
})
const lines = allMapped.slice(0, PAGE_LIMIT)
// If we got more than PAGE_LIMIT rows back, the next cursor points at the
// last delivered row so the next call resumes from after it.
// Apply the cursor in JS: keep rows strictly after (date, voucher_number).
const afterCursor = cursorDate
? allMapped.filter(
(l) =>
l.date > cursorDate! ||
(l.date === cursorDate! && l.voucher_number > cursorVoucherNum)
)
: allMapped
const lines = afterCursor.slice(0, PAGE_LIMIT)
// If more rows remain beyond this page, point the next cursor at the last
// delivered row so the next call resumes from after it.
let next_cursor: string | null = null
if (rows.length > PAGE_LIMIT && lines.length > 0) {
if (afterCursor.length > PAGE_LIMIT && lines.length > 0) {
const last = lines[lines.length - 1]
next_cursor = `${last.date}|${last.voucher_number}`
}
@@ -32,6 +32,8 @@ function buildSupabase(
limit: vi.fn().mockReturnThis(),
or: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
// journal_entry_lines terminates on `.range()` (fetchAllRows).
range: vi.fn().mockResolvedValue(linesResult),
then: (resolve: (v: unknown) => void) => resolve(linesResult),
})),
}
@@ -117,4 +119,95 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
expect(body.data.lines[0].voucher_number).toBe(12)
expect(body.data.lines[0].credit).toBe(250)
})
it('returns 400 when the cursor date component is not a structural ISO date', async () => {
// Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a
// malformed date component must still be rejected structurally.
mockCreateClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never
)
const req = createMockRequest(
'/api/reports/vat-declaration/ruta/10/sources',
{
searchParams: {
periodType: 'monthly',
year: '2026',
period: '5',
cursor: 'notadate|5',
},
}
)
const res = await GET(req, createMockRouteParams({ ruta: '10' }))
expect(res.status).toBe(400)
})
it('sorts lines by entry_date ASC then voucher_number ASC regardless of DB return order', async () => {
// Regression: this endpoint relied on `.order({ foreignTable })`, which
// sorts the embedded resource — not the parent — so lines came back in
// arbitrary order and the drill-down showed "different rows on reload".
const linesData = [
{
account_number: '2611',
debit_amount: 0,
credit_amount: 500,
journal_entries: {
id: 'je-late',
voucher_number: 30,
voucher_series: 'A',
entry_date: '2026-05-20',
description: 'Late',
status: 'posted',
company_id: 'company-1',
},
},
{
account_number: '2611',
debit_amount: 0,
credit_amount: 100,
journal_entries: {
id: 'je-early',
voucher_number: 4,
voucher_series: 'A',
entry_date: '2026-05-02',
description: 'Early',
status: 'posted',
company_id: 'company-1',
},
},
{
account_number: '2611',
debit_amount: 0,
credit_amount: 250,
journal_entries: {
id: 'je-mid',
voucher_number: 18,
voucher_series: 'A',
entry_date: '2026-05-11',
description: 'Mid',
status: 'posted',
company_id: 'company-1',
},
},
]
mockCreateClient.mockResolvedValue(
buildSupabase({ id: 'user-1' }, { data: linesData, error: null }) as never
)
const req = createMockRequest(
'/api/reports/vat-declaration/ruta/10/sources',
{ searchParams: { periodType: 'monthly', year: '2026', period: '5' } }
)
const res = await GET(req, createMockRouteParams({ ruta: '10' }))
expect(res.status).toBe(200)
const body = (await res.json()) as {
data: { lines: Array<{ journal_entry_id: string }> }
}
expect(body.data.lines.map((l) => l.journal_entry_id)).toEqual([
'je-early',
'je-mid',
'je-late',
])
})
})
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
ACCOUNT_RUTA,
calculatePeriodDates,
@@ -93,66 +94,94 @@ export async function GET(
end = dates.end
}
let query = supabase
.from('journal_entry_lines')
.select(`
account_number,
debit_amount,
credit_amount,
journal_entries!inner(
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id
)
`)
.in('account_number', accountsForRuta)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
.order('entry_date', { foreignTable: 'journal_entries', ascending: true })
.order('voucher_number', { foreignTable: 'journal_entries', ascending: true })
.limit(PAGE_LIMIT + 1)
// Parse the optional cursor up front (format: <iso-date>|<voucher_number>).
// Pagination is applied in JS after a full, deterministically-ordered fetch.
let cursorDate: string | null = null
let cursorVoucherNum = 0
if (cursor) {
const [cursorDate, cursorVoucher] = cursor.split('|')
const cursorVoucherNum = parseInt(cursorVoucher, 10)
if (!cursorDate || isNaN(cursorVoucherNum)) {
const [cd, cv] = cursor.split('|')
cursorVoucherNum = parseInt(cv, 10)
// The cursor is applied in JS (string compare); structurally validating the
// date component here is defense-in-depth against malformed/injection cursors.
if (!cd || !/^\d{4}-\d{2}-\d{2}$/.test(cd) || isNaN(cursorVoucherNum)) {
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
}
query = query.or(
`entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`,
{ foreignTable: 'journal_entries' }
)
cursorDate = cd
}
const { data, error } = await query
// Pull ALL contributing lines, then sort + paginate in JS.
//
// Why not order/limit in SQL: `.order(col, { foreignTable })` in PostgREST
// sorts the *embedded* resource's rows, not the parent result set, so it
// cannot give us a chronological parent order. Without a stable parent order
// a raw `.limit()` returns an arbitrary subset that varies between identical
// requests, making the drill-down show "different rows on every reload". We
// page on the line PK (`id`) for a stable total order (see fetch-all.ts) and
// do the chronological sort here, mirroring `generateGeneralLedger` and the
// trial-balance sources route.
const rows = await fetchAllRows<{
id: string
debit_amount: number
credit_amount: number
// eslint-disable-next-line @typescript-eslint/no-explicit-any
journal_entries: any
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
id,
account_number,
debit_amount,
credit_amount,
journal_entries!inner(
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id
)
`)
.in('account_number', accountsForRuta)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
.order('id', { ascending: true })
.range(from, to), { dedupeBy: (r) => r.id })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
// a final deterministic tiebreak).
const allMapped: ReportSourceLine[] = rows.map((row) => ({
journal_entry_id: row.journal_entries.id,
voucher_number: row.journal_entries.voucher_number,
voucher_series: row.journal_entries.voucher_series || 'A',
date: row.journal_entries.entry_date,
description: row.journal_entries.description || '',
debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
}))
allMapped.sort((a, b) => {
const dateComp = a.date.localeCompare(b.date)
if (dateComp !== 0) return dateComp
if (a.voucher_number !== b.voucher_number) return a.voucher_number - b.voucher_number
return a.journal_entry_id.localeCompare(b.journal_entry_id)
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = (data || []) as any[]
// Apply the cursor in JS: keep rows strictly after (date, voucher_number).
const afterCursor = cursorDate
? allMapped.filter(
(l) =>
l.date > cursorDate! ||
(l.date === cursorDate! && l.voucher_number > cursorVoucherNum)
)
: allMapped
const lines: ReportSourceLine[] = rows
.slice(0, PAGE_LIMIT)
.map((row) => ({
journal_entry_id: row.journal_entries.id,
voucher_number: row.journal_entries.voucher_number,
voucher_series: row.journal_entries.voucher_series || 'A',
date: row.journal_entries.entry_date,
description: row.journal_entries.description || '',
debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
}))
const lines = afterCursor.slice(0, PAGE_LIMIT)
let next_cursor: string | null = null
if (rows.length > PAGE_LIMIT && lines.length > 0) {
if (afterCursor.length > PAGE_LIMIT && lines.length > 0) {
const last = lines[lines.length - 1]
next_cursor = `${last.date}|${last.voucher_number}`
}