* feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) Two deliverables from the community report where a bad SIE test import left no way out short of deleting the company: A) Discoverability: the voucher list shows one attn line linking to /import?history=sie whenever the page contains import-sourced vouchers, and /import?history=sie deep-links straight into the fold-open SIE import history where per-import Angra already lives. B) Reset of an UNLOCKED fiscal year regardless of how the entries arrived: new reset_fiscal_year RPC (same gnubok.allow_delete escape hatch as undo_sie_import; no enforcement trigger touched) behind GET/POST /api/bookkeeping/fiscal-periods/[id]/reset and a typed type-the-year-name confirmation dialog on the fiscal years settings list. Refuses on: locked/closed year, company lock date over any part of the year, executed year-end, arsredovisning state, later year depending on this year's UB, VAT-declared evidence (vat_settlement verifikat, SKV lock/submit audit rows, extension workflow keys, fail closed) and AGI-declared months. Entries referenced by RESTRICT/NO ACTION FKs abort the whole reset (all-or-nothing). Documents are detached, never deleted (BFL 7 kap); every delete is audit-logged plus one behandlingshistorik summary row. Fixes #1883 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): harden fiscal-year reset after skeptic review (#1883) Blocking skeptic findings on PR #1897, one consolidated pass: - New snapshot blocker cross_year_reference: an entry outside the year whose correction_of_id / reverses_id / reversed_by_id points into the year made the delete crash with an uncaught P0001 (immutability trigger refusing the ON DELETE SET NULL referential UPDATE) after an eligible:true preview, and silently severed draft chains. 12 such chains exist in prod today. - New snapshot blocker rot_rut_state: a begaran om utbetalning that reached Skatteverket (submitted/paid/partially_paid/rejected) was silently unlinked via SET NULL, erasing the bokforing behind a filed and possibly decided myndighetsarende. - Rakenskapsinformation preservation (BFL 7 kap): line-level trigger audit rows carry no company_id and header rows no amounts, so a reset destroyed konton/belopp with no company-readable trace. The RPC now archives the full content of every verifikat in company-scoped RESET_SNAPSHOT audit rows before deleting (action added to audit_log_action_check, NOT VALID), and behandlingshistorik renders them. - Dimension registry lockstep on reset (mirrors undo_sie_import): flipped imports can never be undone again, so their dimensions/values would have been orphaned forever. - EXCEPTION WHEN raise_exception now returns a typed FISCAL_YEAR_RESET_LINKED_ENTRIES envelope instead of a bare 500; gnubok.allow_delete is cleared before leaving the guarded block. - Voucher-list attn line fires only for source_type 'import': opening_balance is also written by year-end closing and the manual IB flows, which mislabelled every year-2+ company as SIE-imported. - /import?history=sie now scrolls the SIE history into view. - Reset dialog copy (sv+en) discloses that linked invoices, payments and bank transactions become unbooked; new blocker strings in both locales. - pg fixture fix: document_attachments seeded without company_id (23502); new pg tests for both blockers, RESET_SNAPSHOT rows and the lockstep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1217,12 +1217,15 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-25] WhatsApp instant received-signal = emoji reaction (U+2705) sent from the webhook, not an extra text message: reactions add no chat bubble so the one-combined-ack-per-burst design survives; best-effort and not persisted as an outbound row (cosmetic, like mark-read), gated on the CHAT_ALLOWED_MIME_TYPES allowlist so junk never earns a checkmark.
|
||||
[2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway.
|
||||
[2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden.
|
||||
[2026-08-25] reset_fiscal_year (#1883) catches foreign_key_violation and aborts instead of enumerating every table that references journal_entries: RESTRICT/NO ACTION FKs (assets, accruals, salary runs) fail the whole reset with FISCAL_YEAR_RESET_LINKED_ENTRIES, SET NULL FKs (transactions, invoice payments) unlink as their designers intended. Future FKs stay covered without touching the RPC.
|
||||
[2026-08-25] Fiscal-year reset treats a posted vat_settlement verifikat, SKV declaration lock/submit audit rows and skatteverket-extension submission_* keys as "VAT declared" evidence and refuses: SKV's actual declaration state is not observable from the DB, so every local trace fails closed (same posture as company_migration_reset 20260818224000).
|
||||
[2026-08-25] Proposal prefill/preview (lib/bookkeeping/proposal-lines.ts) mirrors the ENGINE's formulas, not the historical preview: net leg = gross minus single-rounded VAT (independently rounded extractNet/extractVat unbalances 12% grosses at 14 mod 28 ore), plain Math.round ore parity via a local engineRound (roundOre's EPSILON nudge diverges from booked verifikat at exact-half floats like 8.62*0.25), legacy counterparty proposals now render the 2645/2614 fiktiv-moms pair the legacy booking path actually emits (the 2026-07-29 decision claimed preview/engine parity but the preview omitted the pair; once 'Andra rader' made the preview bookable, the omission would book RC expenses without fiktiv moms), sign-mismatched counterparty matches are mirrored like the server, static template accounts are entity-resolved (_ab), the 'none' VAT sentinel is resolved via resolveExplicitVat before line computation, and the settlement swap applies only to a literal 1930 leg (applySettlementAccount parity). Chosen over sourcing the prefill from MappingResult builders directly to keep the client dialog free of server-only inputs; skeptic counterexamples are locked in proposal-lines.test.ts.
|
||||
[2026-08-24] gnubok_create_company (#1814 PR 3) is a direct write with an explicit two-phase confirm (preview, then confirm=true) instead of a staged pending_operation: pending_operations rows are company-scoped and there is no company to attach the staging to before it exists.
|
||||
[2026-08-24] gnubok_connect_bank / gnubok_connect_skatteverket hand the user a browser link plus connection status instead of driving the PSD2 or Skatteverket flow from the MCP server: both extension handlers need a cookie session and BankID in a browser, and cross-extension imports are forbidden. The web app's /import?mode=psd2 and the skatteverket authorize route are the links.
|
||||
[2026-08-24] The MCP consent page pre-ticks companies:write for an account that has no company yet: that account is connecting in order to create a company, and a default that dead-ends on insufficient scope right after signup would be the worse default. Still an untickable checkbox, still bounded by the client's scope ceiling.
|
||||
[2026-08-25] gnubok_create_company / POST /api/v1/companies require f_skatt explicitly and org_number whenever vat_registered (review findings on #1864): a defaulted F-skatt approval or a VAT-registered company with no momsregistreringsnummer would flow straight into invoices (ML 17 kap 24 §, SE-R-005). Explicit beats convenient on a legal fact.
|
||||
[2026-08-25] An enskild firma's first fiscal year must end on 31 December in the programmatic setup paths, mirroring the wizard's own rule text: the calendar-year mandate (BFL 3 kap. 1 §) is not lifted by the first-year extension.
|
||||
[2026-08-25] reset_fiscal_year skeptic hardening (#1897): filed ROT/RUT begaran and cross-year rattelse/storno chains BLOCK the reset (new snapshot guards) instead of being silently unlinked or crashing on the RI UPDATE; other SET NULL links (invoices, payments, transactions) unlink by design with dialog disclosure, since they are re-bookable and the year's external reliance states all refuse. Full verifikat content is archived in company-scoped RESET_SNAPSHOT audit rows before deletion: the trigger's line-level audit rows carry no company_id and header rows carry no amounts, which would otherwise destroy rakenskapsinformation (BFL 7 kap).
|
||||
[2026-08-24] The OAuth AS metadata advertises client_id_metadata_document_supported (CIMD, #1814 PR 4) without fetching or validating the client's metadata document: authorize/token never keyed anything on client_id, the redirect_uri allowlist is the trust boundary, and CIMD only changes what Claude/Codex send as client_id (an HTTPS URL instead of a DCR-minted UUID). Fetching the document would add a network dependency to every consent for no gain in this design. DCR stays for ChatGPT.
|
||||
[2026-08-25] CIMD is NOT advertised after all (reverses the 2026-08-24 entry; CodeRabbit on #1866): the spec expects an AS that advertises client_id_metadata_document_supported to fetch the document and match redirect_uri exactly against it, and our authorize endpoint only checks the global allowlist. Advertising would claim a check we skip. Add the flag together with an SSRF-safe cached CIMD fetch + exact redirect matching (localhost port-agnostic for Claude Code/Codex); DCR is free for us (stateless register), so nothing is lost meanwhile.
|
||||
[2026-08-25] Proposal line-pattern settlement leg now takes the counterparty template's learned legacy pair (credit for expense, debit for income, mirror-swapped, || 1930), passed raw from QuickReviewDialog: two skeptics refuted the 1930 default (engine books e.g. 2440 from SIE-learned patterns; preview/prefill showed 1930). Declined CodeRabbit's two suggestions on #1894 deliberately: the 3740 rounding line keeps the engine's business-side placement for BOTH diff signs (parity contract; the engine's negative-diff imbalance cannot reach the ledger, commit_journal_entry rejects it; engine-side sign fix is a separate issue) and the naiveOreRound baseline stays raised to 622 (engineRound is a documented parity exception, not drift).
|
||||
|
||||
@@ -2370,6 +2370,20 @@ export default function ImportPage() {
|
||||
if (viewParam === 'export' || viewParam === 'import') {
|
||||
setView(viewParam)
|
||||
}
|
||||
// Deep link from surfaces where a bad import is discovered (e.g. the
|
||||
// voucher list): /import?history=sie lands directly on the fold-open
|
||||
// SIE import history so "Ångra import" is one click away.
|
||||
if (searchParams.get('history') === 'sie') {
|
||||
setView('import')
|
||||
setSieHistoryOpen(true)
|
||||
// The history table renders below ~8 import rows: scroll it into view
|
||||
// (same pattern as the #cloud-backup hash deep link below).
|
||||
setTimeout(() => {
|
||||
document
|
||||
.getElementById('sie-import-history')
|
||||
?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}, 80)
|
||||
}
|
||||
}, [isSandbox, searchParams])
|
||||
|
||||
// Hash-based deep links: all live on the export tab; #sie-export opens
|
||||
@@ -2547,7 +2561,7 @@ export default function ImportPage() {
|
||||
/>
|
||||
</div>
|
||||
{sieHistoryOpen && (
|
||||
<div className="mt-6">
|
||||
<div className="mt-6" id="sie-import-history">
|
||||
<SIEImportHistory />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Tests for GET/POST /api/bookkeeping/fiscal-periods/[id]/reset.
|
||||
*
|
||||
* Exercises the routes through the real withRouteContext wrapper, mocking its
|
||||
* auth/company/write dependencies and the fiscal-year-reset service. Covers:
|
||||
* 401, 403 viewer, validation 400, 404, the eligibility passthrough, the
|
||||
* happy path, and every refusal envelope (ineligible with blockers,
|
||||
* confirmation mismatch, linked entries).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
const getEligibilityMock = vi.fn()
|
||||
const resetFiscalYearMock = vi.fn()
|
||||
vi.mock('@/lib/core/bookkeeping/fiscal-year-reset', () => ({
|
||||
getFiscalYearResetEligibility: (...args: unknown[]) => getEligibilityMock(...args),
|
||||
resetFiscalYear: (...args: unknown[]) => resetFiscalYearMock(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
const routeParams = () => createMockRouteParams({ id: 'period-1' })
|
||||
|
||||
const ELIGIBILITY = {
|
||||
eligible: true,
|
||||
blockers: [],
|
||||
period: {
|
||||
id: 'period-1',
|
||||
name: '2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
},
|
||||
counts: { vouchers: 42, documents_to_detach: 3 },
|
||||
}
|
||||
|
||||
describe('GET /api/bookkeeping/fiscal-periods/[id]/reset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(getEligibilityMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the eligibility preview', async () => {
|
||||
getEligibilityMock.mockResolvedValue({ ok: true, eligibility: ELIGIBILITY })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof ELIGIBILITY }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(ELIGIBILITY)
|
||||
expect(getEligibilityMock).toHaveBeenCalledWith(supabase, 'company-1', 'period-1')
|
||||
})
|
||||
|
||||
it('returns 404 when the period does not exist', async () => {
|
||||
getEligibilityMock.mockResolvedValue({ ok: false, code: 'FISCAL_YEAR_RESET_NOT_FOUND' })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 403 when the caller is not owner/admin', async () => {
|
||||
getEligibilityMock.mockResolvedValue({ ok: false, code: 'FISCAL_YEAR_RESET_FORBIDDEN' })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_FORBIDDEN')
|
||||
})
|
||||
|
||||
it('maps unexpected codes to the generic 500 envelope', async () => {
|
||||
getEligibilityMock.mockResolvedValue({ ok: false, code: 'SOMETHING_ELSE' })
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset'),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_FAILED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/fiscal-periods/[id]/reset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
const postRequest = (body?: unknown) =>
|
||||
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/reset', {
|
||||
method: 'POST',
|
||||
body: body ?? { confirm_name: '2026' },
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(resetFiscalYearMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(resetFiscalYearMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when confirm_name is missing', async () => {
|
||||
const response = await POST(postRequest({}), routeParams())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(resetFiscalYearMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('executes the reset and returns the counts', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: true,
|
||||
deleted: 42,
|
||||
detachedDocuments: 3,
|
||||
periodName: '2026',
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { deleted: number; detachedDocuments: number; periodName: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ deleted: 42, detachedDocuments: 3, periodName: '2026' })
|
||||
expect(resetFiscalYearMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'period-1',
|
||||
'user-1',
|
||||
'2026',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 409 with blockers when the year is ineligible (e.g. locked)', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_INELIGIBLE',
|
||||
blockers: [{ code: 'period_locked' }],
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { blockers?: Array<{ code: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_INELIGIBLE')
|
||||
expect(body.error.details?.blockers).toEqual([{ code: 'period_locked' }])
|
||||
})
|
||||
|
||||
it('returns 400 on confirmation mismatch', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH',
|
||||
})
|
||||
|
||||
const response = await POST(postRequest({ confirm_name: 'fel namn' }), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH')
|
||||
})
|
||||
|
||||
it('returns 409 when entries are linked to other records', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_LINKED_ENTRIES',
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_LINKED_ENTRIES')
|
||||
})
|
||||
|
||||
it('returns 404 when the period does not exist', async () => {
|
||||
resetFiscalYearMock.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_NOT_FOUND',
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(), routeParams())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('FISCAL_YEAR_RESET_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { FiscalYearResetSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
getFiscalYearResetEligibility,
|
||||
resetFiscalYear,
|
||||
} from '@/lib/core/bookkeeping/fiscal-year-reset'
|
||||
|
||||
type Params = { params: Promise<{ id: string }> }
|
||||
|
||||
// Hard-deleting a year of audit-logged journal entries (+ cascading lines)
|
||||
// can take well over the default function timeout. Match the SIE undo route
|
||||
// so the serverless function doesn't kill the request first.
|
||||
export const maxDuration = 300
|
||||
|
||||
const EXPECTED_CODES = new Set([
|
||||
'FISCAL_YEAR_RESET_NOT_FOUND',
|
||||
'FISCAL_YEAR_RESET_FORBIDDEN',
|
||||
'FISCAL_YEAR_RESET_INELIGIBLE',
|
||||
'FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH',
|
||||
'FISCAL_YEAR_RESET_LINKED_ENTRIES',
|
||||
])
|
||||
|
||||
function knownCode(code: string): string {
|
||||
return EXPECTED_CODES.has(code) ? code : 'FISCAL_YEAR_RESET_FAILED'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/fiscal-periods/[id]/reset
|
||||
*
|
||||
* Owner/admin-only, fail-closed eligibility preview for the fiscal-year
|
||||
* reset: which guards block it, and what a reset would delete (voucher
|
||||
* count) and detach (document links; the documents themselves are never
|
||||
* deleted, BFL 7 kap). The execution RPC rechecks every condition, so this
|
||||
* response is informational only.
|
||||
*/
|
||||
export const GET = withRouteContext<Params>(
|
||||
'period.reset.preview',
|
||||
async (_request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
const opLog = log.child({ periodId: id })
|
||||
|
||||
const result = await getFiscalYearResetEligibility(supabase, companyId!, id)
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(knownCode(result.code), opLog, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result.eligibility })
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/fiscal-periods/[id]/reset
|
||||
*
|
||||
* Executes the reset: hard-deletes ALL vouchers in the open fiscal year.
|
||||
* Requires the year's label restated as typed confirmation (confirm_name);
|
||||
* refused past any lock/close/declared/year-end/next-year-dependency state.
|
||||
* All guards are enforced inside the reset_fiscal_year RPC.
|
||||
*/
|
||||
export const POST = withRouteContext<Params>(
|
||||
'period.reset.execute',
|
||||
async (request, { supabase, companyId, user, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
const opLog = log.child({ periodId: id })
|
||||
|
||||
const validation = await validateBody(request, FiscalYearResetSchema, {
|
||||
log: opLog,
|
||||
operation: 'period.reset.execute',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const result = await resetFiscalYear(
|
||||
supabase,
|
||||
companyId!,
|
||||
id,
|
||||
user.id,
|
||||
validation.data.confirm_name,
|
||||
)
|
||||
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(knownCode(result.code), opLog, {
|
||||
requestId,
|
||||
details: result.blockers ? { blockers: result.blockers } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
opLog.info('fiscal year reset executed', {
|
||||
deleted: result.deleted,
|
||||
detachedDocuments: result.detachedDocuments,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
deleted: result.deleted,
|
||||
detachedDocuments: result.detachedDocuments,
|
||||
periodName: result.periodName,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE,
|
||||
} from '@/components/common/FiscalYearSelector'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { OpenInNewTab } from '@/components/ui/open-in-new-tab'
|
||||
import {
|
||||
@@ -1441,6 +1442,21 @@ export default function JournalEntryList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discoverability for "Ångra import" (issue #1883): when the page
|
||||
shows import-sourced vouchers, point at the SIE import history
|
||||
where a bad import can be undone in one step. One page-domain
|
||||
attn line (design convention 6). Only source_type 'import' is an
|
||||
SIE marker: 'opening_balance' is also written by year-end closing
|
||||
and the manual IB flows, which have nothing to undo here. */}
|
||||
{entries.some((e) => e.source_type === 'import') && (
|
||||
<AttnLine
|
||||
className="px-1 pb-2"
|
||||
action={{ label: t('import_attn_action'), href: '/import?history=sie' }}
|
||||
>
|
||||
{t('import_attn')}
|
||||
</AttnLine>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { FiscalYearResetBlocker, FiscalYearResetEligibility } from '@/types'
|
||||
|
||||
interface FiscalYearResetDialogProps {
|
||||
periodId: string
|
||||
periodName: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Called after a successful reset so the parent can refetch. */
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
function readApiError(body: unknown, fallback: string): string {
|
||||
if (!body || typeof body !== 'object') return fallback
|
||||
const error = (body as { error?: unknown }).error
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructive fiscal-year reset (issue #1883): hard-deletes ALL vouchers in
|
||||
* one OPEN fiscal year after typed confirmation of the year's label. Mirrors
|
||||
* the CompanyMigrationResetDialog pattern: eligibility preview with blockers,
|
||||
* a clear statement of what is deleted (voucher count, year label) and what
|
||||
* is NOT (documents are detached, never deleted: BFL 7 kap), and a
|
||||
* type-the-name confirmation. Every guard is re-enforced server-side.
|
||||
*/
|
||||
export function FiscalYearResetDialog({
|
||||
periodId,
|
||||
periodName,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReset,
|
||||
}: FiscalYearResetDialogProps) {
|
||||
const t = useTranslations('settings_bookkeeping')
|
||||
const { toast } = useToast()
|
||||
const loadFailedMessage = t('fy_reset_load_failed')
|
||||
const [eligibility, setEligibility] = useState<FiscalYearResetEligibility | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isResetting, setIsResetting] = useState(false)
|
||||
const [confirmName, setConfirmName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
let cancelled = false
|
||||
async function loadEligibility() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
setEligibility(null)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/bookkeeping/fiscal-periods/${periodId}/reset`,
|
||||
{ cache: 'no-store' },
|
||||
)
|
||||
const body = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(readApiError(body, loadFailedMessage))
|
||||
}
|
||||
if (!cancelled) setEligibility(body.data as FiscalYearResetEligibility)
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoadError(
|
||||
error instanceof Error ? getUserErrorMessage(error) : loadFailedMessage,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadEligibility()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [periodId, loadFailedMessage, open])
|
||||
|
||||
function resetForm() {
|
||||
setEligibility(null)
|
||||
setLoadError(null)
|
||||
setConfirmName('')
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (isResetting) return
|
||||
onOpenChange(nextOpen)
|
||||
if (!nextOpen) resetForm()
|
||||
}
|
||||
|
||||
function blockerMessage(blocker: FiscalYearResetBlocker): string {
|
||||
switch (blocker.code) {
|
||||
case 'period_closed':
|
||||
return t('fy_reset_blocker_closed')
|
||||
case 'period_locked':
|
||||
return t('fy_reset_blocker_locked')
|
||||
case 'company_lock_date':
|
||||
return t('fy_reset_blocker_lock_date', { date: blocker.date ?? '' })
|
||||
case 'year_end_state':
|
||||
return t('fy_reset_blocker_year_end')
|
||||
case 'arsredovisning_state':
|
||||
return t('fy_reset_blocker_arsredovisning')
|
||||
case 'next_year_dependency':
|
||||
return t('fy_reset_blocker_next_year')
|
||||
case 'vat_declared':
|
||||
return t('fy_reset_blocker_vat')
|
||||
case 'agi_declared':
|
||||
return t('fy_reset_blocker_agi')
|
||||
case 'rot_rut_state':
|
||||
return t('fy_reset_blocker_rot_rut')
|
||||
case 'cross_year_reference':
|
||||
return t('fy_reset_blocker_cross_year')
|
||||
default:
|
||||
return t('fy_reset_blocker_other')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmationName = eligibility?.period.name ?? periodName
|
||||
const canReset =
|
||||
eligibility?.eligible === true &&
|
||||
confirmName.trim() === confirmationName.trim() &&
|
||||
!isResetting
|
||||
|
||||
async function handleReset() {
|
||||
if (!canReset) return
|
||||
setIsResetting(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/bookkeeping/fiscal-periods/${periodId}/reset`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confirm_name: confirmName }),
|
||||
},
|
||||
)
|
||||
const body = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(readApiError(body, t('fy_reset_failed_default')))
|
||||
}
|
||||
|
||||
const deleted = (body.data as { deleted?: number } | undefined)?.deleted ?? 0
|
||||
toast({
|
||||
title: t('fy_reset_success_title'),
|
||||
description: t('fy_reset_success_description', { count: deleted }),
|
||||
})
|
||||
setIsResetting(false)
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
onReset()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('fy_reset_failed_title'),
|
||||
description:
|
||||
error instanceof Error
|
||||
? getUserErrorMessage(error)
|
||||
: t('fy_reset_failed_default'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('fy_reset_dialog_title', { name: periodName })}</DialogTitle>
|
||||
<DialogDescription>{t('fy_reset_dialog_description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('fy_reset_checking')}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{loadError}
|
||||
</div>
|
||||
) : eligibility ? (
|
||||
<div className="space-y-5">
|
||||
{eligibility.blockers.length > 0 ? (
|
||||
<div className="space-y-2 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t('fy_reset_blocked_title')}
|
||||
</div>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{eligibility.blockers.map((blocker) => (
|
||||
<li key={blocker.code}>{blockerMessage(blocker)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">{t('fy_reset_summary_heading')}</h3>
|
||||
<dl className="divide-y divide-border border-y border-border">
|
||||
<div className="flex items-center justify-between py-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t('fy_reset_summary_year')}</dt>
|
||||
<dd className="font-medium">
|
||||
{eligibility.period.name}
|
||||
<span className="ml-2 text-muted-foreground tabular-nums">
|
||||
{formatDate(eligibility.period.period_start)} -{' '}
|
||||
{formatDate(eligibility.period.period_end)}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t('fy_reset_summary_vouchers')}</dt>
|
||||
<dd className="tabular-nums font-medium">{eligibility.counts.vouchers}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t('fy_reset_summary_documents')}</dt>
|
||||
<dd className="tabular-nums font-medium">
|
||||
{eligibility.counts.documents_to_detach}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('fy_reset_documents_note')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{eligibility.eligible ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fy-reset-name">
|
||||
{t.rich('fy_reset_confirm_name', {
|
||||
name: confirmationName,
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="fy-reset-name"
|
||||
value={confirmName}
|
||||
onChange={(event) => setConfirmName(event.target.value)}
|
||||
placeholder={confirmationName}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{t('fy_confirm_cancel')}
|
||||
</Button>
|
||||
{eligibility?.eligible ? (
|
||||
<Button variant="destructive" onClick={handleReset} disabled={!canReset}>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('fy_reset_resetting')}
|
||||
</>
|
||||
) : (
|
||||
t('fy_reset_submit')
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Plus, Lock, Unlock, Loader2 } from 'lucide-react'
|
||||
import { Plus, Lock, Unlock, Loader2, Eraser } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
|
||||
import { FiscalYearResetDialog } from '@/components/settings/FiscalYearResetDialog'
|
||||
import { suggestSeedDate } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -43,6 +44,7 @@ export function FiscalYearsManager() {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [mutatingId, setMutatingId] = useState<string | null>(null)
|
||||
const [resetTarget, setResetTarget] = useState<FiscalPeriod | null>(null)
|
||||
|
||||
// Only owners/admins may change a period's lock state. The API enforces this
|
||||
// too (requireWrite); this just hides controls a viewer/member can't use.
|
||||
@@ -162,6 +164,18 @@ export function FiscalYearsManager() {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && status === 'open' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isMutating}
|
||||
onClick={() => setResetTarget(p)}
|
||||
>
|
||||
<Eraser className="mr-1.5 h-4 w-4" />
|
||||
{t('fy_action_reset')}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && status === 'locked' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -208,6 +222,18 @@ export function FiscalYearsManager() {
|
||||
onCreated={fetchPeriods}
|
||||
/>
|
||||
|
||||
{resetTarget && (
|
||||
<FiscalYearResetDialog
|
||||
periodId={resetTarget.id}
|
||||
periodName={resetTarget.name}
|
||||
open={resetTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResetTarget(null)
|
||||
}}
|
||||
onReset={fetchPeriods}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
</SettingsGroup>
|
||||
)
|
||||
|
||||
@@ -3754,6 +3754,21 @@ export const CompanyMigrationResetSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/fiscal-periods/[id]/reset
|
||||
*
|
||||
* Typed confirmation for the destructive fiscal-year reset: the caller must
|
||||
* restate the year's label (fiscal_periods.name) exactly. The RPC repeats
|
||||
* the match server-side, so this only provides early Swedish feedback.
|
||||
*/
|
||||
export const FiscalYearResetSchema = z.object({
|
||||
confirm_name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Ange räkenskapsårets namn exakt som det visas')
|
||||
.max(200, 'Räkenskapsårets namn får vara högst 200 tecken'),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/notices/dismiss
|
||||
*
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Unit tests for the fiscal-year-reset service wrapper.
|
||||
*
|
||||
* The guards themselves live in the reset_fiscal_year RPC and are pinned by
|
||||
* tests/pg/reset-fiscal-year.pg.test.ts against real Postgres; these tests
|
||||
* pin the envelope mapping (RPC jsonb -> typed outcomes), the fail-closed
|
||||
* default code, and that the execution escalates through
|
||||
* rpcClientForBulkDelete with an explicit p_user_id.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
getFiscalYearResetEligibility,
|
||||
resetFiscalYear,
|
||||
} from '@/lib/core/bookkeeping/fiscal-year-reset'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const bulkRpcMock = vi.fn()
|
||||
const bulkClient = { rpc: bulkRpcMock } as unknown as SupabaseClient
|
||||
vi.mock('@/lib/import/sie-import', () => ({
|
||||
rpcClientForBulkDelete: vi.fn(async () => bulkClient),
|
||||
}))
|
||||
|
||||
function sessionClient(rpcResult: { data?: unknown; error?: unknown }): SupabaseClient {
|
||||
return { rpc: vi.fn(async () => rpcResult) } as unknown as SupabaseClient
|
||||
}
|
||||
|
||||
const SNAPSHOT = {
|
||||
ok: true,
|
||||
eligible: true,
|
||||
blockers: [],
|
||||
period: {
|
||||
id: 'period-1',
|
||||
name: '2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
},
|
||||
counts: { vouchers: 7, documents_to_detach: 2 },
|
||||
}
|
||||
|
||||
describe('getFiscalYearResetEligibility', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('maps the RPC snapshot to a typed eligibility', async () => {
|
||||
const supabase = sessionClient({ data: SNAPSHOT, error: null })
|
||||
|
||||
const result = await getFiscalYearResetEligibility(supabase, 'company-1', 'period-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
eligibility: {
|
||||
eligible: true,
|
||||
blockers: [],
|
||||
period: SNAPSHOT.period,
|
||||
counts: SNAPSHOT.counts,
|
||||
},
|
||||
})
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('get_fiscal_year_reset_eligibility', {
|
||||
p_company_id: 'company-1',
|
||||
p_period_id: 'period-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('passes through blockers for an ineligible year', async () => {
|
||||
const supabase = sessionClient({
|
||||
data: {
|
||||
...SNAPSHOT,
|
||||
eligible: false,
|
||||
blockers: [{ code: 'period_locked' }, { code: 'vat_declared', count: 1 }],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await getFiscalYearResetEligibility(supabase, 'company-1', 'period-1')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.eligibility.eligible).toBe(false)
|
||||
expect(result.eligibility.blockers).toEqual([
|
||||
{ code: 'period_locked' },
|
||||
{ code: 'vat_declared', count: 1 },
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the RPC error code when the RPC refuses', async () => {
|
||||
const supabase = sessionClient({
|
||||
data: { ok: false, code: 'FISCAL_YEAR_RESET_FORBIDDEN' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await getFiscalYearResetEligibility(supabase, 'company-1', 'period-1')
|
||||
|
||||
expect(result).toEqual({ ok: false, code: 'FISCAL_YEAR_RESET_FORBIDDEN' })
|
||||
})
|
||||
|
||||
it('fails closed on a transport error', async () => {
|
||||
const supabase = sessionClient({ data: null, error: { message: 'boom' } })
|
||||
|
||||
const result = await getFiscalYearResetEligibility(supabase, 'company-1', 'period-1')
|
||||
|
||||
expect(result).toEqual({ ok: false, code: 'FISCAL_YEAR_RESET_FAILED' })
|
||||
})
|
||||
|
||||
it('fails closed on a null RPC payload', async () => {
|
||||
const supabase = sessionClient({ data: null, error: null })
|
||||
|
||||
const result = await getFiscalYearResetEligibility(supabase, 'company-1', 'period-1')
|
||||
|
||||
expect(result).toEqual({ ok: false, code: 'FISCAL_YEAR_RESET_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetFiscalYear', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('executes through the bulk-delete client with an explicit user id', async () => {
|
||||
bulkRpcMock.mockResolvedValue({
|
||||
data: { ok: true, deleted: 7, detached_documents: 2, period_name: '2026' },
|
||||
error: null,
|
||||
})
|
||||
const supabase = sessionClient({ data: null, error: null })
|
||||
|
||||
const result = await resetFiscalYear(supabase, 'company-1', 'period-1', 'user-1', '2026')
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
deleted: 7,
|
||||
detachedDocuments: 2,
|
||||
periodName: '2026',
|
||||
})
|
||||
expect(bulkRpcMock).toHaveBeenCalledWith('reset_fiscal_year', {
|
||||
p_company_id: 'company-1',
|
||||
p_period_id: 'period-1',
|
||||
p_confirmed_name: '2026',
|
||||
p_user_id: 'user-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('passes through refusal codes and blockers', async () => {
|
||||
bulkRpcMock.mockResolvedValue({
|
||||
data: {
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_INELIGIBLE',
|
||||
blockers: [{ code: 'company_lock_date', date: '2026-06-30' }],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const supabase = sessionClient({ data: null, error: null })
|
||||
|
||||
const result = await resetFiscalYear(supabase, 'company-1', 'period-1', 'user-1', '2026')
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_INELIGIBLE',
|
||||
blockers: [{ code: 'company_lock_date', date: '2026-06-30' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed on a transport error', async () => {
|
||||
bulkRpcMock.mockResolvedValue({ data: null, error: { message: 'boom' } })
|
||||
const supabase = sessionClient({ data: null, error: null })
|
||||
|
||||
const result = await resetFiscalYear(supabase, 'company-1', 'period-1', 'user-1', '2026')
|
||||
|
||||
expect(result).toEqual({ ok: false, code: 'FISCAL_YEAR_RESET_FAILED' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { rpcClientForBulkDelete } from '@/lib/import/sie-import'
|
||||
import type { FiscalYearResetEligibility, FiscalYearResetRpcResult } from '@/types'
|
||||
|
||||
/**
|
||||
* Fiscal-year reset (issue #1883): guarded hard-delete of ALL vouchers in one
|
||||
* OPEN fiscal year, regardless of how they arrived (SIE import, manual,
|
||||
* agent). The heavy lifting and every guard live in the `reset_fiscal_year`
|
||||
* RPC (migration 20260825150000), which reuses the same
|
||||
* `gnubok.allow_delete` escape hatch as `undo_sie_import`: this module is a
|
||||
* thin typed wrapper.
|
||||
*
|
||||
* The RPC refuses whenever any reliance state exists: locked/closed year,
|
||||
* company lock date over the year, year-end/arsredovisning state, a later
|
||||
* year depending on this year's UB, VAT/AGI declared evidence, or entries
|
||||
* referenced by other records (assets, accruals, salary runs). Documents are
|
||||
* detached, never deleted (BFL 7 kap).
|
||||
*/
|
||||
|
||||
export type FiscalYearResetOutcome =
|
||||
| { ok: true; deleted: number; detachedDocuments: number; periodName: string }
|
||||
| { ok: false; code: string; blockers?: FiscalYearResetEligibility['blockers'] }
|
||||
|
||||
export type FiscalYearResetEligibilityOutcome =
|
||||
| { ok: true; eligibility: FiscalYearResetEligibility }
|
||||
| { ok: false; code: string }
|
||||
|
||||
/**
|
||||
* Owner/admin-only eligibility preview. Runs on the caller's session client
|
||||
* (auth.uid() present), so no explicit user id is needed. The execution RPC
|
||||
* rechecks every condition; this response is informational only.
|
||||
*/
|
||||
export async function getFiscalYearResetEligibility(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periodId: string,
|
||||
): Promise<FiscalYearResetEligibilityOutcome> {
|
||||
const { data, error } = await supabase.rpc('get_fiscal_year_reset_eligibility', {
|
||||
p_company_id: companyId,
|
||||
p_period_id: periodId,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return { ok: false, code: 'FISCAL_YEAR_RESET_FAILED' }
|
||||
}
|
||||
|
||||
const result = data as FiscalYearResetRpcResult | null
|
||||
if (!result?.ok) {
|
||||
return { ok: false, code: result?.code ?? 'FISCAL_YEAR_RESET_FAILED' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
eligibility: {
|
||||
eligible: result.eligible === true,
|
||||
blockers: result.blockers ?? [],
|
||||
period: result.period!,
|
||||
counts: result.counts ?? { vouchers: 0, documents_to_detach: 0 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the reset. Runs on the service client when available (the
|
||||
* authenticated role's 8s statement_timeout cannot fit a year-sized delete;
|
||||
* see rpcClientForBulkDelete), passing the authorising user explicitly: on
|
||||
* the service client auth.uid() is NULL and the RPC resolves its owner/admin
|
||||
* gate from p_user_id instead. `confirmedName` must restate the year's label
|
||||
* exactly (typed confirmation, verified again inside the RPC).
|
||||
*/
|
||||
export async function resetFiscalYear(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periodId: string,
|
||||
userId: string,
|
||||
confirmedName: string,
|
||||
): Promise<FiscalYearResetOutcome> {
|
||||
const rpcClient = await rpcClientForBulkDelete(supabase)
|
||||
const { data, error } = await rpcClient.rpc('reset_fiscal_year', {
|
||||
p_company_id: companyId,
|
||||
p_period_id: periodId,
|
||||
p_confirmed_name: confirmedName,
|
||||
p_user_id: userId,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return { ok: false, code: 'FISCAL_YEAR_RESET_FAILED' }
|
||||
}
|
||||
|
||||
const result = data as FiscalYearResetRpcResult | null
|
||||
if (!result?.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: result?.code ?? 'FISCAL_YEAR_RESET_FAILED',
|
||||
blockers: result?.blockers,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted: result.deleted ?? 0,
|
||||
detachedDocuments: result.detached_documents ?? 0,
|
||||
periodName: result.period_name ?? '',
|
||||
}
|
||||
}
|
||||
@@ -1526,6 +1526,38 @@ const PERIOD: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Ett stängt räkenskapsår kan inte låsas upp.',
|
||||
message_en: 'A closed fiscal year cannot be unlocked.',
|
||||
},
|
||||
FISCAL_YEAR_RESET_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Räkenskapsåret kunde inte hittas.',
|
||||
message_en: 'Fiscal year not found.',
|
||||
},
|
||||
FISCAL_YEAR_RESET_FORBIDDEN: {
|
||||
httpStatus: 403,
|
||||
message_sv: 'Endast företagets ägare eller administratörer kan nollställa ett räkenskapsår.',
|
||||
message_en: 'Only company owners and admins can reset a fiscal year.',
|
||||
},
|
||||
FISCAL_YEAR_RESET_INELIGIBLE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Räkenskapsåret kan inte nollställas i sitt nuvarande läge.',
|
||||
message_en: 'The fiscal year cannot be reset in its current state.',
|
||||
},
|
||||
FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Räkenskapsårets namn stämmer inte överens.',
|
||||
message_en: 'The fiscal year name does not match.',
|
||||
},
|
||||
FISCAL_YEAR_RESET_LINKED_ENTRIES: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Räkenskapsåret innehåller verifikat som är kopplade till andra poster (t.ex. anläggningstillgångar, periodiseringar eller lönekörningar). Ta bort eller ångra de kopplade flödena först. Inga ändringar har sparats.',
|
||||
message_en:
|
||||
'The fiscal year contains vouchers linked to other records (e.g. assets, accrual schedules or salary runs). Undo those flows first. No changes were saved.',
|
||||
},
|
||||
FISCAL_YEAR_RESET_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Räkenskapsåret kunde inte nollställas. Inga ändringar har sparats.',
|
||||
message_en: 'Failed to reset the fiscal year. No changes were saved.',
|
||||
},
|
||||
// Retired 2026-07-26: PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS. Creating the
|
||||
// next räkenskapsår while a prior one is still fully open is no longer an
|
||||
// error at all: BFL 5 kap 2 § forces the new year's affärshändelser to be
|
||||
|
||||
@@ -702,6 +702,29 @@ function journalEntryAuditEvent(
|
||||
object,
|
||||
details: [`Datum: ${fmtValue(oldState?.entry_date)}`, `Text: ${fmtValue(oldState?.description)}`],
|
||||
})
|
||||
case 'RESET_SNAPSHOT': {
|
||||
// reset_fiscal_year archives the full verifikat content (accounts,
|
||||
// amounts, line text) in a company-scoped row before deleting; the
|
||||
// trigger's DELETE row that follows carries only the header.
|
||||
const rawLines = oldState?.lines
|
||||
const lineDetails = Array.isArray(rawLines)
|
||||
? rawLines.map((raw) => {
|
||||
const line = raw as Record<string, unknown>
|
||||
return `${str(line.account_number) ?? '?'}: debet ${fmtValue(line.debit_amount)}, kredit ${fmtValue(line.credit_amount)}`
|
||||
})
|
||||
: []
|
||||
return auditEvent(row, {
|
||||
category: 'verifikation',
|
||||
code: 'journal_entry.reset_snapshot',
|
||||
event: 'Verifikationsinnehåll arkiverat inför nollställning',
|
||||
object,
|
||||
details: [
|
||||
`Datum: ${fmtValue(oldState?.entry_date)}`,
|
||||
`Text: ${fmtValue(oldState?.description)}`,
|
||||
...lineDetails,
|
||||
],
|
||||
})
|
||||
}
|
||||
case 'COMMITTED_AT_OVERRIDE':
|
||||
return auditEvent(row, {
|
||||
category: 'verifikation',
|
||||
|
||||
@@ -1958,6 +1958,35 @@
|
||||
"fy_lock_success": "Fiscal year locked",
|
||||
"fy_unlock_success": "Fiscal year unlocked",
|
||||
"fy_action_error": "The action could not be completed",
|
||||
"fy_action_reset": "Reset",
|
||||
"fy_reset_dialog_title": "Reset fiscal year {name}?",
|
||||
"fy_reset_dialog_description": "All vouchers in the fiscal year are permanently deleted, regardless of how they were created. Documents are never deleted, only detached. Invoices, payments and bank transactions that were linked to the vouchers become unbooked and need to be booked again. This cannot be undone and is recorded in the audit log.",
|
||||
"fy_reset_checking": "Checking whether the fiscal year can be reset...",
|
||||
"fy_reset_load_failed": "Could not check the fiscal year. Try again.",
|
||||
"fy_reset_blocked_title": "The fiscal year cannot be reset",
|
||||
"fy_reset_blocker_closed": "The fiscal year is closed.",
|
||||
"fy_reset_blocker_locked": "The fiscal year is locked. Unlock it first.",
|
||||
"fy_reset_blocker_lock_date": "The company bookkeeping lock date ({date}) covers part of the fiscal year.",
|
||||
"fy_reset_blocker_year_end": "A year-end closing has been executed for the fiscal year.",
|
||||
"fy_reset_blocker_arsredovisning": "An annual report has been started or filed for the fiscal year.",
|
||||
"fy_reset_blocker_next_year": "A later fiscal year depends on this year's closing balances.",
|
||||
"fy_reset_blocker_vat": "A VAT declaration has been booked or filed for a period in the fiscal year.",
|
||||
"fy_reset_blocker_agi": "An employer declaration (AGI) has been filed for a month in the fiscal year.",
|
||||
"fy_reset_blocker_rot_rut": "A ROT or RUT payout request filed with Skatteverket relies on vouchers in the fiscal year.",
|
||||
"fy_reset_blocker_cross_year": "Vouchers in another fiscal year correct or reverse vouchers in this year.",
|
||||
"fy_reset_blocker_other": "The fiscal year is in a state that cannot be reset.",
|
||||
"fy_reset_summary_heading": "This will be deleted",
|
||||
"fy_reset_summary_year": "Fiscal year",
|
||||
"fy_reset_summary_vouchers": "Vouchers to delete",
|
||||
"fy_reset_summary_documents": "Document links to remove",
|
||||
"fy_reset_documents_note": "Documents stay in the archive (7-year retention); they are only detached from the vouchers.",
|
||||
"fy_reset_confirm_name": "Type <strong>{name}</strong> to confirm",
|
||||
"fy_reset_submit": "Reset fiscal year",
|
||||
"fy_reset_resetting": "Resetting...",
|
||||
"fy_reset_success_title": "Fiscal year reset",
|
||||
"fy_reset_success_description": "{count} vouchers were deleted.",
|
||||
"fy_reset_failed_title": "The fiscal year could not be reset",
|
||||
"fy_reset_failed_default": "Something went wrong. No changes were saved.",
|
||||
"related_heading": "Related",
|
||||
"related_chart_of_accounts": "Chart of accounts (BAS)",
|
||||
"periodisering_label": "Accruals",
|
||||
@@ -4881,6 +4910,8 @@
|
||||
"send_failed_fallback": "Please try again."
|
||||
},
|
||||
"journal_list": {
|
||||
"import_attn": "This list contains vouchers from an SIE import. If something went wrong, the whole import can be undone.",
|
||||
"import_attn_action": "Show import history",
|
||||
"mode_vouchers": "Vouchers",
|
||||
"mode_drafts": "Drafts",
|
||||
"density_compact": "Compact view",
|
||||
|
||||
@@ -1958,6 +1958,35 @@
|
||||
"fy_lock_success": "Räkenskapsåret är låst",
|
||||
"fy_unlock_success": "Räkenskapsåret är upplåst",
|
||||
"fy_action_error": "Åtgärden kunde inte slutföras",
|
||||
"fy_action_reset": "Nollställ",
|
||||
"fy_reset_dialog_title": "Nollställ räkenskapsåret {name}?",
|
||||
"fy_reset_dialog_description": "Alla verifikat i räkenskapsåret raderas permanent, oavsett hur de skapades. Underlag och dokument raderas aldrig, de kopplas bara loss. Fakturor, betalningar och banktransaktioner som var kopplade till verifikaten blir obokförda och behöver bokföras om. Åtgärden kan inte ångras och loggas i behandlingshistoriken.",
|
||||
"fy_reset_checking": "Kontrollerar om räkenskapsåret kan nollställas...",
|
||||
"fy_reset_load_failed": "Kunde inte kontrollera räkenskapsåret. Försök igen.",
|
||||
"fy_reset_blocked_title": "Räkenskapsåret kan inte nollställas",
|
||||
"fy_reset_blocker_closed": "Räkenskapsåret är stängt.",
|
||||
"fy_reset_blocker_locked": "Räkenskapsåret är låst. Lås upp det först.",
|
||||
"fy_reset_blocker_lock_date": "Företagets bokföringslås ({date}) täcker en del av räkenskapsåret.",
|
||||
"fy_reset_blocker_year_end": "Ett bokslut är verkställt för räkenskapsåret.",
|
||||
"fy_reset_blocker_arsredovisning": "En årsredovisning har påbörjats eller lämnats in för räkenskapsåret.",
|
||||
"fy_reset_blocker_next_year": "Ett senare räkenskapsår bygger på det här årets utgående balanser.",
|
||||
"fy_reset_blocker_vat": "En momsdeklaration är bokförd eller inlämnad för en period i räkenskapsåret.",
|
||||
"fy_reset_blocker_agi": "En arbetsgivardeklaration är inlämnad för en månad i räkenskapsåret.",
|
||||
"fy_reset_blocker_rot_rut": "En rot- eller rutbegäran hos Skatteverket bygger på verifikat i räkenskapsåret.",
|
||||
"fy_reset_blocker_cross_year": "Verifikat i ett annat räkenskapsår rättar eller vänder verifikat i det här året.",
|
||||
"fy_reset_blocker_other": "Räkenskapsåret är i ett läge som inte kan nollställas.",
|
||||
"fy_reset_summary_heading": "Detta raderas",
|
||||
"fy_reset_summary_year": "Räkenskapsår",
|
||||
"fy_reset_summary_vouchers": "Verifikat som raderas",
|
||||
"fy_reset_summary_documents": "Dokumentkopplingar som tas bort",
|
||||
"fy_reset_documents_note": "Dokumenten och underlagen ligger kvar i arkivet (7 års arkiveringsplikt), de kopplas bara loss från verifikaten.",
|
||||
"fy_reset_confirm_name": "Skriv <strong>{name}</strong> för att bekräfta",
|
||||
"fy_reset_submit": "Nollställ räkenskapsåret",
|
||||
"fy_reset_resetting": "Nollställer...",
|
||||
"fy_reset_success_title": "Räkenskapsåret är nollställt",
|
||||
"fy_reset_success_description": "{count} verifikat raderades.",
|
||||
"fy_reset_failed_title": "Räkenskapsåret kunde inte nollställas",
|
||||
"fy_reset_failed_default": "Något gick fel. Inga ändringar har sparats.",
|
||||
"related_heading": "Relaterat",
|
||||
"related_chart_of_accounts": "Kontoplan (BAS)",
|
||||
"periodisering_label": "Periodisering",
|
||||
@@ -4881,6 +4910,8 @@
|
||||
"send_failed_fallback": "Försök igen."
|
||||
},
|
||||
"journal_list": {
|
||||
"import_attn": "Listan innehåller verifikat från en SIE-import. Blev något fel kan hela importen ångras.",
|
||||
"import_attn_action": "Visa importhistorik",
|
||||
"mode_vouchers": "Verifikat",
|
||||
"mode_drafts": "Utkast",
|
||||
"density_compact": "Kompakt visning",
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
-- Reset an UNLOCKED fiscal year (issue #1883).
|
||||
--
|
||||
-- After a bad test import (SIE or otherwise) users saw no way out short of
|
||||
-- deleting the whole company: undo_sie_import only covers entries a single
|
||||
-- completed SIE import created, and manual/agent entries booked on top of a
|
||||
-- bad import were unreachable. This adds a guarded, owner/admin-only reset of
|
||||
-- one open fiscal year: hard-deletes ALL of the year's verifikationer
|
||||
-- (every source_type and status), detaches documents (which are never
|
||||
-- deleted: BFL 7 kap retention), resets voucher_sequences, and marks the
|
||||
-- year's completed SIE imports as 'undone'.
|
||||
--
|
||||
-- Legality framing (BFL 5 kap, BFNAR 2013:2): this is the same category of
|
||||
-- operation as the shipped undo_sie_import / replace_sie_import: re-doing the
|
||||
-- löpande bokföring of a year that nothing has relied upon yet. The reset is
|
||||
-- refused whenever any reliance state exists:
|
||||
-- * the year is locked or closed (fiscal_periods.locked_at / is_closed)
|
||||
-- * the company lock date covers any part of the year
|
||||
-- * a year-end closing entry exists (fiscal_periods.closing_entry_id)
|
||||
-- * an arsredovisning submission or signature request exists for the year
|
||||
-- * a later year depends on this year's UB (next period has opening
|
||||
-- balances, or is itself locked/closed/has a closing entry)
|
||||
-- * VAT declared evidence: a posted/reversed vat_settlement verifikat in
|
||||
-- the year, a successful Skatteverket declaration lock/submit for a
|
||||
-- redovisningsperiod inside the year, or a Skatteverket-extension VAT
|
||||
-- submission workflow key for the year (fail-closed on unparsable keys)
|
||||
-- * AGI declared evidence: an agi_declarations row for a month inside the
|
||||
-- year in a submitted/accepted/rejected state
|
||||
-- * ROT/RUT reliance: a begäran om utbetalning that reached Skatteverket
|
||||
-- (submitted/paid/partially_paid/rejected) whose settlement voucher or
|
||||
-- source invoices are booked in the year
|
||||
-- * cross-year rättelse/storno chains: an entry OUTSIDE the year whose
|
||||
-- correction_of_id / reverses_id / reversed_by_id points INTO the year
|
||||
-- (the delete's ON DELETE SET NULL referential action would either be
|
||||
-- refused by the immutability trigger on a posted referrer, or silently
|
||||
-- sever a draft's chain; both mean the year has been relied upon)
|
||||
-- Entries referenced by other records (assets, accrual schedules, salary
|
||||
-- runs: RESTRICT / NO ACTION FKs) make the whole reset roll back with a
|
||||
-- distinct error instead of half-deleting. SET NULL references (invoices,
|
||||
-- invoice payments, supplier invoices, bank/skattekonto transactions,
|
||||
-- mileage trips) unlink and return to unbooked, which is the meaning of
|
||||
-- resetting the year; the UI copy discloses this. Behandlingshistorik is
|
||||
-- preserved: every deleted entry fires write_audit_log, and before the
|
||||
-- delete a company-scoped RESET_SNAPSHOT audit row archives the FULL
|
||||
-- content of every verifikat (accounts, amounts, line text, dimensions) so
|
||||
-- the destroyed räkenskapsinformation stays retrievable (BFL 7 kap), plus
|
||||
-- one summary audit_log row.
|
||||
--
|
||||
-- Deletion mechanism: the SAME escape hatch as undo_sie_import
|
||||
-- (20260727121000): set_config('gnubok.allow_delete','true',true) inside a
|
||||
-- SECURITY DEFINER RPC. No enforcement trigger is modified.
|
||||
--
|
||||
-- Actor gate mirrors undo_sie_import (20260727121000): p_user_id is honored
|
||||
-- only for service_role callers (the cookieless server client, auth.uid()
|
||||
-- NULL); every other caller is pinned to its own auth.uid().
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- audit_log gains a RESET_SNAPSHOT action: the pre-delete verifikat content
|
||||
-- archive written by reset_fiscal_year below. NOT VALID skips the full-table
|
||||
-- validation scan: every existing row satisfies the previous, strictly
|
||||
-- narrower constraint, and NOT VALID still enforces all new rows.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE public.audit_log DROP CONSTRAINT IF EXISTS audit_log_action_check;
|
||||
ALTER TABLE public.audit_log ADD CONSTRAINT audit_log_action_check
|
||||
CHECK (action = ANY (ARRAY[
|
||||
'INSERT','UPDATE','DELETE','COMMIT','REVERSE','CORRECT',
|
||||
'LOCK_PERIOD','CLOSE_PERIOD','DOCUMENT_DELETE_BLOCKED',
|
||||
'RETENTION_BLOCK','SECURITY_EVENT','INTEGRITY_FAILURE',
|
||||
'COMMITTED_AT_OVERRIDE','RESET_SNAPSHOT'
|
||||
])) NOT VALID;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Internal snapshot: eligibility + counts. Not callable by clients (REVOKEd);
|
||||
-- both public functions below call it so the preview and the execution can
|
||||
-- never drift apart.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.fiscal_year_reset_snapshot(
|
||||
p_company_id uuid,
|
||||
p_period_id uuid
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_period record;
|
||||
v_next record;
|
||||
v_blockers jsonb := '[]'::jsonb;
|
||||
v_lock_through date;
|
||||
v_arsred integer;
|
||||
v_vat integer;
|
||||
v_agi integer;
|
||||
v_vouchers integer;
|
||||
v_docs integer;
|
||||
v_start_ym text;
|
||||
v_end_ym text;
|
||||
v_xref integer;
|
||||
v_rotrut integer;
|
||||
BEGIN
|
||||
SELECT id, name, period_start, period_end, is_closed, locked_at,
|
||||
closing_entry_id, opening_balance_entry_id
|
||||
INTO v_period
|
||||
FROM public.fiscal_periods
|
||||
WHERE id = p_period_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
IF v_period.is_closed THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'period_closed'));
|
||||
END IF;
|
||||
IF v_period.locked_at IS NOT NULL THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'period_locked'));
|
||||
END IF;
|
||||
|
||||
SELECT bookkeeping_locked_through
|
||||
INTO v_lock_through
|
||||
FROM public.company_settings
|
||||
WHERE company_id = p_company_id;
|
||||
IF v_lock_through IS NOT NULL AND v_lock_through >= v_period.period_start THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'company_lock_date', 'date', to_char(v_lock_through, 'YYYY-MM-DD')
|
||||
));
|
||||
END IF;
|
||||
|
||||
IF v_period.closing_entry_id IS NOT NULL THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'year_end_state'));
|
||||
END IF;
|
||||
|
||||
SELECT (SELECT count(*) FROM public.arsredovisning_submissions
|
||||
WHERE company_id = p_company_id AND fiscal_period_id = p_period_id)
|
||||
+ (SELECT count(*) FROM public.arsredovisning_signature_requests
|
||||
WHERE company_id = p_company_id AND fiscal_period_id = p_period_id)
|
||||
INTO v_arsred;
|
||||
IF v_arsred > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'arsredovisning_state', 'count', v_arsred
|
||||
));
|
||||
END IF;
|
||||
|
||||
-- Later-year dependency: chain lookup first, then date-based fallback
|
||||
-- (mirrors findNextPeriod / scripts/undo-year-end-closing.ts). A next year
|
||||
-- whose IB was generated from this year's UB, or that is itself locked,
|
||||
-- closed or has a closing entry, blocks the reset.
|
||||
SELECT id, is_closed, locked_at, closing_entry_id,
|
||||
opening_balance_entry_id, opening_balances_set
|
||||
INTO v_next
|
||||
FROM public.fiscal_periods
|
||||
WHERE company_id = p_company_id
|
||||
AND previous_period_id = p_period_id
|
||||
LIMIT 1;
|
||||
IF NOT FOUND THEN
|
||||
SELECT id, is_closed, locked_at, closing_entry_id,
|
||||
opening_balance_entry_id, opening_balances_set
|
||||
INTO v_next
|
||||
FROM public.fiscal_periods
|
||||
WHERE company_id = p_company_id
|
||||
AND period_start = v_period.period_end + 1
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
IF v_next.id IS NOT NULL AND (
|
||||
v_next.is_closed
|
||||
OR v_next.locked_at IS NOT NULL
|
||||
OR v_next.closing_entry_id IS NOT NULL
|
||||
OR v_next.opening_balance_entry_id IS NOT NULL
|
||||
OR v_next.opening_balances_set
|
||||
) THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'next_year_dependency'));
|
||||
END IF;
|
||||
|
||||
-- Cross-year rättelse/storno chains: an entry OUTSIDE the year whose
|
||||
-- correction_of_id / reverses_id / reversed_by_id points INTO the year.
|
||||
-- Deleting the target fires the FK's ON DELETE SET NULL as an UPDATE on
|
||||
-- the referrer; enforce_journal_entry_immutability refuses that on a
|
||||
-- posted referrer (the delete escape hatch covers only TG_OP = 'DELETE'),
|
||||
-- and on a draft it would silently sever the rättelse chain. Either way
|
||||
-- the year has been relied upon: refuse up front, so the preview and the
|
||||
-- execution agree (mirrors the delete_last_voucher reference check,
|
||||
-- 20260528120600).
|
||||
SELECT count(*) INTO v_xref
|
||||
FROM public.journal_entries outside
|
||||
WHERE outside.company_id = p_company_id
|
||||
AND outside.fiscal_period_id <> p_period_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.journal_entries inside
|
||||
WHERE inside.company_id = p_company_id
|
||||
AND inside.fiscal_period_id = p_period_id
|
||||
AND inside.id IN (outside.correction_of_id, outside.reverses_id, outside.reversed_by_id)
|
||||
);
|
||||
IF v_xref > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'cross_year_reference', 'count', v_xref
|
||||
));
|
||||
END IF;
|
||||
|
||||
-- VAT declared evidence. Skatteverket declaration state cannot be observed
|
||||
-- reliably from this database (the final signature happens at SKV), so
|
||||
-- every local trace counts and unparsable workflow keys fail closed. Same
|
||||
-- conservative posture as company_migration_reset (20260818224000).
|
||||
v_start_ym := to_char(v_period.period_start, 'YYYYMM');
|
||||
v_end_ym := to_char(v_period.period_end, 'YYYYMM');
|
||||
SELECT (SELECT count(*) FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_period_id
|
||||
AND source_type = 'vat_settlement'
|
||||
AND status IN ('posted', 'reversed'))
|
||||
+ (SELECT count(*) FROM public.skatteverket_api_audit_log
|
||||
WHERE company_id = p_company_id
|
||||
AND outcome = 'ok'
|
||||
AND endpoint IN ('declaration/lock', 'declaration/submit')
|
||||
AND (redovisningsperiod IS NULL
|
||||
OR (redovisningsperiod >= v_start_ym AND redovisningsperiod <= v_end_ym)))
|
||||
+ (SELECT count(*) FROM public.extension_data
|
||||
WHERE company_id = p_company_id
|
||||
AND extension_id = 'skatteverket'
|
||||
AND key LIKE 'submission\_%' ESCAPE '\'
|
||||
AND (substring(key FROM 12) !~ '^[0-9]{6}$'
|
||||
OR (substring(key FROM 12) >= v_start_ym AND substring(key FROM 12) <= v_end_ym)))
|
||||
INTO v_vat;
|
||||
IF v_vat > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'vat_declared', 'count', v_vat
|
||||
));
|
||||
END IF;
|
||||
|
||||
-- AGI declared evidence for months inside the year.
|
||||
SELECT count(*) INTO v_agi
|
||||
FROM public.agi_declarations
|
||||
WHERE company_id = p_company_id
|
||||
AND (submitted_at IS NOT NULL OR status IN ('submitted', 'accepted', 'rejected'))
|
||||
AND make_date(period_year, period_month, 1)
|
||||
BETWEEN date_trunc('month', v_period.period_start)::date AND v_period.period_end;
|
||||
IF v_agi > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'agi_declared', 'count', v_agi
|
||||
));
|
||||
END IF;
|
||||
|
||||
-- ROT/RUT reliance: a begäran om utbetalning that has reached Skatteverket
|
||||
-- (submitted, or decided: paid/partially_paid/rejected) is external
|
||||
-- reliance in the same category as VAT/AGI. Its links into the year are
|
||||
-- ON DELETE SET NULL, so without this guard the reset would silently erase
|
||||
-- the bokföring behind a filed and possibly decided myndighetsärende.
|
||||
-- 'generated' (file never uploaded) and 'cancelled' do not block.
|
||||
SELECT count(*) INTO v_rotrut
|
||||
FROM public.rot_rut_payout_requests r
|
||||
WHERE r.company_id = p_company_id
|
||||
AND r.status IN ('submitted', 'paid', 'partially_paid', 'rejected')
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.journal_entries je
|
||||
WHERE je.id = r.settlement_journal_entry_id
|
||||
AND je.fiscal_period_id = p_period_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM public.rot_rut_payout_request_items ri
|
||||
JOIN public.invoices inv ON inv.id = ri.invoice_id
|
||||
JOIN public.journal_entries je ON je.id = inv.journal_entry_id
|
||||
WHERE ri.request_id = r.id
|
||||
AND je.fiscal_period_id = p_period_id
|
||||
)
|
||||
);
|
||||
IF v_rotrut > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'rot_rut_state', 'count', v_rotrut
|
||||
));
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_vouchers
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_period_id;
|
||||
|
||||
SELECT count(*) INTO v_docs
|
||||
FROM public.document_attachments da
|
||||
WHERE da.journal_entry_id IN (
|
||||
SELECT je.id FROM public.journal_entries je
|
||||
WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id)
|
||||
OR da.journal_entry_line_id IN (
|
||||
SELECT jel.id
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
|
||||
WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'eligible', jsonb_array_length(v_blockers) = 0,
|
||||
'blockers', v_blockers,
|
||||
'period', jsonb_build_object(
|
||||
'id', v_period.id,
|
||||
'name', v_period.name,
|
||||
'period_start', to_char(v_period.period_start, 'YYYY-MM-DD'),
|
||||
'period_end', to_char(v_period.period_end, 'YYYY-MM-DD')
|
||||
),
|
||||
'counts', jsonb_build_object(
|
||||
'vouchers', v_vouchers,
|
||||
'documents_to_detach', v_docs
|
||||
)
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.fiscal_year_reset_snapshot(uuid, uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.fiscal_year_reset_snapshot(uuid, uuid) IS
|
||||
'Internal fail-closed eligibility snapshot for reset_fiscal_year. Not client-callable.';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Eligibility preview for the UI. Owner/admin only.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.get_fiscal_year_reset_eligibility(
|
||||
p_company_id uuid,
|
||||
p_period_id uuid,
|
||||
p_user_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_actor uuid;
|
||||
v_caller_role text;
|
||||
BEGIN
|
||||
-- Actor resolution: p_user_id is honored only for service_role callers
|
||||
-- (auth.uid() NULL on the cookieless server client); everyone else is
|
||||
-- pinned to their own auth.uid(). Same shape as undo_sie_import
|
||||
-- (20260727121000).
|
||||
IF auth.role() = 'service_role' THEN
|
||||
v_actor := COALESCE(p_user_id, auth.uid());
|
||||
ELSE
|
||||
v_actor := auth.uid();
|
||||
END IF;
|
||||
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = v_actor;
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_FORBIDDEN');
|
||||
END IF;
|
||||
|
||||
RETURN public.fiscal_year_reset_snapshot(p_company_id, p_period_id);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION public.get_fiscal_year_reset_eligibility(uuid, uuid, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_fiscal_year_reset_eligibility(uuid, uuid, uuid) TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.get_fiscal_year_reset_eligibility(uuid, uuid, uuid) IS
|
||||
'Owner/admin-only eligibility preview for reset_fiscal_year. p_user_id is honored only for service_role callers.';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- The reset itself.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.reset_fiscal_year(
|
||||
p_company_id uuid,
|
||||
p_period_id uuid,
|
||||
p_confirmed_name text,
|
||||
p_user_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
-- The authenticated role carries statement_timeout=8s on hosted Supabase; a
|
||||
-- year-sized batch delete (each row firing write_audit_log with a JSONB
|
||||
-- snapshot, plus cascading lines) must not race that budget. Same shape as
|
||||
-- undo_sie_import (20260629160100 / 20260727121000).
|
||||
SET statement_timeout TO '290s'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_actor uuid;
|
||||
v_caller_role text;
|
||||
v_snapshot jsonb;
|
||||
v_period_name text;
|
||||
v_period_start date;
|
||||
v_period_end date;
|
||||
v_deleted integer := 0;
|
||||
v_docs_detached integer := 0;
|
||||
v_flipped uuid[] := '{}';
|
||||
BEGIN
|
||||
IF auth.role() = 'service_role' THEN
|
||||
v_actor := COALESCE(p_user_id, auth.uid());
|
||||
ELSE
|
||||
v_actor := auth.uid();
|
||||
END IF;
|
||||
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = v_actor;
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_FORBIDDEN');
|
||||
END IF;
|
||||
|
||||
-- Serialize concurrent resets / lock-state changes on the same year.
|
||||
SELECT name, period_start, period_end
|
||||
INTO v_period_name, v_period_start, v_period_end
|
||||
FROM public.fiscal_periods
|
||||
WHERE id = p_period_id
|
||||
AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_NOT_FOUND');
|
||||
END IF;
|
||||
|
||||
v_snapshot := public.fiscal_year_reset_snapshot(p_company_id, p_period_id);
|
||||
IF NOT (v_snapshot ->> 'ok')::boolean THEN
|
||||
RETURN v_snapshot;
|
||||
END IF;
|
||||
IF NOT (v_snapshot ->> 'eligible')::boolean THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'FISCAL_YEAR_RESET_INELIGIBLE',
|
||||
'blockers', v_snapshot -> 'blockers'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Typed confirmation: the caller must restate the year's label exactly.
|
||||
IF p_confirmed_name IS NULL OR btrim(p_confirmed_name) <> btrim(v_period_name) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH');
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
-- The sanctioned escape hatch (same as undo_sie_import): disarms the
|
||||
-- immutability/retention DELETE guards for this transaction only. The
|
||||
-- enforcement triggers themselves are untouched.
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
-- Detach documents (entry- and line-level). Documents are NEVER deleted:
|
||||
-- BFL 7 kap retention. They stay in the archive as unlinked underlag.
|
||||
UPDATE public.document_attachments da
|
||||
SET journal_entry_id = NULL,
|
||||
journal_entry_line_id = NULL
|
||||
WHERE da.journal_entry_id IN (
|
||||
SELECT je.id FROM public.journal_entries je
|
||||
WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id)
|
||||
OR da.journal_entry_line_id IN (
|
||||
SELECT jel.id
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
|
||||
WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id);
|
||||
GET DIAGNOSTICS v_docs_detached = ROW_COUNT;
|
||||
|
||||
-- Clear the fiscal-period OB pointer (two-step around
|
||||
-- enforce_opening_balance_immutability, same as undo_sie_import).
|
||||
UPDATE public.fiscal_periods
|
||||
SET opening_balances_set = false
|
||||
WHERE id = p_period_id
|
||||
AND opening_balance_entry_id IS NOT NULL;
|
||||
UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE id = p_period_id
|
||||
AND opening_balance_entry_id IS NOT NULL;
|
||||
|
||||
-- Drop sie_imports -> opening_balance_entry FKs before the delete.
|
||||
UPDATE public.sie_imports
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_period_id
|
||||
AND opening_balance_entry_id IS NOT NULL;
|
||||
|
||||
-- Räkenskapsinformation preservation (BFL 7 kap, BFNAR 2013:2 kap 8):
|
||||
-- the trigger's per-entry DELETE audit row keeps only the voucher
|
||||
-- header, and line-level trigger rows carry no company_id (invisible to
|
||||
-- every company-scoped surface). Archive the FULL content of every
|
||||
-- verifikat (accounts, amounts, line text, dimensions) in company-scoped
|
||||
-- RESET_SNAPSHOT rows BEFORE deleting, so what is destroyed stays
|
||||
-- retrievable for the retention period.
|
||||
INSERT INTO public.audit_log (
|
||||
user_id, company_id, action, table_name, record_id, actor_id,
|
||||
old_state, description
|
||||
)
|
||||
SELECT v_actor, p_company_id, 'RESET_SNAPSHOT', 'journal_entries', je.id, v_actor,
|
||||
to_jsonb(je) || jsonb_build_object('lines', COALESCE(l.lines, '[]'::jsonb)),
|
||||
'Fiscal year reset: full verifikat content archived before deletion (BFL 7 kap)'
|
||||
FROM public.journal_entries je
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', jel.account_number,
|
||||
'debit_amount', jel.debit_amount,
|
||||
'credit_amount', jel.credit_amount,
|
||||
'line_description', jel.line_description,
|
||||
'dimensions', jel.dimensions
|
||||
) ORDER BY jel.sort_order) AS lines
|
||||
FROM public.journal_entry_lines jel
|
||||
WHERE jel.journal_entry_id = je.id
|
||||
) l ON true
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.fiscal_period_id = p_period_id;
|
||||
|
||||
-- Hard-delete the year's journal entries: every source_type and status.
|
||||
-- SET NULL FKs (transactions, invoice payments) unlink, which is the
|
||||
-- meaning of resetting the year; RESTRICT / NO ACTION FKs (assets,
|
||||
-- accrual schedules, salary runs) abort the whole reset below.
|
||||
WITH deleted AS (
|
||||
DELETE FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_period_id
|
||||
RETURNING id
|
||||
)
|
||||
SELECT count(*) INTO v_deleted FROM deleted;
|
||||
|
||||
-- The year's completed SIE imports no longer have any vouchers: mark
|
||||
-- them undone so the import history reflects reality and the partial
|
||||
-- unique slot (sie_imports_company_id_file_hash_active_idx) frees for a
|
||||
-- clean re-import.
|
||||
WITH flipped AS (
|
||||
UPDATE public.sie_imports
|
||||
SET status = 'undone', replaced_at = now()
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_period_id
|
||||
AND status = 'completed'
|
||||
RETURNING id
|
||||
)
|
||||
SELECT COALESCE(array_agg(id), '{}') INTO v_flipped FROM flipped;
|
||||
|
||||
-- Registry lockstep (mirrors undo_sie_import, 20260702154500): dimension
|
||||
-- values and custom dimensions the flipped imports introduced would
|
||||
-- otherwise be orphaned forever: undo_sie_import requires
|
||||
-- status = 'completed' and can never run for an import this reset just
|
||||
-- marked undone. Remove the ones no surviving posted/reversed line still
|
||||
-- references; user-created rows (created_by_import_id NULL) are never
|
||||
-- touched.
|
||||
DELETE FROM public.dimension_values dv
|
||||
USING public.dimensions d
|
||||
WHERE dv.created_by_import_id = ANY (v_flipped)
|
||||
AND dv.company_id = p_company_id
|
||||
AND d.id = dv.dimension_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.journal_entries je
|
||||
JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.status IN ('posted', 'reversed')
|
||||
AND jel.dimensions ->> d.sie_dim_no::text = dv.code
|
||||
);
|
||||
|
||||
DELETE FROM public.dimensions d
|
||||
WHERE d.created_by_import_id = ANY (v_flipped)
|
||||
AND d.company_id = p_company_id
|
||||
AND d.is_system = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.dimension_values dv WHERE dv.dimension_id = d.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.journal_entries je
|
||||
JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.status IN ('posted', 'reversed')
|
||||
AND jel.dimensions ? d.sie_dim_no::text
|
||||
);
|
||||
|
||||
-- Gap explanations describe a numbering that no longer exists.
|
||||
DELETE FROM public.voucher_gap_explanations
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_period_id;
|
||||
|
||||
-- Reset voucher_sequences per series to the max remaining number (0
|
||||
-- after a full delete; same query as undo_sie_import for consistency).
|
||||
UPDATE public.voucher_sequences vs
|
||||
SET last_number = COALESCE((
|
||||
SELECT MAX(je.voucher_number)
|
||||
FROM public.journal_entries je
|
||||
WHERE je.company_id = vs.company_id
|
||||
AND je.fiscal_period_id = vs.fiscal_period_id
|
||||
AND je.voucher_series = vs.voucher_series
|
||||
AND je.voucher_number > 0
|
||||
), 0),
|
||||
updated_at = now()
|
||||
WHERE vs.company_id = p_company_id
|
||||
AND vs.fiscal_period_id = p_period_id;
|
||||
|
||||
-- The year's IB continuity claim no longer describes anything.
|
||||
UPDATE public.fiscal_periods
|
||||
SET continuity_verified = NULL,
|
||||
opening_balances_set = false
|
||||
WHERE id = p_period_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
-- Disarm the escape hatch before leaving the block (mirrors
|
||||
-- cleanup_sandbox_user, 20260807130000): nothing later in the same
|
||||
-- transaction may run with the delete guard down.
|
||||
PERFORM set_config('gnubok.allow_delete', '', true);
|
||||
|
||||
EXCEPTION
|
||||
WHEN foreign_key_violation THEN
|
||||
-- An entry in the year is referenced by another record (asset,
|
||||
-- periodisering, salary run, ...). Half-deleting is worse than
|
||||
-- refusing: the exception rolls back every change made in this block.
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_LINKED_ENTRIES');
|
||||
WHEN raise_exception THEN
|
||||
-- A protection trigger refused part of the reset (defense in depth
|
||||
-- behind the snapshot guards, e.g. an immutability RAISE on a
|
||||
-- referential-action UPDATE). The exception rolls back every change
|
||||
-- made in this block; surface a typed refusal instead of a bare 500.
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', 'FISCAL_YEAR_RESET_LINKED_ENTRIES',
|
||||
'detail', SQLERRM
|
||||
);
|
||||
END;
|
||||
|
||||
-- Behandlingshistorik (BFNAR 2013:2 kap 8): each deleted entry already
|
||||
-- fired write_audit_log; this summary row records the reset as one control
|
||||
-- action with who/when/what.
|
||||
INSERT INTO public.audit_log (
|
||||
user_id, company_id, action, table_name, record_id, actor_id,
|
||||
old_state, new_state, description
|
||||
) VALUES (
|
||||
v_actor, p_company_id, 'DELETE', 'journal_entries', p_period_id, v_actor,
|
||||
jsonb_build_object(
|
||||
'fiscal_period_id', p_period_id,
|
||||
'period_name', v_period_name,
|
||||
'period_start', v_period_start,
|
||||
'period_end', v_period_end
|
||||
),
|
||||
jsonb_build_object(
|
||||
'deleted_entries', v_deleted,
|
||||
'detached_documents', v_docs_detached
|
||||
),
|
||||
'Fiscal year reset: all verifikationer in the open year hard-deleted on user request; documents detached, never deleted (BFL 7 kap)'
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'deleted', v_deleted,
|
||||
'detached_documents', v_docs_detached,
|
||||
'period_name', v_period_name
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- Least privilege, same discipline as undo_sie_import (20260727121000).
|
||||
REVOKE EXECUTE ON FUNCTION public.reset_fiscal_year(uuid, uuid, text, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.reset_fiscal_year(uuid, uuid, text, uuid) TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.reset_fiscal_year(uuid, uuid, text, uuid) IS
|
||||
'Hard-deletes ALL verifikationer in one OPEN, un-relied-upon fiscal year (any source_type/status), detaches documents (never deletes them), resets voucher_sequences and marks the year''s completed SIE imports undone. Refuses on lock/close/lock-date/year-end/arsredovisning/VAT/AGI/ROT-RUT/next-year-dependency/cross-year-reference state and on entries referenced by other records. Archives every verifikat''s full content in RESET_SNAPSHOT audit rows before deleting (BFL 7 kap). Requires owner/admin; p_user_id honored only for service_role callers. Typed confirmation: p_confirmed_name must equal the period name.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* pg-real tests for the fiscal-year reset RPCs (migration 20260825150000).
|
||||
*
|
||||
* Pins: the actor gate (service-role p_user_id, owner/admin only), every
|
||||
* eligibility guard (locked, closed, company lock date, year-end state,
|
||||
* next-year dependency, VAT declared evidence, ROT/RUT reliance, cross-year
|
||||
* rättelse/storno references), the typed-confirmation mismatch, the happy
|
||||
* path (all source types and statuses deleted, documents DETACHED never
|
||||
* deleted, sie_imports flipped to undone, dimension registry lockstep,
|
||||
* voucher_sequences reset, RESET_SNAPSHOT content archive), and the
|
||||
* all-or-nothing rollback when an entry is referenced by another record
|
||||
* (RESTRICT FK).
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool, runAsServiceRole } from '@/tests/pg/setup'
|
||||
import {
|
||||
seedCompany,
|
||||
insertAuthUser,
|
||||
insertCompanyMember,
|
||||
insertDraftJournalEntry,
|
||||
insertBalancedLines,
|
||||
insertFiscalPeriod,
|
||||
} from '@/tests/pg/fixtures'
|
||||
|
||||
type RpcResult = {
|
||||
ok: boolean
|
||||
code?: string
|
||||
eligible?: boolean
|
||||
blockers?: Array<{ code: string; count?: number; date?: string }>
|
||||
counts?: { vouchers: number; documents_to_detach: number }
|
||||
deleted?: number
|
||||
detached_documents?: number
|
||||
period_name?: string
|
||||
}
|
||||
|
||||
async function callReset(
|
||||
companyId: string,
|
||||
periodId: string,
|
||||
confirmedName: string | null,
|
||||
actor: string | null,
|
||||
): Promise<RpcResult> {
|
||||
const res = await runAsServiceRole((client) =>
|
||||
client.query<{ result: RpcResult }>(
|
||||
`SELECT public.reset_fiscal_year($1::uuid, $2::uuid, $3::text, $4::uuid) AS result`,
|
||||
[companyId, periodId, confirmedName, actor],
|
||||
),
|
||||
)
|
||||
return res.rows[0].result
|
||||
}
|
||||
|
||||
async function callEligibility(
|
||||
companyId: string,
|
||||
periodId: string,
|
||||
actor: string | null,
|
||||
): Promise<RpcResult> {
|
||||
const res = await runAsServiceRole((client) =>
|
||||
client.query<{ result: RpcResult }>(
|
||||
`SELECT public.get_fiscal_year_reset_eligibility($1::uuid, $2::uuid, $3::uuid) AS result`,
|
||||
[companyId, periodId, actor],
|
||||
),
|
||||
)
|
||||
return res.rows[0].result
|
||||
}
|
||||
|
||||
/** Post an entry (any source_type/status) with balanced lines. */
|
||||
async function insertPostedEntry(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
fiscalPeriodId: string
|
||||
sourceType?: string
|
||||
voucherNumber?: number
|
||||
entryDate?: string
|
||||
}): Promise<string> {
|
||||
const jeId = await insertDraftJournalEntry({
|
||||
userId: params.userId,
|
||||
companyId: params.companyId,
|
||||
fiscalPeriodId: params.fiscalPeriodId,
|
||||
sourceType: params.sourceType ?? 'manual',
|
||||
status: 'draft',
|
||||
voucherNumber: params.voucherNumber ?? 1,
|
||||
entryDate: params.entryDate,
|
||||
})
|
||||
await insertBalancedLines(jeId, 1000)
|
||||
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [
|
||||
jeId,
|
||||
])
|
||||
return jeId
|
||||
}
|
||||
|
||||
async function entryCount(companyId: string, periodId: string): Promise<number> {
|
||||
const { rows } = await getPool().query<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM public.journal_entries
|
||||
WHERE company_id = $1 AND fiscal_period_id = $2`,
|
||||
[companyId, periodId],
|
||||
)
|
||||
return Number(rows[0].n)
|
||||
}
|
||||
|
||||
describe('reset_fiscal_year: actor gate', () => {
|
||||
it('refuses without an authorising identity', async () => {
|
||||
const { companyId, fiscalPeriodId } = await seedCompany()
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', null)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_FORBIDDEN' })
|
||||
})
|
||||
|
||||
it('refuses a plain member', async () => {
|
||||
const { companyId, fiscalPeriodId } = await seedCompany()
|
||||
const memberId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', memberId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_FORBIDDEN' })
|
||||
})
|
||||
|
||||
it('refuses an owner of a DIFFERENT company', async () => {
|
||||
const { companyId, fiscalPeriodId } = await seedCompany()
|
||||
const other = await seedCompany()
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', other.userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_FORBIDDEN' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('get_fiscal_year_reset_eligibility', () => {
|
||||
it('reports a clean open year as eligible with correct counts', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertPostedEntry({ companyId, userId, fiscalPeriodId, sourceType: 'import' })
|
||||
await insertPostedEntry({
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId,
|
||||
sourceType: 'manual',
|
||||
voucherNumber: 2,
|
||||
})
|
||||
|
||||
const result = await callEligibility(companyId, fiscalPeriodId, userId)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.eligible).toBe(true)
|
||||
expect(result.blockers).toEqual([])
|
||||
expect(result.counts?.vouchers).toBe(2)
|
||||
})
|
||||
|
||||
it('returns NOT_FOUND for a period of another company', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
|
||||
const result = await callEligibility(a.companyId, b.fiscalPeriodId, a.userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset_fiscal_year: eligibility guards', () => {
|
||||
it('refuses a locked year and leaves it untouched', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`,
|
||||
[fiscalPeriodId],
|
||||
)
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_INELIGIBLE' })
|
||||
expect(result.blockers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'period_locked' })]),
|
||||
)
|
||||
expect(await entryCount(companyId, fiscalPeriodId)).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses a closed year', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany({ isClosed: true })
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_INELIGIBLE' })
|
||||
expect(result.blockers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'period_closed' })]),
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses when the company lock date covers any part of the year', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, bookkeeping_locked_through)
|
||||
VALUES ($1, $2, '2026-03-31')`,
|
||||
[userId, companyId],
|
||||
)
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_INELIGIBLE' })
|
||||
expect(result.blockers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'company_lock_date' })]),
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a year with an executed year-end closing', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const closingId = await insertPostedEntry({
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId,
|
||||
sourceType: 'year_end',
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET closing_entry_id = $1 WHERE id = $2`,
|
||||
[closingId, fiscalPeriodId],
|
||||
)
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_INELIGIBLE' })
|
||||
expect(result.blockers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'year_end_state' })]),
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses when a later year depends on this year (IB generated)', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const nextId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
name: '2027',
|
||||
periodStart: '2027-01-01',
|
||||
periodEnd: '2027-12-31',
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET previous_period_id = $1, opening_balances_set = true
|
||||
WHERE id = $2`,
|
||||
[fiscalPeriodId, nextId],
|
||||
)
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_INELIGIBLE' })
|
||||
expect(result.blockers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'next_year_dependency' })]),
|
||||
)
|
||||
})
|
||||
|
||||
it('allows a later year that carries no dependency yet', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
const nextId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
name: '2027',
|
||||
periodStart: '2027-01-01',
|
||||
periodEnd: '2027-12-31',
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET previous_period_id = $1 WHERE id = $2`,
|
||||
[fiscalPeriodId, nextId],
|
||||
)
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.deleted).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses a year with a booked momsdeklaration (vat_settlement)', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertPostedEntry({
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId,
|
||||
sourceType: 'vat_settlement',
|
||||
})
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_INELIGIBLE' })
|
||||
expect(result.blockers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'vat_declared' })]),
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses when an entry in another year corrects an entry in this year', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const targetId = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
const nextId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
name: '2027',
|
||||
periodStart: '2027-01-01',
|
||||
periodEnd: '2027-12-31',
|
||||
})
|
||||
// A draft in 2027 correcting a 2026 entry: deleting the target would
|
||||
// fire the FK's ON DELETE SET NULL as an UPDATE on the referrer, which
|
||||
// either trips the immutability trigger (posted) or silently severs the
|
||||
// chain (draft). Both must be refused up front.
|
||||
const referrerId = await insertDraftJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: nextId,
|
||||
sourceType: 'manual',
|
||||
status: 'draft',
|
||||
voucherNumber: 0,
|
||||
entryDate: '2027-03-15',
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.journal_entries SET correction_of_id = $1 WHERE id = $2`,
|
||||
[targetId, referrerId],
|
||||
)
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_INELIGIBLE' })
|
||||
expect(result.blockers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'cross_year_reference' })]),
|
||||
)
|
||||
expect(await entryCount(companyId, fiscalPeriodId)).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses when a filed rot/rut payout request relies on the year', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const settlementId = await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
await getPool().query(
|
||||
`INSERT INTO public.rot_rut_payout_requests
|
||||
(company_id, user_id, deduction_type, name, status, requested_total,
|
||||
file_name, settlement_journal_entry_id, submitted_at)
|
||||
VALUES ($1, $2, 'rot', 'Begaran 1', 'submitted', 12500, 'begaran.xml', $3, now())`,
|
||||
[companyId, userId, settlementId],
|
||||
)
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_INELIGIBLE' })
|
||||
expect(result.blockers).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'rot_rut_state' })]),
|
||||
)
|
||||
expect(await entryCount(companyId, fiscalPeriodId)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset_fiscal_year: typed confirmation', () => {
|
||||
it('refuses a wrong name and deletes nothing', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, 'fel namn', userId)
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: 'FISCAL_YEAR_RESET_CONFIRMATION_MISMATCH',
|
||||
})
|
||||
expect(await entryCount(companyId, fiscalPeriodId)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset_fiscal_year: happy path', () => {
|
||||
it('deletes all statuses and source types, detaches documents, resets state', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
|
||||
const importedId = await insertPostedEntry({
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId,
|
||||
sourceType: 'import',
|
||||
voucherNumber: 1,
|
||||
})
|
||||
await insertPostedEntry({
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId,
|
||||
sourceType: 'manual',
|
||||
voucherNumber: 2,
|
||||
})
|
||||
// A draft too: the reset covers every status, not just posted.
|
||||
await insertDraftJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
sourceType: 'manual',
|
||||
status: 'draft',
|
||||
voucherNumber: 0,
|
||||
})
|
||||
|
||||
// A linked document: must survive the reset, detached (BFL 7 kap).
|
||||
const docId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.document_attachments
|
||||
(id, user_id, company_id, storage_path, file_name, sha256_hash, journal_entry_id)
|
||||
VALUES ($1, $2, $3, 'test/reset.pdf', 'reset.pdf', $4, $5)`,
|
||||
[docId, userId, companyId, `hash-${docId}`, importedId],
|
||||
)
|
||||
|
||||
// A completed SIE import on the year: must flip to 'undone'.
|
||||
const importId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.sie_imports
|
||||
(id, user_id, company_id, filename, file_hash, sie_type,
|
||||
accounts_count, transactions_count, status, fiscal_period_id, imported_at)
|
||||
VALUES ($1, $2, $3, 'reset-test.se', $4, 4, 0, 1, 'completed', $5, now())`,
|
||||
[importId, userId, companyId, `hash-${importId}`, fiscalPeriodId],
|
||||
)
|
||||
|
||||
// A voucher sequence with a nonzero cursor: must reset to 0.
|
||||
await getPool().query(
|
||||
`INSERT INTO public.voucher_sequences
|
||||
(user_id, company_id, fiscal_period_id, voucher_series, last_number)
|
||||
VALUES ($1, $2, $3, 'A', 2)`,
|
||||
[userId, companyId, fiscalPeriodId],
|
||||
)
|
||||
|
||||
// A dimension + value the import introduced: registry lockstep (mirrors
|
||||
// undo_sie_import) must remove them once nothing references them, since
|
||||
// undo_sie_import can never run for an import the reset marked undone.
|
||||
const dimId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.dimensions (id, company_id, sie_dim_no, name, created_by_import_id)
|
||||
VALUES ($1, $2, 7, 'Projekt (import)', $3)`,
|
||||
[dimId, companyId, importId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.dimension_values (company_id, dimension_id, code, name, created_by_import_id)
|
||||
VALUES ($1, $2, 'P1', 'Testprojekt', $3)`,
|
||||
[companyId, dimId, importId],
|
||||
)
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.deleted).toBe(3)
|
||||
expect(result.detached_documents).toBe(1)
|
||||
expect(result.period_name).toBe('2026')
|
||||
|
||||
expect(await entryCount(companyId, fiscalPeriodId)).toBe(0)
|
||||
|
||||
const { rows: docRows } = await getPool().query<{
|
||||
journal_entry_id: string | null
|
||||
}>(`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`, [docId])
|
||||
expect(docRows).toHaveLength(1)
|
||||
expect(docRows[0].journal_entry_id).toBeNull()
|
||||
|
||||
const { rows: impRows } = await getPool().query<{ status: string }>(
|
||||
`SELECT status FROM public.sie_imports WHERE id = $1`,
|
||||
[importId],
|
||||
)
|
||||
expect(impRows[0].status).toBe('undone')
|
||||
|
||||
const { rows: seqRows } = await getPool().query<{ last_number: number }>(
|
||||
`SELECT last_number FROM public.voucher_sequences
|
||||
WHERE company_id = $1 AND fiscal_period_id = $2 AND voucher_series = 'A'`,
|
||||
[companyId, fiscalPeriodId],
|
||||
)
|
||||
expect(seqRows[0].last_number).toBe(0)
|
||||
|
||||
// Behandlingshistorik: the summary row exists.
|
||||
const { rows: auditRows } = await getPool().query<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM public.audit_log
|
||||
WHERE company_id = $1 AND record_id = $2
|
||||
AND description LIKE 'Fiscal year reset%'`,
|
||||
[companyId, fiscalPeriodId],
|
||||
)
|
||||
expect(Number(auditRows[0].n)).toBe(1)
|
||||
|
||||
// Räkenskapsinformation archive: one company-scoped RESET_SNAPSHOT row
|
||||
// per deleted verifikat, with the full lines (accounts + amounts).
|
||||
const { rows: snapRows } = await getPool().query<{
|
||||
old_state: { lines: Array<Record<string, unknown>> }
|
||||
}>(
|
||||
`SELECT old_state FROM public.audit_log
|
||||
WHERE company_id = $1 AND action = 'RESET_SNAPSHOT'
|
||||
AND table_name = 'journal_entries'`,
|
||||
[companyId],
|
||||
)
|
||||
expect(snapRows).toHaveLength(3)
|
||||
const withLines = snapRows.filter(
|
||||
(r) => Array.isArray(r.old_state.lines) && r.old_state.lines.length > 0,
|
||||
)
|
||||
expect(withLines.length).toBeGreaterThanOrEqual(2)
|
||||
expect(withLines[0].old_state.lines[0]).toHaveProperty('account_number')
|
||||
expect(withLines[0].old_state.lines[0]).toHaveProperty('debit_amount')
|
||||
expect(withLines[0].old_state.lines[0]).toHaveProperty('credit_amount')
|
||||
|
||||
// Dimension registry lockstep: the import-created dimension and value
|
||||
// are gone (nothing references them any more).
|
||||
const { rows: dimRows } = await getPool().query(
|
||||
`SELECT id FROM public.dimensions WHERE id = $1`,
|
||||
[dimId],
|
||||
)
|
||||
expect(dimRows).toHaveLength(0)
|
||||
const { rows: dimValRows } = await getPool().query(
|
||||
`SELECT id FROM public.dimension_values WHERE dimension_id = $1`,
|
||||
[dimId],
|
||||
)
|
||||
expect(dimValRows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset_fiscal_year: linked entries roll back everything', () => {
|
||||
it('refuses when an entry is referenced by a RESTRICT FK and leaves all state intact', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const freeId = await insertPostedEntry({
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId,
|
||||
voucherNumber: 1,
|
||||
})
|
||||
const linkedId = await insertPostedEntry({
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId,
|
||||
voucherNumber: 2,
|
||||
})
|
||||
|
||||
// Reference the second entry from a depreciation schedule (ON DELETE
|
||||
// RESTRICT), the smallest seedable RESTRICT link.
|
||||
const assetId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.assets
|
||||
(id, user_id, company_id, name, category, acquisition_date,
|
||||
acquisition_cost, useful_life_months,
|
||||
bas_asset_account, bas_accumulated_account, bas_expense_account)
|
||||
VALUES ($1, $2, $3, 'Test asset', 'equipment', '2026-01-15',
|
||||
10000, 60, '1220', '1229', '7832')`,
|
||||
[assetId, userId, companyId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.depreciation_schedules
|
||||
(user_id, company_id, asset_id, fiscal_period_id,
|
||||
planned_depreciation, journal_entry_id, posted_at)
|
||||
VALUES ($1, $2, $3, $4, 1000, $5, now())`,
|
||||
[userId, companyId, assetId, fiscalPeriodId, linkedId],
|
||||
)
|
||||
|
||||
// A document on the FREE entry: must still be attached after the failed
|
||||
// reset (all-or-nothing).
|
||||
const docId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.document_attachments
|
||||
(id, user_id, company_id, storage_path, file_name, sha256_hash, journal_entry_id)
|
||||
VALUES ($1, $2, $3, 'test/rollback.pdf', 'rollback.pdf', $4, $5)`,
|
||||
[docId, userId, companyId, `hash-${docId}`, freeId],
|
||||
)
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_LINKED_ENTRIES' })
|
||||
|
||||
// Nothing was deleted or detached.
|
||||
expect(await entryCount(companyId, fiscalPeriodId)).toBe(2)
|
||||
const { rows: docRows } = await getPool().query<{
|
||||
journal_entry_id: string | null
|
||||
}>(`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`, [docId])
|
||||
expect(docRows[0].journal_entry_id).toBe(freeId)
|
||||
})
|
||||
})
|
||||
@@ -117,6 +117,53 @@ export interface CompanyMigrationResetRpcResult {
|
||||
counts?: CompanyMigrationResetEligibility['counts']
|
||||
}
|
||||
|
||||
// Fiscal-year reset (issue #1883): guarded hard-delete of one OPEN fiscal
|
||||
// year's vouchers. Mirrors the migration-reset envelope shapes above.
|
||||
export type FiscalYearResetBlockerCode =
|
||||
| 'period_closed'
|
||||
| 'period_locked'
|
||||
| 'company_lock_date'
|
||||
| 'year_end_state'
|
||||
| 'arsredovisning_state'
|
||||
| 'next_year_dependency'
|
||||
| 'vat_declared'
|
||||
| 'agi_declared'
|
||||
| 'rot_rut_state'
|
||||
| 'cross_year_reference'
|
||||
|
||||
export interface FiscalYearResetBlocker {
|
||||
code: FiscalYearResetBlockerCode
|
||||
count?: number
|
||||
date?: string
|
||||
}
|
||||
|
||||
export interface FiscalYearResetEligibility {
|
||||
eligible: boolean
|
||||
blockers: FiscalYearResetBlocker[]
|
||||
period: {
|
||||
id: string
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
counts: {
|
||||
vouchers: number
|
||||
documents_to_detach: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface FiscalYearResetRpcResult {
|
||||
ok: boolean
|
||||
code?: string
|
||||
eligible?: boolean
|
||||
blockers?: FiscalYearResetBlocker[]
|
||||
period?: FiscalYearResetEligibility['period']
|
||||
counts?: FiscalYearResetEligibility['counts']
|
||||
deleted?: number
|
||||
detached_documents?: number
|
||||
period_name?: string
|
||||
}
|
||||
|
||||
// User preferences (cross-company)
|
||||
export interface UserPreferences {
|
||||
id: string
|
||||
@@ -3658,6 +3705,7 @@ export type AuditAction =
|
||||
| 'SECURITY_EVENT'
|
||||
| 'INTEGRITY_FAILURE'
|
||||
| 'COMMITTED_AT_OVERRIDE'
|
||||
| 'RESET_SNAPSHOT'
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user