diff --git a/DECISIONS.md b/DECISIONS.md index a97b4f22..b7223d46 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1657,6 +1657,9 @@ One line per decision: `[YYYY-MM-DD] : `. 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. +[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. diff --git a/app/(dashboard)/expenses/page.tsx b/app/(dashboard)/expenses/page.tsx index 53d55cfb..3e0bf812 100644 --- a/app/(dashboard)/expenses/page.tsx +++ b/app/(dashboard)/expenses/page.tsx @@ -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 diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 9c50224f..1d5e352b 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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 diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 51c55ada..d0f6a5ab 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -4051,6 +4051,7 @@ export default function TransactionsPage() { quickReview?.template, quickReview?.templateId, quickReview?.category, + entityType as EntityType, ) return ( diff --git a/app/(onboarding)/onboarding/agent/page.tsx b/app/(onboarding)/onboarding/agent/page.tsx index 44d299a8..864e78a5 100644 --- a/app/(onboarding)/onboarding/agent/page.tsx +++ b/app/(onboarding)/onboarding/agent/page.tsx @@ -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 diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index 02ca1a6d..5979e6f7 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -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' diff --git a/app/api/agent/categorize/route.ts b/app/api/agent/categorize/route.ts index e4f1b199..7e3bffaf 100644 --- a/app/api/agent/categorize/route.ts +++ b/app/api/agent/categorize/route.ts @@ -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, }) diff --git a/app/api/bookkeeping/mapping-rules/evaluate/route.ts b/app/api/bookkeeping/mapping-rules/evaluate/route.ts index ad9bf5f7..61f95ee7 100644 --- a/app/api/bookkeeping/mapping-rules/evaluate/route.ts +++ b/app/api/bookkeeping/mapping-rules/evaluate/route.ts @@ -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( diff --git a/app/api/invoices/[id]/book/route.ts b/app/api/invoices/[id]/book/route.ts index 6b9f0460..1e757259 100644 --- a/app/api/invoices/[id]/book/route.ts +++ b/app/api/invoices/[id]/book/route.ts @@ -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).entity_type as EntityType) || 'enskild_firma' + const entityType = await resolveCompanyEntityType( + supabase, + companyId, + (settings as Partial).entity_type, + ) const result = await bookInvoiceDeferred({ supabase, diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 7a1dfa58..7e72c595 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -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. diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 5e4a177d..a8f026f9 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -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 diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 6e7e2717..47468bdb 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -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') diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 2064c9f7..379e71f9 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -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).accounting_method || 'accrual') as AccountingMethod, log: opLog, }) diff --git a/app/api/invoices/bulk-book/route.ts b/app/api/invoices/bulk-book/route.ts index 9cee66c5..4213b03f 100644 --- a/app/api/invoices/bulk-book/route.ts +++ b/app/api/invoices/bulk-book/route.ts @@ -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).entity_type as EntityType) || 'enskild_firma' + const entityType = await resolveCompanyEntityType( + supabase, + companyId, + (settings as Partial).entity_type, + ) const { data: invoices, error: fetchError } = await supabase .from('invoices') diff --git a/app/api/pending-operations/[id]/route.ts b/app/api/pending-operations/[id]/route.ts index 5e665dc3..08db879e 100644 --- a/app/api/pending-operations/[id]/route.ts +++ b/app/api/pending-operations/[id]/route.ts @@ -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' diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 444c3224..5d288dc9 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -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. diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 820acad0..e1effcc2 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -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 diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts index c17ed037..fb1328d2 100644 --- a/app/api/transactions/[id]/match-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-invoice/preview/route.ts @@ -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 diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 9361e907..8f6e2b81 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts index d8cc289b..5b110481 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts @@ -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). diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index a4fe75d5..cc04e828 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.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) { diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts index 9f733b85..22dcb1cd 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index 095372ba..e01ef50d 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index 16eaa1b0..aafe1396 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -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++) { diff --git a/app/api/v1/companies/route.ts b/app/api/v1/companies/route.ts index 6fd05a1b..a30565db 100644 --- a/app/api/v1/companies/route.ts +++ b/app/api/v1/companies/route.ts @@ -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(), diff --git a/components/bookkeeping/BookingTemplatePicker.tsx b/components/bookkeeping/BookingTemplatePicker.tsx index ec1c3ba5..83432eab 100644 --- a/components/bookkeeping/BookingTemplatePicker.tsx +++ b/components/bookkeeping/BookingTemplatePicker.tsx @@ -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 = { + 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 {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}`} {t.description && ( diff --git a/components/bookkeeping/accrual-k2-hint.ts b/components/bookkeeping/accrual-k2-hint.ts index 3a5d1cb7..241cdb12 100644 --- a/components/bookkeeping/accrual-k2-hint.ts +++ b/components/bookkeeping/accrual-k2-hint.ts @@ -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' } diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx index 3f8423ff..b192119f 100644 --- a/components/extensions/general/BookDirectlyDialog.tsx +++ b/components/extensions/general/BookDirectlyDialog.tsx @@ -86,6 +86,7 @@ const TEMPLATE_ENTITY_LABELS: Record = { all: 'Alla', enskild_firma: 'Enskild firma', aktiebolag: 'Aktiebolag', + ideell_forening: 'Ideell förening', } interface Props { diff --git a/components/onboarding/BankIdCompanyPicker.tsx b/components/onboarding/BankIdCompanyPicker.tsx index 39e41c7e..07b79f2c 100644 --- a/components/onboarding/BankIdCompanyPicker.tsx +++ b/components/onboarding/BankIdCompanyPicker.tsx @@ -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') { diff --git a/components/onboarding/journey/OnboardingJourney.tsx b/components/onboarding/journey/OnboardingJourney.tsx index bb366420..e3c4b34c 100644 --- a/components/onboarding/journey/OnboardingJourney.tsx +++ b/components/onboarding/journey/OnboardingJourney.tsx @@ -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 = { + 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 ( ({ + 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, ]) } diff --git a/components/settings/BookingTemplatesPanel.tsx b/components/settings/BookingTemplatesPanel.tsx index 107a0738..d7625867 100644 --- a/components/settings/BookingTemplatesPanel.tsx +++ b/components/settings/BookingTemplatesPanel.tsx @@ -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 diff --git a/components/settings/TaxSettingsForm.tsx b/components/settings/TaxSettingsForm.tsx index 4a02418d..973e4971 100644 --- a/components/settings/TaxSettingsForm.tsx +++ b/components/settings/TaxSettingsForm.tsx @@ -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. */} - {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'), + }) + : ''} diff --git a/components/settings/TemplateForm.tsx b/components/settings/TemplateForm.tsx index 789dfa20..cc881c29 100644 --- a/components/settings/TemplateForm.tsx +++ b/components/settings/TemplateForm.tsx @@ -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(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(() => diff --git a/components/supplier-invoices/supplier-invoice-defaults.ts b/components/supplier-invoices/supplier-invoice-defaults.ts index 43347ab4..8b65e876 100644 --- a/components/supplier-invoices/supplier-invoice-defaults.ts +++ b/components/supplier-invoices/supplier-invoice-defaults.ts @@ -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', diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index eeee9dbf..e83e1760 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -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 diff --git a/extensions/general/invoice-inbox/__tests__/suggest-booking.test.ts b/extensions/general/invoice-inbox/__tests__/suggest-booking.test.ts index 04683339..d3d217e6 100644 --- a/extensions/general/invoice-inbox/__tests__/suggest-booking.test.ts +++ b/extensions/general/invoice-inbox/__tests__/suggest-booking.test.ts @@ -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(), diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index feaacf00..04ba8bb7 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -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, diff --git a/extensions/general/mcp-server/__tests__/kontantmetod-cutoff-tool.test.ts b/extensions/general/mcp-server/__tests__/kontantmetod-cutoff-tool.test.ts index facee462..c1fd426c 100644 --- a/extensions/general/mcp-server/__tests__/kontantmetod-cutoff-tool.test.ts +++ b/extensions/general/mcp-server/__tests__/kontantmetod-cutoff-tool.test.ts @@ -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( diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 794cffde..562a66d7 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 && diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index de15420a..da707050 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -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) diff --git a/extensions/general/skatteverket/lib/agi-kvittens-reconcile.ts b/extensions/general/skatteverket/lib/agi-kvittens-reconcile.ts index e45eea26..43e75e48 100644 --- a/extensions/general/skatteverket/lib/agi-kvittens-reconcile.ts +++ b/extensions/general/skatteverket/lib/agi-kvittens-reconcile.ts @@ -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) diff --git a/extensions/general/skatteverket/lib/connection-store.ts b/extensions/general/skatteverket/lib/connection-store.ts index ba72f976..cd3c0f66 100644 --- a/extensions/general/skatteverket/lib/connection-store.ts +++ b/extensions/general/skatteverket/lib/connection-store.ts @@ -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> { 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 } diff --git a/extensions/general/skatteverket/lib/skattekonto-booking.ts b/extensions/general/skatteverket/lib/skattekonto-booking.ts index ab5b40e3..3be7541e 100644 --- a/extensions/general/skatteverket/lib/skattekonto-booking.ts +++ b/extensions/general/skatteverket/lib/skattekonto-booking.ts @@ -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 diff --git a/extensions/general/stripe/__tests__/sync.test.ts b/extensions/general/stripe/__tests__/sync.test.ts index 52f70ad9..b4292bf7 100644 --- a/extensions/general/stripe/__tests__/sync.test.ts +++ b/extensions/general/stripe/__tests__/sync.test.ts @@ -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 diff --git a/extensions/general/stripe/lib/sync.ts b/extensions/general/stripe/lib/sync.ts index 067f3e6a..3abbe9a7 100644 --- a/extensions/general/stripe/lib/sync.ts +++ b/extensions/general/stripe/lib/sync.ts @@ -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] diff --git a/lib/agent/composer/fallback.ts b/lib/agent/composer/fallback.ts index 355cf5e1..e930a124 100644 --- a/lib/agent/composer/fallback.ts +++ b/lib/agent/composer/fallback.ts @@ -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) { diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index cc3bff45..0fb9cba8 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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']) diff --git a/lib/bokslut/__tests__/readiness-aggregator.test.ts b/lib/bokslut/__tests__/readiness-aggregator.test.ts index 33c47977..3d773447 100644 --- a/lib/bokslut/__tests__/readiness-aggregator.test.ts +++ b/lib/bokslut/__tests__/readiness-aggregator.test.ts @@ -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({ diff --git a/lib/bokslut/accruals/auto-detect.ts b/lib/bokslut/accruals/auto-detect.ts index 52ce62ce..02a5efb0 100644 --- a/lib/bokslut/accruals/auto-detect.ts +++ b/lib/bokslut/accruals/auto-detect.ts @@ -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}).` } diff --git a/lib/bokslut/assets/k2-account-guard.ts b/lib/bokslut/assets/k2-account-guard.ts index 9e3c9e3b..9601baea 100644 --- a/lib/bokslut/assets/k2-account-guard.ts +++ b/lib/bokslut/assets/k2-account-guard.ts @@ -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) { diff --git a/lib/bokslut/dispositions-proposal-builder.ts b/lib/bokslut/dispositions-proposal-builder.ts index 1798e687..5f4726ff 100644 --- a/lib/bokslut/dispositions-proposal-builder.ts +++ b/lib/bokslut/dispositions-proposal-builder.ts @@ -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 diff --git a/lib/bokslut/readiness-aggregator.ts b/lib/bokslut/readiness-aggregator.ts index 4bc673d3..b8af31c9 100644 --- a/lib/bokslut/readiness-aggregator.ts +++ b/lib/bokslut/readiness-aggregator.ts @@ -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') diff --git a/lib/bokslut/reserves/overavskrivningar-calculator.ts b/lib/bokslut/reserves/overavskrivningar-calculator.ts index d31a1a17..c15dd4ff 100644 --- a/lib/bokslut/reserves/overavskrivningar-calculator.ts +++ b/lib/bokslut/reserves/overavskrivningar-calculator.ts @@ -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 { +async function loadEntityType(supabase: SupabaseClient, companyId: string): Promise { 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( diff --git a/lib/bokslut/types.ts b/lib/bokslut/types.ts index 4bae2057..0475f632 100644 --- a/lib/bokslut/types.ts +++ b/lib/bokslut/types.ts @@ -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 diff --git a/lib/bookkeeping/__tests__/booking-templates.test.ts b/lib/bookkeeping/__tests__/booking-templates.test.ts index 7a4e2de7..d54b6ab7 100644 --- a/lib/bookkeeping/__tests__/booking-templates.test.ts +++ b/lib/bookkeeping/__tests__/booking-templates.test.ts @@ -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 }) diff --git a/lib/bookkeeping/__tests__/category-mapping.test.ts b/lib/bookkeeping/__tests__/category-mapping.test.ts index 3c837f53..bf22ca4b 100644 --- a/lib/bookkeeping/__tests__/category-mapping.test.ts +++ b/lib/bookkeeping/__tests__/category-mapping.test.ts @@ -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') }) }) diff --git a/lib/bookkeeping/__tests__/mapping-engine-transfer-guard.test.ts b/lib/bookkeeping/__tests__/mapping-engine-transfer-guard.test.ts index fc65d99c..6c5a50bf 100644 --- a/lib/bookkeeping/__tests__/mapping-engine-transfer-guard.test.ts +++ b/lib/bookkeeping/__tests__/mapping-engine-transfer-guard.test.ts @@ -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', ) diff --git a/lib/bookkeeping/__tests__/mapping-engine.test.ts b/lib/bookkeeping/__tests__/mapping-engine.test.ts index 79bf0655..1abd5d86 100644 --- a/lib/bookkeeping/__tests__/mapping-engine.test.ts +++ b/lib/bookkeeping/__tests__/mapping-engine.test.ts @@ -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') }) diff --git a/lib/bookkeeping/__tests__/proposal-lines.test.ts b/lib/bookkeeping/__tests__/proposal-lines.test.ts index 5030ad47..67fa6c4e 100644 --- a/lib/bookkeeping/__tests__/proposal-lines.test.ts +++ b/lib/bookkeeping/__tests__/proposal-lines.test.ts @@ -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', diff --git a/lib/bookkeeping/booking-templates.ts b/lib/bookkeeping/booking-templates.ts index 61cedd24..ac451a55 100644 --- a/lib/bookkeeping/booking-templates.ts +++ b/lib/bookkeeping/booking-templates.ts @@ -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. diff --git a/lib/bookkeeping/category-mapping.ts b/lib/bookkeeping/category-mapping.ts index a472a3bf..dce1e503 100644 --- a/lib/bookkeeping/category-mapping.ts +++ b/lib/bookkeeping/category-mapping.ts @@ -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 = { - 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 = { @@ -67,9 +65,15 @@ const INCOME_ACCOUNTS: Record = { * 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_')) { diff --git a/lib/bookkeeping/invoice-accounts.ts b/lib/bookkeeping/invoice-accounts.ts index bd6bb345..f8c7fc3c 100644 --- a/lib/bookkeeping/invoice-accounts.ts +++ b/lib/bookkeeping/invoice-accounts.ts @@ -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' } diff --git a/lib/bookkeeping/mapping-engine.ts b/lib/bookkeeping/mapping-engine.ts index cddc093e..9853437c 100644 --- a/lib/bookkeeping/mapping-engine.ts +++ b/lib/bookkeeping/mapping-engine.ts @@ -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 { 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 { 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 diff --git a/lib/bookkeeping/proposal-lines.ts b/lib/bookkeeping/proposal-lines.ts index 297c5637..1949bf55 100644 --- a/lib/bookkeeping/proposal-lines.ts +++ b/lib/bookkeeping/proposal-lines.ts @@ -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 } } /** diff --git a/lib/company-lookup/__tests__/entity-type-map.test.ts b/lib/company-lookup/__tests__/entity-type-map.test.ts index 0f012031..fad24072 100644 --- a/lib/company-lookup/__tests__/entity-type-map.test.ts +++ b/lib/company-lookup/__tests__/entity-type-map.test.ts @@ -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() + }) +}) diff --git a/lib/company-lookup/entity-type-map.ts b/lib/company-lookup/entity-type-map.ts index 1abc2d72..643683e7 100644 --- a/lib/company-lookup/entity-type-map.ts +++ b/lib/company-lookup/entity-type-map.ts @@ -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([ '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([ + '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 +} diff --git a/lib/company/__tests__/entity-type.test.ts b/lib/company/__tests__/entity-type.test.ts new file mode 100644 index 00000000..62317a5d --- /dev/null +++ b/lib/company/__tests__/entity-type.test.ts @@ -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']) + }) +}) diff --git a/lib/company/__tests__/onboarding-input.test.ts b/lib/company/__tests__/onboarding-input.test.ts index 14ef18ba..7a8ef4e6 100644 --- a/lib/company/__tests__/onboarding-input.test.ts +++ b/lib/company/__tests__/onboarding-input.test.ts @@ -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') + }) +}) diff --git a/lib/company/actions.ts b/lib/company/actions.ts index 4f6e8177..ea67be35 100644 --- a/lib/company/actions.ts +++ b/lib/company/actions.ts @@ -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.' } } diff --git a/lib/company/compute-fiscal-period.ts b/lib/company/compute-fiscal-period.ts index ba0db221..2846619e 100644 --- a/lib/company/compute-fiscal-period.ts +++ b/lib/company/compute-fiscal-period.ts @@ -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` diff --git a/lib/company/entity-type.ts b/lib/company/entity-type.ts new file mode 100644 index 00000000..5b626da6 --- /dev/null +++ b/lib/company/entity-type.ts @@ -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 +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 = { + 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` makes every + * arm mandatory at compile time; the runtime check catches a corrupt string + * that slipped past the DB CHECK. + */ +export function byEntityType(entityType: EntityType, arms: Record): 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 { + 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(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' }) +} diff --git a/lib/company/fiscal-year.ts b/lib/company/fiscal-year.ts index b3a22955..8dbb91b6 100644 --- a/lib/company/fiscal-year.ts +++ b/lib/company/fiscal-year.ts @@ -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() diff --git a/lib/company/onboarding-input.ts b/lib/company/onboarding-input.ts index 7eda0c32..4bcd0634 100644 --- a/lib/company/onboarding-input.ts +++ b/lib/company/onboarding-input.ts @@ -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 = { 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 } : {}), diff --git a/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts index 0c627e90..c037f5b7 100644 --- a/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts +++ b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts @@ -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) }) }) diff --git a/lib/core/bookkeeping/__tests__/result-appropriation.test.ts b/lib/core/bookkeeping/__tests__/result-appropriation.test.ts index dd5218b7..01a75704 100644 --- a/lib/core/bookkeeping/__tests__/result-appropriation.test.ts +++ b/lib/core/bookkeeping/__tests__/result-appropriation.test.ts @@ -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') diff --git a/lib/core/bookkeeping/__tests__/year-end-service.test.ts b/lib/core/bookkeeping/__tests__/year-end-service.test.ts index bf793c2a..2495fcee 100644 --- a/lib/core/bookkeeping/__tests__/year-end-service.test.ts +++ b/lib/core/bookkeeping/__tests__/year-end-service.test.ts @@ -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', diff --git a/lib/core/bookkeeping/kontantmetod-cutoff.ts b/lib/core/bookkeeping/kontantmetod-cutoff.ts index 31d447fd..b0ec2cdd 100644 --- a/lib/core/bookkeeping/kontantmetod-cutoff.ts +++ b/lib/core/bookkeeping/kontantmetod-cutoff.ts @@ -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 { 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. */ diff --git a/lib/core/bookkeeping/result-appropriation-repair.ts b/lib/core/bookkeeping/result-appropriation-repair.ts index 2c092ab3..a9c13043 100644 --- a/lib/core/bookkeeping/result-appropriation-repair.ts +++ b/lib/core/bookkeeping/result-appropriation-repair.ts @@ -171,7 +171,9 @@ export function classifyHistoricalResultRepair( reason: Exclude, ): 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 || diff --git a/lib/core/bookkeeping/result-appropriation-service.ts b/lib/core/bookkeeping/result-appropriation-service.ts index 7c147f46..60065749 100644 --- a/lib/core/bookkeeping/result-appropriation-service.ts +++ b/lib/core/bookkeeping/result-appropriation-service.ts @@ -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, diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts index 7d1a899b..ee35f894 100644 --- a/lib/core/bookkeeping/year-end-service.ts +++ b/lib/core/bookkeeping/year-end-service.ts @@ -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' }) diff --git a/lib/expenses/__tests__/payer.test.ts b/lib/expenses/__tests__/payer.test.ts index a09fdc06..437d3acf 100644 --- a/lib/expenses/__tests__/payer.test.ts +++ b/lib/expenses/__tests__/payer.test.ts @@ -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') diff --git a/lib/expenses/expense-claims-service.ts b/lib/expenses/expense-claims-service.ts index 3377a93e..bd69b194 100644 --- a/lib/expenses/expense-claims-service.ts +++ b/lib/expenses/expense-claims-service.ts @@ -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 diff --git a/lib/expenses/payer.ts b/lib/expenses/payer.ts index db51c2d4..442066e0 100644 --- a/lib/expenses/payer.ts +++ b/lib/expenses/payer.ts @@ -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 } diff --git a/lib/invariants/__tests__/org-number.test.ts b/lib/invariants/__tests__/org-number.test.ts index 93bf9e9a..0028c542 100644 --- a/lib/invariants/__tests__/org-number.test.ts +++ b/lib/invariants/__tests__/org-number.test.ts @@ -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') + }) }) diff --git a/lib/invariants/org-number.ts b/lib/invariants/org-number.ts index 5d92daa3..f5fb60e0 100644 --- a/lib/invariants/org-number.ts +++ b/lib/invariants/org-number.ts @@ -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). diff --git a/lib/invoices/issue-and-book-invoice.ts b/lib/invoices/issue-and-book-invoice.ts index 236ed53b..70925e97 100644 --- a/lib/invoices/issue-and-book-invoice.ts +++ b/lib/invoices/issue-and-book-invoice.ts @@ -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. diff --git a/lib/invoices/self-billed-sale.ts b/lib/invoices/self-billed-sale.ts index bfe72628..e63d2410 100644 --- a/lib/invoices/self-billed-sale.ts +++ b/lib/invoices/self-billed-sale.ts @@ -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') diff --git a/lib/onboarding-journey/reducer.ts b/lib/onboarding-journey/reducer.ts index b2aa52ec..c940cc27 100644 --- a/lib/onboarding-journey/reducer.ts +++ b/lib/onboarding-journey/reducer.ts @@ -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 = { ...state.settings, entity_type: mapped ?? state.settings.entity_type, diff --git a/lib/packs/schema.ts b/lib/packs/schema.ts index 1f7cdca8..b4185ec1 100644 --- a/lib/packs/schema.ts +++ b/lib/packs/schema.ts @@ -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 diff --git a/lib/pending-operations/__tests__/kontantmetod-cutoff-executor.test.ts b/lib/pending-operations/__tests__/kontantmetod-cutoff-executor.test.ts index 24d3f70f..d7c84033 100644 --- a/lib/pending-operations/__tests__/kontantmetod-cutoff-executor.test.ts +++ b/lib/pending-operations/__tests__/kontantmetod-cutoff-executor.test.ts @@ -32,7 +32,7 @@ const collection = { } function makePendingOp(overrides: Partial = {}): 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( diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 1d15a499..e0a2f1f4 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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, }) diff --git a/lib/reports/behandlingshistorik.ts b/lib/reports/behandlingshistorik.ts index ff932274..947091fd 100644 --- a/lib/reports/behandlingshistorik.ts +++ b/lib/reports/behandlingshistorik.ts @@ -361,7 +361,7 @@ const SETTINGS_VALUE_LABELS: Record> = { // 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 = { diff --git a/lib/skatteverket/format.ts b/lib/skatteverket/format.ts index 84c4534c..bfc86f14 100644 --- a/lib/skatteverket/format.ts +++ b/lib/skatteverket/format.ts @@ -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) } diff --git a/lib/skatteverket/skattekonto-ocr.ts b/lib/skatteverket/skattekonto-ocr.ts index e3736cce..02833481 100644 --- a/lib/skatteverket/skattekonto-ocr.ts +++ b/lib/skatteverket/skattekonto-ocr.ts @@ -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 { const reported = await readReportedOcr(supabase, companyId) return reported ?? generateSkattekontoOcr(orgOrPersonnummer, entityType) diff --git a/lib/tax/__tests__/deadline-config.test.ts b/lib/tax/__tests__/deadline-config.test.ts index c8a1e757..7aab8407 100644 --- a/lib/tax/__tests__/deadline-config.test.ts +++ b/lib/tax/__tests__/deadline-config.test.ts @@ -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', () => { diff --git a/lib/tax/deadline-config.ts b/lib/tax/deadline-config.ts index c08e28f5..3fee5779 100644 --- a/lib/tax/deadline-config.ts +++ b/lib/tax/deadline-config.ts @@ -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[] = [] diff --git a/lib/tax/deadline-generator.ts b/lib/tax/deadline-generator.ts index 8a69410e..055c27d9 100644 --- a/lib/tax/deadline-generator.ts +++ b/lib/tax/deadline-generator.ts @@ -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): boolean { export function toDeadlineSettings( settings: Partial, ): 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') } diff --git a/lib/transactions/__tests__/counterparty-suggestion.test.ts b/lib/transactions/__tests__/counterparty-suggestion.test.ts index 2bdca36d..322a27ec 100644 --- a/lib/transactions/__tests__/counterparty-suggestion.test.ts +++ b/lib/transactions/__tests__/counterparty-suggestion.test.ts @@ -85,6 +85,7 @@ describe('buildCounterpartySuggestion', () => { }, undefined, 'expense_other', + 'enskild_firma', ) expect(account).toBe('6570') expect(() => account.startsWith('2')).not.toThrow() diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts index 3bb58d37..8a6436ff 100644 --- a/lib/transactions/__tests__/ingest.test.ts +++ b/lib/transactions/__tests__/ingest.test.ts @@ -70,7 +70,12 @@ function createQueueMockSupabase() { const handler: ProxyHandler = { 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') { diff --git a/lib/transactions/__tests__/quick-review-defaults.test.ts b/lib/transactions/__tests__/quick-review-defaults.test.ts index 671cde04..36266fd6 100644 --- a/lib/transactions/__tests__/quick-review-defaults.test.ts +++ b/lib/transactions/__tests__/quick-review-defaults.test.ts @@ -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) }) }) diff --git a/lib/transactions/categorize-core.ts b/lib/transactions/categorize-core.ts index 1f227550..19dd6e3d 100644 --- a/lib/transactions/categorize-core.ts +++ b/lib/transactions/categorize-core.ts @@ -25,6 +25,7 @@ * resolve it, not because the amount already is kronor. */ import type { SupabaseClient } from '@supabase/supabase-js' +import { resolveCompanyEntityType } from '@/lib/company/entity-type' import { eventBus } from '@/lib/events' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { applyAccountOverride } from '@/lib/bookkeeping/account-override' @@ -378,7 +379,7 @@ export async function categorizeMatchedTransaction( const { data: settings } = await supabase .from('company_settings').select('entity_type, fiscal_year_start_month').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 = settings?.fiscal_year_start_month ?? 1 let mappingResult = buildMappingResultFromCategory( diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index 310fafa1..7c2c15e4 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from '@supabase/supabase-js' +import { resolveCompanyEntityType } from '@/lib/company/entity-type' import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' @@ -1164,7 +1165,7 @@ export async function ingestTransactions( supabase, companyId, newTransaction as Transaction, - undefined, + await resolveCompanyEntityType(supabase, companyId), options?.settlementAccount ) diff --git a/lib/transactions/quick-review-defaults.ts b/lib/transactions/quick-review-defaults.ts index d509d108..26fa139a 100644 --- a/lib/transactions/quick-review-defaults.ts +++ b/lib/transactions/quick-review-defaults.ts @@ -2,7 +2,7 @@ import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory, } from '@/lib/bookkeeping/category-mapping' -import type { TransactionCategory, VatTreatment } from '@/types' +import type { EntityType, TransactionCategory, VatTreatment } from '@/types' /** * The template shape the transaction review dialog actually reads. @@ -60,12 +60,13 @@ export function resolveQuickReviewDefaults( template: ReviewTemplate | null | undefined, templateId: string | undefined, category: TransactionCategory | null | undefined, + entityType: EntityType, ): QuickReviewDefaults { const useTemplateDefaults = !templateId && !!template const account = (useTemplateDefaults ? template.debit_account : undefined) || - (category ? getDefaultAccountForCategory(category) : '') || + (category ? getDefaultAccountForCategory(category, entityType) : '') || '' const vat: VatTreatment | 'none' = useTemplateDefaults diff --git a/messages/en.json b/messages/en.json index 30f4fed1..b76b5eac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1453,9 +1453,10 @@ "journey_ceased_sub": "According to Bolagsverket the company is deregistered.", "journey_ceased_continue": "Continue anyway", "journey_form_title": "What type of company is it?", - "journey_form_info": "An aktiebolag has its own organisation number from Bolagsverket. An enskild firma uses your personal identity number. The company type determines the fiscal-year and tax rules.", + "journey_form_info": "An aktiebolag has its own organisation number from Bolagsverket. An enskild firma uses your personal identity number. An ideell förening has an organisation number from Skatteverket. The company type determines the fiscal-year and tax rules.", "journey_form_ab": "Aktiebolag", "journey_form_ef": "Enskild firma", + "journey_form_forening": "Ideell förening (beta)", "journey_name_ab_title": "What is the company called?", "journey_name_ab_sub": "The name as registered at Bolagsverket.", "journey_name_ab_placeholder": "Nordvik Bygg & Konsult AB", @@ -2463,6 +2464,7 @@ "entity_all": "All", "entity_enskild_firma": "Enskild firma", "entity_aktiebolag": "Aktiebolag", + "entity_ideell_forening": "Ideell förening", "toast_fetch_failed": "Could not load templates", "toast_delete_failed": "Could not delete template", "toast_deleted": "Template deleted", @@ -2568,6 +2570,7 @@ "entity_form_heading": "Company form", "entity_aktiebolag": "Aktiebolag", "entity_enskild_firma": "Enskild firma", + "entity_ideell_forening": "Ideell förening", "entity_form_help": "Company form cannot be changed. Contact support if needed.", "tax_vat_heading": "Tax & VAT", "f_skatt_label": "F-skattsedel", diff --git a/messages/sv.json b/messages/sv.json index 78d85a19..144f502e 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1453,9 +1453,10 @@ "journey_ceased_sub": "Enligt Bolagsverket är företaget avregistrerat.", "journey_ceased_continue": "Fortsätt ändå", "journey_form_title": "Vilken företagsform har ni?", - "journey_form_info": "Aktiebolag har ett eget organisationsnummer från Bolagsverket. Enskild firma använder ditt personnummer. Företagsformen avgör reglerna för räkenskapsår och skatt.", + "journey_form_info": "Aktiebolag har ett eget organisationsnummer från Bolagsverket. Enskild firma använder ditt personnummer. Ideell förening har ett organisationsnummer från Skatteverket. Företagsformen avgör reglerna för räkenskapsår och skatt.", "journey_form_ab": "Aktiebolag", "journey_form_ef": "Enskild firma", + "journey_form_forening": "Ideell förening (beta)", "journey_name_ab_title": "Vad heter bolaget?", "journey_name_ab_sub": "Namnet enligt registreringen hos Bolagsverket.", "journey_name_ab_placeholder": "Nordvik Bygg & Konsult AB", @@ -2463,6 +2464,7 @@ "entity_all": "Alla", "entity_enskild_firma": "Enskild firma", "entity_aktiebolag": "Aktiebolag", + "entity_ideell_forening": "Ideell förening", "toast_fetch_failed": "Kunde inte hämta mallar", "toast_delete_failed": "Kunde inte ta bort mall", "toast_deleted": "Mall borttagen", @@ -2568,6 +2570,7 @@ "entity_form_heading": "Företagsform", "entity_aktiebolag": "Aktiebolag", "entity_enskild_firma": "Enskild firma", + "entity_ideell_forening": "Ideell förening", "entity_form_help": "Företagsform kan inte ändras. Kontakta support vid behov.", "tax_vat_heading": "Skatt & moms", "f_skatt_label": "F-skattsedel", diff --git a/scripts/checks/typecheck-baseline.json b/scripts/checks/typecheck-baseline.json index 9d88104d..bad3bdcf 100644 --- a/scripts/checks/typecheck-baseline.json +++ b/scripts/checks/typecheck-baseline.json @@ -1,5 +1,5 @@ { - "totalErrors": 535, + "totalErrors": 533, "perFile": { "app/api/assets/__tests__/id.test.ts": 9, "app/api/auth/email-hook/__tests__/route.test.ts": 1, @@ -41,7 +41,7 @@ "app/api/settings/eu-trade-signal/__tests__/route.test.ts": 3, "app/api/settings/ku-signal/__tests__/route.test.ts": 4, "app/api/settings/rot-rut-signal/__tests__/route.test.ts": 4, - "app/api/supplier-invoices/__tests__/route.test.ts": 41, + "app/api/supplier-invoices/__tests__/route.test.ts": 39, "app/api/supplier-invoices/payment-batches/__tests__/route.test.ts": 12, "app/api/tax-assessment-notices/__tests__/route.test.ts": 5, "app/api/transactions/bulk-book/__tests__/route.test.ts": 16, diff --git a/skills/accounted-api/references/core.md b/skills/accounted-api/references/core.md index 74faf739..57537865 100644 --- a/skills/accounted-api/references/core.md +++ b/skills/accounted-api/references/core.md @@ -82,7 +82,7 @@ Request body: ```ts { name: string, - entity_type: "enskild_firma" | "aktiebolag", + entity_type: "enskild_firma" | "aktiebolag" | "ideell_forening", org_number?: string, vat_registered: boolean, moms_period?: "monthly" | "quarterly" | "yearly", @@ -116,7 +116,7 @@ Response `200`: data: { id: string, name: string, - entity_type: "enskild_firma" | "aktiebolag", + entity_type: "enskild_firma" | "aktiebolag" | "ideell_forening", org_number: string, vat_registered: boolean, moms_period: "monthly" | "quarterly" | "yearly", diff --git a/supabase/migrations/20260908143051_ideell_forening_entity_type.sql b/supabase/migrations/20260908143051_ideell_forening_entity_type.sql new file mode 100644 index 00000000..78a28341 --- /dev/null +++ b/supabase/migrations/20260908143051_ideell_forening_entity_type.sql @@ -0,0 +1,480 @@ +-- ============================================================================= +-- Ideell förening as a third legal form (issue #2072, step 1) +-- +-- Widens entity_type from {enskild_firma, aktiebolag} to also accept +-- 'ideell_forening' everywhere the value is stored or validated: +-- 1. CHECK constraints on companies, company_settings and +-- booking_template_library (the latter keeps 'all'). +-- 2. supported_entity_types(): the one list the create RPCs validate +-- against, so the next form is one function change, not four. +-- 3. The three live create RPCs (create_company_with_owner, +-- create_company_for_user, create_company_for_brand_signup): bodies +-- byte-identical to 20260826130400 / 20260826130600 / 20260827120000 +-- except the entity_type guard. +-- 4. seed_chart_of_accounts(): a förening equity block. BAS 2060-2069 is +-- the equity group for ideella föreningar; the seed gives 2067 +-- (balanserat), 2068 (föregående år), 2069 (årets resultat) and 2890 +-- (member settlement: a förening has no owner accounts). Personnel +-- accounts are auto-created on first payroll, as for enskild firma. +-- sru_code stays NULL: föreningar file INK3, which is not modelled. +-- Everything else in seed_chart_of_accounts is byte-identical to +-- 20260731090000. +-- ============================================================================= + +ALTER TABLE public.companies + DROP CONSTRAINT IF EXISTS companies_entity_type_check; +ALTER TABLE public.companies + ADD CONSTRAINT companies_entity_type_check + CHECK (entity_type IN ('enskild_firma', 'aktiebolag', 'ideell_forening')); + +ALTER TABLE public.company_settings + DROP CONSTRAINT IF EXISTS company_settings_entity_type_check; +ALTER TABLE public.company_settings + ADD CONSTRAINT company_settings_entity_type_check + CHECK (entity_type IN ('enskild_firma', 'aktiebolag', 'ideell_forening')); + +ALTER TABLE public.booking_template_library + DROP CONSTRAINT IF EXISTS booking_template_library_entity_type_check; +ALTER TABLE public.booking_template_library + ADD CONSTRAINT booking_template_library_entity_type_check + CHECK (entity_type IN ('all', 'enskild_firma', 'aktiebolag', 'ideell_forening')); + +-- ----------------------------------------------------------------------------- +-- The single list the create RPCs validate against. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.supported_entity_types() +RETURNS text[] +LANGUAGE sql +IMMUTABLE +SET search_path = public +AS $$ + SELECT ARRAY['enskild_firma', 'aktiebolag', 'ideell_forening']::text[]; +$$; + +REVOKE ALL ON FUNCTION public.supported_entity_types() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.supported_entity_types() TO anon, authenticated, service_role; + +-- ----------------------------------------------------------------------------- +-- create_company_with_owner: 20260826130400 with the widened guard. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.create_company_with_owner( + p_name text, + p_entity_type text, + p_set_active boolean DEFAULT true, + p_team_id uuid DEFAULT NULL +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user_id uuid; + v_company_id uuid; + v_team_kind text; + v_team_role text; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + IF p_entity_type IS NULL OR p_entity_type <> ALL (public.supported_entity_types()) THEN + RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type; + END IF; + + -- Authorize p_team_id before any write. SECURITY DEFINER bypasses RLS, so + -- we must verify membership ourselves; without this any authenticated user + -- could attach a company to an arbitrary team (20260519180000). On byrå + -- teams the bar is higher: WL-15 locks client company creation to team + -- owner/admin because every created company is +1 on the byrå's invoice. + IF p_team_id IS NOT NULL THEN + SELECT tm.role, t.kind + INTO v_team_role, v_team_kind + FROM public.team_members tm + JOIN public.teams t ON t.id = tm.team_id + WHERE tm.team_id = p_team_id + AND tm.user_id = v_user_id; + + IF v_team_role IS NULL THEN + RAISE EXCEPTION 'Not a member of team %', p_team_id + USING ERRCODE = '42501'; -- insufficient_privilege + END IF; + + IF v_team_kind = 'byra' AND v_team_role NOT IN ('owner', 'admin') THEN + RAISE EXCEPTION 'Only byrå team owners and admins can create client companies' + USING ERRCODE = '42501'; -- insufficient_privilege + END IF; + END IF; + + INSERT INTO public.companies (name, entity_type, created_by, team_id) + VALUES (p_name, p_entity_type, v_user_id, p_team_id) + RETURNING id INTO v_company_id; + + INSERT INTO public.company_members (company_id, user_id, role) + VALUES (v_company_id, v_user_id, 'owner'); + + -- Seed default 1930 SEK cash account so reconciliation routes work before + -- any PSD2 connection is established. is_primary so the __PRIMARY_SEK__ + -- sentinel in skattekonto-booking resolves on day one. + INSERT INTO public.cash_accounts ( + company_id, ledger_account, currency, name, enabled, is_primary, source + ) + VALUES ( + v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual' + ) + ON CONFLICT (company_id, ledger_account) DO NOTHING; + + IF p_set_active THEN + INSERT INTO public.user_preferences (user_id, active_company_id) + VALUES (v_user_id, v_company_id) + ON CONFLICT (user_id) + DO UPDATE SET active_company_id = EXCLUDED.active_company_id; + END IF; + + IF p_team_id IS NOT NULL THEN + PERFORM public.sync_team_to_company(v_company_id, p_team_id); + END IF; + + RETURN v_company_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.create_company_with_owner(text, text, boolean, uuid) TO authenticated; + +-- ----------------------------------------------------------------------------- +-- create_company_for_user: 20260826130600 with the widened guard. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.create_company_for_user( + p_user_id uuid, + p_name text, + p_entity_type text, + p_team_id uuid DEFAULT NULL +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_company_id uuid; + v_team_kind text; + v_team_role text; +BEGIN + IF p_user_id IS NULL THEN + RAISE EXCEPTION 'p_user_id is required'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN + RAISE EXCEPTION 'Unknown user %', p_user_id + USING ERRCODE = '23503'; -- foreign_key_violation + END IF; + + IF p_entity_type IS NULL OR p_entity_type <> ALL (public.supported_entity_types()) THEN + RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type; + END IF; + + IF p_name IS NULL OR length(btrim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name is required'; + END IF; + + -- Same authorization as create_company_with_owner (20260826130400), against + -- the explicit owner: SECURITY DEFINER bypasses RLS, so team membership is + -- checked here. On byrå teams the bar is higher: WL-15 locks client company + -- creation to team owner/admin because every created company is +1 on the + -- byrå's invoice. + IF p_team_id IS NOT NULL THEN + SELECT tm.role, t.kind + INTO v_team_role, v_team_kind + FROM public.team_members tm + JOIN public.teams t ON t.id = tm.team_id + WHERE tm.team_id = p_team_id + AND tm.user_id = p_user_id; + + IF v_team_role IS NULL THEN + RAISE EXCEPTION 'Not a member of team %', p_team_id + USING ERRCODE = '42501'; -- insufficient_privilege + END IF; + + IF v_team_kind = 'byra' AND v_team_role NOT IN ('owner', 'admin') THEN + RAISE EXCEPTION 'Only byrå team owners and admins can create client companies' + USING ERRCODE = '42501'; -- insufficient_privilege + END IF; + END IF; + + INSERT INTO public.companies (name, entity_type, created_by, team_id) + VALUES (btrim(p_name), p_entity_type, p_user_id, p_team_id) + RETURNING id INTO v_company_id; + + INSERT INTO public.company_members (company_id, user_id, role) + VALUES (v_company_id, p_user_id, 'owner'); + + INSERT INTO public.cash_accounts ( + company_id, ledger_account, currency, name, enabled, is_primary, source + ) + VALUES ( + v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual' + ) + ON CONFLICT (company_id, ledger_account) DO NOTHING; + + INSERT INTO public.user_preferences (user_id, active_company_id) + VALUES (p_user_id, v_company_id) + ON CONFLICT (user_id) + DO UPDATE SET active_company_id = EXCLUDED.active_company_id; + + IF p_team_id IS NOT NULL THEN + PERFORM public.sync_team_to_company(v_company_id, p_team_id); + END IF; + + RETURN v_company_id; +END; +$$; + +-- Service role only. PostgREST exposes functions to every role by default +-- (PUBLIC grant), so revoke first, then grant the one role that may call it. +REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM anon; +REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) TO service_role; + +-- ----------------------------------------------------------------------------- +-- create_company_for_brand_signup: 20260827120000 with the widened guard. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.create_company_for_brand_signup( + p_user_id uuid, + p_name text, + p_entity_type text, + p_brand_id uuid +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_company_id uuid; + v_team_id uuid; + v_email text; +BEGIN + IF p_user_id IS NULL THEN + RAISE EXCEPTION 'p_user_id is required'; + END IF; + + IF p_brand_id IS NULL THEN + RAISE EXCEPTION 'p_brand_id is required'; + END IF; + + SELECT lower(u.email) INTO v_email FROM auth.users u WHERE u.id = p_user_id; + IF v_email IS NULL THEN + RAISE EXCEPTION 'Unknown user %', p_user_id + USING ERRCODE = '23503'; -- foreign_key_violation + END IF; + + IF p_entity_type IS NULL OR p_entity_type <> ALL (public.supported_entity_types()) THEN + RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type; + END IF; + + IF p_name IS NULL OR length(btrim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name is required'; + END IF; + + SELECT b.team_id INTO v_team_id FROM public.brands b WHERE b.id = p_brand_id; + IF v_team_id IS NULL THEN + RAISE EXCEPTION 'Unknown brand %', p_brand_id + USING ERRCODE = '23503'; -- foreign_key_violation + END IF; + + -- The allowlist entry IS the authorization: it was recorded by a byrå + -- owner/admin (RLS above) or ops, standing in for the WL-15 admin gate. + IF NOT EXISTS ( + SELECT 1 + FROM public.brand_signup_allowlist a + WHERE a.brand_id = p_brand_id + AND a.email = v_email + ) THEN + RAISE EXCEPTION 'User % is not on the signup allowlist for brand %', p_user_id, p_brand_id + USING ERRCODE = '42501'; -- insufficient_privilege + END IF; + + INSERT INTO public.companies (name, entity_type, created_by, team_id) + VALUES (btrim(p_name), p_entity_type, p_user_id, v_team_id) + RETURNING id INTO v_company_id; + + INSERT INTO public.company_members (company_id, user_id, role) + VALUES (v_company_id, p_user_id, 'owner'); + + INSERT INTO public.cash_accounts ( + company_id, ledger_account, currency, name, enabled, is_primary, source + ) + VALUES ( + v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual' + ) + ON CONFLICT (company_id, ledger_account) DO NOTHING; + + INSERT INTO public.user_preferences (user_id, active_company_id) + VALUES (p_user_id, v_company_id) + ON CONFLICT (user_id) + DO UPDATE SET active_company_id = EXCLUDED.active_company_id; + + PERFORM public.sync_team_to_company(v_company_id, v_team_id); + + RETURN v_company_id; +END; +$$; + +-- Service role only. PostgREST exposes functions to every role by default +-- (PUBLIC grant), so revoke first, then grant the one role that may call it. +REVOKE ALL ON FUNCTION public.create_company_for_brand_signup(uuid, text, text, uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.create_company_for_brand_signup(uuid, text, text, uuid) FROM anon; +REVOKE ALL ON FUNCTION public.create_company_for_brand_signup(uuid, text, text, uuid) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.create_company_for_brand_signup(uuid, text, text, uuid) TO service_role; + +-- ----------------------------------------------------------------------------- +-- seed_chart_of_accounts: 20260731090000 plus the ideell förening block. +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.seed_chart_of_accounts(p_company_id uuid, p_entity_type text) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_account_count integer; + v_user_id uuid; +BEGIN + SELECT created_by INTO v_user_id FROM public.companies WHERE id = p_company_id; + + SELECT count(*) INTO v_account_count + FROM public.chart_of_accounts + WHERE company_id = p_company_id; + + IF v_account_count > 0 THEN + RETURN; + END IF; + + -- Assets (1xxx) + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '1510', 'Kundfordringar', 1, '15', 'asset', 'debit', 'k1', true, '7211'), + (v_user_id, p_company_id, '1910', 'Kassa', 1, '19', 'asset', 'debit', 'k1', true, '7212'), + (v_user_id, p_company_id, '1930', 'Företagskonto / checkkonto', 1, '19', 'asset', 'debit', 'k1', true, '7212'), + (v_user_id, p_company_id, '1940', 'Övriga bankkonton', 1, '19', 'asset', 'debit', 'k1', true, '7212'); + + -- Equity (2xxx) + IF p_entity_type = 'enskild_firma' THEN + -- Enskild firma equity accounts: sru_code intentionally NULL. + -- BAS reference maps these to INK2 SRU 7221 ("Övrigt eget kapital"), + -- which is the aktiebolag tax form. EF entities file NE-bilaga, not + -- INK2, and owner drawings/contributions on 2013/2018 must not be + -- reported as balance-sheet equity by SIE/INK2 consumers. + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '2010', 'Eget kapital', 2, '20', 'equity', 'credit', 'k1', true, NULL), + (v_user_id, p_company_id, '2013', 'Övriga egna uttag', 2, '20', 'equity', 'credit', 'k1', true, NULL), + (v_user_id, p_company_id, '2018', 'Övriga egna insättningar', 2, '20', 'equity', 'credit', 'k1', true, NULL); + END IF; + + IF p_entity_type = 'aktiebolag' THEN + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '2081', 'Aktiekapital', 2, '20', 'equity', 'credit', 'k1', true, '7220'), + (v_user_id, p_company_id, '2091', 'Balanserat resultat', 2, '20', 'equity', 'credit', 'k1', true, '7221'), + (v_user_id, p_company_id, '2099', 'Årets resultat', 2, '20', 'equity', 'credit', 'k1', true, '7222'); + END IF; + + IF p_entity_type = 'ideell_forening' THEN + -- Ideell förening equity (BAS 2060-2069). The year closes to 2069 and is + -- carried to 2068 at the next year start (lib/company/entity-type.ts). + -- sru_code NULL: föreningar file INK3, whose SRU codes are not modelled. + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '2067', 'Balanserat överskott eller underskott', 2, '20', 'equity', 'credit', 'k1', true, NULL), + (v_user_id, p_company_id, '2068', 'Överskott eller underskott från föregående år', 2, '20', 'equity', 'credit', 'k1', true, NULL), + (v_user_id, p_company_id, '2069', 'Årets resultat', 2, '20', 'equity', 'credit', 'k1', true, NULL); + END IF; + + -- Liabilities (2xxx) - BAS 2026 VAT account labels + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '2440', 'Leverantörsskulder', 2, '24', 'liability', 'credit', 'k1', true, '7230'), + (v_user_id, p_company_id, '2611', 'Utgående moms försäljning inom Sverige, 25%', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2621', 'Utgående moms försäljning inom Sverige, 12%', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2631', 'Utgående moms försäljning inom Sverige, 6%', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2641', 'Debiterad ingående moms', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2650', 'Redovisningskonto för moms', 2, '26', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2710', 'Personalskatt', 2, '27', 'liability', 'credit', 'k1', true, '7231'), + (v_user_id, p_company_id, '2731', 'Avräkning socialavgifter', 2, '27', 'liability', 'credit', 'k1', true, '7231'); + + IF p_entity_type = 'aktiebolag' THEN + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '2893', 'Skuld till aktieägare', 2, '28', 'liability', 'credit', 'k1', true, '7231'); + END IF; + + IF p_entity_type = 'ideell_forening' THEN + -- A förening has no owner: money settled with a member (utlägg, an + -- advance) is a plain short-term liability, the counterpart of EF + -- 2013/2018 and AB 2893 in the booking paths. + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '2890', 'Övriga kortfristiga skulder', 2, '28', 'liability', 'credit', 'k1', true, NULL); + END IF; + + -- Revenue (3xxx). 3001/3002 carry the official BAS 2026 names: 3001 takes + -- ALL 25% revenue and 3002 is the 12% account (invoice booking, category + -- mapping and default_vat_rate all treat it as 12%), so the name must say + -- so. + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '3001', 'Försäljning inom Sverige, 25 % moms', 3, '30', 'revenue', 'credit', 'k1', true, '7310'), + (v_user_id, p_company_id, '3002', 'Försäljning inom Sverige, 12 % moms', 3, '30', 'revenue', 'credit', 'k1', true, '7310'), + (v_user_id, p_company_id, '3100', 'Momsfri försäljning', 3, '31', 'revenue', 'credit', 'k1', true, '7311'), + (v_user_id, p_company_id, '3900', 'Övriga rörelseintäkter', 3, '39', 'revenue', 'credit', 'k1', true, '7311'), + (v_user_id, p_company_id, '3960', 'Valutakursvinster', 3, '39', 'revenue', 'credit', 'k1', true, '7310'); + + -- COGS (4xxx) + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '4000', 'Varuinköp', 4, '40', 'expense', 'debit', 'k1', true, '7320'); + + -- External expenses (5xxx-6xxx) + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '5010', 'Lokalhyra', 5, '50', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5410', 'Förbrukningsinventarier', 5, '54', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5420', 'Programvaror', 5, '54', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5460', 'Förbrukningsmaterial', 5, '54', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5800', 'Resekostnader', 5, '58', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '5910', 'Annonsering', 5, '59', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6071', 'Representation avdragsgill', 6, '60', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6110', 'Kontorsmateriel', 6, '61', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6212', 'Mobiltelefon', 6, '62', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6230', 'Datakommunikation', 6, '62', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6530', 'Redovisningstjänster', 6, '65', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6570', 'Bankavgifter', 6, '65', 'expense', 'debit', 'k1', true, '7321'), + (v_user_id, p_company_id, '6991', 'Övriga avdragsgilla kostnader', 6, '69', 'expense', 'debit', 'k1', true, '7330'); + + -- Personnel (7xxx). BAS names: 7010 kollektivanställda, 7210 tjänstemän. + -- The payroll engine books gross salaries to 7210 and vacation pay to + -- 7285 (auto-created with its BAS name when first needed). + IF p_entity_type = 'aktiebolag' THEN + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '7010', 'Löner till kollektivanställda', 7, '70', 'expense', 'debit', 'k1', true, '7322'), + (v_user_id, p_company_id, '7210', 'Löner till tjänstemän', 7, '72', 'expense', 'debit', 'k1', true, '7322'), + (v_user_id, p_company_id, '7510', 'Arbetsgivaravgifter', 7, '75', 'expense', 'debit', 'k1', true, '7322'); + END IF; + + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '7960', 'Valutakursförluster', 7, '79', 'expense', 'debit', 'k1', true, '7360'); + + -- Financial (8xxx) + INSERT INTO public.chart_of_accounts (user_id, company_id, account_number, account_name, account_class, account_group, account_type, normal_balance, plan_type, is_system_account, sru_code) + VALUES + (v_user_id, p_company_id, '8310', 'Ränteintäkter', 8, '83', 'revenue', 'credit', 'k1', true, '7313'), + (v_user_id, p_company_id, '8410', 'Räntekostnader', 8, '84', 'expense', 'debit', 'k1', true, '7323'); +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/__tests__/ideell-forening-entity-type.pg.test.ts b/supabase/migrations/__tests__/ideell-forening-entity-type.pg.test.ts new file mode 100644 index 00000000..83663336 --- /dev/null +++ b/supabase/migrations/__tests__/ideell-forening-entity-type.pg.test.ts @@ -0,0 +1,131 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { insertAuthUser, insertCompany } from '@/tests/pg/fixtures' +import { getPool } from '@/tests/pg/setup' + +/** + * 20260908143051_ideell_forening_entity_type.sql: the third legal form is + * accepted by the CHECK constraints, the create RPCs and the chart seed, and + * an unknown form still fails loud everywhere. + */ + +async function constraintDef(table: string, name: string): Promise { + const res = await getPool().query<{ def: string }>( + `SELECT pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = $1 AND c.conname = $2`, + [table, name], + ) + return res.rows[0]?.def ?? '' +} + +async function accountNumbers(companyId: string): Promise { + const res = await getPool().query<{ account_number: string }>( + `SELECT account_number FROM public.chart_of_accounts WHERE company_id = $1 ORDER BY account_number`, + [companyId], + ) + return res.rows.map((r) => r.account_number) +} + +describe('ideell_forening: CHECK constraints', () => { + it('companies, company_settings and booking_template_library accept the value', async () => { + expect(await constraintDef('companies', 'companies_entity_type_check')).toContain('ideell_forening') + expect(await constraintDef('company_settings', 'company_settings_entity_type_check')).toContain( + 'ideell_forening', + ) + expect( + await constraintDef('booking_template_library', 'booking_template_library_entity_type_check'), + ).toContain('ideell_forening') + }) + + it('still rejects an unknown form on companies', async () => { + const userId = await insertAuthUser() + await expect( + getPool().query( + `INSERT INTO public.companies (id, name, entity_type, created_by) VALUES ($1, 'HB', 'handelsbolag', $2)`, + [randomUUID(), userId], + ), + ).rejects.toMatchObject({ code: '23514' }) + }) + + it('supported_entity_types() is the list the RPCs validate against', async () => { + const res = await getPool().query<{ list: string[] }>(`SELECT public.supported_entity_types() AS list`) + expect(res.rows[0].list).toEqual(['enskild_firma', 'aktiebolag', 'ideell_forening']) + }) +}) + +describe('ideell_forening: create_company_for_user', () => { + it('creates a förening and seeds its 1930 cash account', async () => { + const userId = await insertAuthUser() + const res = await getPool().query<{ id: string }>( + `SELECT public.create_company_for_user($1::uuid, $2::text, $3::text, NULL::uuid) AS id`, + [userId, 'SS Testklubb', 'ideell_forening'], + ) + const companyId = res.rows[0].id + const company = await getPool().query<{ entity_type: string }>( + `SELECT entity_type FROM public.companies WHERE id = $1`, + [companyId], + ) + expect(company.rows[0].entity_type).toBe('ideell_forening') + const cash = await getPool().query( + `SELECT 1 FROM public.cash_accounts WHERE company_id = $1 AND ledger_account = '1930' AND is_primary`, + [companyId], + ) + expect(cash.rowCount).toBe(1) + }) + + it('rejects an unknown form with the RPC message, not a constraint error', async () => { + const userId = await insertAuthUser() + await expect( + getPool().query(`SELECT public.create_company_for_user($1::uuid, $2::text, $3::text, NULL::uuid)`, [ + userId, + 'HB', + 'handelsbolag', + ]), + ).rejects.toThrow(/Invalid entity_type: handelsbolag/) + }) +}) + +describe('ideell_forening: seed_chart_of_accounts', () => { + it('seeds the 2060-series equity block and 2890, and no owner or AB accounts', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId, entityType: 'ideell_forening', name: 'IF' }) + await getPool().query(`SELECT public.seed_chart_of_accounts($1::uuid, $2::text)`, [ + companyId, + 'ideell_forening', + ]) + const numbers = await accountNumbers(companyId) + + for (const expected of ['2067', '2068', '2069', '2890', '1930', '2440', '3100', '6991']) { + expect(numbers).toContain(expected) + } + for (const absent of ['2010', '2013', '2018', '2081', '2091', '2099', '2893', '7010', '7210', '7510']) { + expect(numbers).not.toContain(absent) + } + + const names = await getPool().query<{ account_number: string; account_name: string; sru_code: string | null }>( + `SELECT account_number, account_name, sru_code FROM public.chart_of_accounts + WHERE company_id = $1 AND account_number IN ('2069', '2890') + ORDER BY account_number`, + [companyId], + ) + expect(names.rows).toEqual([ + { account_number: '2069', account_name: 'Årets resultat', sru_code: null }, + { account_number: '2890', account_name: 'Övriga kortfristiga skulder', sru_code: null }, + ]) + }) + + it('leaves the aktiebolag seed unchanged', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId, entityType: 'aktiebolag' }) + await getPool().query(`SELECT public.seed_chart_of_accounts($1::uuid, $2::text)`, [companyId, 'aktiebolag']) + const numbers = await accountNumbers(companyId) + for (const expected of ['2081', '2091', '2099', '2893', '7010', '7210', '7510']) { + expect(numbers).toContain(expected) + } + for (const absent of ['2067', '2068', '2069', '2890']) { + expect(numbers).not.toContain(absent) + } + }) +}) diff --git a/tests/pg/fixtures.ts b/tests/pg/fixtures.ts index 7185b382..7878dc31 100644 --- a/tests/pg/fixtures.ts +++ b/tests/pg/fixtures.ts @@ -21,7 +21,7 @@ export async function insertAuthUser(id: string = randomUUID()): Promise export async function insertCompany(params: { createdBy: string name?: string - entityType?: 'enskild_firma' | 'aktiebolag' + entityType?: 'enskild_firma' | 'aktiebolag' | 'ideell_forening' }): Promise { const id = randomUUID() await getPool().query( diff --git a/types/index.ts b/types/index.ts index f66946ae..d7b02055 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,5 +1,7 @@ -// Entity types -export type EntityType = 'enskild_firma' | 'aktiebolag' +// Entity types (legal forms). Every form-dependent fact goes through +// lib/company/entity-type.ts (byEntityType): adding a member here must +// fail compilation there until each site has an answer for it. +export type EntityType = 'enskild_firma' | 'aktiebolag' | 'ideell_forening' // Swedish accounting framework. K2 (BFNAR 2016:10) is the default simplified // ruleset for smaller AB; K3 (BFNAR 2012:1) is the principles-based ruleset