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,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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user