fix(bookkeeping): a following year's own IB no longer blocks nollställ, and a re-dated räkenskapsår gets the right name (#2286)
Customer report (Aisen & Adison AB, 2026-09-03): Fortnox years 2024-2026
imported first, then the first year 2022/2023 backfilled. Two bugs surfaced.
1. The backfilled year was saved as "Räkenskapsår 2027": CreatePeriodDialog
seeds the next forward year and kept that name when the user re-dated the
form. The name now follows the typed dates until the user edits the name
(fiscalYearName exported from suggest-fiscal-period).
2. Nollställ of the backfilled year was refused with next_year_dependency
because 2024 carried an opening-balance verifikat. Any IB in the next year
counted as reliance, so a backfilled year could never be reset, while a
next year WITHOUT an IB (whose balansrapport really rolls from this year)
was allowed. Migration 20260904163000 redefines fiscal_year_reset_snapshot:
the block fires only when the next year is locked, closed or has its own
closing entry; a bokslut-generated IB is still refused via this year's
closing_entry_id (year_end_state). The snapshot returns next_period
{id, name, has_opening_balances} and the dialog states that the following
year's IB stays as it is.
pg-real: reset-fiscal-year.pg.test.ts pins the narrowed guard (closed next
year, next year with closing entry, next year with its own IB survives the
reset untouched).
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
db289e3bdc
commit
9618bab273
@@ -1582,3 +1582,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-04] Fortnox/provider migration (#2211, #2238, fix from first principles): the three-year fiscal-year window (getAllowedFiscalYears, #718) became a DEFAULT selection with a year picker, not a fixed lift to every year: the window gates only the SIE fetch (documents, invoices, assets never were), the import is one request per year (300 s function, 290 s RPC) so its cost is bounded per year, but /preview and /sie-data fetch and parse one SIE export per selected year inside a single 300 s invocation (15 s timeout, 3 attempts, 30 s backoff) and no Fortnox export latency is measured in the repo; a selection keeps the default cost identical and an oversized selection fails in /sie-data before any ledger write. Explaining the cap and pointing at SIE (the first version of #2280) was replaced, not kept: the user's problem was a missing year with its documents, and the SIE path leaves document re-attachment as manual work, which is what the import exists to remove. Source years are keyed by start year in the selection (years=2022,2024), consistent with fileStatuses.fiscalYear; two fiscal years starting in the same calendar year already collide in the existing import and were not fixed here. Splitting /sie-data into per-year requests is a restructure (the mapping step needs the account union across files), deferred until large selections time out in practice. The selection per run is capped at MAX_SELECTED_FISCAL_YEARS = 6, derived from the /sie-data budget (300 s function; 48 s worst case per year = 3 x 15 s timeout + 1 s + 2 s backoff; 6 x 48 = 288 s), enforced in the route before the consent is resolved and mirrored in the picker via /preview rather than a client-side constant; unknown years are refused after the year listing and before the first export (Superagent P2 on #2280). Lifting the cap means per-year /sie-data requests, a restructure, not a bigger number.
|
||||
[2026-09-04] Supplier-invoice list overflow (#2262) fixed per page, not in a shared list component: none exists (every list hand-writes the overflow-x-auto wrapper) and the three overflow reports had three different causes, so the column budget went into .claude/rules/design.md instead. Fakturadatum was dropped rather than Kvar or the action column (förfaller is the payer's date and the default order, the customer list has no invoice-date column either); the always-visible sort icon from #2091 was kept although it was the proximate regression.
|
||||
[2026-09-04] Parties: the model reads a counterpart only on demand (picker, review list), never when the queue builds: a five-hundred-row queue would cost five hundred calls nobody asked for and a rebuild would repeat them; the reading is a 'model' fact and a search query, never a hard key. The review list ticks rows with exactly one active SCB match but writes nothing until a person approves: an exact legal name plus one active hit is high precision, auto-attaching would still be the system choosing. Exact legal-form names ("Visma Spcs AB", not "Visma") group keys and attach to existing parties: registered company names are unique in Sweden, so this is a key in all but form; the "name never merges" rule keeps applying to fuzzy and form-less names.
|
||||
[2026-09-04] reset_fiscal_year's next_year_dependency no longer counts an opening-balance verifikat in the following year as reliance (migration 20260904163000 redefines fiscal_year_reset_snapshot; the block now fires only when the following year is locked, closed or has its own closing entry). Why: the old check (opening_balance_entry_id / opening_balances_set on the next period) fired for the dominant migration shape, import the first year with its own #IB and later backfill the year before it, so a backfilled year could never be reset (Aisen & Adison AB, 2026-09-03), while a next year WITHOUT an IB, whose balansrapport really rolls from this year's books, was allowed: the check was inverted relative to actual reliance. An IB in the next year is its own verifikat with its own underlag and survives the reset untouched; the one IB that IS derived from this year's books, the bokslut-generated one, is still refused via this year's closing_entry_id (year_end_state). The preview now returns next_period {name, has_opening_balances} and the dialog says the following year's IB stays as it is, instead of a blocker. Rejected alternative: stornoing the next year's IB inside the reset (it would destroy a correct migration boundary and re-create the #1022 dead end). Sibling fix in the same change: CreatePeriodDialog now derives the name from the dates the user types until the name is hand-edited, which is how a 2022-07-01..2023-12-31 year got saved as "Räkenskapsår 2027" (the seed suggestion is always the next forward year).
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { computeSuggestedPeriod } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
import { computeSuggestedPeriod, fiscalYearName } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
import { fiscalPeriodAdvisoryText } from '@/lib/bookkeeping/fiscal-period-warnings'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
@@ -61,6 +61,11 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
|
||||
const suggested = useMemo(() => computeSuggestedPeriod(entryDate, periods), [entryDate, periods])
|
||||
|
||||
const [name, setName] = useState(suggested.name)
|
||||
// The name follows the dates until the user types a name of their own.
|
||||
// Without this the seed suggestion (the next forward year, e.g.
|
||||
// "Räkenskapsår 2027") survived the user re-dating the form to a backfilled
|
||||
// first year and was saved verbatim.
|
||||
const [nameEdited, setNameEdited] = useState(false)
|
||||
const [periodStart, setPeriodStart] = useState(suggested.period_start)
|
||||
const [periodEnd, setPeriodEnd] = useState(suggested.period_end)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -72,12 +77,21 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
|
||||
const [lastSuggested, setLastSuggested] = useState(suggested)
|
||||
if (suggested.name !== lastSuggested.name || suggested.period_start !== lastSuggested.period_start) {
|
||||
setName(suggested.name)
|
||||
setNameEdited(false)
|
||||
setPeriodStart(suggested.period_start)
|
||||
setPeriodEnd(suggested.period_end)
|
||||
setLastSuggested(suggested)
|
||||
setCreated(null)
|
||||
}
|
||||
|
||||
// A date input yields '' while incomplete and a full YYYY-MM-DD otherwise:
|
||||
// only derive a name once both ends are known.
|
||||
const updateDates = (start: string, end: string) => {
|
||||
setPeriodStart(start)
|
||||
setPeriodEnd(end)
|
||||
if (!nameEdited && start && end) setName(fiscalYearName(start, end))
|
||||
}
|
||||
|
||||
// Close, and refetch in the parent if this session created a period. The
|
||||
// refetch is deferred to here on the advisory path on purpose: it swaps the
|
||||
// `periods` prop, which resets the form above, and that would pull the
|
||||
@@ -175,16 +189,23 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Namn</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
setNameEdited(true)
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Startdatum</Label>
|
||||
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="mt-1" />
|
||||
<Input type="date" value={periodStart} onChange={(e) => updateDates(e.target.value, periodEnd)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Slutdatum</Label>
|
||||
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="mt-1" />
|
||||
<Input type="date" value={periodEnd} onChange={(e) => updateDates(periodStart, e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -242,6 +242,11 @@ export function FiscalYearResetDialog({
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('fy_reset_documents_note')}
|
||||
</p>
|
||||
{eligibility.next_period?.has_opening_balances ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('fy_reset_next_year_ib_note', { name: eligibility.next_period.name })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{eligibility.eligible ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
computeSuggestedPeriod,
|
||||
fiscalYearName,
|
||||
suggestSeedDate,
|
||||
resolveCurrentPeriodId,
|
||||
} from '../suggest-fiscal-period'
|
||||
@@ -10,6 +11,22 @@ type Range = { period_start: string; period_end: string }
|
||||
const FY2024: Range = { period_start: '2024-01-01', period_end: '2024-12-31' }
|
||||
const FY2026: Range = { period_start: '2026-01-01', period_end: '2026-12-31' }
|
||||
|
||||
describe('fiscalYearName', () => {
|
||||
it('names a calendar year by its year', () => {
|
||||
expect(fiscalYearName('2025-01-01', '2025-12-31')).toBe('Räkenskapsår 2025')
|
||||
})
|
||||
|
||||
it('names a straddling year by both years', () => {
|
||||
expect(fiscalYearName('2024-07-01', '2025-06-30')).toBe('Räkenskapsår 2024/2025')
|
||||
})
|
||||
|
||||
it('names a backfilled first year that spans two calendar years by both years', () => {
|
||||
// The customer case: the create dialog seeded "Räkenskapsår 2027" and the
|
||||
// user re-dated the form to the company's first, extended year.
|
||||
expect(fiscalYearName('2022-07-28', '2023-12-31')).toBe('Räkenskapsår 2022/2023')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeSuggestedPeriod', () => {
|
||||
it('suggests a calendar year around the entry date when there are no periods', () => {
|
||||
expect(computeSuggestedPeriod('2025-06-15', [])).toEqual({
|
||||
|
||||
@@ -28,13 +28,19 @@ export interface SuggestedPeriod {
|
||||
* A fiscal-year name: `Räkenskapsår 2025`, or `Räkenskapsår 2024/2025` when it
|
||||
* straddles two calendar years. Swedish by default to match the app's existing
|
||||
* fiscal-year naming (Swedish-first); the field stays editable in the dialog.
|
||||
* Exported so the create dialog can keep the name in step with the dates the
|
||||
* user actually types: a suggested "Räkenskapsår 2027" must not survive the
|
||||
* user re-dating the year to 2022-07-01..2023-12-31. Callers pass full
|
||||
* YYYY-MM-DD strings.
|
||||
*/
|
||||
function periodName(start: string, end: string): string {
|
||||
const startYear = Number(start.slice(0, 4))
|
||||
const endYear = Number(end.slice(0, 4))
|
||||
export function fiscalYearName(start: string, end: string): string {
|
||||
const startYear = start.slice(0, 4)
|
||||
const endYear = end.slice(0, 4)
|
||||
return startYear === endYear ? `Räkenskapsår ${startYear}` : `Räkenskapsår ${startYear}/${endYear}`
|
||||
}
|
||||
|
||||
const periodName = fiscalYearName
|
||||
|
||||
/**
|
||||
* Suggest a fiscal period for the create dialog, given the date the user is
|
||||
* trying to book and the company's existing periods. Three cases:
|
||||
|
||||
@@ -35,6 +35,7 @@ const SNAPSHOT = {
|
||||
period_end: '2026-12-31',
|
||||
},
|
||||
counts: { vouchers: 7, documents_to_detach: 2 },
|
||||
next_period: { id: 'period-2', name: '2027', has_opening_balances: true },
|
||||
}
|
||||
|
||||
describe('getFiscalYearResetEligibility', () => {
|
||||
@@ -52,6 +53,7 @@ describe('getFiscalYearResetEligibility', () => {
|
||||
blockers: [],
|
||||
period: SNAPSHOT.period,
|
||||
counts: SNAPSHOT.counts,
|
||||
next_period: SNAPSHOT.next_period,
|
||||
},
|
||||
})
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('get_fiscal_year_reset_eligibility', {
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function getFiscalYearResetEligibility(
|
||||
blockers: result.blockers ?? [],
|
||||
period: result.period!,
|
||||
counts: result.counts ?? { vouchers: 0, documents_to_detach: 0 },
|
||||
next_period: result.next_period ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1854,6 +1854,7 @@
|
||||
"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_next_year_ib_note": "Opening balances in {name} stay as they are. Reconcile them against this year once it has been booked again.",
|
||||
"fy_reset_confirm_name": "Type <strong>{name}</strong> to confirm",
|
||||
"fy_reset_submit": "Reset fiscal year",
|
||||
"fy_reset_resetting": "Resetting...",
|
||||
|
||||
@@ -1854,6 +1854,7 @@
|
||||
"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_next_year_ib_note": "Ingående balanser i {name} ligger kvar oförändrade. Stäm av dem mot det här året när det är bokfört igen.",
|
||||
"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...",
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
-- Fiscal-year reset: a following year's own opening balances are not reliance.
|
||||
--
|
||||
-- Customer report 2026-09-03 (Aisen & Adison AB): Fortnox years 2024-2026
|
||||
-- imported first (2024 got its IB from the file's #IB), then the first year
|
||||
-- 2022/2023 was backfilled by SIE import, which relinked 2024 onto it and
|
||||
-- resynced 2024's IB. Resetting the backfilled year was then refused with
|
||||
-- next_year_dependency ("ett senare rakenskapsar bygger pa det har arets
|
||||
-- utgaende balanser"), although nothing in 2024 was derived from the books
|
||||
-- in this system: its IB is its own verifikat with the SIE file as underlag,
|
||||
-- and it survives the reset unchanged. The old check treated ANY IB in the
|
||||
-- next year (opening_balance_entry_id / opening_balances_set) as reliance,
|
||||
-- which made every backfilled year permanently un-resettable, while a next
|
||||
-- year WITHOUT an IB, whose balansrapport really does roll from this year's
|
||||
-- books, was allowed. Reliance is structural: the next year is locked,
|
||||
-- closed, or has its own closing entry (finalised on top of this year), or
|
||||
-- this year has a closing entry (year_end_state), which is the only way an
|
||||
-- IB in the next year is generated from this year's books.
|
||||
--
|
||||
-- Same function, same guards otherwise. The snapshot now also returns
|
||||
-- next_period {id, name, has_opening_balances} so the preview can state
|
||||
-- that the following year's IB is left as it is.
|
||||
|
||||
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). The next
|
||||
-- year blocks the reset only when it has been FINALISED on top of this
|
||||
-- year: locked, closed, or carrying its own closing entry. An opening
|
||||
-- balance verifikat in the next year is not reliance: the dominant
|
||||
-- migration shape (import the first year with its own #IB, later backfill
|
||||
-- the year before it) always leaves an IB there, and that IB is its own
|
||||
-- verifikat with its own underlag, which the reset leaves untouched and
|
||||
-- reports back as next_period.has_opening_balances so the UI can say so.
|
||||
-- An IB generated by this year's bokslut is still refused, via
|
||||
-- closing_entry_id on THIS period (year_end_state above).
|
||||
SELECT id, name, is_closed, locked_at, closing_entry_id,
|
||||
opening_balance_entry_id
|
||||
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, name, is_closed, locked_at, closing_entry_id,
|
||||
opening_balance_entry_id
|
||||
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
|
||||
) 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
|
||||
),
|
||||
'next_period', CASE
|
||||
WHEN v_next.id IS NULL THEN NULL
|
||||
ELSE jsonb_build_object(
|
||||
'id', v_next.id,
|
||||
'name', v_next.name,
|
||||
'has_opening_balances', v_next.opening_balance_entry_id IS NOT NULL
|
||||
)
|
||||
END
|
||||
);
|
||||
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. next_year_dependency fires only on a locked/closed/closed-by-entry following year; a following year''s own opening balances are reported in next_period, not treated as reliance.';
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* pg-real tests for the fiscal-year reset RPCs (migration 20260825150000).
|
||||
* pg-real tests for the fiscal-year reset RPCs (migration 20260825150000,
|
||||
* next-year dependency narrowed in 20260904163000).
|
||||
*
|
||||
* Pins: the actor gate (service-role p_user_id, owner/admin only), every
|
||||
* eligibility guard (locked, closed, company lock date, year-end state,
|
||||
@@ -29,6 +30,7 @@ type RpcResult = {
|
||||
eligible?: boolean
|
||||
blockers?: Array<{ code: string; count?: number; date?: string }>
|
||||
counts?: { vouchers: number; documents_to_detach: number }
|
||||
next_period?: { id: string; name: string; has_opening_balances: boolean } | null
|
||||
deleted?: number
|
||||
detached_documents?: number
|
||||
period_name?: string
|
||||
@@ -212,7 +214,7 @@ describe('reset_fiscal_year: eligibility guards', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses when a later year depends on this year (IB generated)', async () => {
|
||||
it('refuses when a later year is finalised on top of this one (closed)', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const nextId = await insertFiscalPeriod({
|
||||
userId,
|
||||
@@ -220,11 +222,10 @@ describe('reset_fiscal_year: eligibility guards', () => {
|
||||
name: '2027',
|
||||
periodStart: '2027-01-01',
|
||||
periodEnd: '2027-12-31',
|
||||
isClosed: true,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET previous_period_id = $1, opening_balances_set = true
|
||||
WHERE id = $2`,
|
||||
`UPDATE public.fiscal_periods SET previous_period_id = $1 WHERE id = $2`,
|
||||
[fiscalPeriodId, nextId],
|
||||
)
|
||||
|
||||
@@ -235,6 +236,86 @@ describe('reset_fiscal_year: eligibility guards', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses when a later year has its own closing entry', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const nextId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
name: '2027',
|
||||
periodStart: '2027-01-01',
|
||||
periodEnd: '2027-12-31',
|
||||
})
|
||||
const nextClosingId = await insertPostedEntry({
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId: nextId,
|
||||
sourceType: 'year_end',
|
||||
entryDate: '2027-12-31',
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET previous_period_id = $1, closing_entry_id = $2
|
||||
WHERE id = $3`,
|
||||
[fiscalPeriodId, nextClosingId, 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('does not treat the later year\'s own opening balances as a dependency', async () => {
|
||||
// The backfill shape: the first imported year carries its own IB (from
|
||||
// the SIE file's #IB, or resynced from the backfilled year's #UB). Its
|
||||
// IB is a verifikat of its own and survives the reset untouched; the
|
||||
// preview discloses it instead of refusing.
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertPostedEntry({ companyId, userId, fiscalPeriodId, sourceType: 'import' })
|
||||
const nextId = await insertFiscalPeriod({
|
||||
userId,
|
||||
companyId,
|
||||
name: '2027',
|
||||
periodStart: '2027-01-01',
|
||||
periodEnd: '2027-12-31',
|
||||
})
|
||||
const nextIbId = await insertPostedEntry({
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId: nextId,
|
||||
sourceType: 'opening_balance',
|
||||
entryDate: '2027-01-01',
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET previous_period_id = $1,
|
||||
opening_balance_entry_id = $2,
|
||||
opening_balances_set = true
|
||||
WHERE id = $3`,
|
||||
[fiscalPeriodId, nextIbId, nextId],
|
||||
)
|
||||
|
||||
const eligibility = await callEligibility(companyId, fiscalPeriodId, userId)
|
||||
expect(eligibility).toMatchObject({
|
||||
ok: true,
|
||||
eligible: true,
|
||||
blockers: [],
|
||||
next_period: { id: nextId, name: '2027', has_opening_balances: true },
|
||||
})
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.deleted).toBe(1)
|
||||
|
||||
expect(await entryCount(companyId, nextId)).toBe(1)
|
||||
const { rows } = await getPool().query<{ opening_balance_entry_id: string; opening_balances_set: boolean }>(
|
||||
`SELECT opening_balance_entry_id, opening_balances_set FROM public.fiscal_periods WHERE id = $1`,
|
||||
[nextId],
|
||||
)
|
||||
expect(rows[0]).toEqual({ opening_balance_entry_id: nextIbId, opening_balances_set: true })
|
||||
})
|
||||
|
||||
it('allows a later year that carries no dependency yet', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
await insertPostedEntry({ companyId, userId, fiscalPeriodId })
|
||||
@@ -250,6 +331,13 @@ describe('reset_fiscal_year: eligibility guards', () => {
|
||||
[fiscalPeriodId, nextId],
|
||||
)
|
||||
|
||||
const eligibility = await callEligibility(companyId, fiscalPeriodId, userId)
|
||||
expect(eligibility).toMatchObject({
|
||||
ok: true,
|
||||
eligible: true,
|
||||
next_period: { id: nextId, name: '2027', has_opening_balances: false },
|
||||
})
|
||||
|
||||
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.deleted).toBe(1)
|
||||
|
||||
@@ -145,6 +145,14 @@ export interface FiscalYearResetEligibility {
|
||||
vouchers: number
|
||||
documents_to_detach: number
|
||||
}
|
||||
// The following räkenskapsår, when one exists. Its own opening balances
|
||||
// are never touched by the reset and are disclosed, not treated as a
|
||||
// dependency (migration 20260904163000).
|
||||
next_period: {
|
||||
id: string
|
||||
name: string
|
||||
has_opening_balances: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface FiscalYearResetRpcResult {
|
||||
@@ -154,6 +162,7 @@ export interface FiscalYearResetRpcResult {
|
||||
blockers?: FiscalYearResetBlocker[]
|
||||
period?: FiscalYearResetEligibility['period']
|
||||
counts?: FiscalYearResetEligibility['counts']
|
||||
next_period?: FiscalYearResetEligibility['next_period']
|
||||
deleted?: number
|
||||
detached_documents?: number
|
||||
period_name?: string
|
||||
|
||||
Reference in New Issue
Block a user