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:
co-authored by
Claude Opus 4.8
parent
2da9c71eb3
commit
f63d3e3100
@@ -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 },
|
||||
)
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
+87
-1
@@ -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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user