Files
accounted/lib/core/bookkeeping/result-appropriation-service.ts
T
MattssonandClaude Fable 5 4e47335308 feat(year-end): administrative undo of executed year-end closing + skatteverket scope fixes (#1081)
* fix(skatteverket): request the ska scope for skattekonto v2

The skattekonto v2 API rejects skahmst-only tokens with 403 "The required
scopes are not authorized" (observed in prod 2026-07-20; no company has
synced since 2026-05-10). The requested `skattekonto` scope is silently
dropped from every grant, while `ska` appears in one real May grant, so
request it too: SKV grants the intersection, so this is harmless if wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): correct the skattekonto scope model around ska

Root cause of the May 10 skattekonto outage, confirmed via git history and
prod token data: the `ska` scope (the interactive skattekonto API's actual
scope, requested since the extension's first commit in March) was removed
by the "remove unused scopes" cleanup in the #431 series. Every token
issued after that hour lacks it and the API answers 403 "The required
scopes are not authorized"; no company has synced since. The May 15 repair
re-added skahmst, which per its tjanstebeskrivning is a different bulk
E-transport service and does not substitute; `skattekonto` is not a real
SKV scope name and is silently dropped from grants.

Follow-up to the ska re-request (cd8f7a30):
- document the confirmed scope model in oauth.ts so ska is never
  "cleaned up" again
- panel missing-scope warning and reconnect-button now gate on ska,
  not skahmst/skattekonto
- scope badge labels: ska takes the saldo & transaktioner label,
  skahmst relabeled as the E-transport file service
- consent-page note covers both terse scope names and says ska is
  required

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(year-end): warn on untaxed profit at verkstall, Swedish readiness messages, always-visible period selector

An aktiebolag could execute year-end with a profit and zero bolagsskatt
booked without any warning (support case: closing moved 592k to 2099
untaxed). The preview now computes bolagsskattMissing (AB + profit + no
89xx account among closed accounts, 8999 excluded) and both the preview
and execute steps render an advisory, bypassable warning.

validateYearEndReadiness messages are now Swedish (the bokslut wizard is
a stays-Swedish surface); the MCP year_end_readiness classifier matches
both the new Swedish strings and the legacy English ones.

The wizard period selector now always renders, keeps a selected-but-
ineligible period selectable, and resets a stale ?period= id from
another company instead of leaving the user stuck on the wrong year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(year-end): administrative undo of an executed year-end closing

Storno-only reset used when a bokslut was executed prematurely (e.g.
without bolagsskatt) and no arsredovisning exists yet: reverses the next
period's result_appropriation and opening_balance entries, reopens the
period, reverses the closing entry, and detaches closing_entry_id.
Resumable if interrupted midway; attribution per BFL 5 kap 6.

Migration 20260720140000 adds the trigger escape hatch: closing_entry_id
may only change once set when the old closing entry is reversed with a
posted storno chain (status flag alone is forgeable via PostgREST), and
a non-NULL replacement must be a posted year_end entry in the same
period. Covered by a pg-real test.

planResultAppropriation idempotency is now posted-only: a reversed
omforing no longer blocks the re-run from posting a fresh 2099 -> 2098
reclassification (it previously returned null silently, leaving the new
year's equity polluted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address CodeRabbit, PR-Agent and compliance findings

- undo script: company_id filters on verify queries, period-scope the
  arsredovisning precondition checks, validate service-key format,
  escalate audit_log insert failure to a hard error (BFNAR 2013:2)
- detach migration: company-scope the storno chain EXISTS, replace the
  em dash in the new error message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address round-2 compliance swarm and Swedish review findings

- undo script: require --confirm-url with --commit so an env swap fails
  loud; retry the audit_log insert 3x and direct the operator to insert
  the behandlingshistorik row manually on final failure (BFNAR 2013:2)
- year-end preview: document why resultAccountSummary is a complete 89xx
  scan; warning text now also names periodiseringsfond and
  overavskrivningar as legitimate zero-tax reasons

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:17:43 +02:00

198 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { getOpeningBalances } from '@/lib/reports/opening-balances'
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
import { createLogger } from '@/lib/logger'
import type { JournalEntry, CreateJournalEntryLineInput } from '@/types'
const log = createLogger('result-appropriation-service')
/** Årets resultat (current-year result, aktiebolag). */
export const RESULT_ACCOUNT = '2099'
/** Vinst eller förlust från föregående år. */
export const PRIOR_RESULT_ACCOUNT = '2098'
export interface ResultAppropriationPlan {
periodId: string
periodName: string
/** entry_date for the omföring: the new period's first day. */
periodStart: string
/** Net 2099 balance, credit-positive (a profit is > 0, a loss is < 0). */
net: number
/** Absolute, öre-rounded amount that moves between 2099 and 2098. */
amount: number
direction: 'profit' | 'loss'
/** Balanced lines for the omföring verifikat. */
lines: CreateJournalEntryLineInput[]
}
/**
* Read-only computation of the year-open omföring (no writes). Returns the plan
* to move 2099 "Årets resultat" onto 2098 "Vinst eller förlust från föregående
* år", or null when there is nothing to do.
*
* Returns null when:
* - the company is not an aktiebolag (enskild firma books to 2010, no 2099),
* - the period already has a POSTED result_appropriation entry (idempotency;
* a reversed one has been stornoed, no longer moves any balance, and must
* not block re-planning: the year-end undo flow reverses the omföring and
* the subsequent re-run has to be able to post a fresh one), or
* - 2099 carries no balance (within ORE_TOLERANCE).
*
* Shared by generateResultAppropriation (which posts the plan) and the
* retroactive catch-up script (which previews it in dry-run) so the preview
* and the committed entry can never diverge.
*/
export async function planResultAppropriation(
supabase: SupabaseClient,
companyId: string,
periodId: string,
): Promise<ResultAppropriationPlan | null> {
// Aktiebolag only. Same resolution as previewYearEndClosing's closing-account
// decision, so the omföring runs exactly when the result was posted to 2099.
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = settings?.entity_type ?? 'aktiebolag'
if (entityType !== 'aktiebolag') return null
// Idempotency: never plan a second omföring for a period that already has a
// LIVE one. Deliberately posted-only: a reversed omföring is storno-cancelled
// (net zero effect on 2099), so it must not block the re-run after an
// administrative year-end undo (scripts/undo-year-end-closing.ts).
const { data: existing } = await supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', periodId)
.eq('source_type', 'result_appropriation')
.eq('status', 'posted')
.limit(1)
.maybeSingle()
if (existing) return null
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, name, opening_balance_entry_id')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found')
// Read 2099 from the period's INGÅENDE BALANS only: the carried-forward
// prior result that the IB entry mirrored from last year's UB: NOT the full
// trial balance. The omföring must reclassify exactly that carried amount;
// scoping to IB makes it correct even when the period already has current-year
// 2099 activity (e.g. the retroactive catch-up script running mid-year, where
// closing = IB + activity would over/under-reclassify). getOpeningBalances
// reads the committed opening_balance entry, falling back to a server-side
// aggregate of prior posted lines when none is set. credit − debit is positive
// for a profit (2099 is credit-normal).
const { balances } = await getOpeningBalances(supabase, companyId, period)
const ib2099 = balances.get(RESULT_ACCOUNT)
const net = ib2099 ? roundOre(ib2099.credit - ib2099.debit) : 0
if (Math.abs(net) < ORE_TOLERANCE) return null
const amount = roundOre(Math.abs(net))
const lines: CreateJournalEntryLineInput[] =
net > 0
? [
// Profit: move the credit balance off 2099 onto 2098.
{
account_number: RESULT_ACCOUNT,
debit_amount: amount,
credit_amount: 0,
line_description: 'Omföring av föregående års resultat',
},
{
account_number: PRIOR_RESULT_ACCOUNT,
debit_amount: 0,
credit_amount: amount,
line_description: 'Föregående års resultat',
},
]
: [
// Loss: move the debit balance off 2099 onto 2098.
{
account_number: PRIOR_RESULT_ACCOUNT,
debit_amount: amount,
credit_amount: 0,
line_description: 'Föregående års resultat',
},
{
account_number: RESULT_ACCOUNT,
debit_amount: 0,
credit_amount: amount,
line_description: 'Omföring av föregående års resultat',
},
]
return {
periodId,
periodName: period.name,
periodStart: period.period_start,
net,
amount,
direction: net > 0 ? 'profit' : 'loss',
lines,
}
}
/**
* Omföring av föregående års resultat: reclassify 2099 at new-year open.
*
* After a new fiscal year's opening balances are generated, account 2099
* "Årets resultat" carries the prior year's result forward (the IB entry is a
* faithful mirror of the prior period's UB). Per BAS practice the prior result
* must not remain on 2099: each year must start with 2099 = 0 so it only ever
* holds the *current* year's result. This posts the year-open reclassification
* as a SEPARATE verifikat in the new period:
*
* profit (2099 has a credit balance): Dr 2099 / Cr 2098
* loss (2099 has a debit balance): Dr 2098 / Cr 2099
*
* It is deliberately NOT folded into the opening-balance entry. The IB entry
* must stay a faithful mirror of the prior UB, or validateBalanceContinuity():
* which reads IB solely from the period's opening_balance entry, would flag
* 2099 and 2098 as discrepancies and executeYearEndClosing would self-reverse.
* A standalone entry is invisible to that check.
*
* The further disposition 2098 → 2091 (balanserat resultat) / 2898 (utdelning)
* is the bolagsstämma's decision and is intentionally left to a separate step.
*
* Idempotent / AB-only: see planResultAppropriation for the no-op conditions.
* Powers both executeYearEndClosing (steady state) and the retroactive
* catch-up script (clears any accumulated 2099 in a company's open period).
*/
export async function generateResultAppropriation(
supabase: SupabaseClient,
companyId: string,
userId: string,
periodId: string,
): Promise<JournalEntry | null> {
const plan = await planResultAppropriation(supabase, companyId, periodId)
if (!plan) return null
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: periodId,
entry_date: plan.periodStart,
description: `Omföring av föregående års resultat (${RESULT_ACCOUNT} → ${PRIOR_RESULT_ACCOUNT})`,
source_type: 'result_appropriation',
voucher_series: 'A',
lines: plan.lines,
})
log.info('Posted result appropriation omföring', {
operation: 'result_appropriation.post',
companyId,
entityType: 'journal_entry',
entityId: entry.id,
amount: plan.amount,
direction: plan.direction,
})
return entry
}