feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) (#2423)

* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1)

Why the problem occurred: the legal form was modelled as a binary flag in
~300 files. `EntityType` was a two-member union, but nothing dispatched on it
exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize,
match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut,
MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B`
ternary. Widening the union compiled everywhere and changed nothing, so a
förening would have booked as an enskild firma in the app and as an
aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused
föreningar at the door (mapEntityType returned null), which is what the
tester hit.

What was removed or simplified: the silent defaults. One module,
lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser
(never defaults), the resolver (settings hint, then companies.entity_type,
then throw) and `byEntityType`, whose Record arms make the compiler refuse
the next widening until each site has an answer. The form-dependent facts
(closing account, owner settlement account, calendar-year lock, default
method, K1/K2 label, personnummer vs 16-prefix) live there once instead of
in the ternaries. On the SQL side supported_entity_types() replaces four
copies of the literal list in the create RPCs.

Why this shape and not the proposed one: the tracker asked for the enum
widening plus a chart; that alone was the dangerous version (compiles, books
wrong). Bundling stiftelse was considered and dropped: identical plumbing but
no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so
the CHECK, RPCs and seed can ship now and the first partner is switched on
without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut,
Swish) lands on the tracker.

Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before
Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner
accounts, member settlement on 2890; accrual default; brutet räkenskapsår
allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org
number gets the 16 prefix.

Migration 20260908110835 widens the three CHECK constraints, adds
supported_entity_types(), re-creates the three create RPCs with the widened
guard and adds the förening block to seed_chart_of_accounts. Applied to
staging and covered by ideell-forening-entity-type.pg.test.ts.

Part of #2072

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh

* fix(company): close the förening paths the skeptic refuted (#2072)

Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the
shared definition rather than the reported site:

1. Privately paid supplier invoices and the utlägg dialog resolved the owner
   account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening
   member's invoice was built on 2893 and then refused by the expense-claim
   service (which already said 2890), burning an ankomstnummer. The helper now
   uses ownerSettlementAccount.
2. Booking templates substitute their `_ab` accounts only for an aktiebolag;
   the `private_expense` template kept its base 2013 for a förening. Template
   accounts now resolve through templateAccountForForm: EF base, AB override,
   förening base with owner accounts translated to 2890 (booking-templates.ts
   and proposal-lines.ts share it).
3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline:
   the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk
   person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule
   now keys on fiscalYearLockedToCalendar instead of the two literals; same in
   the MCP VAT report.
4. 2069 would have accumulated across years: the year-open omföring was
   AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the
   pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068)
   and skips forms with no carry (EF).
5. With the flag off, a registry lookup that returned "Ideell förening" was
   prefilled into the onboarding journey, the form picker was skipped and the
   create step answered "Ogiltig företagsform" with no way back. The
   journey, the BankID picker, the onboarding page and the MCP lookup now use
   mapSetupEntityType, which maps only creatable forms, so a flagged-off form
   falls through to the picker as before.

Also: form picker keeps its AB-first order; tests for each fix.

Part of #2072

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh

* chore(migrations): move ideell förening migration after main's latest version (20260908143051)

Two migrations landed on main after the branch forked; a lower version
would be skipped by the merge-time apply. Staging history row renamed to
match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh

* chore(skills): regenerate accounted-api reference for the widened entity_type enum

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-08 14:47:50 +02:00
committed by GitHub
parent 1157ff1b66
commit 26e29f47bc
111 changed files with 1644 additions and 320 deletions
+3
View File
@@ -1657,6 +1657,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] Enable Banking's bank_transaction_code object ({description, code, sub_code}) is flattened to a string by one helper in the connect-contract file (normalizeBankTransactionCode: code, code/sub_code, else description), applied by both producers (Connect's normalizeBookedTransaction, the ledger's convertTransaction), and the wire schema stays z.string().nullable(). Rejected: widening the contract to string | object and normalizing only in the ledger's sync.ts. It keeps the wire type dishonest, leaves Connect's own label derivation reading an object, and still needs the direct-path fix, because that path had been writing the object's JSON text into transactions.bank_transaction_code for 78 companies since 2026-08-09 while Connect failed the same type lie loudly on every canary sync from 2026-09-03 (Capstone support case, 2026-09-07). The helper lives in the contract despite its "shape, never behaviour" rule because the two repos already mirror that file byte for byte; a copy per producer is the drift that caused the outage. Repair migration rewrites the stored JSON text with the same rule and nothing else (no transaction_method re-derivation).
[2026-09-07] PR #2397 skeptic + review pass: the reclaim is refused while any of its invoices sits in a later live begäran (avslag → new file is Skatteverket's retry; booking the refused share onto the customer meanwhile would clear 1513 twice), a reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed, and a storno of the reclaim voucher syncs the invoices and the begäran back (lib/invoices/rot-rut-reclaim-reversal.ts, hooked into reverseEntry next to the payment sync). Per-invoice reopen goes through one idempotent RPC (apply_rot_rut_reclaim_invoice, item marker + invoice row in one transaction) so a failure after the voucher is resumable instead of stuck at ALREADY_DONE. Crediting an invoice with a reclaimed share is refused (reverse the reclaim first): the credit note reverses the issue-time 1510/1513 split. Declined: CONCURRENTLY for the partial unique index (Supabase migrations run in one transaction; same shape as the shipped rot_rut_payout index in 20260904021000, partial predicate on a source_type that few rows match).
[2026-09-08] PR #2397 review cycle 3: the reclaim RPCs own the accounting values. apply_rot_rut_reclaim_invoice takes only the refused share and validates it against the locked item, request and invoice (never above the item's requested amount, the beslut's refused total, or the 1513 headroom), then derives remaining_amount and status from the same formula as the INSERT guard (rot_rut_customer_outstanding); revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher and the request link is cleared only after every leg succeeded. Reason: a SECURITY INVOKER function that accepted caller-supplied remaining/status was an unchecked accounting write for any writer-role member (CWE-862). The 20260907160300 signature is dropped in 20260907160400 rather than edited: the preview branch had already applied it.
[2026-09-08] Ideell förening (#2072 step 1) lands as a third EntityType only after every form-dependent fact was routed through lib/company/entity-type.ts (byEntityType with Record arms, resolveCompanyEntityType instead of `?? 'enskild_firma'` / `?? 'aktiebolag'`): widening the union alone compiled everywhere and changed nothing, so a förening would have booked as EF in the app and as AB in bokslut and MCP. 28 silent-default sites replaced; the only compile error the widening produced was one Record<EntityType>.
[2026-09-08] Förening domain facts, chosen from the BAS data in the repo since the compliance skill has no förening chapter (verify with an accountant before Phase 2): result closes to 2069 and the year-open omföring carries it to 2068 (the same result-appropriation service as AB 2099/2098, generalised on resultClosingAccounts; the skeptic showed 2069 would otherwise accumulate across years); no owner accounts, member settlement on 2890 (EF 2013/2018, AB 2893) in category mapping, booking templates (owner accounts in a template's base column translate to 2890), expense claims and privately paid supplier invoices; helårsmoms deadline follows the räkenskapsår schedule like an AB (SFL 26 kap 33 §); accrual default; brutet räkenskapsår allowed (only EF is calendar-locked, BFL 3 kap 1 §); K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix; personnel accounts auto-created on first payroll as for EF. No INK3, no årsbokslut, no förening packs, no deadlines yet: Phase 2 on the tracker.
[2026-09-08] Ideell förening creation is behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED (one flag, read by the onboarding picker and the shared CompanySetupSchema/actions gate) rather than open from day one: the DB CHECK, RPCs and chart seed accept the value regardless, so the first partner (SS Gambit via Roslagens Webbyrå) can be switched on without a migration and the flag can be dropped once Phase 2 lands. stiftelse deliberately not bundled: identical plumbing, no chart block.
[2026-09-08] Invoice list gets an 'Ej skickade' view and the PDF download on an unissued document asks first (#2399): both reports came from the same hidden state, a finalized invoice with an F-number whose DB status is still 'draft'. Chosen: split the status in the UI (lib/invoices/invoice-list-tabs.ts, one predicate for rows, counts and sections) and gate the download with a soft dialog whose primary action is the existing manual mark-sent (and book) path, then download. Rejected: a real 'issued' status in the DB (touches MCP, v1 API, reports and SIE for a distinction invoice_number already carries); removing the UTKAST stamp from numbered drafts (an unbooked invoice is not issued, the stamp is right, the flow around it was wrong); naming the tab 'Godkända' as the user asked (there is no attest step, so the label would promise one; ?status=godkanda aliases to the view).
[2026-09-08] Supplier org_number identity (#2391): one lenient 10-digit key (orgNumberKey in lib/invariants/org-number.ts, digits only, last 10 of 12, no Luhn) now drives the exact matcher, the extractor's self-invoice guard, the web/v1 schemas and the MCP create tool, plus a backfill that strips formatting from existing rows. Luhn stays lenient on suppliers: two rows with the same mistyped number are one supplier, and parties is Luhn-strict at promotion already. Declined: the unique index on (company_id, org_number) in the same PR, because prod holds 94 duplicate groups under the canonical key (18 companies, one group of 124 rows that looks like a placeholder) that need a merge decision first; follow-up issue instead. Declined: rejecting non-Swedish shapes on write, because 68 prod rows hold foreign registration numbers in the column and eu/non-eu suppliers would become unwritable. parties is the end state for counterparty identity: no further constraints go on suppliers beyond this.
[2026-09-08] #2391 skeptic pass: orgNumberKey only strips hyphens and spaces and only unprefixes 12-digit values behind 16/18/19/20. Reason: 26 prod supplier rows hold a VAT number (orgnr + 01, prefixes 55/52/87) in org_number, and 'last 10 of any 12 digits' would have rewritten them to another company's identity; letters stay because BE0123456789 is not the Swedish 0123456789. The matcher scans live suppliers only (archived_at IS NULL), the list and v1 search compare without separators, the CSV import and the provider migration orchestrator key and write through the same rule.
+2 -1
View File
@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEntityType, ownerSettlementAccount } from '@/lib/company/entity-type'
import { useTranslations } from 'next-intl'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
@@ -336,7 +337,7 @@ export default function ExpenseClaimsPage() {
// firma egen insättning); the server resolves the same way and is the
// authority. Outside a provider we fall back to AB's 2893.
const entityType = useCompanyOptional()?.company?.entity_type ?? null
const ownerLiability = entityType === 'enskild_firma' ? '2018' : '2893'
const ownerLiability = isEntityType(entityType) ? ownerSettlementAccount(entityType, 'contribution') : '2893'
const liabilityAccount = claimant === OWNER_VALUE ? ownerLiability : '2820'
const parsedAmount = parseFloat(amount) || 0
const parsedVat = parseFloat(vatAmount) || 0
+5 -7
View File
@@ -34,6 +34,7 @@ import {
} from '@/lib/company/home-domain'
import HomeDomainSignpost from '@/components/dashboard/HomeDomainSignpost'
import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types'
import { parseEntityType } from '@/lib/company/entity-type'
import {
getDashboardAuthContext,
getDashboardCompanyId,
@@ -398,13 +399,10 @@ export default async function DashboardLayout({
// Resolve entity type the same way the report engines and
// getCompanyEntityType do: company_settings is read-primary, companies is the
// canonical fallback, then default to enskild_firma. Mirroring it onto the
// active company keeps the settings rail (useSettingsNavItems, which reads
// context) and the sidebar in agreement on who is an employer. #782
const entityType =
(settings?.entity_type as EntityType) ||
(companyRow.entity_type as EntityType) ||
'enskild_firma'
// canonical (NOT NULL) fallback; never a guessed default. Mirroring it onto
// the active company keeps the settings rail (useSettingsNavItems, which
// reads context) and the sidebar in agreement on who is an employer. #782
const entityType: EntityType = parseEntityType(settings?.entity_type ?? companyRow.entity_type)
const paysSalaries = settings?.pays_salaries ?? false
// Dimensions register visibility (Kostnadsställen & projekt nav row). Same
// mechanism as paysSalaries: UI gate only, never load-bearing for
+1
View File
@@ -4051,6 +4051,7 @@ export default function TransactionsPage() {
quickReview?.template,
quickReview?.templateId,
quickReview?.category,
entityType as EntityType,
)
return (
+3 -2
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { ENTITY_TYPE_LABELS_SV, isEntityType } from '@/lib/company/entity-type'
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import { getActiveCompanyId } from '@/lib/company/context'
@@ -170,8 +171,8 @@ function buildInitialFields(
const entityLabel =
company.entity_type === 'aktiebolag'
? 'AB'
: company.entity_type === 'enskild_firma'
? 'Enskild firma'
: isEntityType(company.entity_type)
? ENTITY_TYPE_LABELS_SV[company.entity_type]
: company.entity_type
// Tier the resolution: TIC snapshot (if cached) wins because it's the
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from '@/lib/company/pending-invites'
import type { EntityType } from '@/types'
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
import { mapEntityType as mapTicEntityType } from '@/lib/company-lookup/entity-type-map'
import { mapSetupEntityType as mapTicEntityType } from '@/lib/company-lookup/entity-type-map'
export const dynamic = 'force-dynamic'
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { parseEntityType } from '@/lib/company/entity-type'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
@@ -119,7 +120,7 @@ export const POST = withRouteContext(
},
underlag,
candidates,
entityType: ((company?.entity_type as EntityType | undefined) ?? 'enskild_firma'),
entityType: parseEntityType(company?.entity_type),
vatRegistered: settings?.vat_registered ?? false,
samples: parsed.data.samples,
})
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { withRouteContext } from '@/lib/api/with-route-context'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
@@ -39,7 +40,12 @@ export const POST = withRouteContext('mapping_rules.evaluate', async (request, c
}
try {
const result = await evaluateMappingRules(supabase, companyId, transaction)
const result = await evaluateMappingRules(
supabase,
companyId,
transaction,
await resolveCompanyEntityType(supabase, companyId),
)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
+6 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
@@ -61,7 +62,11 @@ export const POST = withRouteContext(
if ((settings.accounting_method || 'accrual') !== 'accrual') {
return errorResponseFromCode('INVOICE_BOOK_CASH_METHOD', log, { requestId })
}
const entityType = ((settings as Partial<CompanySettings>).entity_type as EntityType) || 'enskild_firma'
const entityType = await resolveCompanyEntityType(
supabase,
companyId,
(settings as Partial<CompanySettings>).entity_type,
)
const result = await bookInvoiceDeferred({
supabase,
@@ -371,6 +371,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
// Fetch invoice
enqueue({ data: invoice, error: null })
// No customer: the duplicate guard skips straight to the settings read
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
const unbalancedLines = [
{ account_number: '1920', debit_amount: 12500, credit_amount: 0 },
@@ -627,6 +629,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
const overpayLines = [
{ account_number: '1930', debit_amount: 15000, credit_amount: 0 },
@@ -722,6 +725,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
})
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
@@ -752,6 +756,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
})
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
// Bank 90 000 exceeds the 86 800 customer share even after the 1513
// exclusion: the overpayment guard must still fire.
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -238,7 +239,7 @@ export const POST = withRouteContext(
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const entityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
// paymentAmountInInvoiceCurrency was resolved above, before the
// duplicate-payment guard, so the guard comparison and the ledger math run
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import {
creditNoteNeedsJournalEntry,
@@ -156,7 +157,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
const accountingMethod = (settings.accounting_method || 'accrual') as AccountingMethod
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
const entityType = await resolveCompanyEntityType(supabase, companyId, settings.entity_type)
const { data: original } = await supabase
.from('invoices')
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { renderToBuffer } from '@react-pdf/renderer'
@@ -449,7 +450,7 @@ export const POST = withRouteContext(
userId: user.id,
creditNote: invoice as CreditNote,
originalInvoice,
entityType: ((company as CompanySettings).entity_type as EntityType) || 'enskild_firma',
entityType: await resolveCompanyEntityType(supabase, companyId!, (company as CompanySettings).entity_type),
accountingMethod: ((company as Record<string, unknown>).accounting_method || 'accrual') as AccountingMethod,
log: opLog,
})
+6 -2
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
@@ -75,8 +76,11 @@ export const POST = withRouteContext(
if ((settings.accounting_method || 'accrual') !== 'accrual') {
return errorResponseFromCode('INVOICE_BOOK_CASH_METHOD', log, { requestId })
}
const entityType =
((settings as Partial<CompanySettings>).entity_type as EntityType) || 'enskild_firma'
const entityType = await resolveCompanyEntityType(
supabase,
companyId,
(settings as Partial<CompanySettings>).entity_type,
)
const { data: invoices, error: fetchError } = await supabase
.from('invoices')
+2 -1
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -122,7 +123,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma')
const entityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
const isBusiness = newCategory !== 'private'
+4 -3
View File
@@ -24,7 +24,8 @@ import {
} from '@/lib/currency/supplier-invoice-rate'
import { roundOre } from '@/lib/money'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import type { Currency, SupplierInvoice, SupplierInvoiceItem } from '@/types'
import type { Currency, EntityType, SupplierInvoice, SupplierInvoiceItem } from '@/types'
import { parseEntityType } from '@/lib/company/entity-type'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -251,7 +252,7 @@ export const POST = withRouteContext(
// Entity type drives the credit account for privately-paid invoices:
// AB → 2893 (skuld till aktieägare), EF → 2018 (egen insättning). Loaded
// up front so we can fail early if the company row is missing.
let entityType: 'aktiebolag' | 'enskild_firma' | null = null
let entityType: EntityType | null = null
if (paidPrivately) {
const { data: company } = await supabase
.from('companies')
@@ -264,7 +265,7 @@ export const POST = withRouteContext(
details: { reason: 'company entity_type missing, cannot pick owner account' },
})
}
entityType = company.entity_type as 'aktiebolag' | 'enskild_firma'
entityType = parseEntityType(company.entity_type)
if (body.employee_id) {
// Checked before the arrival-number sequence is touched: a claim the
// service would refuse must not burn an ankomstnummer.
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
@@ -273,7 +274,7 @@ export const POST = withRouteContext(
.eq('company_id', companyId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const entityType: EntityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1
let finalCategory: TransactionCategory
@@ -20,6 +20,7 @@
* the lack of any preview was part of the reported bug.
*/
import { NextResponse } from 'next/server'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode'
@@ -92,7 +93,7 @@ export const GET = withRouteContext(
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const entityType: EntityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
// Same resolution as the POST handler: debit the cash account this
// transaction is actually linked to, never a hardcoded 1930, so the
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode'
import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries'
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
@@ -412,7 +413,7 @@ export const POST = withRouteContext(
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const entityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
// Debit the cash account THIS transaction actually belongs to, never a
// hardcoded 1930: cash_account_id -> cash_accounts.ledger_account is the
@@ -39,6 +39,7 @@
*/
import { z } from 'zod'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
@@ -255,7 +256,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
const accountingMethod = companySettings.accounting_method ?? 'accrual'
const entityType = (companySettings.entity_type ?? 'enskild_firma') as EntityType
const entityType = await resolveCompanyEntityType(ctx.supabase, ctx.companyId!, companySettings.entity_type)
const isRealInvoice = !typed.document_type || typed.document_type === 'invoice'
// #967: kontantmetoden and defer_invoice_booking companies mark sent
// WITHOUT booking (same gate as the dashboard, issue-and-book-invoice.ts).
@@ -40,6 +40,7 @@
*/
import { z } from 'zod'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { renderToBuffer } from '@react-pdf/renderer'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
@@ -729,7 +730,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
ctx.companyId!,
ctx.userId,
renderableInvoice,
(settings.entity_type ?? 'enskild_firma') as EntityType,
await resolveCompanyEntityType(ctx.supabase, ctx.companyId!, settings.entity_type),
customer.name,
)
if (entry) {
@@ -20,6 +20,7 @@
* without inserting the journal entry or mutating the transaction.
*/
import { z } from 'zod'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
@@ -174,7 +175,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
.select('entity_type')
.eq('company_id', ctx.companyId!)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const entityType: EntityType = await resolveCompanyEntityType(ctx.supabase, ctx.companyId!, settings?.entity_type)
// Resolve final category and mapping result. Mirrors the internal route.
let finalCategory: TransactionCategory
@@ -23,6 +23,7 @@
* resolved preview before commit. Skip the flag here; document it.
*/
import { z } from 'zod'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
@@ -440,8 +441,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
.eq('company_id', ctx.companyId!)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType: EntityType =
(settings?.entity_type as EntityType) || 'enskild_firma'
const entityType: EntityType = await resolveCompanyEntityType(ctx.supabase, ctx.companyId!, settings?.entity_type)
// Debit the cash account THIS transaction actually belongs to, never a
// hardcoded 1930: cash_account_id -> cash_accounts.ledger_account is the
@@ -10,6 +10,7 @@
* Idempotent over the whole batch. Dry-runnable.
*/
import { z } from 'zod'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
@@ -543,8 +544,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
.select('entity_type')
.eq('company_id', ctx.companyId!)
.single()
const entityType: EntityType =
(settings?.entity_type as EntityType) || 'enskild_firma'
const entityType: EntityType = await resolveCompanyEntityType(ctx.supabase, ctx.companyId!, settings?.entity_type)
const results: Item[] = []
for (let i = 0; i < body.items.length; i++) {
+2 -1
View File
@@ -23,6 +23,7 @@ import { readV1JsonBody } from '@/lib/api/v1/body'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { createCompanyCore } from '@/lib/company/create-company'
import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input'
import { EntityTypeSchema } from '@/lib/api/schemas'
const Company = z.object({
id: z.string().uuid(),
@@ -77,7 +78,7 @@ registerEndpoint({
const CreatedCompany = z.object({
id: z.string().uuid(),
name: z.string(),
entity_type: z.enum(['enskild_firma', 'aktiebolag']),
entity_type: EntityTypeSchema,
org_number: z.string().nullable(),
vat_registered: z.boolean(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).nullable(),
@@ -17,6 +17,13 @@ import { TEMPLATE_CATEGORY_LABELS, SCOPE_LABELS, getTemplateScope, applyTemplate
import type { BookingTemplateCategory, EntityType } from '@/types'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
// Statutory short forms, kept in Swedish in both locales.
const ENTITY_SHORT_LABELS: Record<EntityType, string> = {
enskild_firma: 'EF',
aktiebolag: 'AB',
ideell_forening: 'Förening',
}
interface Props {
onApply: (lines: FormLine[], description: string, category?: BookingTemplateCategory) => void
entityType?: EntityType
@@ -188,8 +195,7 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou
<ScopeIcon className="h-3 w-3 shrink-0" />
<span>
{SCOPE_LABELS[scope]}
{t.entity_type !== 'all' &&
` · ${t.entity_type === 'enskild_firma' ? 'EF' : 'AB'}`}
{t.entity_type !== 'all' && ` · ${ENTITY_SHORT_LABELS[t.entity_type] ?? t.entity_type}`}
</span>
</div>
{t.description && (
+6 -2
View File
@@ -21,6 +21,9 @@
*/
/** BFNAR 2016:10: the K2 accrual simplification ceiling, in SEK. */
import type { EntityType } from '@/types'
import { simplifiedYearEndRegelverk } from '@/lib/company/entity-type'
export const K2_ACCRUAL_THRESHOLD_SEK = 5000
export interface AccrualAmountInput {
@@ -86,7 +89,8 @@ export function shouldShowK2AccrualHint(input: AccrualAmountInput): boolean {
* and correct for every aktiebolag.
*/
export function accrualHintKey(
entityType?: 'enskild_firma' | 'aktiebolag' | null,
entityType?: EntityType | null,
): 'k1_hint' | 'k2_hint' {
return entityType === 'enskild_firma' ? 'k1_hint' : 'k2_hint'
if (!entityType) return 'k2_hint'
return simplifiedYearEndRegelverk(entityType) === 'K1' ? 'k1_hint' : 'k2_hint'
}
@@ -86,6 +86,7 @@ const TEMPLATE_ENTITY_LABELS: Record<string, string> = {
all: 'Alla',
enskild_firma: 'Enskild firma',
aktiebolag: 'Aktiebolag',
ideell_forening: 'Ideell förening',
}
interface Props {
@@ -8,7 +8,8 @@ import { Loader2 } from 'lucide-react'
import { AttnLine } from '@/components/ui/attn-line'
import { useToast } from '@/components/ui/use-toast'
import { switchCompany } from '@/lib/company/actions'
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
import { mapEntityType, mapSetupEntityType } from '@/lib/company-lookup/entity-type-map'
import { ENTITY_TYPE_LABELS_SV, isEntityType } from '@/lib/company/entity-type'
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
import { getBranding } from '@/lib/branding/service'
import '@/components/onboarding/journey/journey.css'
@@ -55,15 +56,13 @@ type SetupState = { kind: 'idle' } | { kind: 'opening'; companyId: string }
// terms: kept in Swedish in both locales.
function humanEntityType(t: string | null | undefined): string {
if (!t) return ''
if (t === 'aktiebolag') return 'Aktiebolag'
if (t === 'enskild_firma') return 'Enskild firma'
if (isEntityType(t)) return ENTITY_TYPE_LABELS_SV[t]
return t
}
function humanTicEntityType(t: string): string {
const mapped = mapEntityType(t)
if (mapped === 'aktiebolag') return 'Aktiebolag'
if (mapped === 'enskild_firma') return 'Enskild firma'
if (mapped) return ENTITY_TYPE_LABELS_SV[mapped]
if (t.toLowerCase().includes('handelsbolag') || t.toLowerCase() === 'hb') return 'Handelsbolag'
if (t.toLowerCase().includes('kommanditbolag') || t.toLowerCase() === 'kb') return 'Kommanditbolag'
return t
@@ -183,7 +182,7 @@ export default function BankIdCompanyPicker({
const cleaned = role.companyRegistrationNumber.replace(/[\s-]/g, '')
const position = positionLabel(role)
const entityLabel = humanTicEntityType(role.legalEntityType)
const mappable = mapEntityType(role.legalEntityType) !== null
const mappable = mapSetupEntityType(role.legalEntityType) !== null
const metaParts = [entityLabel, position].filter(Boolean)
if (!mappable) metaParts.push(t('setup_manually'))
if (status === 'exists') {
@@ -34,7 +34,18 @@ import {
type FirstYearEndOption,
} from '@/lib/onboarding-journey/fiscal-options'
import type { EntityType } from '@/types'
import { isEntityTypeCreatable, usesPersonnummerAsOrgNumber } from '@/lib/company/entity-type'
import JourneyOrb, { type OrbState } from './JourneyOrb'
/** Display order of the form picker (AB first, as before); flags filter it. */
const FORM_PICKER_ORDER: EntityType[] = ['aktiebolag', 'enskild_firma', 'ideell_forening']
/** i18n key per legal form for the picker chips and the summary card. */
const FORM_LABEL_KEY: Record<EntityType, 'journey_form_ab' | 'journey_form_ef' | 'journey_form_forening'> = {
aktiebolag: 'journey_form_ab',
enskild_firma: 'journey_form_ef',
ideell_forening: 'journey_form_forening',
}
import JourneyTrack from './JourneyTrack'
import Question from './Question'
import ChipRow from './ChipRow'
@@ -379,7 +390,7 @@ export default function OnboardingJourney({
const lk = state.ticLookup
if (!lk || station > 0) return []
const facts: { text: string; warn?: boolean }[] = []
if (entity) facts.push({ text: entity === 'aktiebolag' ? t('journey_form_ab') : t('journey_form_ef') })
if (entity) facts.push({ text: t(FORM_LABEL_KEY[entity]) })
if (lk.address?.city) facts.push({ text: lk.address.city })
if (lk.sniCodes[0]?.name) facts.push({ text: lk.sniCodes[0].name })
if (lk.registration.fTax) facts.push({ text: 'F-skatt' })
@@ -492,10 +503,10 @@ export default function OnboardingJourney({
return (
<Question title={t('journey_form_title')} info={t('journey_form_info')}>
<ChipRow
options={[
{ key: 'aktiebolag', label: t('journey_form_ab') },
{ key: 'enskild_firma', label: t('journey_form_ef') },
]}
options={FORM_PICKER_ORDER.filter(isEntityTypeCreatable).map((key) => ({
key,
label: t(FORM_LABEL_KEY[key]),
}))}
onPick={(k) => dispatch({ type: 'ENTITY_PICKED', entityType: k as EntityType })}
{...flyProps}
/>
@@ -1107,11 +1118,11 @@ function DoneStep({
const s = state.settings
const shortName = (s.company_name ?? '').split(' ')[0] || ''
const rows: [string, string][] = [
[t('journey_card_form'), s.entity_type === 'aktiebolag' ? t('journey_form_ab') : t('journey_form_ef')],
[t('journey_card_form'), s.entity_type ? t(FORM_LABEL_KEY[s.entity_type]) : ''],
]
if (s.org_number) {
rows.push([
s.entity_type === 'enskild_firma' ? t('journey_card_persnr') : t('journey_card_orgnr'),
s.entity_type && usesPersonnummerAsOrgNumber(s.entity_type) ? t('journey_card_persnr') : t('journey_card_orgnr'),
s.org_number,
])
}
@@ -35,6 +35,7 @@ export function BookingTemplatesPanel() {
all: t('entity_all'),
enskild_firma: t('entity_enskild_firma'),
aktiebolag: t('entity_aktiebolag'),
ideell_forening: t('entity_ideell_forening'),
}
// The panel renders the same session-cached list the pickers use
+8 -1
View File
@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { byEntityType, isEntityType } from '@/lib/company/entity-type'
import { Switch } from '@/components/ui/switch'
import { HelpPopover } from '@/components/ui/help-popover'
import {
@@ -83,7 +84,13 @@ export function TaxSettingsForm({
{/* Entity type: read-only. Changing it is a support operation. */}
<SettingsRow label={t('entity_form_heading')} help={t('entity_form_help')}>
<span className="text-sm">
{settings.entity_type === 'aktiebolag' ? t('entity_aktiebolag') : t('entity_enskild_firma')}
{isEntityType(settings.entity_type)
? byEntityType(settings.entity_type, {
aktiebolag: t('entity_aktiebolag'),
enskild_firma: t('entity_enskild_firma'),
ideell_forening: t('entity_ideell_forening'),
})
: ''}
</span>
</SettingsRow>
+2 -1
View File
@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl'
import { useState, useMemo } from 'react'
import type { EntityType } from '@/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -51,7 +52,7 @@ export function TemplateForm({
)
const [description, setDescription] = useState(initialTemplate?.description ?? '')
const [category, setCategory] = useState<BookingTemplateCategory>(initialTemplate?.category ?? 'other')
const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>(
const [entityType, setEntityType] = useState<'all' | EntityType>(
initialTemplate?.entity_type ?? 'all',
)
const [lines, setLines] = useState<BookingTemplateLibraryLine[]>(() =>
@@ -1,4 +1,5 @@
import type { CompanySettings, EntityType } from '@/types'
import { isEntityType } from '@/lib/company/entity-type'
export interface SupplierInvoiceDefaults {
entityType: EntityType
@@ -26,8 +27,11 @@ export function deriveSupplierInvoiceDefaults(
settings: CompanySettings | null | undefined,
fallbackEntityType?: EntityType | null,
): SupplierInvoiceDefaults {
const entityType =
(settings?.entity_type as EntityType | null | undefined) ?? fallbackEntityType ?? 'enskild_firma'
// UI prefill only: the server re-resolves the form on submit
// (resolveCompanyEntityType), so a not-yet-loaded settings row may fall
// back to the company row and, failing that, to the enskild firma defaults.
const stored = settings?.entity_type ?? fallbackEntityType
const entityType: EntityType = isEntityType(stored) ? stored : 'enskild_firma'
return {
entityType,
accountingMethod: settings?.accounting_method === 'cash' ? 'cash' : 'accrual',
@@ -330,7 +330,9 @@ export default function QuickReviewDialog({
// booked 25% moms against an explicit "Ingen moms" while the preview
// showed none. See resolveExplicitVat.
const resolvedVat = resolveExplicitVat(vatTreatment, defaultVat)
const catDefault = getDefaultAccountForCategory(category)
// Only decides whether the account is sent as an override; the server
// resolves the form itself, so a missing prop may assume the EF default.
const catDefault = getDefaultAccountForCategory(category, entityType ?? 'enskild_firma')
const override = accountOverride && accountOverride !== catDefault
? accountOverride
: undefined
@@ -304,13 +304,14 @@ describe('POST /items/:id/suggest-booking', () => {
)
})
it('falls back to enskild firma when no entity type is stored', async () => {
it('resolves the form from companies when the settings row has none', async () => {
const mock = createQueuedMockSupabase()
mock.enqueue({
data: { id: 'item-1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null },
})
mock.enqueue({ data: transaction() })
mock.enqueue({ data: null })
mock.enqueue({ data: null }) // company_settings: no row
mock.enqueue({ data: { entity_type: 'enskild_firma' } }) // companies fallback (never a guessed default)
await route.handler(req(), buildCtx(mock.supabase))
expect(evaluateMappingRules).toHaveBeenCalledWith(
expect.anything(), 'company-1', expect.anything(), 'enskild_firma', expect.anything(),
+9 -4
View File
@@ -1,4 +1,5 @@
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { NextResponse } from 'next/server'
import { createServiceRoleClient } from '@/lib/supabase/service-client'
import { z } from 'zod'
@@ -3339,10 +3340,14 @@ export const invoiceInboxExtension: Extension = {
.select('entity_type')
.eq('company_id', ctx.companyId)
.maybeSingle()
// Same default as categorize-core. Leaving it undefined silently
// proposed enskild-firma accounts to aktiebolag: 2013 instead of
// 2893 for an owner expense, 6991 instead of 7610 for a course.
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Resolved, never defaulted: a guessed form proposes the wrong
// owner account (2013 vs 2893 vs 2890) and the wrong course
// account (6991 vs 7610).
const entityType: EntityType = await resolveCompanyEntityType(
ctx.supabase,
ctx.companyId,
settings?.entity_type,
)
const settlementAccount = await resolveSettlementAccount(
ctx.supabase,
@@ -78,7 +78,7 @@ beforeEach(() => {
} as never)
vi.mocked(assessKontantmetodCutoff).mockResolvedValue({
collection,
lines: buildCutoffLines(collection.receivables, collection.payables),
lines: buildCutoffLines(collection.receivables, collection.payables, 'aktiebolag'),
postings: {
complete: false, hasAny: false, receivableEntryId: null,
receivableReversalId: null, payableEntryId: null, payableReversalId: null,
@@ -140,7 +140,7 @@ describe('gnubok_post_kontantmetod_cutoff', () => {
expect(result.preview.entries.map((entry) => entry.entry_date)).toEqual([
'2026-12-31', '2027-01-01', '2026-12-31', '2027-01-01',
])
expect(result.preview.entries[0]?.lines).toEqual(buildCutoffLines(collection.receivables, []).receivableLines)
expect(result.preview.entries[0]?.lines).toEqual(buildCutoffLines(collection.receivables, [], 'aktiebolag').receivableLines)
expect(supabase.inserts).toHaveLength(1)
expect(supabase.inserts[0]).toMatchObject({
operation_type: 'post_kontantmetod_cutoff',
@@ -168,7 +168,7 @@ describe('gnubok_post_kontantmetod_cutoff', () => {
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
collection: { ...collection, unknownVatTreatment: ['F-9'] },
lines: buildCutoffLines([], []),
lines: buildCutoffLines([], [], 'aktiebolag'),
postings: { complete: false, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
})
await expect(tool.execute(
@@ -177,7 +177,7 @@ describe('gnubok_post_kontantmetod_cutoff', () => {
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
collection,
lines: buildCutoffLines(collection.receivables, collection.payables),
lines: buildCutoffLines(collection.receivables, collection.payables, 'aktiebolag'),
postings: { complete: true, hasAny: true, receivableEntryId: 'je-1', receivableReversalId: 'je-2', payableEntryId: 'je-3', payableReversalId: 'je-4', missing: [], duplicates: [] },
})
await expect(tool.execute(
@@ -186,7 +186,7 @@ describe('gnubok_post_kontantmetod_cutoff', () => {
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
collection: { receivables: [], payables: [], unknownVatTreatment: [], strayVatOnZeroRate: [] },
lines: buildCutoffLines([], []),
lines: buildCutoffLines([], [], 'aktiebolag'),
postings: { complete: true, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
})
await expect(tool.execute(
+22 -14
View File
@@ -1,4 +1,13 @@
import { UUID_RE } from '@/lib/invariants/uuid'
import {
ENTITY_TYPES,
ENTITY_TYPE_LABELS_SV,
creatableEntityTypes,
fiscalYearLockedToCalendar,
isEntityType,
parseEntityType,
resolveCompanyEntityType,
} from '@/lib/company/entity-type'
import { NextResponse, after } from 'next/server'
import {
TASKS_EXTENSION_ID,
@@ -25,7 +34,7 @@ import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-i
import { lookupCompanyByOrgNumber } from '@/extensions/general/tic/lib/lookup'
import { TICAPIError } from '@/extensions/general/tic/lib/tic-types'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
import { mapSetupEntityType } from '@/lib/company-lookup/entity-type-map'
import { deriveFirstYearDefaults, parseStartMonthDay } from '@/lib/company/first-year-defaults'
import {
ANONYMOUS_METHODS,
@@ -1444,7 +1453,7 @@ async function categorizeTransactionCore(
.eq('company_id', companyId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const entityType: EntityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
// Build mapping
let mappingResult = buildMappingResultFromCategory(
@@ -2766,9 +2775,7 @@ export async function computeVatCloseCheck(
.eq('company_id', companyId)
.single()
const momsPeriod = (settings?.moms_period as 'monthly' | 'quarterly' | 'yearly' | null) ?? null
const entityType = settings?.entity_type === 'aktiebolag' || settings?.entity_type === 'enskild_firma'
? settings.entity_type
: null
const entityType = isEntityType(settings?.entity_type) ? settings.entity_type : null
// 3) Deadline: based on the *requested* period type, not company setting,
// so the model gets the right deadline even when querying ad-hoc periods.
// Never turn missing settings into a plausible statutory date. Monthly
@@ -2795,10 +2802,11 @@ export async function computeVatCloseCheck(
: null
const reportEndMonth = Number(end.slice(5, 7))
const reportStartMonth = reportEndMonth === 12 ? 1 : reportEndMonth + 1
const fiscalYearMatches = entityType === 'enskild_firma'
const calendarYearOnly = fiscalYearLockedToCalendar(entityType)
const fiscalYearMatches = calendarYearOnly
? reportEndMonth === 12
: configuredStartMonth === reportStartMonth
const filingMethodRequired = entityType === 'aktiebolag' && settings.vat_has_eu_trade === false
const filingMethodRequired = !calendarYearOnly && settings.vat_has_eu_trade === false
const filingProfileComplete = typeof settings.vat_has_eu_trade === 'boolean'
&& (!filingMethodRequired
|| settings.vat_filing_method === 'electronic'
@@ -3698,7 +3706,7 @@ export const tools: McpTool[] = [
const askEverything = [
'name',
'entity_type (enskild firma or aktiebolag)',
'entity_type (enskild_firma, aktiebolag or ideell_forening)',
'f_skatt',
'vat_registered (and moms_period if yes)',
'accounting_method (accrual or cash)',
@@ -3736,7 +3744,7 @@ export const tools: McpTool[] = [
}
}
const entityType = mapEntityType(lookup.legalEntityType)
const entityType = mapSetupEntityType(lookup.legalEntityType)
const warnings: string[] = []
if (lookup.isCeased) {
warnings.push(
@@ -3745,7 +3753,7 @@ export const tools: McpTool[] = [
}
if (!entityType) {
warnings.push(
`Legal form "${lookup.legalEntityType ?? 'unknown'}" is not supported for automatic setup: only enskild firma and aktiebolag can be created here.`
`Legal form "${lookup.legalEntityType ?? 'unknown'}" is not supported for automatic setup: only ${creatableEntityTypes().map((t) => ENTITY_TYPE_LABELS_SV[t]).join(', ')} can be created here.`
)
}
@@ -3757,7 +3765,7 @@ export const tools: McpTool[] = [
// accounting method are ALWAYS the user's answer.
const vatIsFact = lookup.registration.vat === true
const stillToAsk: string[] = []
if (!entityType) stillToAsk.push('entity_type (enskild firma or aktiebolag)')
if (!entityType) stillToAsk.push('entity_type (enskild_firma, aktiebolag or ideell_forening)')
if (entityType === 'enskild_firma') {
stillToAsk.push(
'name: for enskild firma the verksamhetsnamn is freely choosable; suggest the registered name but let the user pick'
@@ -3850,7 +3858,7 @@ export const tools: McpTool[] = [
additionalProperties: false,
properties: {
name: { type: 'string', minLength: 1, maxLength: 200 },
entity_type: { type: 'string', enum: ['enskild_firma', 'aktiebolag'] },
entity_type: { type: 'string', enum: [...ENTITY_TYPES] },
org_number: { type: 'string', description: '10 digits; required when VAT-registered' },
vat_registered: { type: 'boolean' },
moms_period: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Required when vat_registered' },
@@ -17926,7 +17934,7 @@ export const tools: McpTool[] = [
companyId,
period,
nextPeriod.id,
(settings.entity_type ?? 'aktiebolag') as EntityType,
parseEntityType(settings.entity_type),
)
if (assessment.collection.unknownVatTreatment.length > 0) {
throw new Error(
@@ -17956,7 +17964,7 @@ export const tools: McpTool[] = [
}
const reversalDate = nextDay(period.period_end)
const entityType = (settings.entity_type ?? 'aktiebolag') as EntityType
const entityType = parseEntityType(settings.entity_type)
const entries = [
...(assessment.lines.receivableLines.length > 0 &&
!assessment.postings.receivableEntryId &&
+3 -2
View File
@@ -1,6 +1,7 @@
import { sleep } from '@/lib/utils'
import crypto from 'crypto'
import { z } from 'zod'
import { parseEntityType } from '@/lib/company/entity-type'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse, after } from 'next/server'
@@ -923,7 +924,7 @@ export const skatteverketExtension: Extension = {
}
const orgNumber = formatRedovisare(
settings.org_number as string,
settings.entity_type as 'enskild_firma' | 'aktiebolag'
parseEntityType(settings.entity_type)
)
const contestedForVerify = await contestedOrgNumberResponse(orgNumber)
@@ -989,7 +990,7 @@ export const skatteverketExtension: Extension = {
}
const orgNumber = formatRedovisare(
settings.org_number as string,
settings.entity_type as 'enskild_firma' | 'aktiebolag'
parseEntityType(settings.entity_type)
)
const contestedForLink = await contestedOrgNumberResponse(orgNumber)
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { parseEntityType } from '@/lib/company/entity-type'
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
import { agiGetKvittenser } from './agi-client'
import { resolveReadAuth } from './resolve-auth'
@@ -84,7 +85,7 @@ export async function reconcileAgiDeclaration(
const arbetsgivare = formatRedovisare(
settings.org_number as string,
settings.entity_type as 'enskild_firma' | 'aktiebolag',
parseEntityType(settings.entity_type),
)
const kvittRes = await agiGetKvittenser(resolved.auth, arbetsgivare, period)
@@ -1,4 +1,5 @@
import { type SupabaseClient } from '@supabase/supabase-js'
import { parseEntityType } from '@/lib/company/entity-type'
import { createServiceRoleClient } from '@/lib/supabase/service-client'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { toRedovisare12 } from '@/lib/invariants/org-number'
@@ -298,7 +299,7 @@ export async function findContestedOrgNumbers(): Promise<Set<string>> {
if (!row.org_number || archivedIds.has(row.company_id)) continue
let redovisare: string
try {
redovisare = toRedovisare12(row.org_number, row.entity_type === 'enskild_firma' ? 'enskild_firma' : 'aktiebolag')
redovisare = toRedovisare12(row.org_number, parseEntityType(row.entity_type))
} catch {
continue
}
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { commitEntry, createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-service'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
@@ -297,7 +298,7 @@ export async function loadRuleContext(
let primary: string | null = null
return {
rules,
entityType: (settingsResult.data?.entity_type as EntityType) ?? 'aktiebolag',
entityType: await resolveCompanyEntityType(supabase, companyId, settingsResult.data?.entity_type),
// Same signal as lib/tax/deadline-config.ts: employer_registered is the
// explicit attestation (nullable, 20260717151000; null = never attested)
// and falls back to the onboarding pays_salaries answer. An explicit
@@ -232,12 +232,13 @@ describe('syncStripeConnection', () => {
})
})
it('falls back to accrual/enskild_firma when the company has no settings row', async () => {
it('falls back to accrual and the companies.entity_type row when the company has no settings row', async () => {
stubEvents([makeEvent(makeSession())])
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'spe-1' }] }) // claim insert
enqueue({ data: payableInvoice() }) // invoice by payment link
enqueue({ data: null }) // company_settings: no row (maybeSingle -> null, no error)
enqueue({ data: { entity_type: 'enskild_firma' } }) // companies: canonical form, never a guessed default
enqueue({ data: null }) // event row finalize
enqueue({ data: null }) // cursor update
+2 -1
View File
@@ -1,4 +1,5 @@
import type Stripe from 'stripe'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getStripe } from '@/lib/stripe/client'
import { eventBus } from '@/lib/events/bus'
@@ -393,7 +394,7 @@ async function processCheckoutSessionEvent(
.eq('company_id', connection.company_id)
.maybeSingle()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const entityType = await resolveCompanyEntityType(supabase, connection.company_id, settings?.entity_type)
const paymentDate = new Date(event.created * 1000).toISOString().split('T')[0]
+2 -3
View File
@@ -1,5 +1,6 @@
import type { ComposerInputs } from './inputs'
import type { AtomSelection } from './schemas'
import { ENTITY_TYPE_LABELS_SV, isEntityType } from '@/lib/company/entity-type'
// Deterministic atom selection used when the Opus call times out or fails.
//
@@ -108,9 +109,7 @@ function buildFallbackQuestions(inputs: ComposerInputs): string[] {
export function fallbackNarrative(inputs: ComposerInputs): string {
const parts: string[] = []
const name = inputs.companyName || 'företaget'
const isAB = inputs.entityType === 'aktiebolag'
const isEF = inputs.entityType === 'enskild_firma'
const form = isAB ? 'aktiebolag' : isEF ? 'enskild firma' : null
const form = isEntityType(inputs.entityType) ? ENTITY_TYPE_LABELS_SV[inputs.entityType].toLowerCase() : null
if (inputs.userIsConfirmedDirector) {
if (form) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { ENTITY_TYPES } from '@/lib/company/entity-type'
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
import { normalizeVatNumber } from '@/lib/vat/vat-number'
import { ACCOUNT_VAT_TREATMENTS } from '@/lib/vat/account-vat-treatment'
@@ -192,7 +193,7 @@ function validateAccrualPeriod(
// Enum schemas (matching types/index.ts)
// ============================================================
export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
export const EntityTypeSchema = z.enum(ENTITY_TYPES)
export const AccountingFrameworkSchema = z.enum(['k2', 'k3'])
@@ -57,6 +57,10 @@ function makeSupabase(handlers: {
b.single.mockResolvedValue(handlers.period)
} else if (table === 'company_settings') {
b.maybeSingle.mockResolvedValue(handlers.settings)
} else if (table === 'companies') {
// Canonical NOT NULL fallback read by resolveCompanyEntityType when the
// settings row is missing; never a guessed default.
b.maybeSingle.mockResolvedValue({ data: { entity_type: 'aktiebolag' }, error: null })
} else if (table === 'cash_accounts') {
// The aggregator resolves 1930 to its cash_accounts row so the bank total
// is scoped to that account (#1290).
@@ -292,7 +296,7 @@ describe('buildBokslutReadinessReport', () => {
).rejects.toThrow(/not found/i)
})
it('defaults to aktiebolag when company_settings is missing', async () => {
it('falls back to companies.entity_type when company_settings is missing', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
+2 -1
View File
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EntityType } from '@/types'
import { roundOre } from '@/lib/money'
import { simplifiedYearEndRegelverk } from '@/lib/company/entity-type'
import { parseInvoiceDateRange } from './date-range-parser'
export type PeriodiseringSource = 'invoice' | 'supplier_invoice'
@@ -198,7 +199,7 @@ function buildSuggestion(args: {
!touchesPersonnelCost
) {
confidence = 'low'
const regelverk = entityType === 'enskild_firma' ? 'K1' : 'K2'
const regelverk = entityType ? simplifiedYearEndRegelverk(entityType) : 'K2'
reason = `${reason} Under 5 000 kr: behöver normalt inte periodiseras (${regelverk}).`
}
+2 -1
View File
@@ -54,6 +54,7 @@
* be handed a legal citation on a guess.
*/
import type { EntityType } from '@/types'
import { preparesArsredovisning as preparesArsredovisningByForm } from '@/lib/company/entity-type'
import { getBASReference, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
/** Swedish and English rejection text, mirroring the structured-errors registry shape. */
@@ -108,7 +109,7 @@ export function k2ExcludedAccountMessages(
entityType?: EntityType | null,
): K2ExcludedAccountMessages {
const label = `${account.account_number} (${account.account_name})`
const preparesArsredovisning = entityType === 'aktiebolag'
const preparesArsredovisning = entityType ? preparesArsredovisningByForm(entityType) : false
if (isEgenupparbetadImmateriell(account)) {
if (!preparesArsredovisning) {
+6 -1
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import {
calculateBolagsskatt,
@@ -43,7 +44,11 @@ export async function buildDispositionsProposal(
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = (settings?.entity_type ?? 'aktiebolag') as DispositionsProposal['entityType']
const entityType: DispositionsProposal['entityType'] = await resolveCompanyEntityType(
supabase,
companyId,
settings?.entity_type,
)
if (entityType !== 'aktiebolag') {
// Non-AB entities (enskild firma, handelsbolag, etc.) do not produce
+7 -2
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
@@ -60,7 +61,7 @@ export interface BokslutReadinessReport {
closing_entry_id: string | null
}
/** Entity type drives which dispositions apply (e.g. bolagsskatt only for AB). */
entityType: 'aktiebolag' | 'enskild_firma' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
entityType: 'aktiebolag' | 'enskild_firma' | 'ideell_forening' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
/** The full raw validation, for callers that want every field. */
rawValidation: YearEndValidation
}
@@ -125,7 +126,11 @@ export async function buildBokslutReadinessReport(
}
const period = periodResult.data
const entityType = (settingsResult.data?.entity_type ?? 'aktiebolag') as BokslutReadinessReport['entityType']
const entityType: BokslutReadinessReport['entityType'] = await resolveCompanyEntityType(
supabase,
companyId,
settingsResult.data?.entity_type,
)
const accountingMethod =
((settingsResult.data as { accounting_method?: string | null } | null)?.accounting_method ??
'accrual')
@@ -1,4 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EntityType } from '@/types'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
import { proposeAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
@@ -335,14 +337,14 @@ function notApplicable(): OveravskrivningarCalculation {
}
}
async function loadEntityType(supabase: SupabaseClient, companyId: string): Promise<string> {
async function loadEntityType(supabase: SupabaseClient, companyId: string): Promise<EntityType> {
const { data, error } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Failed to load company entity type: ${error.message}`)
return data?.entity_type ?? 'aktiebolag'
return resolveCompanyEntityType(supabase, companyId, data?.entity_type)
}
async function loadFiscalPeriodCohorts(
+1 -1
View File
@@ -71,7 +71,7 @@ export interface CompletedDisposition {
* the next one (bolagsskatt comes last because it depends on everything else).
*/
export interface DispositionsProposal {
entityType: 'aktiebolag' | 'enskild_firma' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
entityType: 'aktiebolag' | 'enskild_firma' | 'ideell_forening' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
fiscalPeriod: {
id: string
name: string
@@ -614,6 +614,16 @@ describe('buildMappingResultFromTemplate', () => {
expect(abResult.debit_account).toBe('2893')
})
it('books an ideell förening private expense to the member account 2890, never an owner account', () => {
const tx = makeTransaction({ amount: -300 })
const privat = buildMappingResultFromTemplate(getTemplate('private_expense'), tx, 'ideell_forening')
expect(privat.debit_account).toBe('2890')
expect(privat.credit_account).toBe('1930')
// Non-owner templates keep their base (EF) account for a förening.
const course = buildMappingResultFromTemplate(getTemplate('education_course'), tx, 'ideell_forening')
expect(course.debit_account).toBe('6991')
})
it('includes template_id in the MappingResult', () => {
const template = getTemplate('bank_fees')
const tx = makeTransaction({ amount: -49 })
@@ -13,20 +13,20 @@ import type { TransactionCategory, VatTreatment } from '@/types'
describe('getCategoryAccountMapping', () => {
describe('income_products uses correct account', () => {
it('maps income_products to 3001 (25% moms)', () => {
const result = getCategoryAccountMapping('income_products', 1000, true)
const result = getCategoryAccountMapping('income_products', 1000, true, 'enskild_firma')
expect(result.creditAccount).toBe('3001')
})
it('income_products matches income_services account', () => {
const products = getCategoryAccountMapping('income_products', 1000, true)
const services = getCategoryAccountMapping('income_services', 1000, true)
const products = getCategoryAccountMapping('income_products', 1000, true, 'enskild_firma')
const services = getCategoryAccountMapping('income_services', 1000, true, 'enskild_firma')
expect(products.creditAccount).toBe(services.creditAccount)
})
})
describe('expense_office maps to 6110 (Kontorsförbrukning)', () => {
it('maps expense_office to 6110 (not 5010 Lokalhyra)', () => {
const result = getCategoryAccountMapping('expense_office', -500, true)
const result = getCategoryAccountMapping('expense_office', -500, true, 'enskild_firma')
expect(result.debitAccount).toBe('6110')
})
})
@@ -42,10 +42,17 @@ describe('getCategoryAccountMapping', () => {
expect(result.debitAccount).toBe('7610')
})
it('defaults to 6991 when no entityType provided', () => {
const result = getCategoryAccountMapping('expense_education', -500, true)
it('uses 6991 for an ideell förening (no personnel cost assumed)', () => {
const result = getCategoryAccountMapping('expense_education', -500, true, 'ideell_forening')
expect(result.debitAccount).toBe('6991')
})
it('settles a private förening transaction on 2890, never an owner account', () => {
const out = getCategoryAccountMapping('private', -500, false, 'ideell_forening')
expect(out.debitAccount).toBe('2890')
const inn = getCategoryAccountMapping('private', 500, false, 'ideell_forening')
expect(inn.creditAccount).toBe('2890')
})
})
})
@@ -63,17 +70,21 @@ describe('getExpenseAccountForCategory', () => {
describe('getDefaultAccountForCategory', () => {
it('returns expense account for expense categories', () => {
expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410')
expect(getDefaultAccountForCategory('expense_software')).toBe('5420')
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
expect(getDefaultAccountForCategory('expense_office')).toBe('6110')
expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570')
expect(getDefaultAccountForCategory('expense_equipment', 'enskild_firma')).toBe('5410')
expect(getDefaultAccountForCategory('expense_software', 'enskild_firma')).toBe('5420')
expect(getDefaultAccountForCategory('expense_travel', 'enskild_firma')).toBe('5890')
expect(getDefaultAccountForCategory('expense_office', 'enskild_firma')).toBe('6110')
expect(getDefaultAccountForCategory('expense_bank_fees', 'enskild_firma')).toBe('6570')
})
it('returns income account for income categories', () => {
expect(getDefaultAccountForCategory('income_services')).toBe('3001')
expect(getDefaultAccountForCategory('income_products')).toBe('3001')
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
expect(getDefaultAccountForCategory('income_services', 'enskild_firma')).toBe('3001')
expect(getDefaultAccountForCategory('income_products', 'enskild_firma')).toBe('3001')
expect(getDefaultAccountForCategory('income_other', 'enskild_firma')).toBe('3999')
})
it('returns the member settlement account for an ideell förening', () => {
expect(getDefaultAccountForCategory('private', 'ideell_forening')).toBe('2890')
})
it('returns private account for enskild firma', () => {
@@ -90,7 +101,7 @@ describe('getDefaultAccountForCategory', () => {
})
it('returns fallback for uncategorized', () => {
expect(getDefaultAccountForCategory('uncategorized')).toBe('6991')
expect(getDefaultAccountForCategory('uncategorized', 'enskild_firma')).toBe('6991')
})
})
@@ -340,7 +351,7 @@ describe('buildMappingResultFromCategory returns non-empty accounts', () => {
it.each(allCategories)('returns non-empty debit_account and credit_account for "%s"', (category) => {
const tx = makeTransaction({ amount: category.startsWith('income') ? 1000 : -1000 })
const isBusiness = category !== 'private'
const result = buildMappingResultFromCategory(category, tx, isBusiness)
const result = buildMappingResultFromCategory(category, tx, isBusiness, 'enskild_firma')
expect(result.debit_account).toBeTruthy()
expect(result.credit_account).toBeTruthy()
@@ -380,14 +391,14 @@ describe('representation VAT (reduced 12%, ML 13 kap 24-25 §§)', () => {
})
it('getCategoryAccountMapping has vatTreatment: reduced_12 for representation', () => {
const result = getCategoryAccountMapping('expense_representation', -500, true)
const result = getCategoryAccountMapping('expense_representation', -500, true, 'enskild_firma')
expect(result.vatTreatment).toBe('reduced_12')
expect(result.vatDebitAccount).toBe('2641')
})
it('buildMappingResultFromCategory generates 12% VAT line for representation', () => {
const tx = makeTransaction({ amount: -500 })
const result = buildMappingResultFromCategory('expense_representation', tx, true)
const result = buildMappingResultFromCategory('expense_representation', tx, true, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
})
@@ -421,7 +432,7 @@ describe('income account resolves by VAT treatment', () => {
})
it('defaults to 3001 when no vatTreatment provided', () => {
const result = getCategoryAccountMapping('income_services', 1000, true)
const result = getCategoryAccountMapping('income_services', 1000, true, 'enskild_firma')
expect(result.creditAccount).toBe('3001')
})
})
@@ -454,19 +465,19 @@ describe('private transaction accounts by entity type and direction', () => {
describe('incoming expense refund (positive amount, expense category)', () => {
it('getCategoryAccountMapping swaps accounts: bank debited, expense account credited', () => {
const result = getCategoryAccountMapping('expense_software', 500, true)
const result = getCategoryAccountMapping('expense_software', 500, true, 'enskild_firma')
expect(result.debitAccount).toBe('1930')
expect(result.creditAccount).toBe('5420')
})
it('getCategoryAccountMapping sets vatCreditAccount 2641 and clears vatDebitAccount for refund', () => {
const result = getCategoryAccountMapping('expense_software', 500, true)
const result = getCategoryAccountMapping('expense_software', 500, true, 'enskild_firma')
expect(result.vatDebitAccount).toBeNull()
expect(result.vatCreditAccount).toBe('2641')
})
it('VAT-exempt expense refund (bank_fees) has no VAT accounts', () => {
const result = getCategoryAccountMapping('expense_bank_fees', 100, true)
const result = getCategoryAccountMapping('expense_bank_fees', 100, true, 'enskild_firma')
expect(result.debitAccount).toBe('1930')
expect(result.creditAccount).toBe('6570')
expect(result.vatDebitAccount).toBeNull()
@@ -475,7 +486,7 @@ describe('incoming expense refund (positive amount, expense category)', () => {
it('buildMappingResultFromCategory generates credit line on 2641 for expense refund', () => {
const tx = makeTransaction({ amount: 1000 })
const result = buildMappingResultFromCategory('expense_software', tx, true)
const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].credit_amount).toBe(200)
@@ -484,19 +495,19 @@ describe('incoming expense refund (positive amount, expense category)', () => {
it('buildMappingResultFromCategory uses återföring description for expense refund VAT', () => {
const tx = makeTransaction({ amount: 1000 })
const result = buildMappingResultFromCategory('expense_software', tx, true)
const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma')
expect(result.vat_lines[0].description).toBe('Återföring ingående moms 25%')
})
it('buildMappingResultFromCategory generates no VAT line for VAT-exempt expense refund', () => {
const tx = makeTransaction({ amount: 100 })
const result = buildMappingResultFromCategory('expense_bank_fees', tx, true)
const result = buildMappingResultFromCategory('expense_bank_fees', tx, true, 'enskild_firma')
expect(result.vat_lines).toHaveLength(0)
})
it('buildMappingResultFromCategory maps debit/credit correctly (bank debited, expense credited)', () => {
const tx = makeTransaction({ amount: 1250 })
const result = buildMappingResultFromCategory('expense_software', tx, true)
const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma')
expect(result.debit_account).toBe('1930')
expect(result.credit_account).toBe('5420')
})
@@ -545,24 +556,26 @@ describe('category default → leaf account guarantee', () => {
]
it.each(categoriesUnderGuard)('%s default does not resolve to a gruppkonto', (category) => {
const target = getDefaultAccountForCategory(category)
expect(groupAccountNumbers.has(target)).toBe(false)
for (const entityType of ['enskild_firma', 'aktiebolag', 'ideell_forening'] as const) {
const target = getDefaultAccountForCategory(category, entityType)
expect(groupAccountNumbers.has(target)).toBe(false)
}
})
it('uncategorized positive amount does not credit a gruppkonto', () => {
const result = getCategoryAccountMapping('uncategorized', 1000, true)
const result = getCategoryAccountMapping('uncategorized', 1000, true, 'enskild_firma')
expect(groupAccountNumbers.has(result.creditAccount)).toBe(false)
})
it('expense_telecom resolves to 6230 (Datakommunikation, leaf)', () => {
expect(getDefaultAccountForCategory('expense_telecom')).toBe('6230')
expect(getDefaultAccountForCategory('expense_telecom', 'enskild_firma')).toBe('6230')
})
it('expense_travel resolves to 5890 (Övriga resekostnader, leaf)', () => {
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
expect(getDefaultAccountForCategory('expense_travel', 'enskild_firma')).toBe('5890')
})
it('income_other resolves to 3999 (Övriga rörelseintäkter, leaf)', () => {
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
expect(getDefaultAccountForCategory('income_other', 'enskild_firma')).toBe('3999')
})
})
@@ -43,7 +43,7 @@ describe('evaluateMappingRules own-account transfer guard', () => {
supabase as never,
'company-1',
makeTransaction({ amount: -1000, currency: 'SEK' }),
undefined,
'enskild_firma',
'1930',
)
@@ -65,7 +65,7 @@ describe('evaluateMappingRules own-account transfer guard', () => {
supabase as never,
'company-1',
makeTransaction({ amount: 217.04, currency: 'SEK' }),
undefined,
'enskild_firma',
'1940',
)
@@ -126,7 +126,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('6540')
})
@@ -144,7 +144,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('6540')
})
@@ -164,7 +164,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('6540')
})
@@ -182,7 +182,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('6991') // default expense fallback
})
@@ -192,7 +192,7 @@ describe('mapping-engine', () => {
const tx = makeTransaction({ amount: -100, merchant_name: 'Unknown' })
mockResult({ data: [], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('6991')
expect(result.credit_account).toBe('1930')
@@ -206,7 +206,7 @@ describe('mapping-engine', () => {
const tx = makeTransaction({ amount: 500, merchant_name: 'Unknown' })
mockResult({ data: [], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('1930')
expect(result.credit_account).toBe('3900')
@@ -345,7 +345,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// 30,000 > 28,650 (2024 half-PBB) → should capitalize to 1250
expect(result.debit_account).toBe('1250')
})
@@ -395,7 +395,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// 29,000 < 29,400 (2025 half-PBB) → should NOT capitalize
expect(result.debit_account).toBe('5410')
})
@@ -444,7 +444,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('5410')
expect(result.credit_account).toBe('1930')
@@ -495,7 +495,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// Fiktiv-moms pair + basbelopp pair = 4 lines (FK004 guard)
expect(result.vat_lines).toHaveLength(4)
@@ -552,7 +552,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// Only fiktiv-moms pair: basbelopp already covered by the expense line
expect(result.vat_lines).toHaveLength(2)
@@ -609,7 +609,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// 100 EUR at 11 = 1100 kr gross; 25% extraction = 220 kr, not 20
expect(result.vat_lines).toHaveLength(1)
@@ -665,7 +665,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// 950 kr gross: fiktiv moms 237.50, basbelopp 950 (not 25/100 off USD)
expect(result.vat_lines).toHaveLength(4)
@@ -728,7 +728,7 @@ describe('mapping-engine', () => {
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('1250')
expect(result.requires_review).toBe(false)
@@ -745,7 +745,7 @@ describe('mapping-engine', () => {
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('5410')
expect(result.requires_review).toBe(false)
@@ -765,7 +765,7 @@ describe('mapping-engine', () => {
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('1250')
expect(result.requires_review).toBe(false)
@@ -784,7 +784,7 @@ describe('mapping-engine', () => {
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('1250')
})
@@ -802,7 +802,7 @@ describe('mapping-engine', () => {
})
mockResult({ data: [makeRule()], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// The rule alone would auto-book (confidence 0.9, requires_review false).
// Without a SEK value the capitalization branch is a guess, so the
@@ -825,7 +825,7 @@ describe('mapping-engine', () => {
})
mockResult({ data: [makeRule({ capitalized_debit_account: null })], error: null })
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// No capitalization decision to make: the missing rate is irrelevant.
expect(result.debit_account).toBe('5410')
@@ -857,7 +857,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// No match: falls through to the uncategorized default.
expect(result.rule).toBeNull()
@@ -889,7 +889,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('5410')
expect(result.confidence).toBe(0.9)
@@ -917,7 +917,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
// The band is unevaluable, so the rule does not apply. The transaction
// lands in the uncategorized default where the user picks it up.
@@ -946,7 +946,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
expect(result.debit_account).toBe('5410')
})
@@ -300,6 +300,13 @@ describe('computeProposalLines', () => {
})
})
it('translates the owner account to 2890 for an ideell förening', () => {
expect(resolveTemplateAccountsForEntity(template, 'ideell_forening')).toEqual({
debitAccount: '2890',
creditAccount: '1930',
})
})
it('substitutes AB accounts for aktiebolag, falling back per side', () => {
expect(resolveTemplateAccountsForEntity(template, 'aktiebolag')).toEqual({
debitAccount: '2893',
+7 -9
View File
@@ -14,6 +14,7 @@ import {
generateInputVatLine,
} from './vat-entries'
import { resolveSekAmount } from './currency-utils'
import { templateAccountForForm } from '@/lib/company/entity-type'
// ============================================================
// Types
@@ -44,7 +45,7 @@ export interface BookingTemplate {
name_en: string
group: TemplateGroup
direction: 'expense' | 'income' | 'transfer'
entity_applicability: 'all' | 'enskild_firma' | 'aktiebolag'
entity_applicability: 'all' | EntityType
debit_account: string
credit_account: string
debit_account_ab?: string
@@ -1873,18 +1874,15 @@ function isBasisAccount(account: string): boolean {
export function buildMappingResultFromTemplate(
template: BookingTemplate,
transaction: Transaction,
entityType: EntityType = 'enskild_firma'
entityType: EntityType
): MappingResult {
const isExpense = transaction.amount < 0
const isBusiness = !template.default_private
// Resolve entity-specific accounts
let debitAccount = template.debit_account
let creditAccount = template.credit_account
if (entityType === 'aktiebolag') {
if (template.debit_account_ab) debitAccount = template.debit_account_ab
if (template.credit_account_ab) creditAccount = template.credit_account_ab
}
// Resolve entity-specific accounts (EF base, AB override, förening: base
// with owner accounts translated to the member settlement account).
const debitAccount = templateAccountForForm(entityType, template.debit_account, template.debit_account_ab)!
const creditAccount = templateAccountForForm(entityType, template.credit_account, template.credit_account_ab)!
// Always work in SEK. For non-SEK transactions, resolve the SEK-equivalent
// (via amount_sek or amount * exchange_rate); for SEK rows this is a no-op.
+17 -18
View File
@@ -2,6 +2,7 @@ import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, E
import { getVatRate, generateReverseChargeLines } from './vat-entries'
import { resolveSekAmount } from './currency-utils'
import { roundOre } from '@/lib/money'
import { byEntityType, ownerSettlementAccount } from '@/lib/company/entity-type'
/**
* Maps TransactionCategory to BAS accounts for journal entry creation
@@ -32,11 +33,8 @@ interface CategoryAccountMapping {
// Default bank account - typically 1930 (Företagskonto/checkkonto)
const BANK_ACCOUNT = '1930'
// Private/owner transaction accounts by entity type
const PRIVATE_ACCOUNTS: Record<EntityType, string> = {
enskild_firma: '2013', // Övriga egna uttag
aktiebolag: '2893', // Skuld till aktieägare/delägare
}
// Private/owner transaction accounts live in lib/company/entity-type.ts
// (ownerSettlementAccount): EF 2013/2018, AB 2893, ideell förening 2890.
// Single source of truth for category -> expense account mapping
const EXPENSE_ACCOUNTS: Record<string, string> = {
@@ -67,9 +65,15 @@ const INCOME_ACCOUNTS: Record<string, string> = {
* Get the expense account for a category, with entity-specific overrides.
* Education (expense_education) differs: AB uses 7610, EF uses 6991.
*/
function getExpenseAccount(category: string, entityType: EntityType = 'enskild_firma'): string {
function getExpenseAccount(category: string, entityType: EntityType): string {
if (category === 'expense_education') {
return entityType === 'aktiebolag' ? '7610' : '6991'
// 7610 is a personnel cost: only a form with employees by default books
// education there; the others take the general external-cost account.
return byEntityType(entityType, {
aktiebolag: '7610',
enskild_firma: '6991',
ideell_forening: '6991',
})
}
return EXPENSE_ACCOUNTS[category] || '6991'
}
@@ -108,19 +112,14 @@ export function getCategoryAccountMapping(
category: TransactionCategory,
amount: number,
isBusiness: boolean,
entityType: EntityType = 'enskild_firma',
entityType: EntityType,
vatTreatment?: VatTreatment
): CategoryAccountMapping {
// Private/owner transactions use entity-specific accounts
// EF: 2013 for withdrawals (uttag), 2018 for deposits (insättningar)
// AB: 2893 for both directions
// AB: 2893 for both directions; ideell förening: 2890 (no owner)
if (!isBusiness) {
let privateAccount: string
if (entityType === 'enskild_firma') {
privateAccount = amount < 0 ? '2013' : '2018'
} else {
privateAccount = PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
}
const privateAccount = ownerSettlementAccount(entityType, amount < 0 ? 'withdrawal' : 'contribution')
return {
debitAccount: amount < 0 ? privateAccount : BANK_ACCOUNT,
creditAccount: amount < 0 ? BANK_ACCOUNT : privateAccount,
@@ -241,7 +240,7 @@ export function buildMappingResultFromCategory(
category: TransactionCategory,
transaction: Transaction,
isBusiness: boolean,
entityType: EntityType = 'enskild_firma',
entityType: EntityType,
vatTreatment?: VatTreatment,
vatAmountOverride?: number | null
): MappingResult {
@@ -404,10 +403,10 @@ export function getExpenseAccountForCategory(category: TransactionCategory): str
*/
export function getDefaultAccountForCategory(
category: TransactionCategory,
entityType: EntityType = 'enskild_firma'
entityType: EntityType
): string {
if (category === 'private') {
return PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
return ownerSettlementAccount(entityType, 'withdrawal')
}
if (category.startsWith('expense_')) {
+6 -1
View File
@@ -7,6 +7,7 @@
*/
import type { EntityType, VatTreatment } from '@/types'
import { byEntityType } from '@/lib/company/entity-type'
/**
* Stable code for the "foreign-currency customer invoice without a rate"
@@ -67,7 +68,11 @@ export function getRevenueAccount(vatTreatment: VatTreatment, entityType: Entity
case 'export':
return '3305' // Försäljning tjänst Export
case 'exempt':
return entityType === 'aktiebolag' ? '3004' : '3100'
return byEntityType(entityType, {
aktiebolag: '3004',
enskild_firma: '3100',
ideell_forening: '3100',
})
default:
return '3001'
}
+8 -7
View File
@@ -21,6 +21,7 @@ import type {
VatJournalLine,
} from '@/types'
import { createLogger } from '@/lib/logger'
import { ownerSettlementAccount } from '@/lib/company/entity-type'
const log = createLogger('mapping-engine')
@@ -82,7 +83,7 @@ export async function evaluateMappingRules(
supabase: SupabaseClient,
companyId: string,
transaction: Transaction,
entityType?: EntityType,
entityType: EntityType,
settlementAccount?: string
): Promise<MappingResult> {
const bankAccount = settlementAccount || '1930'
@@ -161,7 +162,7 @@ export async function evaluateMappingRules(
*/
function evaluateTemplateRules(
transaction: Transaction,
entityType?: EntityType
entityType: EntityType
): MappingResult | null {
const matches = findMatchingTemplates(transaction, entityType)
if (matches.length === 0 || matches[0].confidence < 0.3) return null
@@ -170,7 +171,7 @@ function evaluateTemplateRules(
const result = buildMappingResultFromTemplate(
best.template,
transaction,
entityType || 'enskild_firma'
entityType
)
// Override the confidence with the auto-match confidence (not 1.0)
result.confidence = best.confidence
@@ -186,7 +187,7 @@ async function evaluateCounterpartyTemplates(
supabase: SupabaseClient,
companyId: string,
transaction: Transaction,
entityType?: EntityType
entityType: EntityType
): Promise<MappingResult | null> {
try {
const match = await findCounterpartyTemplate(supabase, companyId, transaction)
@@ -198,7 +199,7 @@ async function evaluateCounterpartyTemplates(
return buildMappingResultFromCounterpartyTemplate(
match,
transaction,
entityType || 'enskild_firma'
entityType
)
} catch {
// Non-critical: fall through to next fallback
@@ -303,7 +304,7 @@ function matchesRule(rule: MappingRule, transaction: Transaction): boolean {
/**
* Build a MappingResult from a matched rule
*/
function buildResult(rule: MappingRule, transaction: Transaction, entityType?: EntityType): MappingResult {
function buildResult(rule: MappingRule, transaction: Transaction, entityType: EntityType): MappingResult {
// VAT figures land on journal entry lines, which are always SEK, so they
// are derived from the SEK value of the transaction. The LENIENT resolver
// is deliberate: buildTransactionEntryLines resolves the gross with the
@@ -353,7 +354,7 @@ function buildResult(rule: MappingRule, transaction: Transaction, entityType?: E
// If default_private, use entity-specific private account
if (rule.default_private && isExpense) {
debitAccount = entityType === 'aktiebolag' ? '2893' : '2013'
debitAccount = ownerSettlementAccount(entityType, 'withdrawal')
}
// Generate VAT lines if applicable
+5 -6
View File
@@ -36,6 +36,7 @@ import { getVatRate } from '@/lib/bookkeeping/vat-entries'
import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
import { buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
import { roundOre } from '@/lib/money'
import { templateAccountForForm } from '@/lib/company/entity-type'
import {
legacyTemplateDirection as legacyDirection,
patternDirection,
@@ -137,13 +138,11 @@ export function resolveTemplateAccountsForEntity(
},
entityType: EntityType | undefined,
): { debitAccount?: string; creditAccount?: string } {
if (entityType === 'aktiebolag') {
return {
debitAccount: template.debit_account_ab ?? template.debit_account,
creditAccount: template.credit_account_ab ?? template.credit_account,
}
if (!entityType) return { debitAccount: template.debit_account, creditAccount: template.credit_account }
return {
debitAccount: templateAccountForForm(entityType, template.debit_account, template.debit_account_ab),
creditAccount: templateAccountForForm(entityType, template.credit_account, template.credit_account_ab),
}
return { debitAccount: template.debit_account, creditAccount: template.credit_account }
}
/**
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { mapEntityType } from '../entity-type-map'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { mapEntityType, mapSetupEntityType } from '../entity-type-map'
describe('mapEntityType', () => {
it('maps the exact AB codes and labels to aktiebolag', () => {
@@ -44,3 +44,30 @@ describe('mapEntityType', () => {
expect(mapEntityType(undefined)).toBeNull()
})
})
describe('mapSetupEntityType: only creatable forms are prefilled', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('maps ideell förening only when the creation flag is on', () => {
vi.stubEnv('NEXT_PUBLIC_IDEELL_FORENING_ENABLED', '')
expect(mapSetupEntityType('Ideell förening')).toBeNull()
expect(mapSetupEntityType('Aktiebolag')).toBe('aktiebolag')
vi.stubEnv('NEXT_PUBLIC_IDEELL_FORENING_ENABLED', 'true')
expect(mapSetupEntityType('Ideell förening')).toBe('ideell_forening')
})
})
describe('mapEntityType: ideell förening (issue #2072)', () => {
it('maps the registry spelling of ideell förening', () => {
expect(mapEntityType('Ideell förening')).toBe('ideell_forening')
expect(mapEntityType('ideell forening')).toBe('ideell_forening')
})
it('does not map other föreningar or stiftelser', () => {
expect(mapEntityType('Ekonomisk förening')).toBeNull()
expect(mapEntityType('Registrerat trossamfund')).toBeNull()
expect(mapEntityType('Stiftelse')).toBeNull()
})
})
+27
View File
@@ -1,4 +1,5 @@
import type { EntityType } from '@/types'
import { isEntityTypeCreatable } from '@/lib/company/entity-type'
/**
* Explicit allow-lists for TIC/Bolagsverket `legalEntityType` Accounted
@@ -25,10 +26,36 @@ const ENSKILD_FIRMA_VALUES = new Set<string>([
'enskild näringsidkare',
])
/**
* Ideell förening (issue #2072). Most föreningar carry an 8-series org number
* issued by Skatteverket, so the Bolagsverket-backed lookup legitimately
* misses them; this arm matters for the registered ones and for BankID
* company roles. Ekonomisk förening, stiftelse and trossamfund are NOT
* mapped: different equity, tax form and regelverk.
*/
const IDEELL_FORENING_VALUES = new Set<string>([
'ideell förening',
'ideell forening',
'ideella föreningar',
])
export function mapEntityType(ticType: string | null | undefined): EntityType | null {
if (!ticType) return null
const normalized = ticType.trim().toLowerCase()
if (AKTIEBOLAG_VALUES.has(normalized)) return 'aktiebolag'
if (ENSKILD_FIRMA_VALUES.has(normalized)) return 'enskild_firma'
if (IDEELL_FORENING_VALUES.has(normalized)) return 'ideell_forening'
return null
}
/**
* The form a registry lookup may PREFILL for automatic setup: mapEntityType
* narrowed to forms this deployment can create. A form behind a feature flag
* maps to null here so the onboarding journey falls through to the form
* picker (which lists only creatable forms) instead of prefilling a value the
* create path will refuse at the last step.
*/
export function mapSetupEntityType(ticType: string | null | undefined): EntityType | null {
const mapped = mapEntityType(ticType)
return mapped && isEntityTypeCreatable(mapped) ? mapped : null
}
+135
View File
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
ENTITY_TYPES,
UnknownEntityTypeError,
byEntityType,
creatableEntityTypes,
defaultAccountingMethod,
fiscalYearLockedToCalendar,
isEntityType,
isEntityTypeCreatable,
ownerSettlementAccount,
parseEntityType,
preparesArsredovisning,
resolveCompanyEntityType,
resultClosingAccounts,
simplifiedYearEndRegelverk,
usesPersonnummerAsOrgNumber,
} from '@/lib/company/entity-type'
function stubSupabase(companyRow: { entity_type: string } | null, error: { message: string } | null = null) {
const maybeSingle = vi.fn().mockResolvedValue({ data: companyRow, error })
const eq = vi.fn().mockReturnValue({ maybeSingle })
const select = vi.fn().mockReturnValue({ eq })
const from = vi.fn().mockReturnValue({ select })
return { client: { from } as unknown as SupabaseClient, from, eq }
}
describe('entity-type: parsing', () => {
it('lists the three supported forms', () => {
expect([...ENTITY_TYPES]).toEqual(['enskild_firma', 'aktiebolag', 'ideell_forening'])
})
it('narrows known values and rejects everything else', () => {
expect(isEntityType('ideell_forening')).toBe(true)
expect(isEntityType('handelsbolag')).toBe(false)
expect(isEntityType(null)).toBe(false)
expect(isEntityType(1930)).toBe(false)
expect(parseEntityType('aktiebolag')).toBe('aktiebolag')
expect(() => parseEntityType('handelsbolag')).toThrow(UnknownEntityTypeError)
expect(() => parseEntityType(undefined)).toThrow(/expected one of/)
})
it('byEntityType refuses a corrupt value at runtime', () => {
expect(byEntityType('ideell_forening', { enskild_firma: 1, aktiebolag: 2, ideell_forening: 3 })).toBe(3)
expect(() =>
byEntityType('stiftelse' as never, { enskild_firma: 1, aktiebolag: 2, ideell_forening: 3 }),
).toThrow(UnknownEntityTypeError)
})
})
describe('entity-type: resolveCompanyEntityType', () => {
it('uses a valid hint without touching the database', async () => {
const { client, from } = stubSupabase(null)
await expect(resolveCompanyEntityType(client, 'c1', 'ideell_forening')).resolves.toBe('ideell_forening')
expect(from).not.toHaveBeenCalled()
})
it('falls back to companies.entity_type when the hint is missing', async () => {
const { client, eq } = stubSupabase({ entity_type: 'enskild_firma' })
await expect(resolveCompanyEntityType(client, 'c1', null)).resolves.toBe('enskild_firma')
expect(eq).toHaveBeenCalledWith('id', 'c1')
})
it('never defaults: throws when neither source has a valid form', async () => {
const { client } = stubSupabase(null)
await expect(resolveCompanyEntityType(client, 'c1', undefined)).rejects.toThrow(UnknownEntityTypeError)
})
it('surfaces a read error instead of guessing', async () => {
const { client } = stubSupabase(null, { message: 'boom' })
await expect(resolveCompanyEntityType(client, 'c1')).rejects.toThrow(/boom/)
})
})
describe('entity-type: domain facts', () => {
it('closes the year to the equity account of each form', () => {
expect(resultClosingAccounts('enskild_firma')).toEqual({
closing: '2010',
closingName: 'Eget kapital',
priorYearCarry: null,
})
expect(resultClosingAccounts('aktiebolag')).toEqual({
closing: '2099',
closingName: 'Årets resultat',
priorYearCarry: '2098',
})
expect(resultClosingAccounts('ideell_forening')).toEqual({
closing: '2069',
closingName: 'Årets resultat',
priorYearCarry: '2068',
})
})
it('settles owner money on the form-specific account, 2890 for a förening', () => {
expect(ownerSettlementAccount('enskild_firma', 'withdrawal')).toBe('2013')
expect(ownerSettlementAccount('enskild_firma', 'contribution')).toBe('2018')
expect(ownerSettlementAccount('aktiebolag', 'withdrawal')).toBe('2893')
expect(ownerSettlementAccount('aktiebolag', 'contribution')).toBe('2893')
expect(ownerSettlementAccount('ideell_forening', 'withdrawal')).toBe('2890')
expect(ownerSettlementAccount('ideell_forening', 'contribution')).toBe('2890')
})
it('keeps the form-specific defaults', () => {
expect(preparesArsredovisning('aktiebolag')).toBe(true)
expect(preparesArsredovisning('ideell_forening')).toBe(false)
expect(fiscalYearLockedToCalendar('enskild_firma')).toBe(true)
expect(fiscalYearLockedToCalendar('ideell_forening')).toBe(false)
expect(usesPersonnummerAsOrgNumber('enskild_firma')).toBe(true)
expect(usesPersonnummerAsOrgNumber('ideell_forening')).toBe(false)
expect(defaultAccountingMethod('enskild_firma')).toBe('cash')
expect(defaultAccountingMethod('ideell_forening')).toBe('accrual')
expect(simplifiedYearEndRegelverk('ideell_forening')).toBe('K1')
expect(simplifiedYearEndRegelverk('aktiebolag')).toBe('K2')
})
})
describe('entity-type: creation flag', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('hides ideell_forening until the flag is on', () => {
vi.stubEnv('NEXT_PUBLIC_IDEELL_FORENING_ENABLED', '')
expect(isEntityTypeCreatable('ideell_forening')).toBe(false)
expect(isEntityTypeCreatable('aktiebolag')).toBe(true)
expect(creatableEntityTypes()).toEqual(['enskild_firma', 'aktiebolag'])
})
it('offers ideell_forening when the flag is on', () => {
vi.stubEnv('NEXT_PUBLIC_IDEELL_FORENING_ENABLED', 'true')
expect(isEntityTypeCreatable('ideell_forening')).toBe(true)
expect(creatableEntityTypes()).toEqual(['enskild_firma', 'aktiebolag', 'ideell_forening'])
})
})
+40 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CompanySetupSchema, planCompanySetup } from '../onboarding-input'
const base = {
@@ -198,3 +198,42 @@ describe('planCompanySetup', () => {
expect(plan.input.settings.moms_period).toBeNull()
})
})
describe('CompanySetupSchema: ideell_forening', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('is refused while the creation flag is off', () => {
vi.stubEnv('NEXT_PUBLIC_IDEELL_FORENING_ENABLED', '')
const result = CompanySetupSchema.safeParse({
name: 'SS Testklubb',
entity_type: 'ideell_forening',
org_number: '8144009464',
vat_registered: false,
f_skatt: false,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues.map((i) => i.path.join('.'))).toContain('entity_type')
}
})
it('defaults to accrual and keeps a broken fiscal year once enabled', () => {
vi.stubEnv('NEXT_PUBLIC_IDEELL_FORENING_ENABLED', 'true')
const setup = CompanySetupSchema.parse({
name: 'SS Testklubb',
entity_type: 'ideell_forening',
org_number: '8144009464',
vat_registered: false,
f_skatt: false,
fiscal_year_start_month: 7,
})
const plan = planCompanySetup(setup)
expect(plan.ok).toBe(true)
if (!plan.ok) return
expect(plan.resolved).toEqual({ accountingMethod: 'accrual', accountingMethodDefaulted: true })
expect(plan.input.settings.fiscal_year_start_month).toBe(7)
expect(plan.input.entityType).toBe('ideell_forening')
})
})
+3 -2
View File
@@ -5,6 +5,7 @@ import { createClient, createServiceClient } from '@/lib/supabase/server'
import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
import { revalidatePath } from 'next/cache'
import { createCompanyCore } from '@/lib/company/create-company'
import { isEntityType, isEntityTypeCreatable } from '@/lib/company/entity-type'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -95,8 +96,8 @@ async function createCompanyFromOnboardingImpl(params: {
return { error: 'Unauthorized' }
}
const entityType = params.settings.entity_type as string | undefined
if (entityType !== 'enskild_firma' && entityType !== 'aktiebolag') {
const entityType = params.settings.entity_type
if (!isEntityType(entityType) || !isEntityTypeCreatable(entityType)) {
return { error: 'Ogiltig företagsform.' }
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { parseDateParts, validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import type { CompanySettings } from '@/types'
import { fiscalYearLockedToCalendar, isEntityType } from '@/lib/company/entity-type'
export interface ComputedFiscalPeriod {
error: string | null
@@ -38,7 +39,7 @@ export function computeFiscalPeriod(
: `Första räkenskapsåret ${startYear}/${endYear}`
} else {
let startMonth = (s.fiscal_year_start_month as number) || 1
if (s.entity_type === 'enskild_firma') startMonth = 1
if (isEntityType(s.entity_type) && fiscalYearLockedToCalendar(s.entity_type)) startMonth = 1
const currentYear = new Date().getFullYear()
startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01`
+206
View File
@@ -0,0 +1,206 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EntityType } from '@/types'
import { flagEnabled } from '@/lib/env/public-flags'
/**
* The one place that knows which legal forms Accounted books for and what
* follows from each of them.
*
* Before this module the form was a binary flag spread over ~300 files:
* `=== 'aktiebolag' ? A : B` ternaries and `?? 'enskild_firma'` /
* `?? 'aktiebolag'` defaults. Widening the `EntityType` union compiled
* everywhere and changed nothing, so a third form silently booked as an
* enskild firma in the app and as an aktiebolag in bokslut and MCP. Every
* form-dependent fact now goes through `byEntityType`, whose `Record` arms
* make the compiler refuse the next widening until each site has an answer.
*
* Domain facts (issue #2072, DECISIONS.md 2026-09-08):
* - Ideell förening closes its result to 2069 "Årets resultat" and carries
* it to 2068 at the next year start, mirroring the AB 2099/2098 pair on the
* BAS 2060-2069 group for föreningar.
* - A förening has no owner: there are no egna uttag/insättningar (EF
* 2013/2018) and no delägarskuld (AB 2893). Money settled with a member is
* a plain short-term liability, 2890.
* - A förening is a juridisk person, so BFL 3 kap does not force the
* calendar year on it (the EF rule) and its default method is accrual.
*/
export const ENTITY_TYPES = [
'enskild_firma',
'aktiebolag',
'ideell_forening',
] as const satisfies readonly EntityType[]
// Compile-time proof that ENTITY_TYPES lists every member of the union.
type MissingFromList = Exclude<EntityType, (typeof ENTITY_TYPES)[number]>
const entityTypesAreExhaustive: MissingFromList extends never ? true : never = true
void entityTypesAreExhaustive
/** Statutory Swedish names, kept in Swedish in both locales. */
export const ENTITY_TYPE_LABELS_SV: Record<EntityType, string> = {
enskild_firma: 'Enskild firma',
aktiebolag: 'Aktiebolag',
ideell_forening: 'Ideell förening',
}
export class UnknownEntityTypeError extends Error {
readonly code = 'COMPANY_ENTITY_TYPE_UNKNOWN'
constructor(value: unknown) {
super(
`Unknown company entity_type ${JSON.stringify(value)}: expected one of ${ENTITY_TYPES.join(', ')}`,
)
this.name = 'UnknownEntityTypeError'
}
}
export function isEntityType(value: unknown): value is EntityType {
return typeof value === 'string' && (ENTITY_TYPES as readonly string[]).includes(value)
}
/** Narrow a raw DB/JSON value; throws instead of defaulting. */
export function parseEntityType(value: unknown): EntityType {
if (isEntityType(value)) return value
throw new UnknownEntityTypeError(value)
}
/**
* Exhaustive dispatch on the legal form. `Record<EntityType, T>` makes every
* arm mandatory at compile time; the runtime check catches a corrupt string
* that slipped past the DB CHECK.
*/
export function byEntityType<T>(entityType: EntityType, arms: Record<EntityType, T>): T {
if (!Object.prototype.hasOwnProperty.call(arms, entityType)) {
throw new UnknownEntityTypeError(entityType)
}
return arms[entityType]
}
/**
* Resolve a company's legal form for a booking path. `hint` is whatever the
* caller already loaded (usually `company_settings.entity_type`); when it is
* missing or invalid the canonical `companies.entity_type` (NOT NULL, CHECKed)
* is read. Never defaults: a wrong form books to the wrong equity account.
*/
export async function resolveCompanyEntityType(
supabase: SupabaseClient,
companyId: string,
hint?: unknown,
): Promise<EntityType> {
if (isEntityType(hint)) return hint
const { data, error } = await supabase
.from('companies')
.select('entity_type')
.eq('id', companyId)
.maybeSingle()
if (error) throw new Error(`Failed to load company entity type: ${error.message}`)
return parseEntityType(data?.entity_type)
}
/**
* Creation gate for forms still in beta. `NEXT_PUBLIC_` so the onboarding
* picker and the server-side create paths read the same switch; the DB CHECK
* accepts the value regardless, so flipping the flag never needs a migration.
* The literal `process.env.NEXT_PUBLIC_...` spelling is what Next.js inlines
* into client bundles; a computed key would read undefined in the browser.
*/
export const IDEELL_FORENING_FLAG = 'NEXT_PUBLIC_IDEELL_FORENING_ENABLED'
export function isEntityTypeCreatable(entityType: EntityType): boolean {
return byEntityType(entityType, {
enskild_firma: true,
aktiebolag: true,
ideell_forening: flagEnabled(process.env.NEXT_PUBLIC_IDEELL_FORENING_ENABLED),
})
}
/** Forms a user may pick right now (feature flags applied). */
export function creatableEntityTypes(): EntityType[] {
return ENTITY_TYPES.filter(isEntityTypeCreatable)
}
// ── Domain facts ─────────────────────────────────────────────────────
export interface ResultClosingAccounts {
/** Account the year's net result is closed to. */
closing: string
closingName: string
/**
* Account the previous year's result is moved to at the next year start
* (null when the form closes straight into an equity account, EF 2010).
*/
priorYearCarry: string | null
}
export function resultClosingAccounts(entityType: EntityType): ResultClosingAccounts {
return byEntityType<ResultClosingAccounts>(entityType, {
enskild_firma: { closing: '2010', closingName: 'Eget kapital', priorYearCarry: null },
aktiebolag: { closing: '2099', closingName: 'Årets resultat', priorYearCarry: '2098' },
ideell_forening: { closing: '2069', closingName: 'Årets resultat', priorYearCarry: '2068' },
})
}
/**
* Account for money settled with the owner (EF: egna uttag/insättningar, AB:
* skuld till aktieägare). A förening has no owner; a member who pays or is
* paid is a plain short-term counterparty on 2890.
*/
export function ownerSettlementAccount(
entityType: EntityType,
direction: 'withdrawal' | 'contribution',
): string {
return byEntityType(entityType, {
enskild_firma: direction === 'withdrawal' ? '2013' : '2018',
aktiebolag: '2893',
ideell_forening: '2890',
})
}
/** Owner-side accounts a booking template may name in its base/AB columns. */
const OWNER_SETTLEMENT_ACCOUNTS = new Set(['2013', '2018', '2893'])
/**
* Resolve a booking template's account for the form. Templates carry a base
* (enskild firma) account and an optional `_ab` override; an ideell förening
* takes the base account (6991 for a course, 3100 for exempt revenue) except
* that any owner account becomes the member settlement account, since a
* förening has no egna uttag/insättningar and no delägarskuld.
*/
export function templateAccountForForm(
entityType: EntityType,
base: string | undefined,
abOverride: string | undefined,
): string | undefined {
return byEntityType(entityType, {
enskild_firma: base,
aktiebolag: abOverride ?? base,
ideell_forening:
base && OWNER_SETTLEMENT_ACCOUNTS.has(base) ? ownerSettlementAccount('ideell_forening', 'withdrawal') : base,
})
}
/** Only an aktiebolag prepares an årsredovisning in Accounted today. */
export function preparesArsredovisning(entityType: EntityType): boolean {
return byEntityType(entityType, { enskild_firma: false, aktiebolag: true, ideell_forening: false })
}
/** BFL 3 kap 1 §: a fysisk person (enskild firma) is bound to the calendar year. */
export function fiscalYearLockedToCalendar(entityType: EntityType): boolean {
return byEntityType(entityType, { enskild_firma: true, aktiebolag: false, ideell_forening: false })
}
/** The org number is the owner's personnummer only for an enskild firma. */
export function usesPersonnummerAsOrgNumber(entityType: EntityType): boolean {
return byEntityType(entityType, { enskild_firma: true, aktiebolag: false, ideell_forening: false })
}
export function defaultAccountingMethod(entityType: EntityType): 'accrual' | 'cash' {
return byEntityType(entityType, { enskild_firma: 'cash', aktiebolag: 'accrual', ideell_forening: 'accrual' })
}
/**
* Simplified year-end regelverk label used by the accrual threshold logic
* (K1: 5 000 kr per post may stay unperiodised). EF: BFNAR 2006:1; ideell
* förening: BFNAR 2010:1; AB prepares under K2.
*/
export function simplifiedYearEndRegelverk(entityType: EntityType): 'K1' | 'K2' {
return byEntityType(entityType, { enskild_firma: 'K1', aktiebolag: 'K2', ideell_forening: 'K1' })
}
+3 -2
View File
@@ -1,4 +1,5 @@
import type { CompanySettings } from '@/types'
import { fiscalYearLockedToCalendar, isEntityType } from '@/lib/company/entity-type'
/**
* Return the ISO date (YYYY-MM-DD) for the start of the fiscal year that
@@ -14,7 +15,7 @@ export function getCurrentFiscalYearStart(
today: Date = new Date(),
): string {
let startMonth = settings?.fiscal_year_start_month || 1
if (settings?.entity_type === 'enskild_firma') startMonth = 1
if (isEntityType(settings?.entity_type) && fiscalYearLockedToCalendar(settings.entity_type)) startMonth = 1
const year = today.getMonth() + 1 >= startMonth ? today.getFullYear() : today.getFullYear() - 1
return `${year}-${String(startMonth).padStart(2, '0')}-01`
@@ -29,7 +30,7 @@ export function getPreviousFiscalYearStart(
today: Date = new Date(),
): string {
let startMonth = settings?.fiscal_year_start_month || 1
if (settings?.entity_type === 'enskild_firma') startMonth = 1
if (isEntityType(settings?.entity_type) && fiscalYearLockedToCalendar(settings.entity_type)) startMonth = 1
const currentYearStart = today.getMonth() + 1 >= startMonth
? today.getFullYear()
+18 -5
View File
@@ -3,6 +3,12 @@ import { saneIsoDateSchema } from '@/lib/invariants/zod'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { deriveSwedishVatNumber } from '@/lib/vat/vat-number'
import {
ENTITY_TYPES,
defaultAccountingMethod,
fiscalYearLockedToCalendar,
isEntityTypeCreatable,
} from '@/lib/company/entity-type'
import type { CreateCompanyInput } from '@/lib/company/create-company'
/**
@@ -25,7 +31,7 @@ import type { CreateCompanyInput } from '@/lib/company/create-company'
export const CompanySetupSchema = z
.object({
name: z.string().trim().min(1).max(200),
entity_type: z.enum(['enskild_firma', 'aktiebolag']),
entity_type: z.enum(ENTITY_TYPES),
org_number: z.string().trim().min(1).max(20).optional(),
vat_registered: z.boolean(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(),
@@ -59,6 +65,13 @@ export const CompanySetupSchema = z
team_id: z.string().uuid().optional(),
})
.superRefine((value, ctx) => {
if (!isEntityTypeCreatable(value.entity_type)) {
ctx.addIssue({
code: 'custom',
path: ['entity_type'],
message: `entity_type ${value.entity_type} is not enabled on this deployment yet.`,
})
}
if (value.vat_registered && !value.moms_period) {
ctx.addIssue({
code: 'custom',
@@ -121,10 +134,10 @@ export type CompanySetupPlan =
* left at this point is an invalid fiscal period (validatePeriodDuration).
*/
export function planCompanySetup(setup: CompanySetup): CompanySetupPlan {
const isEf = setup.entity_type === 'enskild_firma'
const calendarYearOnly = fiscalYearLockedToCalendar(setup.entity_type)
const firstYear = setup.first_fiscal_year
const startMonth = isEf ? 1 : (setup.fiscal_year_start_month ?? 1)
const accountingMethod = setup.accounting_method ?? (isEf ? 'cash' : 'accrual')
const startMonth = calendarYearOnly ? 1 : (setup.fiscal_year_start_month ?? 1)
const accountingMethod = setup.accounting_method ?? defaultAccountingMethod(setup.entity_type)
const settings: Record<string, unknown> = {
entity_type: setup.entity_type,
@@ -136,7 +149,7 @@ export function planCompanySetup(setup: CompanySetup): CompanySetupPlan {
accounting_method: accountingMethod,
f_skatt: setup.f_skatt,
// Enskild firma is calendar-year by law, with or without a first year.
fiscal_year_start_month: isEf ? 1 : firstYear ? nextMonthAfter(firstYear.end) : startMonth,
fiscal_year_start_month: calendarYearOnly ? 1 : firstYear ? nextMonthAfter(firstYear.end) : startMonth,
...(setup.address_line1 ? { address_line1: setup.address_line1 } : {}),
...(setup.postal_code ? { postal_code: setup.postal_code } : {}),
...(setup.city ? { city: setup.city } : {}),
@@ -75,7 +75,7 @@ describe('distributeOre', () => {
describe('buildCutoffLines: fordringar', () => {
it('books the receivable against revenue and VILANDE output moms', () => {
const { receivableLines } = buildCutoffLines([receivable()], [])
const { receivableLines } = buildCutoffLines([receivable()], [], 'aktiebolag')
const debit = receivableLines.find((l) => l.debit_amount > 0)
expect(debit?.account_number).toBe('1510')
@@ -98,6 +98,7 @@ describe('buildCutoffLines: fordringar', () => {
receivable({ id: 'c', outstanding: 106, vat: 6, vatTreatment: 'reduced_6' }),
],
[],
'aktiebolag',
)
const totals = sum(receivableLines)
expect(totals.debit).toBe(totals.credit)
@@ -110,6 +111,7 @@ describe('buildCutoffLines: fordringar', () => {
const { receivableLines } = buildCutoffLines(
[receivable({ outstanding: 1000.01, vat: 200.003 })],
[],
'aktiebolag',
)
const totals = sum(receivableLines)
expect(totals.debit).toBe(totals.credit)
@@ -122,6 +124,7 @@ describe('buildCutoffLines: fordringar', () => {
receivable({ id: 'b', outstanding: 1120, vat: 120, vatTreatment: 'reduced_12' }),
],
[],
'aktiebolag',
)
expect(receivableLines.find((l) => l.account_number === VILANDE_OUTPUT_VAT_ACCOUNTS.standard_25)).toBeDefined()
expect(receivableLines.find((l) => l.account_number === VILANDE_OUTPUT_VAT_ACCOUNTS.reduced_12)).toBeDefined()
@@ -133,6 +136,7 @@ describe('buildCutoffLines: fordringar', () => {
const { receivableLines } = buildCutoffLines(
[receivable({ vatTreatment: 'export', outstanding: 5000, vat: 0 })],
[],
'aktiebolag',
)
expect(receivableLines.some((l) => l.account_number.startsWith('26'))).toBe(false)
expect(receivableLines.find((l) => l.account_number === '3305')?.credit_amount).toBe(5000)
@@ -147,6 +151,7 @@ describe('buildCutoffLines: fordringar', () => {
const { receivableLines } = buildCutoffLines(
[receivable({ vatTreatment: 'export', outstanding: 5000, vat: 100 })],
[],
'aktiebolag',
)
const totals = sum(receivableLines)
expect(totals.debit).toBe(totals.credit)
@@ -154,14 +159,14 @@ describe('buildCutoffLines: fordringar', () => {
})
it('emits nothing when there is nothing outstanding', () => {
expect(buildCutoffLines([], []).receivableLines).toEqual([])
expect(buildCutoffLines([receivable({ outstanding: 0, vat: 0 })], []).receivableLines).toEqual([])
expect(buildCutoffLines([], [], 'aktiebolag').receivableLines).toEqual([])
expect(buildCutoffLines([receivable({ outstanding: 0, vat: 0 })], [], 'aktiebolag').receivableLines).toEqual([])
})
})
describe('buildCutoffLines: skulder', () => {
it('books the payable against expense and VILANDE input moms', () => {
const { payableLines } = buildCutoffLines([], [payable()])
const { payableLines } = buildCutoffLines([], [payable()], 'aktiebolag')
const credit = payableLines.find((l) => l.credit_amount > 0)
expect(credit?.account_number).toBe('2440')
@@ -188,6 +193,7 @@ describe('buildCutoffLines: skulder', () => {
],
}),
],
'aktiebolag',
)
const totals = sum(payableLines)
expect(totals.debit).toBe(totals.credit)
@@ -209,6 +215,7 @@ describe('buildCutoffLines: skulder', () => {
],
}),
],
'aktiebolag',
)
const totals = sum(payableLines)
expect(totals.debit).toBe(totals.credit)
@@ -216,7 +223,7 @@ describe('buildCutoffLines: skulder', () => {
})
it('falls back to a generic expense account when item detail is missing', () => {
const { payableLines } = buildCutoffLines([], [payable({ netByAccount: [] })])
const { payableLines } = buildCutoffLines([], [payable({ netByAccount: [] })], 'aktiebolag')
expect(payableLines.find((l) => l.account_number === '6990')?.debit_amount).toBe(1000)
const totals = sum(payableLines)
expect(totals.debit).toBe(totals.credit)
@@ -226,6 +233,7 @@ describe('buildCutoffLines: skulder', () => {
const lines = buildCutoffLines(
[receivable({ outstanding: -1250, vat: -250 })],
[payable({ outstanding: -1250, vat: -250 })],
'aktiebolag',
)
expect(lines.receivableLines.find((line) => line.account_number === '1510')).toMatchObject({
debit_amount: 0,
@@ -250,7 +258,7 @@ describe('buildCutoffLines: skulder', () => {
describe('reverseLines', () => {
it('swaps every debit and credit so the vändning nets to zero', () => {
const { receivableLines } = buildCutoffLines([receivable()], [])
const { receivableLines } = buildCutoffLines([receivable()], [], 'aktiebolag')
const reversed = reverseLines(receivableLines)
const original = sum(receivableLines)
@@ -374,7 +382,7 @@ describe('cut-off snapshot and posting inspection', () => {
})
it('requires exact cut-off lines and exact next-period reversals', async () => {
const lines = buildCutoffLines([receivable()], [payable()])
const lines = buildCutoffLines([receivable()], [payable()], 'aktiebolag')
const rows = [
{
id: 'ar', fiscal_period_id: 'fp-1',
@@ -413,7 +421,7 @@ describe('cut-off snapshot and posting inspection', () => {
})
it('treats a single stale immutable marker as a conflict', async () => {
const lines = buildCutoffLines([receivable()], [])
const lines = buildCutoffLines([receivable()], [], 'aktiebolag')
const stale = lines.receivableLines.map((line) =>
line.account_number === '1510' ? { ...line, debit_amount: 999 } : line,
)
@@ -439,7 +447,7 @@ describe('cut-off snapshot and posting inspection', () => {
})
it('treats an otherwise exact marker on the wrong date as a conflict', async () => {
const lines = buildCutoffLines([receivable()], [])
const lines = buildCutoffLines([receivable()], [], 'aktiebolag')
const status = await inspectKontantmetodCutoffPostings(
makeJournalSupabase([{
id: 'ar',
@@ -456,7 +464,7 @@ describe('cut-off snapshot and posting inspection', () => {
})
it('treats multiple exact markers as a duplicate conflict', async () => {
const lines = buildCutoffLines([receivable()], [])
const lines = buildCutoffLines([receivable()], [], 'aktiebolag')
const rows = [
{
id: 'ar', fiscal_period_id: 'fp-1',
@@ -487,7 +495,7 @@ describe('cut-off snapshot and posting inspection', () => {
await expect(
inspectKontantmetodCutoffPostings(
makeJournalSupabase([], { message: 'connection lost' }),
'co-1', 'fp-1', 'fp-2', '2026-12-31', buildCutoffLines([], []),
'co-1', 'fp-1', 'fp-2', '2026-12-31', buildCutoffLines([], [], 'aktiebolag'),
),
).rejects.toThrow(/kunde inte kontrolleras/i)
})
@@ -626,7 +634,7 @@ describe('collectKontantmetodCutoff', () => {
invoice_payments: paymentOf(17500),
}) as never, 'co-1', '2026-01-01', '2026-12-31')
expect(result.receivables).toEqual([])
expect(buildCutoffLines(result.receivables, []).receivableLines).toEqual([])
expect(buildCutoffLines(result.receivables, [], 'aktiebolag').receivableLines).toEqual([])
})
it('carries only the customer residual on a part-paid ROT invoice, moms scaled by the customer share', async () => {
@@ -639,7 +647,7 @@ describe('collectKontantmetodCutoff', () => {
expect(result.receivables).toEqual([
expect.objectContaining({ id: 'inv-rot', outstanding: 7500, vat: 2142.86 }),
])
const { receivableLines } = buildCutoffLines(result.receivables, [])
const { receivableLines } = buildCutoffLines(result.receivables, [], 'aktiebolag')
expect(receivableLines.find((l) => l.account_number === '1510')?.debit_amount).toBe(7500)
expect(receivableLines.find((l) => l.account_number === '2618')?.credit_amount).toBe(2142.86)
expect(receivableLines.find((l) => l.account_number === '3001')?.credit_amount).toBe(5357.14)
@@ -684,7 +692,7 @@ describe('collectKontantmetodCutoff', () => {
}) as never, 'co-1', '2026-01-01', '2026-12-31')
expect(result.receivables.map((r) => r.outstanding)).toEqual([17500, -17500])
expect(result.receivables.map((r) => r.vat)).toEqual([5000, -5000])
expect(buildCutoffLines(result.receivables, []).receivableLines).toEqual([])
expect(buildCutoffLines(result.receivables, [], 'aktiebolag').receivableLines).toEqual([])
})
it('leaves a plain invoice with the same figures exactly as before', async () => {
@@ -701,7 +709,7 @@ describe('collectKontantmetodCutoff', () => {
expect(result.receivables).toEqual([
expect.objectContaining({ id: 'inv-rot', outstanding: 7500, vat: 1500 }),
])
const { receivableLines } = buildCutoffLines(result.receivables, [])
const { receivableLines } = buildCutoffLines(result.receivables, [], 'aktiebolag')
expect(receivableLines.find((l) => l.account_number === '1510')?.debit_amount).toBe(7500)
expect(receivableLines.find((l) => l.account_number === '3001')?.credit_amount).toBe(6000)
expect(receivableLines.find((l) => l.account_number === '2618')?.credit_amount).toBe(1500)
@@ -737,7 +745,7 @@ describe('collectKontantmetodCutoff', () => {
supplierType: 'eu_business',
}],
})
const lines = buildCutoffLines([], result.payables).payableLines
const lines = buildCutoffLines([], result.payables, 'aktiebolag').payableLines
expect(lines.find((line) => line.account_number === '2624')?.credit_amount).toBe(1104)
expect(lines.find((line) => line.account_number === '2645')?.debit_amount).toBe(1104)
expect(lines.find((line) => line.account_number === '4536')?.debit_amount).toBe(9200)
@@ -799,7 +807,7 @@ describe('collectKontantmetodCutoff', () => {
}) as never, 'co-1', '2026-01-01', '2026-12-31')
expect(result.receivables.map((item) => item.outstanding)).toEqual([1250, -1250])
expect(result.payables.map((item) => item.outstanding)).toEqual([1250, -1250])
const lines = buildCutoffLines(result.receivables, result.payables)
const lines = buildCutoffLines(result.receivables, result.payables, 'aktiebolag')
expect(lines.receivableLines).toEqual([])
expect(lines.payableLines).toEqual([])
})
@@ -849,6 +857,7 @@ describe('postKontantmetodCutoff', () => {
periodEnd: '2026-12-31',
receivables: [receivable()],
payables: [],
entityType: 'aktiebolag' as const,
}
beforeEach(() => {
@@ -933,7 +942,7 @@ describe('postKontantmetodCutoff', () => {
id: 'existing',
fiscal_period_id: 'fp-1',
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
lines: buildCutoffLines([receivable()], []).receivableLines,
lines: buildCutoffLines([receivable()], [], 'aktiebolag').receivableLines,
}]),
'co-1',
'user-1',
@@ -944,7 +953,7 @@ describe('postKontantmetodCutoff', () => {
})
it('resumes with the missing payable pair after a prior receivable pair succeeded', async () => {
const receivableLines = buildCutoffLines([receivable()], []).receivableLines
const receivableLines = buildCutoffLines([receivable()], [], 'aktiebolag').receivableLines
const existingRows = [
{
id: 'ar', fiscal_period_id: 'fp-1',
@@ -1066,6 +1075,7 @@ describe('buildCutoffLines: omvänd betalningsskyldighet', () => {
}],
netByAccount: [{ account: '6540', amount: 1000 }],
})],
'aktiebolag',
)
expect(payableLines.some((l) => l.account_number === VILANDE_INPUT_VAT_ACCOUNT)).toBe(false)
expect(payableLines.find((l) => l.account_number === '2645')?.debit_amount).toBe(250)
@@ -1079,7 +1089,7 @@ describe('buildCutoffLines: omvänd betalningsskyldighet', () => {
})
it('still books vilande moms for ordinary (non-RC) supplier invoices', () => {
const { payableLines } = buildCutoffLines([], [payable({ reverseCharge: false })])
const { payableLines } = buildCutoffLines([], [payable({ reverseCharge: false })], 'aktiebolag')
expect(payableLines.find((l) => l.account_number === VILANDE_INPUT_VAT_ACCOUNT)?.debit_amount).toBe(250)
})
})
@@ -66,6 +66,26 @@ beforeEach(() => {
})
describe('generateResultAppropriation', () => {
it('posts Dr 2069 / Cr 2068 for an ideell förening profit', async () => {
results = [{ data: { entity_type: 'ideell_forening' }, error: null }, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '2069', debit: 0, credit: 25000 }])
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
expect(entry).toEqual(FAKE_ENTRY)
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
description: string
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.description).toContain('2069 → 2068')
expect(input.lines).toContainEqual(
expect.objectContaining({ account_number: '2069', debit_amount: 25000, credit_amount: 0 })
)
expect(input.lines).toContainEqual(
expect.objectContaining({ account_number: '2068', debit_amount: 0, credit_amount: 25000 })
)
})
it('posts Dr 2099 / Cr 2098 for a profit (AB)', async () => {
results = [AB, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 100000 }])
@@ -163,8 +183,8 @@ describe('generateResultAppropriation', () => {
expect(createJournalEntry).not.toHaveBeenCalled()
})
it('defaults missing company_settings to aktiebolag and posts', async () => {
results = [NO_EXISTING /* settings missing */, NO_EXISTING, PERIOD]
it('falls back to companies.entity_type when company_settings is missing and posts', async () => {
results = [NO_EXISTING /* settings missing */, AB /* companies fallback */, NO_EXISTING, PERIOD]
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 5000 }])
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
@@ -714,7 +714,7 @@ describe('validateYearEndReadiness: kontantmetoden cut-off gate', () => {
const expected = buildCutoffLines([{
id: 'inv-1', reference: 'F-1', vatTreatment: 'standard_25',
outstanding: 1250, vat: 250,
}], [])
}], [], 'aktiebolag')
const markers = [
{
id: 'cutoff', company_id: 'company-1', fiscal_period_id: 'fp-1',
+3 -3
View File
@@ -231,7 +231,7 @@ function signedLine(
export function buildCutoffLines(
receivables: CutoffReceivable[],
payables: CutoffPayable[],
entityType: EntityType = 'aktiebolag',
entityType: EntityType,
): CutoffLines {
const receivableLines: CreateJournalEntryLineInput[] = []
const payableLines: CreateJournalEntryLineInput[] = []
@@ -959,7 +959,7 @@ export async function assessKontantmetodCutoff(
companyId: string,
period: { id: string; period_start: string; period_end: string },
nextFiscalPeriodId: string,
entityType: EntityType = 'aktiebolag',
entityType: EntityType,
): Promise<KontantmetodCutoffAssessment> {
const collection = sortedCutoffCollection(await collectKontantmetodCutoff(
supabase,
@@ -1069,7 +1069,7 @@ export async function postKontantmetodCutoff(
periodEnd: string
receivables: CutoffReceivable[]
payables: CutoffPayable[]
entityType?: EntityType
entityType: EntityType
/** Refuse if any invoice lacked a vat_treatment (see CutoffCollection). */
unknownVatTreatment?: string[]
/** Refuse if any invoice carried moms on a zero-rate treatment. */
@@ -171,7 +171,9 @@ export function classifyHistoricalResultRepair(
reason: Exclude<HistoricalResultRepairReason, 'ready'>,
): HistoricalResultRepairAssessment => ({ ...base, status, reason, plan: null })
if ((snapshot.entityType ?? 'aktiebolag') !== 'aktiebolag') {
// An unknown form is NOT assumed to be an aktiebolag: the repair only ever
// applies to the 2099 -> 2098 chain, so anything else is skipped.
if (snapshot.entityType !== 'aktiebolag') {
return finish('skipped', 'non_aktiebolag')
}
if (snapshot.isClosed) return finish('skipped', 'period_closed')
@@ -276,7 +278,7 @@ export async function assessHistoricalResultRepair(
}
if (
(baseSnapshot.entityType ?? 'aktiebolag') !== 'aktiebolag' ||
baseSnapshot.entityType !== 'aktiebolag' ||
baseSnapshot.isClosed ||
baseSnapshot.lockedAt ||
baseSnapshot.existingPostedAppropriation ||
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveCompanyEntityType, resultClosingAccounts } from '@/lib/company/entity-type'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { getOpeningBalances } from '@/lib/reports/opening-balances'
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
@@ -17,9 +18,13 @@ export interface ResultAppropriationPlan {
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). */
/** The form's "årets resultat" account (AB 2099, ideell förening 2069). */
resultAccount: string
/** Where last year's result is carried (AB 2098, ideell förening 2068). */
priorResultAccount: string
/** Net result-account IB balance, credit-positive (a profit is > 0, a loss is < 0). */
net: number
/** Absolute, öre-rounded amount that moves between 2099 and 2098. */
/** Absolute, öre-rounded amount that moves between the two accounts. */
amount: number
direction: 'profit' | 'loss'
/** Balanced lines for the omföring verifikat. */
@@ -55,8 +60,14 @@ export async function planResultAppropriation(
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = settings?.entity_type ?? 'aktiebolag'
if (entityType !== 'aktiebolag') return null
const entityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
// Only forms that close into a dedicated "årets resultat" account carry it
// forward: AB 2099 -> 2098, ideell förening 2069 -> 2068. An enskild firma
// closes straight into 2010 and has nothing to reclassify.
const accounts = resultClosingAccounts(entityType)
if (!accounts.priorYearCarry) return null
const resultAccount = accounts.closing
const priorResultAccount = accounts.priorYearCarry
// 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
@@ -91,38 +102,38 @@ export async function planResultAppropriation(
// 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
const ibResult = balances.get(resultAccount)
const net = ibResult ? roundOre(ibResult.credit - ibResult.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.
// Profit: move the credit balance off the result account onto the carry.
{
account_number: RESULT_ACCOUNT,
account_number: resultAccount,
debit_amount: amount,
credit_amount: 0,
line_description: 'Omföring av föregående års resultat',
},
{
account_number: PRIOR_RESULT_ACCOUNT,
account_number: priorResultAccount,
debit_amount: 0,
credit_amount: amount,
line_description: 'Föregående års resultat',
},
]
: [
// Loss: move the debit balance off 2099 onto 2098.
// Loss: move the debit balance off the result account onto the carry.
{
account_number: PRIOR_RESULT_ACCOUNT,
account_number: priorResultAccount,
debit_amount: amount,
credit_amount: 0,
line_description: 'Föregående års resultat',
},
{
account_number: RESULT_ACCOUNT,
account_number: resultAccount,
debit_amount: 0,
credit_amount: amount,
line_description: 'Omföring av föregående års resultat',
@@ -133,6 +144,8 @@ export async function planResultAppropriation(
periodId,
periodName: period.name,
periodStart: period.period_start,
resultAccount,
priorResultAccount,
net,
amount,
direction: net > 0 ? 'profit' : 'loss',
@@ -178,7 +191,7 @@ export async function generateResultAppropriation(
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})`,
description: `Omföring av föregående års resultat (${plan.resultAccount}${plan.priorResultAccount})`,
source_type: 'result_appropriation',
voucher_series: 'A',
lines: plan.lines,
+4 -7
View File
@@ -27,6 +27,7 @@ import {
} from '@/lib/bookkeeping/currency-revaluation'
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
import { assessKontantmetodCutoff } from './kontantmetod-cutoff'
import { resolveCompanyEntityType, resultClosingAccounts } from '@/lib/company/entity-type'
import type {
YearEndValidation,
YearEndBlocker,
@@ -368,7 +369,7 @@ export async function validateYearEndReadiness(
companyId,
period,
nextPeriod.id,
settings.entity_type ?? 'aktiebolag',
await resolveCompanyEntityType(supabase, companyId, settings.entity_type),
)
const invalidCount =
assessment.collection.unknownVatTreatment.length +
@@ -465,12 +466,8 @@ export async function previewYearEndClosing(
.eq('company_id', companyId)
.single()
const entityType = settings?.entity_type ?? 'aktiebolag'
const closingAccount = entityType === 'enskild_firma' ? '2010' : '2099'
const closingAccountName =
entityType === 'enskild_firma'
? 'Eget kapital'
: 'Årets resultat'
const entityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
const { closing: closingAccount, closingName: closingAccountName } = resultClosingAccounts(entityType)
// Get trial balance for individual account balances in class 3-8
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' })
+5
View File
@@ -18,6 +18,11 @@ describe('resolveExpenseLiabilityAccount', () => {
expect(resolveExpenseLiabilityAccount('enskild_firma', 'owner')).toBe('2018')
})
it('a member of an ideell förening is a plain short-term creditor (2890)', () => {
expect(resolveExpenseLiabilityAccount('ideell_forening', 'owner')).toBe('2890')
expect(resolveExpenseLiabilityAccount('ideell_forening', 'employee')).toBe('2820')
})
it('an unknown entity type falls back to the AB rule, never to 2018', () => {
expect(resolveExpenseLiabilityAccount(undefined, 'owner')).toBe('2893')
expect(resolveExpenseLiabilityAccount('handelsbolag', 'owner')).toBe('2893')
+2 -1
View File
@@ -27,6 +27,7 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { findPayslipLineForClaim } from '@/lib/salary/expense-claim-lines'
import { roundOre, sumOre } from '@/lib/money'
import { ownerSettlementAccount, parseEntityType } from '@/lib/company/entity-type'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants'
import { createLogger } from '@/lib/logger'
@@ -128,7 +129,7 @@ export async function registerExpenseClaim(
.eq('id', companyId)
.single()
if (!company?.entity_type) return { ok: false, code: 'COMPANY_NOT_FOUND' }
const ownerLiability = company.entity_type === 'enskild_firma' ? '2018' : '2893'
const ownerLiability = ownerSettlementAccount(parseEntityType(company.entity_type), 'contribution')
let claimantName = input.claimant_name?.trim() ?? ''
let employeeId: string | null = null
let liability: string = ownerLiability
+11 -3
View File
@@ -27,20 +27,28 @@ export function isPersonPayer(choice: PayerChoice | null | undefined): choice is
* the owner), so every writer that lets the name default must default to the
* same string or one person shows up as two.
*/
import { isEntityType, ownerSettlementAccount } from '@/lib/company/entity-type'
export const OWNER_FALLBACK_NAME = 'Ägare'
export type ExpenseLiabilityAccount = '2893' | '2820' | '2018'
export type ExpenseLiabilityAccount = '2893' | '2820' | '2018' | '2890'
/**
* Liability account for an utlägg. An employee is always 2820 (kortfristiga
* skulder till anställda). The owner's account follows the entity type: an AB
* owner is a creditor (2893 skulder till närstående); an enskild firma owner
* makes an egen insättning (2018), which is equity, not a debt.
* makes an egen insättning (2018), which is equity, not a debt; a member of an
* ideell förening is a plain short-term creditor (2890).
*
* Same resolver as lib/expenses/expense-claims-service.ts, which is the
* authority at booking time; an unknown form here (a dialog rendering before
* the company context loads) previews the AB account, never books it.
*/
export function resolveExpenseLiabilityAccount(
entityType: string | null | undefined,
payer: ExpensePayer,
): ExpenseLiabilityAccount {
if (payer === 'employee') return '2820'
return entityType === 'enskild_firma' ? '2018' : '2893'
if (!isEntityType(entityType)) return '2893'
return ownerSettlementAccount(entityType, 'contribution') as ExpenseLiabilityAccount
}
@@ -142,4 +142,10 @@ describe('toRedovisare12', () => {
// Skatteverket reject it with its own message. See the module docblock.
expect(toRedovisare12('5560125791', 'aktiebolag')).toBe('165560125791')
})
it('prefixes 16 for an ideell förening like every other juridisk person', () => {
// 8-series org numbers are issued by Skatteverket to föreningar; they
// must never be read as a personnummer century.
expect(toRedovisare12('814400-9464', 'ideell_forening')).toBe('168144009464')
})
})
+6 -2
View File
@@ -1,4 +1,6 @@
import { luhnValidate } from '@/lib/bankgiro/luhn'
import { usesPersonnummerAsOrgNumber } from '@/lib/company/entity-type'
import type { EntityType } from '@/types'
/**
* Swedish organisationsnummer / personnummer: the one place that decides what
@@ -157,7 +159,7 @@ export function formatOrgNumberDisplay(raw: string | null | undefined): string {
*/
export function toRedovisare12(
orgNumber: string,
entityType: 'enskild_firma' | 'aktiebolag',
entityType: EntityType,
): string {
const clean = stripOrgNumberFormatting(orgNumber)
@@ -167,7 +169,9 @@ export function toRedovisare12(
throw new Error(`Ogiltigt organisationsnummer: ${orgNumber} (förväntar 10 eller 12 siffror)`)
}
if (entityType === 'aktiebolag') return `16${clean}`
// Juridiska personer (AB, förening) carry the fixed 16 prefix; only an
// enskild firma identifies by the owner's personnummer.
if (!usesPersonnummerAsOrgNumber(entityType)) return `16${clean}`
// Enskild firma: personnummer. A two-digit year above the current one must
// belong to the previous century (someone born in 98 is 1998, not 2098).
+2 -1
View File
@@ -1,4 +1,5 @@
import { renderToBuffer } from '@react-pdf/renderer'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
@@ -190,7 +191,7 @@ export async function issueAndBookInvoice(
return { ok: false, errorCode: 'INVOICE_CREATE_NUMBER_ASSIGN_FAILED' }
}
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
const entityType = await resolveCompanyEntityType(supabase, companyId, settings.entity_type)
// Compare-and-set prevents two concurrent requests from posting two journal
// entries for the same draft.
+2 -1
View File
@@ -16,6 +16,7 @@
* accepts an optional is_self_billed flag), so the two can never drift.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
@@ -306,7 +307,7 @@ export async function createSelfBilledSaleInvoice(
.eq('company_id', companyId)
.maybeSingle()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const entityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
const { data: completeInvoice } = await supabase
.from('invoices')
+4 -2
View File
@@ -4,7 +4,7 @@ import type {
CompanyLookupOutcome,
CompanySearchOutcome,
} from '@/lib/company-lookup/fetch-company-lookup'
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
import { mapSetupEntityType } from '@/lib/company-lookup/entity-type-map'
import { deriveSwedishVatNumber } from '@/lib/vat/vat-number'
/**
@@ -263,7 +263,9 @@ function withOrgNumber(state: JourneyState, orgNumber: string): JourneyState {
* advances past whatever the lookup already answered.
*/
function applyLookupFound(state: JourneyState, lookup: CompanyLookupResult): JourneyState {
const mapped = mapEntityType(lookup.legalEntityType)
// Only forms this deployment can create are prefilled; a flagged-off form
// falls through to the picker instead of failing at the create step.
const mapped = mapSetupEntityType(lookup.legalEntityType)
const settings: Partial<CompanySettings> = {
...state.settings,
entity_type: mapped ?? state.settings.entity_type,
+1 -1
View File
@@ -52,7 +52,7 @@ export const PACK_CATEGORIES = [
] as const
/** Which entity types a pack applies to. Mirrors the `entity_type` CHECK. */
export const PACK_ENTITY_TYPES = ['all', 'enskild_firma', 'aktiebolag'] as const
export const PACK_ENTITY_TYPES = ['all', 'enskild_firma', 'aktiebolag', 'ideell_forening'] as const
/** Line roles. Drives the amount maths in `applyTemplate()`. */
export const PACK_LINE_TYPES = ['business', 'vat', 'settlement'] as const
@@ -32,7 +32,7 @@ const collection = {
}
function makePendingOp(overrides: Partial<PendingOperation> = {}): PendingOperation {
const lines = buildCutoffLines(collection.receivables, collection.payables)
const lines = buildCutoffLines(collection.receivables, collection.payables, 'aktiebolag')
return {
id: 'op-1', user_id: 'user-1', company_id: 'company-1',
operation_type: 'post_kontantmetod_cutoff', status: 'pending', title: 'cut-off',
@@ -103,7 +103,7 @@ beforeEach(() => {
vi.setSystemTime(new Date('2027-02-01T12:00:00Z'))
vi.mocked(assessKontantmetodCutoff).mockResolvedValue({
collection,
lines: buildCutoffLines(collection.receivables, collection.payables),
lines: buildCutoffLines(collection.receivables, collection.payables, 'aktiebolag'),
postings: {
complete: false, hasAny: false, receivableEntryId: null,
receivableReversalId: null, payableEntryId: null, payableReversalId: null,
@@ -151,7 +151,7 @@ describe('commitPendingOperation: post_kontantmetod_cutoff', () => {
...collection,
receivables: [{ ...collection.receivables[0]!, outstanding: 1300 }],
},
lines: buildCutoffLines([], []),
lines: buildCutoffLines([], [], 'aktiebolag'),
postings: { complete: false, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
})
const result = await commitPendingOperation(
@@ -165,7 +165,7 @@ describe('commitPendingOperation: post_kontantmetod_cutoff', () => {
it('rejects a duplicate, locked period, wrong accounting method, and missing next period', async () => {
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
collection,
lines: buildCutoffLines(collection.receivables, []),
lines: buildCutoffLines(collection.receivables, [], 'aktiebolag'),
postings: { complete: true, hasAny: true, receivableEntryId: 'je-1', receivableReversalId: 'je-2', payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
})
await expect(commitPendingOperation(
+6 -5
View File
@@ -14,6 +14,7 @@
* private to this module: call `commitPendingOperation()` to invoke them.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { parseEntityType, resolveCompanyEntityType } from '@/lib/company/entity-type'
import { eventBus } from '@/lib/events'
import { bulkBookMatchedInboxItems, categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
@@ -352,7 +353,7 @@ async function loadBookingContext(
return {
accountingMethod: (settings?.accounting_method as AccountingMethod) || 'accrual',
entityType: (settings?.entity_type as EntityType) || 'enskild_firma',
entityType: await resolveCompanyEntityType(supabase, companyId, settings?.entity_type),
}
}
@@ -3331,7 +3332,7 @@ async function commitMarkInvoiceSent(
try {
const je = await createInvoiceJournalEntry(
supabase, companyId, userId, invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma',
await resolveCompanyEntityType(supabase, companyId, settings?.entity_type),
invoice.customer?.name
)
if (je) {
@@ -4468,7 +4469,7 @@ async function commitPostKontantmetodCutoff(
companyId,
period,
nextFiscalPeriodId,
settings.entity_type ?? 'aktiebolag',
parseEntityType(settings.entity_type),
)
if (assessment.postings.complete || hasIncompleteKontantmetodCutoffPair(
@@ -4484,7 +4485,7 @@ async function commitPostKontantmetodCutoff(
const currentFingerprint = cutoffPreviewFingerprint({
collection: assessment.collection,
lines: assessment.lines,
entityType: settings.entity_type ?? 'aktiebolag',
entityType: parseEntityType(settings.entity_type),
periodEnd: period.period_end,
})
if (currentFingerprint !== stagedFingerprint) {
@@ -4501,7 +4502,7 @@ async function commitPostKontantmetodCutoff(
periodEnd: period.period_end,
receivables: assessment.collection.receivables,
payables: assessment.collection.payables,
entityType: settings.entity_type ?? 'aktiebolag',
entityType: parseEntityType(settings.entity_type),
unknownVatTreatment: assessment.collection.unknownVatTreatment,
strayVatOnZeroRate: assessment.collection.strayVatOnZeroRate,
})
+1 -1
View File
@@ -361,7 +361,7 @@ const SETTINGS_VALUE_LABELS: Record<string, Record<string, string>> = {
// company_settings CHECK allows 'accrual' | 'cash'; 'invoice' is the legacy spelling.
accounting_method: { accrual: 'Faktureringsmetoden', invoice: 'Faktureringsmetoden', cash: 'Kontantmetoden' },
moms_period: { monthly: 'Månad', quarterly: 'Kvartal', yearly: 'Helår', none: 'Ingen' },
entity_type: { aktiebolag: 'Aktiebolag', enskild_firma: 'Enskild firma' },
entity_type: { aktiebolag: 'Aktiebolag', enskild_firma: 'Enskild firma', ideell_forening: 'Ideell förening' },
}
const PERIOD_FIELDS: Record<string, string> = {
+2 -2
View File
@@ -1,4 +1,4 @@
import type { VatPeriodType } from '@/types'
import type { EntityType, VatPeriodType } from '@/types'
import { toRedovisare12 } from '@/lib/invariants/org-number'
/**
@@ -16,7 +16,7 @@ import { toRedovisare12 } from '@/lib/invariants/org-number'
*/
export function formatRedovisare(
orgNumber: string,
entityType: 'enskild_firma' | 'aktiebolag'
entityType: EntityType
): string {
return toRedovisare12(orgNumber, entityType)
}
+3 -2
View File
@@ -25,6 +25,7 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EntityType } from '@/types'
import { luhnCheckDigit, luhnValidate } from '@/lib/bankgiro/luhn'
import { toRedovisare12 } from '@/lib/invariants/org-number'
@@ -50,7 +51,7 @@ const BALANCE_SNAPSHOT_KEY = 'skattekonto_balance_snapshot'
*/
export function generateSkattekontoOcr(
orgOrPersonnummer: string,
entityType: 'enskild_firma' | 'aktiebolag',
entityType: EntityType,
): string {
const redovisare = toRedovisare12(orgOrPersonnummer, entityType)
return redovisare + luhnCheckDigit(redovisare).toString()
@@ -74,7 +75,7 @@ export async function resolveSkattekontoOcr(
supabase: SupabaseClient,
companyId: string,
orgOrPersonnummer: string,
entityType: 'enskild_firma' | 'aktiebolag',
entityType: EntityType,
): Promise<string> {
const reported = await readReportedOcr(supabase, companyId)
return reported ?? generateSkattekontoOcr(orgOrPersonnummer, entityType)
+28
View File
@@ -83,6 +83,34 @@ describe('VAT filing deadlines', () => {
vat_filing_method: 'paper',
}))[0]).toMatchObject({ day: 12, month: 6, year: 2027, period: '2026' })
})
it('gives an ideell förening the juridisk person helårsmoms schedule, same as an AB', () => {
const config = getConfig('moms_yearly')
const ab = config.generateDates(2027, makeSettings({
entity_type: 'aktiebolag',
moms_period: 'yearly',
vat_filing_method: 'paper',
}))
const forening = config.generateDates(2027, makeSettings({
entity_type: 'ideell_forening',
moms_period: 'yearly',
vat_filing_method: 'paper',
}))
expect(forening).toHaveLength(ab.length)
expect(forening[0]).toMatchObject({ day: 12, month: 6, year: 2027, period: '2026' })
// A broken fiscal year is honoured (a förening is not calendar-locked).
expect(config.generateDates(2027, makeSettings({
entity_type: 'ideell_forening',
moms_period: 'yearly',
fiscal_year_start_month: 7,
vat_filing_method: 'electronic',
}))[0]).toMatchObject(config.generateDates(2027, makeSettings({
entity_type: 'aktiebolag',
moms_period: 'yearly',
fiscal_year_start_month: 7,
vat_filing_method: 'electronic',
}))[0])
})
})
describe('monthly tax and employer deadlines', () => {
+12 -11
View File
@@ -4,6 +4,7 @@
*/
import type { TaxDeadlineType, EntityType, MomsPeriod, TaxFilingMethod } from '@/types'
import { fiscalYearLockedToCalendar, isEntityType } from '@/lib/company/entity-type'
import { isBankingDay } from './swedish-holidays'
// Condition function type for determining if a deadline applies
@@ -92,7 +93,7 @@ export interface VatDeadlineCalculationSettings {
}
interface AnnualVatDeadlineSettings {
entity_type: 'aktiebolag' | 'enskild_firma'
entity_type: EntityType
fiscal_year_start_month: number
vat_has_eu_trade: boolean
vat_filing_method?: TaxFilingMethod | null
@@ -131,8 +132,9 @@ function getAnnualVatDeadline(
// Enskild firma (calendar year only, BFL 3 kap.): without EU trade the
// annual momsdeklaration follows the income tax return (12 May); with EU
// trade it is due 26 February (26 kap. 33-33a §§ SFL, Skatteverket's
// published helårsmoms schedule).
if (settings.entity_type === 'enskild_firma') {
// published helårsmoms schedule). Every juridisk person (AB, ideell
// förening) follows the räkenskapsår schedule below.
if (fiscalYearLockedToCalendar(settings.entity_type)) {
return settings.vat_has_eu_trade
? { day: 26, month: 1, year: fiscalYearEndYear + 1 }
: { day: 12, month: 4, year: fiscalYearEndYear + 1 }
@@ -204,12 +206,13 @@ export function getVatDeadlineForPeriod(
}
if (period !== 1) return null
if (settings.entity_type !== 'aktiebolag' && settings.entity_type !== 'enskild_firma') {
if (!isEntityType(settings.entity_type)) {
return null
}
const calendarYearOnly = fiscalYearLockedToCalendar(settings.entity_type)
if (typeof settings.vat_has_eu_trade !== 'boolean') return null
if (
settings.entity_type === 'aktiebolag'
!calendarYearOnly
&& settings.vat_has_eu_trade === false
&& settings.vat_filing_method !== 'electronic'
&& settings.vat_filing_method !== 'paper'
@@ -222,13 +225,11 @@ export function getVatDeadlineForPeriod(
&& settings.fiscal_year_start_month <= 12
? settings.fiscal_year_start_month
: null
if (settings.entity_type === 'aktiebolag' && configuredFiscalYearStartMonth === null) {
if (!calendarYearOnly && configuredFiscalYearStartMonth === null) {
return null
}
const fiscalYearStartMonth = settings.entity_type === 'enskild_firma'
? 1
: configuredFiscalYearStartMonth!
const fiscalYearEndMonth = settings.entity_type === 'enskild_firma'
const fiscalYearStartMonth = calendarYearOnly ? 1 : configuredFiscalYearStartMonth!
const fiscalYearEndMonth = calendarYearOnly
? 12
: (fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1)
const deadline = getAnnualVatDeadline(fiscalYearEndMonth, year, {
@@ -562,7 +563,7 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
priority: 'normal',
linkedReportType: null,
generateDates: (year, settings) => {
const fyEndMonth = settings.entity_type === 'enskild_firma'
const fyEndMonth = fiscalYearLockedToCalendar(settings.entity_type)
? 12
: (settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1)
const results: DeadlineInstance[] = []
+2 -1
View File
@@ -5,6 +5,7 @@
import { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { isEntityType } from '@/lib/company/entity-type'
import type { TaxDeadlineType, DeadlineStatus } from '@/types'
const log = createLogger('deadline-generator')
@@ -94,7 +95,7 @@ export function hasTaxRelevantFields(body: Record<string, unknown>): boolean {
export function toDeadlineSettings(
settings: Partial<CompanySettingsForDeadlines>,
): CompanySettingsForDeadlines {
if (settings.entity_type !== 'aktiebolag' && settings.entity_type !== 'enskild_firma') {
if (!isEntityType(settings.entity_type)) {
throw new Error('Company entity type is required to generate tax deadlines')
}
@@ -85,6 +85,7 @@ describe('buildCounterpartySuggestion', () => {
},
undefined,
'expense_other',
'enskild_firma',
)
expect(account).toBe('6570')
expect(() => account.startsWith('2')).not.toThrow()
+6 -1
View File
@@ -70,7 +70,12 @@ function createQueueMockSupabase() {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = resultQueue.shift() ?? { data: null, error: null }
// The legal form is resolved from `companies` (never defaulted) before
// mapping rules run; it is not part of the per-test queue.
const next =
table === 'companies'
? { data: { entity_type: 'aktiebolag' }, error: null }
: resultQueue.shift() ?? { data: null, error: null }
return (resolve: (v: unknown) => void) => resolve(next)
}
if (prop === 'insert') {
@@ -23,20 +23,20 @@ describe('resolveQuickReviewDefaults', () => {
it('never returns undefined for the account, whatever the template omits', () => {
const bare: ReviewTemplate = { id: 'counterparty:abc', name_sv: 'Fee' }
const { account, vat } = resolveQuickReviewDefaults(bare, undefined, 'expense_other')
expect(account).toBe(getDefaultAccountForCategory('expense_other'))
const { account, vat } = resolveQuickReviewDefaults(bare, undefined, 'expense_other', 'enskild_firma')
expect(account).toBe(getDefaultAccountForCategory('expense_other', 'enskild_firma'))
expect(typeof account).toBe('string')
expect(vat).toBe('none')
})
it('returns an empty account rather than undefined when there is nothing at all', () => {
expect(resolveQuickReviewDefaults(null, undefined, null)).toEqual({ account: '', vat: 'none' })
expect(resolveQuickReviewDefaults({ id: 'counterparty:abc', name_sv: 'Fee' }, undefined, null))
expect(resolveQuickReviewDefaults(null, undefined, null, 'enskild_firma')).toEqual({ account: '', vat: 'none' })
expect(resolveQuickReviewDefaults({ id: 'counterparty:abc', name_sv: 'Fee' }, undefined, null, 'enskild_firma'))
.toEqual({ account: '', vat: 'none' })
})
it('seeds from the counterparty template accounts, not the category fallback', () => {
const { account, vat } = resolveQuickReviewDefaults(counterparty, undefined, 'expense_other')
const { account, vat } = resolveQuickReviewDefaults(counterparty, undefined, 'expense_other', 'enskild_firma')
expect(account).toBe('6570')
expect(vat).toBe('none')
})
@@ -46,6 +46,7 @@ describe('resolveQuickReviewDefaults', () => {
{ ...counterparty, debit_account: '5420', vat_treatment: 'standard_25' },
undefined,
'expense_other',
'enskild_firma',
)
expect(vat).toBe('standard_25')
})
@@ -58,15 +59,15 @@ describe('resolveQuickReviewDefaults', () => {
credit_account: '1930',
vat_treatment: null,
}
const { account } = resolveQuickReviewDefaults(catalog, 'bank_fees', 'expense_other')
const { account } = resolveQuickReviewDefaults(catalog, 'bank_fees', 'expense_other', 'enskild_firma')
// Catalog templates are validated server-side by id; the form's account
// field is not the source of truth for them.
expect(account).toBe(getDefaultAccountForCategory('expense_other'))
expect(account).toBe(getDefaultAccountForCategory('expense_other', 'enskild_firma'))
})
it('falls back to the category defaults when no template is involved', () => {
const { account, vat } = resolveQuickReviewDefaults(null, undefined, 'expense_other')
expect(account).toBe(getDefaultAccountForCategory('expense_other'))
const { account, vat } = resolveQuickReviewDefaults(null, undefined, 'expense_other', 'enskild_firma')
expect(account).toBe(getDefaultAccountForCategory('expense_other', 'enskild_firma'))
expect(vat === 'none' || typeof vat === 'string').toBe(true)
})
})

Some files were not shown because too many files have changed in this diff Show More