* fix(settings): explain why account deletion is blocked The delete-account button was disabled while the user still owned companies, but the reason only lived behind the "?" on the blocker row, so the greyed-out button read as broken. Surface it as one visible attn sentence directly under the button, and point aria-describedby at it whenever the button is disabled, not only on a load error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(enable-banking): share one PSD2 consent across a user's companies Connecting the same bank for a second company required a second BankID, and at SEB that new authorization silently revoked the first one. A user with four companies at one bank therefore signed four times a quarter and ended up with three dead feeds, each still rendering as "Aktiv" with a stale last_synced_at until someone pressed Synka. Prod says this is not one customer: every SEB customer holding connections in more than one company has had an earlier company stop syncing at the moment the next was authorized, most of them while the consent was still formally valid for weeks. The same measurement over other banks is far quieter, so the one-active-session-per-PSU limit is real and ASPSP-side. Enable Banking already supports the shape we want. POST /auth carries no account restriction, so a session covers every account the user ticked at the bank, and GET /accounts/{uid}/transactions takes no session id, so a second company can sync its own accounts from an existing session. bank_connections has no unique constraint on session_id, so this needs no migration. Adds lib/session-sharing.ts plus GET /reusable-sessions and POST /attach. When a live session in another of the user's companies still exposes accounts no company syncs, the settings panel offers to reuse it: the new row shares session_id and consent_expires, carries only the unclaimed accounts, and lands in pending_selection so the existing IBAN-aware account picker does the ledger mapping. Only the consent is shared; accounts, cash_accounts and transactions stay strictly per-company. Sharing a session changes three lifecycle paths, all handled here: - Disconnect and reconnect now refcount before revoking. A blind revoke would take down a sibling company's feed, which is the exact failure this removes. The count runs on a service-role client because RLS hides a sibling in a company the user has since left, and it fails closed: an uncertain count is treated as shared, since a lingering consent lapses on its own in 90 days while a wrongly revoked one kills a working feed. - A renewed consent fans out to every company sharing the old session, and re-points their account uids by IBAN. Several ASPSPs reissue uids on re-authorization, so carrying the session id alone would have left siblings calling retired uids and re-broken them every quarter. This is also why the superseded session_id is no longer nulled at /connect: the callback needs it. - The nightly probe runs once per distinct session and applies the verdict to every row holding it, and expiry mails are keyed per (user, session), so one dead consent is one probe and one mail rather than four of each. Only enabled cash_accounts rows count as claiming an IBAN. The callback mirrors every account in a consent, deselected ones included, so counting any row as a claim would leave nothing offerable once the first company connects. An account handed to a company also stops being offered while that company's picker is still open, closing the window where two companies could book the same physical account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ink2): read the resultaträkning from the pre-closing books INK2R summed journal entries raw, so it included the resultatavslut that zeroes every P&L account into 2099 at year-end. Nettoomsättning, kostnader, periodiseringsfond and skatt all came out as 0, which cascaded into INK2S 7650/7651 and the taxable result. INK2 is always filed after bokslut, so this was every real declaration, and nothing warned: with the P&L at zero the balance sheet still tied out. INK2R now reads two views of the same period. The balance sheet comes from the closed books so 7302 keeps arets resultat via 2099; the income statement comes from the pre-closing books via excludeFinalClosingEntry, which drops only fiscal_periods.closing_entry_id so skatt and bokslutsdispositioner stay on the form (7525, 7528). The equity adjustment is now conditional on a posted closing entry having moved the result into 2099. Second, independent bug: accounts were mapped by BAS number with no regard for the sign of the balance, so konto 1630 with a credit was reported as a negative fordran instead of a skatteskuld and konto 2641 with a debit was netted off the liabilities. The three sign-reclassification rules the K2 iXBRL mapper already had are extracted to lib/reports/sign-reclassification .ts and applied to INK2R too, so both statutory reports present the same balance sheet. Only the rule table is shared: k2-mapper keeps its sumOre arithmetic because the iXBRL path is ore-exact while INK2R truncates per SFL 22:1. NE-bilaga had the same empty-resultatrakning bug and gets the same fix. Adds the closed-period coverage that was missing: the old tests only exercised the mapping table against an open period, the one state in which the engine happened to work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): make the year-end closing decision explicit at every call site generateTrialBalance took two optional booleans, so a caller that never thought about the resultatavslut silently got 'include'. That is the wrong default for anything summing class 3-8: the closing verifikat posts the mirror image of every P&L account into 2099 inside the same period, so the report reads ZERO across the board while the balance sheet still ties out and nothing warns. The booleans are replaced by a required closingEntry: 'include' | 'exclude-final' | 'exclude-all-year-end' with no default, so the build fails until each call site decides. All 40 were audited individually; every one keeps its current behaviour except the two that were provably broken: - Resultatrapport read zero on every line for a closed year, in JSON, PDF and XLSX, and its prior-year comparison column read zero for anyone whose previous year was closed. - Resultat per projekt (dimension-pnl) had the same defect and must stay in lockstep with Resultatrapport to keep reconciling. Both now pass 'exclude-all-year-end', which keeps them agreeing with the formal Resultaträkning rather than pre-empting Stage 2 of #1051 (DECISIONS.md:632). Deliberately unchanged and recorded in DECISIONS.md: the KPI expense composition, which is blank for a closed year but cannot be fixed without a migration and a displayed-figure change, and getBookedBolagsskatt, whose contract is an open period and whose call chain already caused a too-high-tax customer bug once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(vat): keep the resultatavslut out of the momsdeklaration The closing verifikat posts the mirror image of every P&L account into 2099 inside the same fiscal period. Revenue accounts drive rutor 05, 39 and 40, so any VAT period containing the fiscal-year end reported NEGATED turnover once the year was closed. get_vat_declaration_totals already excluded vat_settlement and opening_balance entries, but not this one. Reproduced read-only against production: for December of a closed year the December declaration reported ruta 39 = -794 734 kr. After the fix that period reports 0 and the January period carrying the real sale is unchanged at 794 734 kr. Keyed on fiscal_periods.closing_entry_id, not source_type = 'year_end': avskrivningar, periodiseringsfond and skatt share that source_type and must keep whatever VAT effect they carry. A reversed closing entry is retained together with its storno so the pair still nets to zero, the same predicate trial-balance.ts uses for closingEntry: 'exclude-final'. Migration applied to the staging branch only; prod gets it via merge. The pg test is written but has NOT been executed locally (no DATABASE_URL configured and no local Postgres), so CI is its first real run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(kpi): keep the resultatavslut off the monthly chart The monthly income/expense chart summed every posted entry in the fiscal period. The closing verifikat posts the mirror image of every P&L account, so once a year was closed the fiscal-year-end month charted the whole year's revenue as negative income. Measured read-only on production: 28 companies across 34 month-rows. The worst case charted December income as -10 347 459,81 kr where the real figure is +12,88 kr. Other examples: -1 868 731 -> +128 730, -1 850 501 -> +431 709. Both paths are fixed together so they keep agreeing: the RPC's monthly section now joins the tb_ex_ye_entries CTE it already computes for tb_ex_year_end, and monthly-breakdown.ts (the dimension-filtered fallback and the MCP path) gains the matching source_type filter plus the storno/correction chain of REVERSED year-end entries, so an undone bokslut does not leave half a pair behind. Migration 20260723180000 had recorded the omission as deliberate, on the grounds that it mirrored the JS scan. It did, but the JS scan was wrong. Migration applied to the staging branch (function body identical; three comment lines differ from the committed file). Prod gets the file via merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(reports): pin every statement generator against a closed fiscal year The per-generator suites all exercised an OPEN fiscal period, which is the one state in which a generator that forgets the resultatavslut happens to work. Declarations are filed AFTER bokslut, so the untested state was the only state that occurs in production. That is why the same defect could ship three times. Two new suites over one shared fixture (closed-year-fixture.ts, a synthetic closed AB with a resultatavslut, a credit 1630 and a debit 2641): closed-year-statements.test.ts enumerates the generators and asserts each reports the year's revenue rather than zero, plus its own bottom line. The table IS the checklist: a new report either appears in it or nothing stops it shipping with this bug. Verified by regressing income-statement back to closingEntry 'include', which fails 2 of its assertions. cross-surface-agreement.test.ts asserts the surfaces agree with each other, which is what every customer complaint actually was. INK2R and the K2 årsredovisning must produce the same årets resultat, the same fritt eget kapital, the same sign reclassifications and the same balance total. The operational family (Resultaträkning, Resultatrapport) must agree internally, and the gap BETWEEN the families is asserted explicitly as bokslutsdispositioner + skatt, so when Stage 2 of #1051 lands the test names the expectation to change instead of failing vaguely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(guards): ratchet against new reports that scan the ledger directly A statement generator that aggregates journal_entry_lines itself has to remember, on its own, that the resultatavslut posts the mirror image of every P&L account into 2099 inside the same fiscal period. Three forgot, and each read ZERO revenue for a closed year while the balance sheet still tied out, so nothing warned. generateTrialBalance now requires an explicit closingEntry mode, which makes that decision a compile error. This guard is what keeps NEW reports on that path: any generator under lib/reports or lib/bokslut that reads journal_entry_lines and is not in the baseline set fails CI. Verified by adding a throwaway report, which the guard rejects by name. Voucher and line listings (general-ledger, journal-register, SIE export, reconciliation, diagnostics) are sanctioned: they show the ledger as posted and have no closingEntry decision to make. Four existing lib/bokslut files are grandfathered rather than migrated. One of them is a genuine open follow-up recorded in DECISIONS.md: sarskild-loneskatt-calculator sums 7410-7419 with no year-end exclusion, so its basis reads ~0 if it runs against an already-closed period. Left alone deliberately: it is a tax figure whose call chain has caused a customer bug before and deserves its own verified change. Also ratchets naive-ore-round down 646 -> 641. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(reports): pin where sign reclassification applies, in both directions No behaviour change. The sweep asked whether the 1630/2641 sign reclassification should be extended to the remaining balance-sheet surfaces; the answer is that there are none left. Both STATUTORY presentations already have it: the K2 iXBRL årsredovisning since 2026-07-23 and INK2R since 2026-07-29. The other two balance-sheet surfaces must NOT have it: /rapporter Balansräkning and Balansrapport are organised by account number under BAS-prefix headings, and balansrapport documents an invariant that depends on every row staying debit-positive where it was booked. Moving konto 1630 into a liability section would break the add-the-rows-to-verify-the-balance property and hide the account from anyone looking it up by number. Asserting both halves is the point. The first half stops the reclassification silently disappearing from one statutory surface again, which is how a customer ended up comparing two of our own reports against each other. The second half stops a future sweep "fixing" the operational reports into disagreeing with their own documented contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(reports): detect statement disagreement instead of waiting for a customer Every year-end problem reported so far was a DISAGREEMENT between two of our own screens, not a single wrong screen. The årsredovisning said one figure, INK2 said another, and the customer did the reconciliation for us. Nothing in the product noticed, because each screen tied out on its own. Two additions: INK2R self-checks. On a closed year it compares the årets resultat it is about to declare against the booked konto 2099, and warns in Swedish when they disagree. This is the alarm that was missing: when INK2R reported 0 kr against a booked 469 542 kr, the balance sheet still balanced, so no warning fired. Mirrors the equivalent check k2-mapper has had since 2026-07-23, so both statutory reports now catch the same fault. reconcileStatements + GET /api/reports/statement-reconciliation return årets resultat from every surface side by side, grouped into families. ledger + statutory must agree and a mismatch is named; operational legitimately differs by bokslutsdispositioner + skatt until Stage 2 of #1051 lands, so that gap is explained rather than flagged. The visual panel is deliberately not built here: it needs a /frontend-design pass against the locked concept conventions plus sv/en strings, and the warning above already puts the alarm where the user looks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): address review findings from PR #1293 pg-real (7 failures, one signature): the new fixture called insertFiscalPeriod({ isClosed: true }) and then inserted journal entries into it, so enforce_period_lock (migration 017, legally required) refused the write. Not worked around: the RPC's predicate keys on fiscal_periods.closing_entry_id and never reads is_closed, so the fixture now links the closing entry and leaves the period open, which exercises the path that actually matters. CodeRabbit, closed-year-fixture: EX_YEAR_END_ROWS dropped only the P&L legs of the year_end entries (8811, 8910) and left their balance-sheet legs (2125, 2512) at pre-closing values, so the 'exclude-all-year-end' view sat 160 000 kr out of balance and misrepresented what generateTrialBalance returns. Latent, because today's consumers read class 3-8 only, but a shared fixture that does not balance is a trap for the next consumer. Both legs now go, and a new test asserts all three views sum to zero. CodeRabbit, INK2 totals: renamed totals.resultAfterFinancial to aretsResultat. It holds the result after bokslutsdispositioner AND skatt, which is årets resultat, not resultat efter finansiella poster, and build-data.ts uses the old name correctly for the different subtotal. The UI already labelled the value "Årets resultat", so the name was simply wrong. CodeRabbit, statement-reconciliation: the statutory branch called a generator and caught any throw as "wrong entity type", mapping genuine failures to a null figure that the comparison then skipped, so a real bug in a declaration generator made the function report isReconciled: true. That is the opposite of its purpose. It now dispatches on entity_type and surfaces a generation failure as a named disagreement. CodeRabbit, enable-banking (Emil's call to include): fetchClaimedIbans returned an empty Set on a cash_accounts read failure, which is indistinguishable from "nothing is claimed" and made every IBAN in the session offerable, including accounts another company already books to. Its own comment said it failed closed and its log said "offering nothing"; it failed open. Returns null now, and findReusableSessions offers nothing when the claimed set is unavailable. The test that pinned the fail-open asserted toHaveLength(1) under the name "offers nothing"; it now asserts []. Also removed an em dash per CLAUDE.md. The remaining enable-banking finding (consent-expiry cooldown stamped only on the selected connection, so it leaks one duplicate mail per sibling company) is deliberately left to Emil: it changes email-sending behaviour in his feature rather than fixing a stated contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): resolve second-round review findings on PR #1293 pg-real, two NEW signatures (the closed-period one from cycle 1 is gone): kpi-report-aggregates-rpc.pg.test.ts asserted the exact contract migration 20260730090000 deliberately changes. Its comment read "year_end entries are NOT excluded from monthly" and expected December expenses 1250. That fixture's December holds only year-end-chain entries, so with the fix the month drops out of the chart entirely, which is the correct operational view: a month whose only activity is bokslut has no operating result. Assertion and file docstring updated to the new contract rather than the test being removed. vat-totals-closing-entry.pg.test.ts passed the wrong account arrays. p_net_ accounts is VAT_SETTLEMENT_NET_ACCOUNTS (2650/1650, the momsredovisning settlement pair), not the output-VAT accounts. Putting 2611 there made the extra year_end entry match the settlement-SHAPE detector, so an ordinary sale-with-VAT was classified a momsredovisning and dropped, and the test read 0 instead of 10 000. The RPC was right; the fixture was not. CodeRabbit, statement-reconciliation: resolveEntityType checked neither query's error, so a genuine DB failure (RLS, permissions, connectivity) returned null indistinguishably from "no entity type set", fell into the unsupported-form branch and reported isReconciled: true. That is the same silent-false-reconciled bug the cycle-1 refactor closed, one level down. The companies error now throws; a missing company_settings ROW stays tolerated, because .single() errors on zero rows and many companies have none. Mirrors the pattern the INK2 and NE engines already use. Still open by Emil's explicit choice: the consent-expiry cooldown is stamped only on the connection it was handed, so it leaks one duplicate mail per sibling company on the shared session. That changes email-sending behaviour in his feature rather than fixing a stated contract, so it stays his. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1162 lines
49 KiB
TypeScript
1162 lines
49 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
|
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
|
|
import { listAssets } from '@/lib/bokslut/assets/asset-service'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { LATENT_TAX_DEFAULT_RATE } from '@/lib/bokslut/tax-provision/latent-tax-calculator'
|
|
import { roundOre } from '@/lib/money'
|
|
import {
|
|
mapTrialBalancesToK2,
|
|
type K2MappingResult,
|
|
type TrialBalancePair,
|
|
} from '@/lib/bokslut/ixbrl/k2-mapper'
|
|
import { buildBrRows, buildRrRows } from './statement-rows'
|
|
import { getNarrative, type NarrativeRow } from './narrative-service'
|
|
import {
|
|
anyAssetHasComponents,
|
|
buildEquityChangesNote,
|
|
buildK3RedovisningsPrinciper,
|
|
buildMateriellaAnlaggningsNot,
|
|
buildUppskjutenSkattNot,
|
|
} from './k3-noter-builder'
|
|
import {
|
|
buildAnlaggningstillgangarNote,
|
|
computeRollforwardTotals,
|
|
type AnlaggningAsset,
|
|
} from './anlaggningstillgangar-note'
|
|
import { computeAssetNoteFigures, loadPostedSchedules } from './asset-note-figures'
|
|
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
|
|
import type {
|
|
ArsredovisningData,
|
|
EgenKapitalRow,
|
|
FlerarsoversiktRow,
|
|
NoteEntry,
|
|
KassaflodesAnalysisSummary,
|
|
} from './types'
|
|
import type { AccountingFramework, Asset, TrialBalanceRow } from '@/types'
|
|
|
|
/**
|
|
* Pre-populate the K2 årsredovisning data for a fiscal period. Loads:
|
|
* - Income statement + balance sheet for the current period
|
|
* - Up to 3 prior periods for the flerårsöversikt
|
|
* - Asset register so noter can list avskrivningstider per category
|
|
* - Active employees count for medelantal anställda
|
|
* - Equity-account movements for förändring av eget kapital
|
|
*
|
|
* Manually-authored fields (description, important_events,
|
|
* resultatdisposition, ställda säkerheter, eventualförpliktelser) are
|
|
* pre-filled with sensible boilerplate the user can replace. The narrative
|
|
* editor in the UI persists overrides via /api/.../arsredovisning POST.
|
|
*/
|
|
export async function buildArsredovisningData(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
fiscalPeriodId: string,
|
|
overrides: Partial<ArsredovisningData['forvaltningsberattelse']> = {},
|
|
): Promise<ArsredovisningData> {
|
|
// The RR/BR are rendered at ÅRL post level from the same K2 risbs mapping
|
|
// that drives the iXBRL filing, never from per-account report rows.
|
|
// Bolagsverket rejects balans-/resultaträkningar med kontonummer, so the
|
|
// statement data must not carry account-level granularity at all. Two TB
|
|
// variants per year (see TrialBalancePair): the FULL trial balance drives
|
|
// the BR (2099 booked), the PRE-CLOSING one drives the RR (class 3-8
|
|
// still open).
|
|
const [periodResult, settingsResult, companyResult, periodList, tbFull, tbPreClosing, narrative] = await Promise.all([
|
|
supabase
|
|
.from('fiscal_periods')
|
|
.select('id, name, period_start, period_end, previous_period_id, closing_entry_id')
|
|
.eq('id', fiscalPeriodId)
|
|
.eq('company_id', companyId)
|
|
.single(),
|
|
supabase
|
|
.from('company_settings')
|
|
.select('company_name, org_number, city, entity_type')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle(),
|
|
// Source-of-truth for entity_type and accounting_framework lives on
|
|
// companies. company_settings.entity_type is a legacy mirror; the
|
|
// framework column was added later and only exists on companies.
|
|
supabase
|
|
.from('companies')
|
|
.select('entity_type, accounting_framework')
|
|
.eq('id', companyId)
|
|
.maybeSingle(),
|
|
fetchAllRows(({ from, to }) =>
|
|
supabase
|
|
.from('fiscal_periods')
|
|
.select('id, name, period_start, period_end')
|
|
.eq('company_id', companyId)
|
|
.order('period_start', { ascending: false })
|
|
.range(from, to),
|
|
),
|
|
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }),
|
|
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'exclude-final' }),
|
|
// Load persisted narrative overrides: replaces the URL-query-param
|
|
// carry from earlier phases. Caller-supplied overrides (passed in via
|
|
// the second arg) still win, so the API can layer per-request edits on
|
|
// top of the saved baseline if needed.
|
|
getNarrative(supabase, companyId, fiscalPeriodId).catch(() => null),
|
|
])
|
|
|
|
if (periodResult.error || !periodResult.data) {
|
|
throw new Error('Fiscal period not found')
|
|
}
|
|
const period = periodResult.data
|
|
const settings = settingsResult.data
|
|
const companyRow = companyResult.data as
|
|
| { entity_type?: string | null; accounting_framework?: AccountingFramework | null }
|
|
| null
|
|
const companyName = settings?.company_name ?? 'Bolaget'
|
|
const orgNumber = settings?.org_number ?? ''
|
|
// Default to 'unknown' (not 'aktiebolag') when entity_type isn't set: // otherwise the K2 guard in buildK2Noter would claim K2 for every
|
|
// unconfigured company, which is exactly the false-assertion the guard
|
|
// was added to prevent. Prefer the companies row over company_settings
|
|
// since the multi-tenant refactor made companies the source of truth.
|
|
const entityType =
|
|
companyRow?.entity_type
|
|
?? (settings as { entity_type?: string } | null)?.entity_type
|
|
?? 'unknown'
|
|
// K3 is opt-in; only AB ever set it. Default to K2 when not set.
|
|
const accountingFramework: AccountingFramework =
|
|
companyRow?.accounting_framework === 'k3' ? 'k3' : 'k2'
|
|
|
|
// company_settings stores the address as flat columns (address_line1,
|
|
// postal_code, city): there is no `address` json column. Selecting one
|
|
// made the whole settings query fail, so every ÅR fell back to "Bolaget"
|
|
// with an empty org number.
|
|
const city = (settings as { city?: string | null } | null)?.city ?? null
|
|
|
|
// Previous fiscal year → jämförelsesiffror (ÅRL 3:5 §). Resolved from the
|
|
// already-fetched period list; a TB failure downgrades to "no comparison
|
|
// year" with a warning instead of blocking the whole document (partial SIE
|
|
// imports can leave prior years without IB continuity).
|
|
const statementWarnings: string[] = []
|
|
const prevPeriodRow = period.previous_period_id
|
|
? ((periodList ?? []) as PeriodRow[]).find((p) => p.id === period.previous_period_id) ?? null
|
|
: null
|
|
|
|
// Flerårsöversikt window: the current period + up to 3 prior (oldest
|
|
// first). Resolved here so the prior-period trial balances it needs can
|
|
// share one parallel wave with the comparative-year pair instead of being
|
|
// fetched sequentially (and, for the previous year, twice).
|
|
const sortedPeriods = [...((periodList ?? []) as PeriodRow[])].sort((a, b) =>
|
|
a.period_start.localeCompare(b.period_start),
|
|
)
|
|
const currentIdx = sortedPeriods.findIndex((p) => p.id === fiscalPeriodId)
|
|
const overviewSlice =
|
|
currentIdx === -1 ? [] : sortedPeriods.slice(Math.max(0, currentIdx - 3), currentIdx + 1)
|
|
|
|
// Every prior period needed by the comparatives and/or the flerårsöversikt
|
|
// gets its TB pair fetched exactly once. Comparative RR figures need the
|
|
// same statutory view as the current year: keep booked depreciation,
|
|
// appropriations, and tax, excluding only the linked final result-closing
|
|
// entry. A failed pair downgrades to null so a broken prior year (e.g. a
|
|
// partial SIE import without IB continuity) never blocks the document.
|
|
const tbTargets = new Map<string, PeriodRow>()
|
|
if (prevPeriodRow) tbTargets.set(prevPeriodRow.id, prevPeriodRow)
|
|
for (const p of overviewSlice) {
|
|
if (p.id !== fiscalPeriodId) tbTargets.set(p.id, p)
|
|
}
|
|
const tbPairs = new Map<string, TrialBalancePair | null>()
|
|
await Promise.all(
|
|
[...tbTargets.values()].map(async (p) => {
|
|
try {
|
|
const [full, preClosing] = await Promise.all([
|
|
generateTrialBalance(supabase, companyId, p.id, { closingEntry: 'include' }),
|
|
generateTrialBalance(supabase, companyId, p.id, { closingEntry: 'exclude-final' }),
|
|
])
|
|
tbPairs.set(p.id, { full: full.rows, preClosing: preClosing.rows })
|
|
} catch {
|
|
tbPairs.set(p.id, null)
|
|
}
|
|
}),
|
|
)
|
|
|
|
// Previous fiscal year comparison (jämförelsesiffror): a TB failure
|
|
// downgrades to "no comparison year" with a warning instead of blocking.
|
|
const previousTb = prevPeriodRow ? tbPairs.get(prevPeriodRow.id) ?? null : null
|
|
if (prevPeriodRow && !previousTb) {
|
|
statementWarnings.push(
|
|
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår, balans- och resultaträkningen visas utan jämförelseår. Kontrollera det föregående årets bokföring.',
|
|
)
|
|
}
|
|
const mapping = mapTrialBalancesToK2(
|
|
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
|
|
previousTb,
|
|
)
|
|
const previousPeriod =
|
|
prevPeriodRow && previousTb
|
|
? {
|
|
name: prevPeriodRow.name,
|
|
period_start: prevPeriodRow.period_start,
|
|
period_end: prevPeriodRow.period_end,
|
|
}
|
|
: null
|
|
|
|
// Merge precedence: caller overrides → persisted narrative → boilerplate
|
|
const persistedDescription = narrative?.description ?? undefined
|
|
const persistedEvents = narrative?.important_events ?? undefined
|
|
const persistedRd = narrative?.resultatdisposition ?? undefined
|
|
const persistedAgmDate = narrative?.agm_date ?? null
|
|
|
|
const flerarsoversikt = buildFlerarsoversikt(overviewSlice, fiscalPeriodId, mapping, tbPairs)
|
|
|
|
const egen_kapital_changes = buildEquityChanges(mapping)
|
|
const proposedDividend = narrative?.proposed_dividend ?? 0
|
|
const retainedEarnings = mapping.br['BalanseratResultat']?.current ?? 0
|
|
const sharePremiumReserve = mapping.br['Overkursfond']?.current ?? 0
|
|
const currentYearResult = mapping.br['AretsResultatEgetKapital']?.current ?? 0
|
|
const distributableEquity = mapping.totals.frittEgetKapital.current
|
|
|
|
// Duplicate-value consistency with the RR (mirrors build-input.ts): the
|
|
// flerårsöversikt is computed from the income statement (ALL class-3
|
|
// revenue), but nettoomsättning per ÅRL is strictly 3000-3799. Override
|
|
// the current + previous year so the FB table ties to the RR two pages
|
|
// later. Older years have no RR in the document and keep the IS values.
|
|
if (flerarsoversikt.length > 0) {
|
|
const lastIdx = flerarsoversikt.length - 1
|
|
flerarsoversikt[lastIdx] = {
|
|
...flerarsoversikt[lastIdx],
|
|
net_revenue: mapping.rr['Nettoomsattning']?.current ?? 0,
|
|
result_after_financial: mapping.totals.resultatEfterFinansiellaPoster.current,
|
|
}
|
|
if (lastIdx > 0 && previousPeriod && flerarsoversikt[lastIdx - 1].year === previousPeriod.name) {
|
|
flerarsoversikt[lastIdx - 1] = {
|
|
...flerarsoversikt[lastIdx - 1],
|
|
net_revenue: mapping.rr['Nettoomsattning']?.previous ?? 0,
|
|
result_after_financial: mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
// K3 vs K2 split: K3 has a richer note set + a kassaflöde + a separate
|
|
// equity-changes statement. The 18a/b warning that flagged "K3 noter not
|
|
// yet emitted" is removed below now that we actually emit them.
|
|
//
|
|
// Kassaflödesanalys + separate equity-changes statement, K3 only. K2
|
|
// mindre företag is exempt from kassaflödesanalys (BFNAR 2016:10 punkt
|
|
// 5.2) and keeps equity changes inside förvaltningsberättelsen. The K3
|
|
// noter and the kassaflödesanalys are independent reads, so they share
|
|
// one round trip; the kassaflöde failure warning still lands AFTER the
|
|
// noter warnings so the warnings array order is unchanged.
|
|
let noter: NoteEntry[]
|
|
let noterWarnings: string[]
|
|
let kassaflodesanalys: KassaflodesAnalysisSummary | undefined
|
|
let equity_changes_statement:
|
|
| { rows: EgenKapitalRow[]; closing_total: number }
|
|
| undefined
|
|
if (accountingFramework === 'k3') {
|
|
const [noterResult, cashFlowSettled] = await Promise.all([
|
|
buildK3Noter(
|
|
supabase,
|
|
companyId,
|
|
entityType,
|
|
period.period_start,
|
|
period.period_end,
|
|
narrative,
|
|
tbFull.rows,
|
|
fiscalPeriodId,
|
|
(periodList ?? []) as PeriodRow[],
|
|
),
|
|
generateKassaflodesanalys(supabase, companyId, fiscalPeriodId).then(
|
|
(cashFlow) => ({ ok: true as const, cashFlow }),
|
|
() => ({ ok: false as const }),
|
|
),
|
|
])
|
|
noter = noterResult.notes
|
|
noterWarnings = noterResult.warnings
|
|
if (cashFlowSettled.ok) {
|
|
const { cashFlow } = cashFlowSettled
|
|
// Strip fiscal_period_id from the embedded report: period info is
|
|
// already on ArsredovisningData.fiscal_period; carrying it twice in
|
|
// the payload would be redundant.
|
|
kassaflodesanalys = {
|
|
period_start: cashFlow.period_start,
|
|
period_end: cashFlow.period_end,
|
|
lopande: cashFlow.lopande,
|
|
investerings: cashFlow.investerings,
|
|
finansierings: cashFlow.finansierings,
|
|
total_cash_flow: cashFlow.total_cash_flow,
|
|
reconciliation: cashFlow.reconciliation,
|
|
}
|
|
} else {
|
|
// A partial SIE import can leave 1xxx without an IB row: the report
|
|
// throws. Surface as a warning instead of blocking the whole ÅR.
|
|
noterWarnings.push(
|
|
'Kassaflödesanalysen kunde inte genereras automatiskt. Kontrollera att ingående och utgående saldo på 19xx finns och kör om bokslutet.',
|
|
)
|
|
}
|
|
|
|
// Equity-changes statement: derived from the post-level mapping. We
|
|
// reuse buildEquityChangesNote's roll-forward to keep one source of
|
|
// truth for the closing total.
|
|
equity_changes_statement = buildK3EquityChangesStatement(mapping)
|
|
} else {
|
|
const k2Noter = await buildK2Noter(
|
|
supabase,
|
|
companyId,
|
|
entityType,
|
|
period.period_start,
|
|
period.period_end,
|
|
narrative,
|
|
tbFull.rows,
|
|
fiscalPeriodId,
|
|
(periodList ?? []) as PeriodRow[],
|
|
)
|
|
noter = k2Noter.notes
|
|
noterWarnings = k2Noter.warnings
|
|
}
|
|
|
|
const resultatrakning = buildRrRows(mapping)
|
|
const brRows = buildBrRows(mapping)
|
|
const balansrakning = {
|
|
assets: brRows.assets,
|
|
total_assets: mapping.totals.tillgangar.current,
|
|
total_assets_previous: mapping.totals.tillgangar.previous,
|
|
equity_liabilities: brRows.equityLiabilities,
|
|
total_equity_liabilities: mapping.totals.egetKapitalSkulder.current,
|
|
total_equity_liabilities_previous: mapping.totals.egetKapitalSkulder.previous,
|
|
}
|
|
|
|
// mapping.warnings carry the compliance-critical signals (unmapped
|
|
// accounts whose balances are MISSING from the document, RR ≠ 2099,
|
|
// obalans, reclass review nudges), surfacing them pre-download is what
|
|
// keeps a non-fileable PDF from reaching Bolagsverket.
|
|
const warnings: string[] = [...statementWarnings, ...mapping.warnings, ...noterWarnings]
|
|
if (entityType !== 'aktiebolag' && entityType !== 'unknown') {
|
|
warnings.push(
|
|
'Den här årsredovisningen genereras med K2-mallen (BFNAR 2016:10) som standard. För K3- eller annan företagsform kan strukturen behöva justeras manuellt innan inlämning.',
|
|
)
|
|
}
|
|
if (entityType === 'aktiebolag' && accountingFramework === 'k3') {
|
|
// Soliditet now reflects the K3 split (79,4 % equity portion of 21xx is
|
|
// folded into eget kapital). 18e/f provides the K3 noter, kassaflöde
|
|
// and separate equity-changes statement so the PDF is now substantively
|
|
// K3-compliant; we keep a soft notice here so the filer remembers to
|
|
// verify the document against their specific obligations before sending
|
|
// to Bolagsverket.
|
|
warnings.push(
|
|
'Bolaget redovisar enligt K3 (BFNAR 2012:1). Soliditeten är beräknad med 79,4 % av obeskattade reserver inräknat i eget kapital. PDF:en innehåller kassaflödesanalys, förändring av eget kapital och utökade noter: granska innehållet mot er specifika redovisning innan inlämning.',
|
|
)
|
|
}
|
|
if (entityType === 'unknown') {
|
|
warnings.push(
|
|
'Företagsform saknas i inställningarna: fyll i Inställningar → Företag för att få rätt redovisningsprinciper i not 1.',
|
|
)
|
|
}
|
|
if (!persistedAgmDate) {
|
|
warnings.push(
|
|
'Datum för årsstämma saknas. Fastställelseintyget i PDF:en lämnas tomt på datumraden tills det fylls i nedan.',
|
|
)
|
|
} else {
|
|
// ÅRL 8 kap 3 § + ÅRL 7 kap 10 §: AGM must be held after the räkenskapsår
|
|
// ends and within 6 months of period end (för privat AB). A date before
|
|
// period_end is logically impossible; after the deadline is a legally
|
|
// defective fastställelseintyg.
|
|
if (persistedAgmDate <= period.period_end) {
|
|
warnings.push(
|
|
`Datum för årsstämma (${persistedAgmDate}) ligger på eller före räkenskapsårets slut (${period.period_end}): fastställelseintyget blir juridiskt felaktigt. Kontrollera datumet.`,
|
|
)
|
|
} else {
|
|
const periodEndDate = new Date(`${period.period_end}T00:00:00Z`)
|
|
const deadline = new Date(periodEndDate)
|
|
deadline.setUTCMonth(deadline.getUTCMonth() + 6)
|
|
const deadlineIso = deadline.toISOString().slice(0, 10)
|
|
if (persistedAgmDate > deadlineIso) {
|
|
warnings.push(
|
|
`Datum för årsstämma (${persistedAgmDate}) är efter 6-månadersgränsen (${deadlineIso}). För privat AB ska årsstämman hållas inom 6 månader från räkenskapsårets slut (ÅRL 7 kap 10 §).`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
company: {
|
|
name: companyName,
|
|
org_number: orgNumber,
|
|
entity_type: entityType,
|
|
city,
|
|
},
|
|
fiscal_period: {
|
|
id: period.id,
|
|
name: period.name,
|
|
period_start: period.period_start,
|
|
period_end: period.period_end,
|
|
},
|
|
previous_period: previousPeriod,
|
|
accounting_framework: accountingFramework,
|
|
forvaltningsberattelse: {
|
|
description:
|
|
overrides.description ??
|
|
persistedDescription ??
|
|
`${companyName} bedriver verksamhet enligt verksamhetsbeskrivningen i bolagsordningen.`,
|
|
important_events:
|
|
overrides.important_events ??
|
|
persistedEvents ??
|
|
'Inga väsentliga händelser utöver löpande verksamhet har inträffat under räkenskapsåret.',
|
|
kontrollbalans_required: overrides.kontrollbalans_required ?? false,
|
|
flerarsoversikt,
|
|
egen_kapital_changes,
|
|
resultatdisposition:
|
|
overrides.resultatdisposition ??
|
|
persistedRd ??
|
|
'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
|
|
proposed_dividend: proposedDividend,
|
|
resultatdisposition_amounts: {
|
|
retained_earnings: retainedEarnings,
|
|
share_premium_reserve: sharePremiumReserve,
|
|
current_year_result: currentYearResult,
|
|
total: distributableEquity,
|
|
proposed_dividend: proposedDividend,
|
|
carried_forward: distributableEquity - proposedDividend,
|
|
},
|
|
agm_date: persistedAgmDate,
|
|
agm_disposition_outcome: narrative?.agm_disposition_outcome ?? null,
|
|
agm_disposition_decision: narrative?.agm_disposition_decision ?? null,
|
|
},
|
|
resultatrakning,
|
|
warnings,
|
|
balansrakning,
|
|
noter,
|
|
kassaflodesanalys,
|
|
equity_changes_statement,
|
|
signatures: [], // populated by signature-flow service in a later phase step
|
|
disclosures: {
|
|
long_term_debt_over_five_years: narrative?.long_term_debt_over_five_years ?? null,
|
|
securities_pledged: narrative?.securities_pledged ?? null,
|
|
contingent_liabilities: narrative?.contingent_liabilities ?? null,
|
|
parent_company_name: narrative?.parent_company_name ?? null,
|
|
parent_company_org_number: narrative?.parent_company_org_number ?? null,
|
|
parent_company_city: narrative?.parent_company_city ?? null,
|
|
confirmations: {
|
|
long_term_debt_over_five_years:
|
|
narrative?.long_term_debt_over_five_years_confirmed ?? false,
|
|
securities_pledged: narrative?.securities_pledged_confirmed ?? false,
|
|
contingent_liabilities: narrative?.contingent_liabilities_confirmed ?? false,
|
|
parent_company: narrative?.parent_company_confirmed ?? false,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
interface PeriodRow {
|
|
id: string
|
|
name: string
|
|
period_start: string
|
|
period_end: string
|
|
}
|
|
|
|
export function calculateSoliditet(mapping: K2MappingResult): number | null {
|
|
const totalAssets = mapping.totals.tillgangar.current
|
|
if (totalAssets <= 0) return null
|
|
const adjustedEquity =
|
|
mapping.totals.egetKapital.current +
|
|
mapping.totals.obeskattadeReserver.current * (1 - LATENT_TAX_DEFAULT_RATE)
|
|
return Math.round((adjustedEquity / totalAssets) * 1000) / 10
|
|
}
|
|
|
|
/**
|
|
* Flerårsöversikt from pre-fetched trial-balance pairs. `overviewSlice` is
|
|
* the current period + up to 3 prior, oldest first (resolved by the caller
|
|
* so the pairs could be fetched in one parallel wave); `tbPairs` holds the
|
|
* prior-period pairs, with null marking a period whose TB fetch failed.
|
|
*/
|
|
function buildFlerarsoversikt(
|
|
overviewSlice: PeriodRow[],
|
|
currentPeriodId: string,
|
|
currentMapping: K2MappingResult,
|
|
tbPairs: Map<string, TrialBalancePair | null>,
|
|
): FlerarsoversiktRow[] {
|
|
const rows: FlerarsoversiktRow[] = []
|
|
for (const p of overviewSlice) {
|
|
try {
|
|
let mapping = currentMapping
|
|
if (p.id !== currentPeriodId) {
|
|
const pair = tbPairs.get(p.id)
|
|
if (!pair) throw new Error('trial balance unavailable')
|
|
mapping = mapTrialBalancesToK2(pair, null)
|
|
}
|
|
const netRevenue = mapping.rr['Nettoomsattning']?.current ?? 0
|
|
const resultAfterFinancial = mapping.totals.resultatEfterFinansiellaPoster.current
|
|
// K2 flerårsöversikt defines soliditet as adjusted equity divided by
|
|
// total assets. Adjusted equity includes the equity portion of untaxed
|
|
// reserves even though those reserves remain a separate BR section.
|
|
const soliditet = calculateSoliditet(mapping)
|
|
rows.push({
|
|
year: p.name,
|
|
net_revenue: Math.round(netRevenue),
|
|
result_after_financial: Math.round(resultAfterFinancial),
|
|
soliditet_pct: soliditet,
|
|
})
|
|
} catch {
|
|
// Prior periods may lack continuity if SIE import was partial. Skip
|
|
// rather than blocking the whole årsredovisning.
|
|
rows.push({
|
|
year: p.name,
|
|
net_revenue: 0,
|
|
result_after_financial: 0,
|
|
soliditet_pct: null,
|
|
})
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
/**
|
|
* Förvaltningsberättelsens "Förändring av eget kapital" table, post-level
|
|
* labels only (no kontonummer). Only genuine equity posts (20xx) appear;
|
|
* obeskattade reserver are NOT eget kapital and were dropped from the table
|
|
* when the account-row version was replaced by the mapping-driven one.
|
|
*/
|
|
function buildEquityChanges(mapping: K2MappingResult): EgenKapitalRow[] {
|
|
const posts: Array<{ label: string; concept: string; alwaysShow?: boolean }> = [
|
|
{ label: 'Aktiekapital', concept: 'Aktiekapital', alwaysShow: true },
|
|
{ label: 'Ej registrerat aktiekapital', concept: 'EjRegistreratAktiekapital' },
|
|
{ label: 'Bunden överkursfond', concept: 'OverkursfondBunden' },
|
|
{ label: 'Uppskrivningsfond', concept: 'Uppskrivningsfond' },
|
|
{ label: 'Reservfond', concept: 'Reservfond' },
|
|
{ label: 'Överkursfond', concept: 'Overkursfond' },
|
|
{ label: 'Balanserat resultat', concept: 'BalanseratResultat', alwaysShow: true },
|
|
{ label: 'Årets resultat', concept: 'AretsResultatEgetKapital', alwaysShow: true },
|
|
]
|
|
const rows: EgenKapitalRow[] = []
|
|
for (const post of posts) {
|
|
const amount = mapping.br[post.concept]?.current ?? 0
|
|
if (amount === 0 && !post.alwaysShow) continue
|
|
rows.push({ label: post.label, amount })
|
|
}
|
|
rows.push({ label: 'Summa eget kapital', amount: mapping.totals.egetKapital.current })
|
|
return rows
|
|
}
|
|
|
|
/**
|
|
* Map register assets to the roll-forward note input, resolving per-asset
|
|
* depreciation figures from posted schedules (engine fallback) so the note
|
|
* ties to the ledger. Shared by the K2 and K3 note builders. Skips the
|
|
* schedules query entirely when the register is empty.
|
|
*/
|
|
async function buildRollforwardAssets(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
assets: Asset[],
|
|
allPeriods: PeriodRow[],
|
|
fiscalPeriodId: string,
|
|
): Promise<AnlaggningAsset[]> {
|
|
if (assets.length === 0) return []
|
|
const schedules = await loadPostedSchedules(supabase, companyId)
|
|
const figures = computeAssetNoteFigures({
|
|
assets,
|
|
postedSchedules: schedules,
|
|
fiscalPeriods: allPeriods,
|
|
currentPeriodId: fiscalPeriodId,
|
|
})
|
|
return assets.map((a) => ({
|
|
category: a.category,
|
|
acquisition_date: a.acquisition_date,
|
|
acquisition_cost: a.acquisition_cost,
|
|
salvage_value: a.salvage_value,
|
|
useful_life_months: a.useful_life_months,
|
|
disposed_at: a.disposed_at,
|
|
figures: figures.get(a.id) ?? { ibAck: 0, aretsAvskrivning: 0, avgaendeAck: 0 },
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Cross-check the roll-forward note's closing book value against the
|
|
* balansräkning (full TB net of accounts 1000-1299: immateriella +
|
|
* materiella anläggningstillgångar; 13xx financial assets are outside the
|
|
* note). Returns a user-facing warning when they diverge by more than 1 kr,
|
|
* which is the exact inconsistency ÅRL 5:8 § forbids in a filed document.
|
|
*/
|
|
function rollforwardTieOutWarning(
|
|
rollforwardAssets: AnlaggningAsset[],
|
|
tbFullRows: TrialBalanceRow[],
|
|
periodStart: string,
|
|
periodEnd: string,
|
|
): string | null {
|
|
const totals = computeRollforwardTotals(rollforwardAssets, periodStart, periodEnd)
|
|
const tbNet = tbFullRows
|
|
.filter((r) => r.account_number >= '1000' && r.account_number < '1300')
|
|
.reduce((sum, r) => sum + (r.closing_debit || 0) - (r.closing_credit || 0), 0)
|
|
if (Math.abs(totals.ubRedovisat - tbNet) <= 1) return null
|
|
const fmtKr = (n: number) => Math.round(n).toLocaleString('sv-SE')
|
|
return `Anläggningsnotens utgående redovisade värde (${fmtKr(totals.ubRedovisat)} kr) stämmer inte med balansräkningens bokförda värde för konto 1000-1299 (${fmtKr(tbNet)} kr). Kontrollera att anläggningsregistret är komplett och att årets avskrivningar är bokförda.`
|
|
}
|
|
|
|
async function buildK2Noter(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
entityType: string,
|
|
periodStart: string,
|
|
periodEnd: string,
|
|
narrative: NarrativeRow | null,
|
|
tbFullRows: TrialBalanceRow[],
|
|
fiscalPeriodId: string,
|
|
allPeriods: PeriodRow[],
|
|
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
|
|
const notes: NoteEntry[] = []
|
|
const warnings: string[] = []
|
|
// Note 1: framework. Only claim K2 explicitly when we know the company is
|
|
// an AB and using K2: otherwise emit a generic principles note so the
|
|
// ÅR doesn't falsely assert a framework the company isn't on.
|
|
// K3 election isn't yet tracked separately; we treat any non-AB as not-K2.
|
|
const isAbK2 = entityType === 'aktiebolag'
|
|
notes.push({
|
|
number: 1,
|
|
title: 'Redovisnings- och värderingsprinciper',
|
|
body: isAbK2
|
|
? 'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen och Bokföringsnämndens allmänna råd BFNAR 2016:10 Årsredovisning i mindre företag (K2).'
|
|
: 'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen och Bokföringsnämndens allmänna råd.',
|
|
})
|
|
|
|
// Note: aktiekapital. K2 punkt 18.x requires AB to disclose share-capital
|
|
// structure. Read from company_settings when present; surface a warning
|
|
// when missing so the user knows to fill it in. We also surface the
|
|
// warning when entityType is 'unknown' since the company may in fact be
|
|
// an AB the user just hasn't configured yet: staying silent would let
|
|
// them download an incomplete K2 ÅR without realising.
|
|
const maybeAb = isAbK2 || entityType === 'unknown'
|
|
|
|
// The three reads feeding the notes below (aktiekapital settings, asset
|
|
// register, employee windows) are independent, so they share one parallel
|
|
// round trip instead of three sequential ones. Note bodies, push order,
|
|
// and numbering (notes.length + 1) are unchanged.
|
|
const [settingsResult, assets, employeesResult] = await Promise.all([
|
|
maybeAb
|
|
? supabase
|
|
.from('company_settings')
|
|
.select('aktiekapital, antal_aktier')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
: Promise.resolve({ data: null }),
|
|
listAssets(supabase, companyId),
|
|
supabase
|
|
.from('employees')
|
|
.select('employment_start, employment_end, employment_degree')
|
|
.eq('company_id', companyId),
|
|
])
|
|
|
|
if (maybeAb) {
|
|
type AktiekapitalShape = { aktiekapital?: number | null; antal_aktier?: number | null }
|
|
const ak = (settingsResult.data ?? null) as AktiekapitalShape | null
|
|
const aktiekapital = ak?.aktiekapital ?? null
|
|
const antalAktier = ak?.antal_aktier ?? null
|
|
// Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier;
|
|
// deriving it here keeps the filed note internally consistent. ÅRL
|
|
// 5 kap 14 § requires BOTH the registered amount and the number of
|
|
// shares, so a partial pair is treated as missing (warn, no note).
|
|
if (aktiekapital && antalAktier) {
|
|
const kvotvarde = roundOre(aktiekapital / antalAktier)
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Aktiekapital',
|
|
body: [
|
|
`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE', { maximumFractionDigits: 0 })} kr.`,
|
|
`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`,
|
|
`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`,
|
|
].join(' '),
|
|
})
|
|
} else {
|
|
// Don't write a "saknas: komplettera" placeholder into the PDF body: // that text would land in the Bolagsverket-filed document as a user-
|
|
// facing error string and the filing would be K2-non-compliant
|
|
// (BFNAR 2016:10 punkt 5.4 / ÅRL 5 kap 14 § require the actual
|
|
// registered amount). Omit the note entirely and surface a warning so
|
|
// the UI can flag this pre-download.
|
|
warnings.push(
|
|
'Aktiekapitalnoten saknas eftersom uppgifter om aktiekapital inte finns i Inställningar → Företag. K2 / ÅRL kräver att noten innehåller registrerat belopp innan inlämning till Bolagsverket.',
|
|
)
|
|
}
|
|
}
|
|
|
|
// Avskrivningstider: derive from asset register (supplementary
|
|
// disclosure; the statutory ÅRL 5:8 § roll-forward follows below).
|
|
if (assets.length > 0) {
|
|
const byCategory = new Map<string, Set<number>>()
|
|
for (const a of assets) {
|
|
if (a.disposed_at) continue
|
|
const years = Math.round(a.useful_life_months / 12)
|
|
if (!byCategory.has(a.category)) byCategory.set(a.category, new Set())
|
|
byCategory.get(a.category)!.add(years)
|
|
}
|
|
if (byCategory.size > 0) {
|
|
const lines: string[] = ['Avskrivningar görs linjärt över bedömd nyttjandeperiod:']
|
|
const categoryLabels: Record<string, string> = {
|
|
immaterial: 'Immateriella anläggningstillgångar',
|
|
building: 'Byggnader',
|
|
land_improvement: 'Markanläggningar',
|
|
machinery: 'Maskiner',
|
|
equipment: 'Inventarier',
|
|
vehicle: 'Fordon',
|
|
computer: 'Datorer',
|
|
other_tangible: 'Övriga materiella anläggningstillgångar',
|
|
}
|
|
for (const [cat, yearsSet] of byCategory.entries()) {
|
|
const yrs = Array.from(yearsSet).sort((a, b) => a - b)
|
|
const yrsLabel = yrs.length === 1 ? `${yrs[0]} år` : `${yrs[0]}-${yrs[yrs.length - 1]} år`
|
|
lines.push(`• ${categoryLabels[cat] ?? cat}: ${yrsLabel}`)
|
|
}
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Avskrivningar',
|
|
body: lines.join('\n'),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Anläggningstillgångar roll-forward (ÅRL 5:8 §). Per-category IB →
|
|
// tillkommande → avgående → UB anskaffningsvärde, same for ackumulerade
|
|
// avskrivningar, ending in utgående redovisat värde. Hard ÅR requirement
|
|
// for any company with assets on the books. Depreciation figures come
|
|
// from posted schedules so the note ties to the balansräkning.
|
|
const rollforwardAssets = await buildRollforwardAssets(
|
|
supabase,
|
|
companyId,
|
|
assets,
|
|
allPeriods,
|
|
fiscalPeriodId,
|
|
)
|
|
const rollforwardNote = buildAnlaggningstillgangarNote({
|
|
noteNumber: notes.length + 1,
|
|
assets: rollforwardAssets,
|
|
periodStart,
|
|
periodEnd,
|
|
})
|
|
if (rollforwardNote) {
|
|
notes.push(rollforwardNote)
|
|
const tieOut = rollforwardTieOutWarning(
|
|
rollforwardAssets,
|
|
tbFullRows,
|
|
periodStart,
|
|
periodEnd,
|
|
)
|
|
if (tieOut) warnings.push(tieOut)
|
|
}
|
|
|
|
// Medelantal anställda: FTE-weighted average per ÅRL 5:20 §. We fetch the
|
|
// full employment-window data because the column 'is_active' doesn't exist
|
|
// on the employees table; a count() filtered by it would always return 0.
|
|
// ÅRL 5:20 § requires the note for AB regardless of value: "0" must be
|
|
// disclosed as "Inga anställda". For enskild firma the disclosure is
|
|
// discretionary, so we still skip when medelantal === 0 there.
|
|
const medelantal = computeMedelantalAnstallda(
|
|
(employeesResult.data ?? []) as Array<{
|
|
employment_start: string
|
|
employment_end: string | null
|
|
employment_degree: number
|
|
}>,
|
|
periodStart,
|
|
periodEnd,
|
|
)
|
|
if (medelantal > 0 || entityType === 'aktiebolag') {
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Medelantal anställda',
|
|
body:
|
|
medelantal > 0
|
|
? `Under räkenskapsåret har medeltalet anställda uppgått till ${medelantal}.`
|
|
: 'Bolaget har inte haft några anställda under räkenskapsåret.',
|
|
})
|
|
}
|
|
|
|
// Långfristiga skulder förfallande efter mer än fem år (ÅRL 5:13 §).
|
|
// Disclosed amount lives on arsredovisning_narratives as a manual entry;
|
|
// loan-maturity data isn't tagged in journal lines so we can't derive it.
|
|
// A null/zero value defaults to "Inga." per Swedish ÅR convention.
|
|
const longTermDebtAmount = narrative?.long_term_debt_over_five_years ?? null
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Långfristiga skulder',
|
|
body:
|
|
longTermDebtAmount && longTermDebtAmount > 0
|
|
? `Av långfristiga skulder förfaller ${longTermDebtAmount.toLocaleString('sv-SE')} kr till betalning senare än fem år efter balansdagen.`
|
|
: 'Inga skulder förfaller till betalning senare än fem år efter balansdagen.',
|
|
})
|
|
|
|
// Ställda säkerheter (ÅRL 5:14 §): separate disclosure from
|
|
// eventualförpliktelser. Manual override on arsredovisning_narratives,
|
|
// defaulting to "Inga.".
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Ställda säkerheter',
|
|
body: narrative?.securities_pledged?.trim() || 'Inga.',
|
|
})
|
|
|
|
// Eventualförpliktelser (ÅRL 5:15 §)
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Eventualförpliktelser',
|
|
body: narrative?.contingent_liabilities?.trim() || 'Inga.',
|
|
})
|
|
|
|
// Koncernförhållanden (BFNAR 2016:10 kap. 19). Emitted only when a parent
|
|
// company is configured: companies without a parent skip this note.
|
|
const parentName = narrative?.parent_company_name?.trim()
|
|
if (parentName) {
|
|
const parts: string[] = [`Moderföretag: ${parentName}.`]
|
|
if (narrative?.parent_company_org_number)
|
|
parts.push(`Organisationsnummer: ${narrative.parent_company_org_number}.`)
|
|
if (narrative?.parent_company_city)
|
|
parts.push(`Säte: ${narrative.parent_company_city}.`)
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Koncernförhållanden',
|
|
body: parts.join(' '),
|
|
})
|
|
}
|
|
|
|
return { notes, warnings }
|
|
}
|
|
|
|
/**
|
|
* Build the K3 note set (BFNAR 2012:1). Differs from K2 in:
|
|
* - Verbose redovisningsprinciper covering all K3 measurement principles
|
|
* - A separate "Uppskjutna skatter" note showing 2240 movement
|
|
* - "Materiella anläggningstillgångar" with per-component breakdown when
|
|
* komponentavskrivning is used
|
|
* - Standard K3 placeholders for händelser efter balansdagen +
|
|
* eventualförpliktelser
|
|
*
|
|
* The aktiekapital note is shared with K2 logic: K3 punkt 18.x also
|
|
* mandates the share-capital disclosure for AB.
|
|
*
|
|
* tbFullRows MUST be the FULL current-period trial balance (tbFull.rows:
|
|
* opening balances included, year-end closing entries NOT excluded). The
|
|
* uppskjutna-skatter note derives its BFNAR 2012:1 ch.29 opening balance,
|
|
* movement, and closing balance for 2240/8940 from these rows; passing
|
|
* tbPreClosing.rows would zero the opening balance and misstate the note.
|
|
* The K3 multiyear snapshot test pins a non-zero 2240 opening balance to
|
|
* guard this contract.
|
|
*/
|
|
async function buildK3Noter(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
entityType: string,
|
|
periodStartIso: string,
|
|
periodEndIso: string,
|
|
narrative: NarrativeRow | null,
|
|
tbFullRows: TrialBalanceRow[],
|
|
fiscalPeriodId: string,
|
|
allPeriods: PeriodRow[],
|
|
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
|
|
const notes: NoteEntry[] = []
|
|
const warnings: string[] = []
|
|
|
|
const isAb = entityType === 'aktiebolag'
|
|
const maybeAb = isAb || entityType === 'unknown'
|
|
|
|
// The three reads feeding the notes below (asset register, aktiekapital
|
|
// settings, employee windows) are independent, so they share one parallel
|
|
// round trip instead of three sequential ones. Note bodies, push order,
|
|
// and numbering (notes.length + 1) are unchanged.
|
|
const [assetsResult, settingsResult, employeesResult] = await Promise.all([
|
|
listAssets(supabase, companyId),
|
|
maybeAb
|
|
? supabase
|
|
.from('company_settings')
|
|
.select('aktiekapital, antal_aktier')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
: Promise.resolve({ data: null }),
|
|
supabase
|
|
.from('employees')
|
|
.select('employment_start, employment_end, employment_degree')
|
|
.eq('company_id', companyId),
|
|
])
|
|
|
|
// 1. Redovisningsprinciper. We check whether any asset has K3 components
|
|
// configured so the principles paragraph only mentions komponentavskrivning
|
|
// when it's actually in use.
|
|
//
|
|
// The stored K3 component shape on assets is
|
|
// { name, cost, useful_life_months, salvage_value? }
|
|
// (per migration 20260526122000_k3_component_depreciation.sql), but the
|
|
// note builder consumes
|
|
// { name, acquisition_cost, accumulated_depreciation, useful_life_months }
|
|
// We compute accumulated_depreciation here using a linear approximation
|
|
// (months elapsed / useful life) which matches what the per-component
|
|
// depreciation engine (computeComponentDepreciation) produces over a year.
|
|
// The fiscal period end is the as-of date for the depreciation snapshot.
|
|
const assets = assetsResult as Asset[]
|
|
const monthsBetween = (fromIso: string, toIso: string): number => {
|
|
const from = new Date(`${fromIso}T00:00:00Z`)
|
|
const to = new Date(`${toIso}T00:00:00Z`)
|
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return 0
|
|
const years = to.getUTCFullYear() - from.getUTCFullYear()
|
|
const months = to.getUTCMonth() - from.getUTCMonth()
|
|
const days = to.getUTCDate() - from.getUTCDate()
|
|
let total = years * 12 + months
|
|
if (days < 0) total -= 1
|
|
return total
|
|
}
|
|
const adaptAsset = (a: Asset) => ({
|
|
name: a.name,
|
|
category: a.category,
|
|
acquisition_date: a.acquisition_date,
|
|
acquisition_cost: a.acquisition_cost,
|
|
k3_components: Array.isArray(a.k3_components)
|
|
? a.k3_components.map((c) => {
|
|
const cost = Number(c.cost) || 0
|
|
const salvage = Number(c.salvage_value ?? 0) || 0
|
|
const life = Number(c.useful_life_months) || 0
|
|
const elapsed = Math.max(
|
|
0,
|
|
Math.min(life, monthsBetween(a.acquisition_date, periodEndIso)),
|
|
)
|
|
const accumulated = life > 0
|
|
? Math.round(((cost - salvage) * elapsed) / life)
|
|
: 0
|
|
return {
|
|
name: c.name,
|
|
acquisition_cost: cost,
|
|
accumulated_depreciation: accumulated,
|
|
useful_life_months: life,
|
|
}
|
|
})
|
|
: null,
|
|
disposed_at: a.disposed_at,
|
|
useful_life_months: a.useful_life_months,
|
|
})
|
|
const adaptedAssets = assets.map(adaptAsset)
|
|
const hasComponents = anyAssetHasComponents(adaptedAssets)
|
|
notes.push(buildK3RedovisningsPrinciper(hasComponents))
|
|
|
|
// 2. Aktiekapital (shared with K2 logic: K3 punkt 18.x mandates the same
|
|
// disclosure for AB).
|
|
if (maybeAb) {
|
|
type AktiekapitalShape = {
|
|
aktiekapital?: number | null
|
|
antal_aktier?: number | null
|
|
}
|
|
const ak = (settingsResult.data ?? null) as AktiekapitalShape | null
|
|
const aktiekapital = ak?.aktiekapital ?? null
|
|
const antalAktier = ak?.antal_aktier ?? null
|
|
// Kvotvärde is defined (ABL 1 kap 6 §) as aktiekapital / antal aktier;
|
|
// deriving it here keeps the filed note internally consistent. ÅRL
|
|
// 5 kap 14 § requires BOTH the registered amount and the number of
|
|
// shares, so a partial pair is treated as missing (warn, no note).
|
|
if (aktiekapital && antalAktier) {
|
|
const kvotvarde = roundOre(aktiekapital / antalAktier)
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Aktiekapital',
|
|
body: [
|
|
`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE', { maximumFractionDigits: 0 })} kr.`,
|
|
`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`,
|
|
`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`,
|
|
].join(' '),
|
|
})
|
|
} else if (isAb) {
|
|
warnings.push(
|
|
'Aktiekapitalnoten saknas eftersom uppgifter om aktiekapital inte finns i Inställningar → Företag. K3 / ÅRL kräver att noten innehåller registrerat belopp innan inlämning till Bolagsverket.',
|
|
)
|
|
}
|
|
}
|
|
|
|
// 3. Materiella anläggningstillgångar: with optional per-component
|
|
// breakdown. The note is omitted when no tangible assets exist. Uses the
|
|
// adapted asset list computed above so the K3-component shape matches what
|
|
// the builder's type guard expects.
|
|
const materialiNote = buildMateriellaAnlaggningsNot({
|
|
noteNumber: notes.length + 1,
|
|
assets: adaptedAssets,
|
|
})
|
|
if (materialiNote) notes.push(materialiNote)
|
|
|
|
// 3b. Anläggningstillgångar roll-forward (ÅRL 5:8 §). Required even under
|
|
// K3: K3 ch.17 layers component depreciation on top, but the basic
|
|
// per-category roll-forward of anskaffningsvärde + ackumulerade
|
|
// avskrivningar is the statutory baseline. Depreciation figures come
|
|
// from posted schedules so the note ties to the balansräkning.
|
|
const rollforwardAssets = await buildRollforwardAssets(
|
|
supabase,
|
|
companyId,
|
|
assets,
|
|
allPeriods,
|
|
fiscalPeriodId,
|
|
)
|
|
const rollforwardNote = buildAnlaggningstillgangarNote({
|
|
noteNumber: notes.length + 1,
|
|
assets: rollforwardAssets,
|
|
periodStart: periodStartIso,
|
|
periodEnd: periodEndIso,
|
|
})
|
|
if (rollforwardNote) {
|
|
notes.push(rollforwardNote)
|
|
const tieOut = rollforwardTieOutWarning(
|
|
rollforwardAssets,
|
|
tbFullRows,
|
|
periodStartIso,
|
|
periodEndIso,
|
|
)
|
|
if (tieOut) warnings.push(tieOut)
|
|
}
|
|
|
|
// 4. Uppskjutna skatter. K3 ch.29 requires disclosure of opening,
|
|
// movement, and closing balance of uppskjuten skatteskuld. We derive
|
|
// these from the current-period full trial balance (passed in by the
|
|
// caller, which already fetched it for the statements) for 2240 (latent
|
|
// tax liability) and 8940 (latent tax expense).
|
|
try {
|
|
const rows = tbFullRows
|
|
const row2240 = rows.find((r) => r.account_number === '2240')
|
|
const row8940 = rows.find((r) => r.account_number === '8940')
|
|
// 2240 is credit-normal liability: opening = opening_credit - opening_debit
|
|
const opening2240 = row2240
|
|
? (row2240.opening_credit || 0) - (row2240.opening_debit || 0)
|
|
: 0
|
|
const closing2240 = row2240
|
|
? (row2240.closing_credit || 0) - (row2240.closing_debit || 0)
|
|
: 0
|
|
// 8940 is an expense (debit-normal): movement = period_debit - period_credit
|
|
// A positive movement = additional avsättning (cost incurred = liability
|
|
// grew). The 2240 balance moves by the same magnitude (with opposite
|
|
// sign convention since 2240 is on the credit side).
|
|
const change8940 = row8940
|
|
? (row8940.period_debit || 0) - (row8940.period_credit || 0)
|
|
: closing2240 - opening2240
|
|
if (opening2240 !== 0 || closing2240 !== 0 || change8940 !== 0) {
|
|
notes.push(
|
|
buildUppskjutenSkattNot({
|
|
noteNumber: notes.length + 1,
|
|
latentTaxOpening: opening2240,
|
|
latentTaxChange: change8940,
|
|
latentTaxClosing: closing2240,
|
|
}),
|
|
)
|
|
}
|
|
} catch {
|
|
// Trial-balance failure should not block the document; flag as warning.
|
|
warnings.push(
|
|
'Uppskjutna skatter-noten kunde inte beräknas automatiskt. Kontrollera kontot 2240 och kör om bokslutet.',
|
|
)
|
|
}
|
|
|
|
// 5. Medelantal anställda: FTE-weighted average per ÅRL 5:20 §. The note is
|
|
// statutory for AB regardless of value (disclose "0" explicitly); for non-AB
|
|
// entities we still skip when there are no employees.
|
|
const medelantal = computeMedelantalAnstallda(
|
|
(employeesResult.data ?? []) as Array<{
|
|
employment_start: string
|
|
employment_end: string | null
|
|
employment_degree: number
|
|
}>,
|
|
periodStartIso,
|
|
periodEndIso,
|
|
)
|
|
if (medelantal > 0 || entityType === 'aktiebolag') {
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Medelantal anställda',
|
|
body:
|
|
medelantal > 0
|
|
? `Under räkenskapsåret har medeltalet anställda uppgått till ${medelantal}.`
|
|
: 'Bolaget har inte haft några anställda under räkenskapsåret.',
|
|
})
|
|
}
|
|
|
|
// 6. Långfristiga skulder förfallande efter mer än fem år (ÅRL 5:13 §).
|
|
const longTermDebtAmount = narrative?.long_term_debt_over_five_years ?? null
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Långfristiga skulder',
|
|
body:
|
|
longTermDebtAmount && longTermDebtAmount > 0
|
|
? `Av långfristiga skulder förfaller ${longTermDebtAmount.toLocaleString('sv-SE')} kr till betalning senare än fem år efter balansdagen.`
|
|
: 'Inga skulder förfaller till betalning senare än fem år efter balansdagen.',
|
|
})
|
|
|
|
// 7. Eventualförpliktelser (K3 punkt 21: separate disclosure).
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Eventualförpliktelser',
|
|
body: narrative?.contingent_liabilities?.trim() || 'Inga.',
|
|
})
|
|
|
|
// 8. Ställda säkerheter (ÅRL 5:14 §).
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Ställda säkerheter',
|
|
body: narrative?.securities_pledged?.trim() || 'Inga.',
|
|
})
|
|
|
|
// 9. Koncernförhållanden (BFNAR 2012:1 kap. 8: moderföretagets namn,
|
|
// organisationsnummer och säte). Emitted only when configured.
|
|
const parentName = narrative?.parent_company_name?.trim()
|
|
if (parentName) {
|
|
const parts: string[] = [`Moderföretag: ${parentName}.`]
|
|
if (narrative?.parent_company_org_number)
|
|
parts.push(`Organisationsnummer: ${narrative.parent_company_org_number}.`)
|
|
if (narrative?.parent_company_city)
|
|
parts.push(`Säte: ${narrative.parent_company_city}.`)
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Koncernförhållanden',
|
|
body: parts.join(' '),
|
|
})
|
|
}
|
|
|
|
// 10. Väsentliga händelser efter balansdagen (K3 ch.32)
|
|
notes.push({
|
|
number: notes.length + 1,
|
|
title: 'Väsentliga händelser efter balansdagen',
|
|
body: 'Inga väsentliga händelser har inträffat efter räkenskapsårets utgång som påverkar bedömningen av företagets ställning och resultat.',
|
|
})
|
|
|
|
return { notes, warnings }
|
|
}
|
|
|
|
/**
|
|
* K3 separate "Förändring av eget kapital" statement, derived from the
|
|
* post-level mapping. With a previous fiscal year the opening balances are
|
|
* the REAL prior-year UB values (mapping .previous), and the year's
|
|
* movements are derived so the roll-forward ties exactly to the booked UB:
|
|
* bundet-EK growth is presented as nyemission, a fritt-EK shortfall beyond
|
|
* årets resultat as utdelning (the overwhelmingly common cases; a positive
|
|
* fritt residual, e.g. aktieägartillskott, is folded into nyemission
|
|
* rather than invent an unbookable row). First fiscal year falls back to
|
|
* opening = closing - årets resultat.
|
|
*/
|
|
function buildK3EquityChangesStatement(
|
|
mapping: K2MappingResult,
|
|
): { rows: EgenKapitalRow[]; closing_total: number } {
|
|
const cur = (concept: string): number => mapping.br[concept]?.current ?? 0
|
|
const prev = (concept: string): number => mapping.br[concept]?.previous ?? 0
|
|
|
|
const aretsResultat = cur('AretsResultatEgetKapital')
|
|
const aktiekapitalClosing = cur('Aktiekapital') + cur('EjRegistreratAktiekapital')
|
|
const bundnaClosing = mapping.totals.bundetEgetKapital.current - aktiekapitalClosing
|
|
const frittClosing = mapping.totals.frittEgetKapital.current
|
|
|
|
const hasPrevious = mapping.totals.egetKapital.previous !== null
|
|
let opening: { aktiekapital: number; bundna_reserver: number; balanserade_vinstmedel: number }
|
|
let nyemission = 0
|
|
let utdelning = 0
|
|
if (hasPrevious) {
|
|
const aktiekapitalOpening = prev('Aktiekapital') + prev('EjRegistreratAktiekapital')
|
|
const bundnaOpening =
|
|
(mapping.totals.bundetEgetKapital.previous ?? 0) - aktiekapitalOpening
|
|
const frittOpening = mapping.totals.frittEgetKapital.previous ?? 0
|
|
opening = {
|
|
aktiekapital: aktiekapitalOpening,
|
|
bundna_reserver: bundnaOpening,
|
|
balanserade_vinstmedel: frittOpening,
|
|
}
|
|
nyemission =
|
|
aktiekapitalClosing - aktiekapitalOpening + (bundnaClosing - bundnaOpening)
|
|
const frittResidual = frittClosing - frittOpening - aretsResultat
|
|
if (frittResidual < 0) utdelning = frittResidual
|
|
else nyemission += frittResidual
|
|
} else {
|
|
opening = {
|
|
aktiekapital: aktiekapitalClosing,
|
|
bundna_reserver: bundnaClosing,
|
|
balanserade_vinstmedel: frittClosing - aretsResultat,
|
|
}
|
|
}
|
|
return buildEquityChangesNote({
|
|
opening,
|
|
changes: { nyemission, utdelning, arets_resultat: aretsResultat },
|
|
})
|
|
}
|
|
|