* 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>
198 lines
7.7 KiB
TypeScript
198 lines
7.7 KiB
TypeScript
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
|
||
}
|