* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) Why the problem occurred: the legal form was modelled as a binary flag in ~300 files. `EntityType` was a two-member union, but nothing dispatched on it exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize, match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut, MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B` ternary. Widening the union compiled everywhere and changed nothing, so a förening would have booked as an enskild firma in the app and as an aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused föreningar at the door (mapEntityType returned null), which is what the tester hit. What was removed or simplified: the silent defaults. One module, lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser (never defaults), the resolver (settings hint, then companies.entity_type, then throw) and `byEntityType`, whose Record arms make the compiler refuse the next widening until each site has an answer. The form-dependent facts (closing account, owner settlement account, calendar-year lock, default method, K1/K2 label, personnummer vs 16-prefix) live there once instead of in the ternaries. On the SQL side supported_entity_types() replaces four copies of the literal list in the create RPCs. Why this shape and not the proposed one: the tracker asked for the enum widening plus a chart; that alone was the dangerous version (compiles, books wrong). Bundling stiftelse was considered and dropped: identical plumbing but no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so the CHECK, RPCs and seed can ship now and the first partner is switched on without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut, Swish) lands on the tracker. Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner accounts, member settlement on 2890; accrual default; brutet räkenskapsår allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix. Migration 20260908110835 widens the three CHECK constraints, adds supported_entity_types(), re-creates the three create RPCs with the widened guard and adds the förening block to seed_chart_of_accounts. Applied to staging and covered by ideell-forening-entity-type.pg.test.ts. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * fix(company): close the förening paths the skeptic refuted (#2072) Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the shared definition rather than the reported site: 1. Privately paid supplier invoices and the utlägg dialog resolved the owner account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening member's invoice was built on 2893 and then refused by the expense-claim service (which already said 2890), burning an ankomstnummer. The helper now uses ownerSettlementAccount. 2. Booking templates substitute their `_ab` accounts only for an aktiebolag; the `private_expense` template kept its base 2013 for a förening. Template accounts now resolve through templateAccountForForm: EF base, AB override, förening base with owner accounts translated to 2890 (booking-templates.ts and proposal-lines.ts share it). 3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline: the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule now keys on fiscalYearLockedToCalendar instead of the two literals; same in the MCP VAT report. 4. 2069 would have accumulated across years: the year-open omföring was AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068) and skips forms with no carry (EF). 5. With the flag off, a registry lookup that returned "Ideell förening" was prefilled into the onboarding journey, the form picker was skipped and the create step answered "Ogiltig företagsform" with no way back. The journey, the BankID picker, the onboarding page and the MCP lookup now use mapSetupEntityType, which maps only creatable forms, so a flagged-off form falls through to the picker as before. Also: form picker keeps its AB-first order; tests for each fix. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(migrations): move ideell förening migration after main's latest version (20260908143051) Two migrations landed on main after the branch forked; a lower version would be skipped by the merge-time apply. Staging history row renamed to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(skills): regenerate accounted-api reference for the widened entity_type enum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
1157ff1b66
commit
26e29f47bc
@@ -1,5 +1,6 @@
|
||||
import type { ComposerInputs } from './inputs'
|
||||
import type { AtomSelection } from './schemas'
|
||||
import { ENTITY_TYPE_LABELS_SV, isEntityType } from '@/lib/company/entity-type'
|
||||
|
||||
// Deterministic atom selection used when the Opus call times out or fails.
|
||||
//
|
||||
@@ -108,9 +109,7 @@ function buildFallbackQuestions(inputs: ComposerInputs): string[] {
|
||||
export function fallbackNarrative(inputs: ComposerInputs): string {
|
||||
const parts: string[] = []
|
||||
const name = inputs.companyName || 'företaget'
|
||||
const isAB = inputs.entityType === 'aktiebolag'
|
||||
const isEF = inputs.entityType === 'enskild_firma'
|
||||
const form = isAB ? 'aktiebolag' : isEF ? 'enskild firma' : null
|
||||
const form = isEntityType(inputs.entityType) ? ENTITY_TYPE_LABELS_SV[inputs.entityType].toLowerCase() : null
|
||||
|
||||
if (inputs.userIsConfirmedDirector) {
|
||||
if (form) {
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { ENTITY_TYPES } from '@/lib/company/entity-type'
|
||||
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
|
||||
import { normalizeVatNumber } from '@/lib/vat/vat-number'
|
||||
import { ACCOUNT_VAT_TREATMENTS } from '@/lib/vat/account-vat-treatment'
|
||||
@@ -192,7 +193,7 @@ function validateAccrualPeriod(
|
||||
// Enum schemas (matching types/index.ts)
|
||||
// ============================================================
|
||||
|
||||
export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
|
||||
export const EntityTypeSchema = z.enum(ENTITY_TYPES)
|
||||
|
||||
export const AccountingFrameworkSchema = z.enum(['k2', 'k3'])
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ function makeSupabase(handlers: {
|
||||
b.single.mockResolvedValue(handlers.period)
|
||||
} else if (table === 'company_settings') {
|
||||
b.maybeSingle.mockResolvedValue(handlers.settings)
|
||||
} else if (table === 'companies') {
|
||||
// Canonical NOT NULL fallback read by resolveCompanyEntityType when the
|
||||
// settings row is missing; never a guessed default.
|
||||
b.maybeSingle.mockResolvedValue({ data: { entity_type: 'aktiebolag' }, error: null })
|
||||
} else if (table === 'cash_accounts') {
|
||||
// The aggregator resolves 1930 to its cash_accounts row so the bank total
|
||||
// is scoped to that account (#1290).
|
||||
@@ -292,7 +296,7 @@ describe('buildBokslutReadinessReport', () => {
|
||||
).rejects.toThrow(/not found/i)
|
||||
})
|
||||
|
||||
it('defaults to aktiebolag when company_settings is missing', async () => {
|
||||
it('falls back to companies.entity_type when company_settings is missing', async () => {
|
||||
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
|
||||
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
|
||||
const supabase = makeSupabase({
|
||||
|
||||
@@ -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}).`
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
|
||||
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
|
||||
@@ -60,7 +61,7 @@ export interface BokslutReadinessReport {
|
||||
closing_entry_id: string | null
|
||||
}
|
||||
/** Entity type drives which dispositions apply (e.g. bolagsskatt only for AB). */
|
||||
entityType: 'aktiebolag' | 'enskild_firma' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
|
||||
entityType: 'aktiebolag' | 'enskild_firma' | 'ideell_forening' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
|
||||
/** The full raw validation, for callers that want every field. */
|
||||
rawValidation: YearEndValidation
|
||||
}
|
||||
@@ -125,7 +126,11 @@ export async function buildBokslutReadinessReport(
|
||||
}
|
||||
|
||||
const period = periodResult.data
|
||||
const entityType = (settingsResult.data?.entity_type ?? 'aktiebolag') as BokslutReadinessReport['entityType']
|
||||
const entityType: BokslutReadinessReport['entityType'] = await resolveCompanyEntityType(
|
||||
supabase,
|
||||
companyId,
|
||||
settingsResult.data?.entity_type,
|
||||
)
|
||||
const accountingMethod =
|
||||
((settingsResult.data as { accounting_method?: string | null } | null)?.accounting_method ??
|
||||
'accrual')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { EntityType } from '@/types'
|
||||
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
|
||||
import { listAssets } from '@/lib/bokslut/assets/asset-service'
|
||||
import { proposeAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
@@ -335,14 +337,14 @@ function notApplicable(): OveravskrivningarCalculation {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntityType(supabase: SupabaseClient, companyId: string): Promise<string> {
|
||||
async function loadEntityType(supabase: SupabaseClient, companyId: string): Promise<EntityType> {
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Failed to load company entity type: ${error.message}`)
|
||||
return data?.entity_type ?? 'aktiebolag'
|
||||
return resolveCompanyEntityType(supabase, companyId, data?.entity_type)
|
||||
}
|
||||
|
||||
async function loadFiscalPeriodCohorts(
|
||||
|
||||
@@ -71,7 +71,7 @@ export interface CompletedDisposition {
|
||||
* the next one (bolagsskatt comes last because it depends on everything else).
|
||||
*/
|
||||
export interface DispositionsProposal {
|
||||
entityType: 'aktiebolag' | 'enskild_firma' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
|
||||
entityType: 'aktiebolag' | 'enskild_firma' | 'ideell_forening' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
|
||||
fiscalPeriod: {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -614,6 +614,16 @@ describe('buildMappingResultFromTemplate', () => {
|
||||
expect(abResult.debit_account).toBe('2893')
|
||||
})
|
||||
|
||||
it('books an ideell förening private expense to the member account 2890, never an owner account', () => {
|
||||
const tx = makeTransaction({ amount: -300 })
|
||||
const privat = buildMappingResultFromTemplate(getTemplate('private_expense'), tx, 'ideell_forening')
|
||||
expect(privat.debit_account).toBe('2890')
|
||||
expect(privat.credit_account).toBe('1930')
|
||||
// Non-owner templates keep their base (EF) account for a förening.
|
||||
const course = buildMappingResultFromTemplate(getTemplate('education_course'), tx, 'ideell_forening')
|
||||
expect(course.debit_account).toBe('6991')
|
||||
})
|
||||
|
||||
it('includes template_id in the MappingResult', () => {
|
||||
const template = getTemplate('bank_fees')
|
||||
const tx = makeTransaction({ amount: -49 })
|
||||
|
||||
@@ -13,20 +13,20 @@ import type { TransactionCategory, VatTreatment } from '@/types'
|
||||
describe('getCategoryAccountMapping', () => {
|
||||
describe('income_products uses correct account', () => {
|
||||
it('maps income_products to 3001 (25% moms)', () => {
|
||||
const result = getCategoryAccountMapping('income_products', 1000, true)
|
||||
const result = getCategoryAccountMapping('income_products', 1000, true, 'enskild_firma')
|
||||
expect(result.creditAccount).toBe('3001')
|
||||
})
|
||||
|
||||
it('income_products matches income_services account', () => {
|
||||
const products = getCategoryAccountMapping('income_products', 1000, true)
|
||||
const services = getCategoryAccountMapping('income_services', 1000, true)
|
||||
const products = getCategoryAccountMapping('income_products', 1000, true, 'enskild_firma')
|
||||
const services = getCategoryAccountMapping('income_services', 1000, true, 'enskild_firma')
|
||||
expect(products.creditAccount).toBe(services.creditAccount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expense_office maps to 6110 (Kontorsförbrukning)', () => {
|
||||
it('maps expense_office to 6110 (not 5010 Lokalhyra)', () => {
|
||||
const result = getCategoryAccountMapping('expense_office', -500, true)
|
||||
const result = getCategoryAccountMapping('expense_office', -500, true, 'enskild_firma')
|
||||
expect(result.debitAccount).toBe('6110')
|
||||
})
|
||||
})
|
||||
@@ -42,10 +42,17 @@ describe('getCategoryAccountMapping', () => {
|
||||
expect(result.debitAccount).toBe('7610')
|
||||
})
|
||||
|
||||
it('defaults to 6991 when no entityType provided', () => {
|
||||
const result = getCategoryAccountMapping('expense_education', -500, true)
|
||||
it('uses 6991 for an ideell förening (no personnel cost assumed)', () => {
|
||||
const result = getCategoryAccountMapping('expense_education', -500, true, 'ideell_forening')
|
||||
expect(result.debitAccount).toBe('6991')
|
||||
})
|
||||
|
||||
it('settles a private förening transaction on 2890, never an owner account', () => {
|
||||
const out = getCategoryAccountMapping('private', -500, false, 'ideell_forening')
|
||||
expect(out.debitAccount).toBe('2890')
|
||||
const inn = getCategoryAccountMapping('private', 500, false, 'ideell_forening')
|
||||
expect(inn.creditAccount).toBe('2890')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,17 +70,21 @@ describe('getExpenseAccountForCategory', () => {
|
||||
|
||||
describe('getDefaultAccountForCategory', () => {
|
||||
it('returns expense account for expense categories', () => {
|
||||
expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410')
|
||||
expect(getDefaultAccountForCategory('expense_software')).toBe('5420')
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
|
||||
expect(getDefaultAccountForCategory('expense_office')).toBe('6110')
|
||||
expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570')
|
||||
expect(getDefaultAccountForCategory('expense_equipment', 'enskild_firma')).toBe('5410')
|
||||
expect(getDefaultAccountForCategory('expense_software', 'enskild_firma')).toBe('5420')
|
||||
expect(getDefaultAccountForCategory('expense_travel', 'enskild_firma')).toBe('5890')
|
||||
expect(getDefaultAccountForCategory('expense_office', 'enskild_firma')).toBe('6110')
|
||||
expect(getDefaultAccountForCategory('expense_bank_fees', 'enskild_firma')).toBe('6570')
|
||||
})
|
||||
|
||||
it('returns income account for income categories', () => {
|
||||
expect(getDefaultAccountForCategory('income_services')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_products')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
|
||||
expect(getDefaultAccountForCategory('income_services', 'enskild_firma')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_products', 'enskild_firma')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_other', 'enskild_firma')).toBe('3999')
|
||||
})
|
||||
|
||||
it('returns the member settlement account for an ideell förening', () => {
|
||||
expect(getDefaultAccountForCategory('private', 'ideell_forening')).toBe('2890')
|
||||
})
|
||||
|
||||
it('returns private account for enskild firma', () => {
|
||||
@@ -90,7 +101,7 @@ describe('getDefaultAccountForCategory', () => {
|
||||
})
|
||||
|
||||
it('returns fallback for uncategorized', () => {
|
||||
expect(getDefaultAccountForCategory('uncategorized')).toBe('6991')
|
||||
expect(getDefaultAccountForCategory('uncategorized', 'enskild_firma')).toBe('6991')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -340,7 +351,7 @@ describe('buildMappingResultFromCategory returns non-empty accounts', () => {
|
||||
it.each(allCategories)('returns non-empty debit_account and credit_account for "%s"', (category) => {
|
||||
const tx = makeTransaction({ amount: category.startsWith('income') ? 1000 : -1000 })
|
||||
const isBusiness = category !== 'private'
|
||||
const result = buildMappingResultFromCategory(category, tx, isBusiness)
|
||||
const result = buildMappingResultFromCategory(category, tx, isBusiness, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBeTruthy()
|
||||
expect(result.credit_account).toBeTruthy()
|
||||
@@ -380,14 +391,14 @@ describe('representation VAT (reduced 12%, ML 13 kap 24-25 §§)', () => {
|
||||
})
|
||||
|
||||
it('getCategoryAccountMapping has vatTreatment: reduced_12 for representation', () => {
|
||||
const result = getCategoryAccountMapping('expense_representation', -500, true)
|
||||
const result = getCategoryAccountMapping('expense_representation', -500, true, 'enskild_firma')
|
||||
expect(result.vatTreatment).toBe('reduced_12')
|
||||
expect(result.vatDebitAccount).toBe('2641')
|
||||
})
|
||||
|
||||
it('buildMappingResultFromCategory generates 12% VAT line for representation', () => {
|
||||
const tx = makeTransaction({ amount: -500 })
|
||||
const result = buildMappingResultFromCategory('expense_representation', tx, true)
|
||||
const result = buildMappingResultFromCategory('expense_representation', tx, true, 'enskild_firma')
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2641')
|
||||
})
|
||||
@@ -421,7 +432,7 @@ describe('income account resolves by VAT treatment', () => {
|
||||
})
|
||||
|
||||
it('defaults to 3001 when no vatTreatment provided', () => {
|
||||
const result = getCategoryAccountMapping('income_services', 1000, true)
|
||||
const result = getCategoryAccountMapping('income_services', 1000, true, 'enskild_firma')
|
||||
expect(result.creditAccount).toBe('3001')
|
||||
})
|
||||
})
|
||||
@@ -454,19 +465,19 @@ describe('private transaction accounts by entity type and direction', () => {
|
||||
|
||||
describe('incoming expense refund (positive amount, expense category)', () => {
|
||||
it('getCategoryAccountMapping swaps accounts: bank debited, expense account credited', () => {
|
||||
const result = getCategoryAccountMapping('expense_software', 500, true)
|
||||
const result = getCategoryAccountMapping('expense_software', 500, true, 'enskild_firma')
|
||||
expect(result.debitAccount).toBe('1930')
|
||||
expect(result.creditAccount).toBe('5420')
|
||||
})
|
||||
|
||||
it('getCategoryAccountMapping sets vatCreditAccount 2641 and clears vatDebitAccount for refund', () => {
|
||||
const result = getCategoryAccountMapping('expense_software', 500, true)
|
||||
const result = getCategoryAccountMapping('expense_software', 500, true, 'enskild_firma')
|
||||
expect(result.vatDebitAccount).toBeNull()
|
||||
expect(result.vatCreditAccount).toBe('2641')
|
||||
})
|
||||
|
||||
it('VAT-exempt expense refund (bank_fees) has no VAT accounts', () => {
|
||||
const result = getCategoryAccountMapping('expense_bank_fees', 100, true)
|
||||
const result = getCategoryAccountMapping('expense_bank_fees', 100, true, 'enskild_firma')
|
||||
expect(result.debitAccount).toBe('1930')
|
||||
expect(result.creditAccount).toBe('6570')
|
||||
expect(result.vatDebitAccount).toBeNull()
|
||||
@@ -475,7 +486,7 @@ describe('incoming expense refund (positive amount, expense category)', () => {
|
||||
|
||||
it('buildMappingResultFromCategory generates credit line on 2641 for expense refund', () => {
|
||||
const tx = makeTransaction({ amount: 1000 })
|
||||
const result = buildMappingResultFromCategory('expense_software', tx, true)
|
||||
const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma')
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2641')
|
||||
expect(result.vat_lines[0].credit_amount).toBe(200)
|
||||
@@ -484,19 +495,19 @@ describe('incoming expense refund (positive amount, expense category)', () => {
|
||||
|
||||
it('buildMappingResultFromCategory uses återföring description for expense refund VAT', () => {
|
||||
const tx = makeTransaction({ amount: 1000 })
|
||||
const result = buildMappingResultFromCategory('expense_software', tx, true)
|
||||
const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma')
|
||||
expect(result.vat_lines[0].description).toBe('Återföring ingående moms 25%')
|
||||
})
|
||||
|
||||
it('buildMappingResultFromCategory generates no VAT line for VAT-exempt expense refund', () => {
|
||||
const tx = makeTransaction({ amount: 100 })
|
||||
const result = buildMappingResultFromCategory('expense_bank_fees', tx, true)
|
||||
const result = buildMappingResultFromCategory('expense_bank_fees', tx, true, 'enskild_firma')
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('buildMappingResultFromCategory maps debit/credit correctly (bank debited, expense credited)', () => {
|
||||
const tx = makeTransaction({ amount: 1250 })
|
||||
const result = buildMappingResultFromCategory('expense_software', tx, true)
|
||||
const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma')
|
||||
expect(result.debit_account).toBe('1930')
|
||||
expect(result.credit_account).toBe('5420')
|
||||
})
|
||||
@@ -545,24 +556,26 @@ describe('category default → leaf account guarantee', () => {
|
||||
]
|
||||
|
||||
it.each(categoriesUnderGuard)('%s default does not resolve to a gruppkonto', (category) => {
|
||||
const target = getDefaultAccountForCategory(category)
|
||||
expect(groupAccountNumbers.has(target)).toBe(false)
|
||||
for (const entityType of ['enskild_firma', 'aktiebolag', 'ideell_forening'] as const) {
|
||||
const target = getDefaultAccountForCategory(category, entityType)
|
||||
expect(groupAccountNumbers.has(target)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('uncategorized positive amount does not credit a gruppkonto', () => {
|
||||
const result = getCategoryAccountMapping('uncategorized', 1000, true)
|
||||
const result = getCategoryAccountMapping('uncategorized', 1000, true, 'enskild_firma')
|
||||
expect(groupAccountNumbers.has(result.creditAccount)).toBe(false)
|
||||
})
|
||||
|
||||
it('expense_telecom resolves to 6230 (Datakommunikation, leaf)', () => {
|
||||
expect(getDefaultAccountForCategory('expense_telecom')).toBe('6230')
|
||||
expect(getDefaultAccountForCategory('expense_telecom', 'enskild_firma')).toBe('6230')
|
||||
})
|
||||
|
||||
it('expense_travel resolves to 5890 (Övriga resekostnader, leaf)', () => {
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5890')
|
||||
expect(getDefaultAccountForCategory('expense_travel', 'enskild_firma')).toBe('5890')
|
||||
})
|
||||
|
||||
it('income_other resolves to 3999 (Övriga rörelseintäkter, leaf)', () => {
|
||||
expect(getDefaultAccountForCategory('income_other')).toBe('3999')
|
||||
expect(getDefaultAccountForCategory('income_other', 'enskild_firma')).toBe('3999')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('evaluateMappingRules own-account transfer guard', () => {
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTransaction({ amount: -1000, currency: 'SEK' }),
|
||||
undefined,
|
||||
'enskild_firma',
|
||||
'1930',
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('evaluateMappingRules own-account transfer guard', () => {
|
||||
supabase as never,
|
||||
'company-1',
|
||||
makeTransaction({ amount: 217.04, currency: 'SEK' }),
|
||||
undefined,
|
||||
'enskild_firma',
|
||||
'1940',
|
||||
)
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
expect(result.debit_account).toBe('6540')
|
||||
})
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
expect(result.debit_account).toBe('6540')
|
||||
})
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
expect(result.debit_account).toBe('6540')
|
||||
})
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
expect(result.debit_account).toBe('6991') // default expense fallback
|
||||
})
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('mapping-engine', () => {
|
||||
const tx = makeTransaction({ amount: -100, merchant_name: 'Unknown' })
|
||||
mockResult({ data: [], error: null })
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('6991')
|
||||
expect(result.credit_account).toBe('1930')
|
||||
@@ -206,7 +206,7 @@ describe('mapping-engine', () => {
|
||||
const tx = makeTransaction({ amount: 500, merchant_name: 'Unknown' })
|
||||
mockResult({ data: [], error: null })
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('1930')
|
||||
expect(result.credit_account).toBe('3900')
|
||||
@@ -345,7 +345,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
// 30,000 > 28,650 (2024 half-PBB) → should capitalize to 1250
|
||||
expect(result.debit_account).toBe('1250')
|
||||
})
|
||||
@@ -395,7 +395,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
// 29,000 < 29,400 (2025 half-PBB) → should NOT capitalize
|
||||
expect(result.debit_account).toBe('5410')
|
||||
})
|
||||
@@ -444,7 +444,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('5410')
|
||||
expect(result.credit_account).toBe('1930')
|
||||
@@ -495,7 +495,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
// Fiktiv-moms pair + basbelopp pair = 4 lines (FK004 guard)
|
||||
expect(result.vat_lines).toHaveLength(4)
|
||||
@@ -552,7 +552,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
// Only fiktiv-moms pair: basbelopp already covered by the expense line
|
||||
expect(result.vat_lines).toHaveLength(2)
|
||||
@@ -609,7 +609,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
// 100 EUR at 11 = 1100 kr gross; 25% extraction = 220 kr, not 20
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
@@ -665,7 +665,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
// 950 kr gross: fiktiv moms 237.50, basbelopp 950 (not 25/100 off USD)
|
||||
expect(result.vat_lines).toHaveLength(4)
|
||||
@@ -728,7 +728,7 @@ describe('mapping-engine', () => {
|
||||
})
|
||||
mockResult({ data: [makeRule()], error: null })
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('1250')
|
||||
expect(result.requires_review).toBe(false)
|
||||
@@ -745,7 +745,7 @@ describe('mapping-engine', () => {
|
||||
})
|
||||
mockResult({ data: [makeRule()], error: null })
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('5410')
|
||||
expect(result.requires_review).toBe(false)
|
||||
@@ -765,7 +765,7 @@ describe('mapping-engine', () => {
|
||||
})
|
||||
mockResult({ data: [makeRule()], error: null })
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('1250')
|
||||
expect(result.requires_review).toBe(false)
|
||||
@@ -784,7 +784,7 @@ describe('mapping-engine', () => {
|
||||
})
|
||||
mockResult({ data: [makeRule()], error: null })
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('1250')
|
||||
})
|
||||
@@ -802,7 +802,7 @@ describe('mapping-engine', () => {
|
||||
})
|
||||
mockResult({ data: [makeRule()], error: null })
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
// The rule alone would auto-book (confidence 0.9, requires_review false).
|
||||
// Without a SEK value the capitalization branch is a guess, so the
|
||||
@@ -825,7 +825,7 @@ describe('mapping-engine', () => {
|
||||
})
|
||||
mockResult({ data: [makeRule({ capitalized_debit_account: null })], error: null })
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
// No capitalization decision to make: the missing rate is irrelevant.
|
||||
expect(result.debit_account).toBe('5410')
|
||||
@@ -857,7 +857,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
// No match: falls through to the uncategorized default.
|
||||
expect(result.rule).toBeNull()
|
||||
@@ -889,7 +889,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('5410')
|
||||
expect(result.confidence).toBe(0.9)
|
||||
@@ -917,7 +917,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
// The band is unevaluable, so the rule does not apply. The transaction
|
||||
// lands in the uncategorized default where the user picks it up.
|
||||
@@ -946,7 +946,7 @@ describe('mapping-engine', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
|
||||
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('5410')
|
||||
})
|
||||
|
||||
@@ -300,6 +300,13 @@ describe('computeProposalLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('translates the owner account to 2890 for an ideell förening', () => {
|
||||
expect(resolveTemplateAccountsForEntity(template, 'ideell_forening')).toEqual({
|
||||
debitAccount: '2890',
|
||||
creditAccount: '1930',
|
||||
})
|
||||
})
|
||||
|
||||
it('substitutes AB accounts for aktiebolag, falling back per side', () => {
|
||||
expect(resolveTemplateAccountsForEntity(template, 'aktiebolag')).toEqual({
|
||||
debitAccount: '2893',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, E
|
||||
import { getVatRate, generateReverseChargeLines } from './vat-entries'
|
||||
import { resolveSekAmount } from './currency-utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { byEntityType, ownerSettlementAccount } from '@/lib/company/entity-type'
|
||||
|
||||
/**
|
||||
* Maps TransactionCategory to BAS accounts for journal entry creation
|
||||
@@ -32,11 +33,8 @@ interface CategoryAccountMapping {
|
||||
// Default bank account - typically 1930 (Företagskonto/checkkonto)
|
||||
const BANK_ACCOUNT = '1930'
|
||||
|
||||
// Private/owner transaction accounts by entity type
|
||||
const PRIVATE_ACCOUNTS: Record<EntityType, string> = {
|
||||
enskild_firma: '2013', // Övriga egna uttag
|
||||
aktiebolag: '2893', // Skuld till aktieägare/delägare
|
||||
}
|
||||
// Private/owner transaction accounts live in lib/company/entity-type.ts
|
||||
// (ownerSettlementAccount): EF 2013/2018, AB 2893, ideell förening 2890.
|
||||
|
||||
// Single source of truth for category -> expense account mapping
|
||||
const EXPENSE_ACCOUNTS: Record<string, string> = {
|
||||
@@ -67,9 +65,15 @@ const INCOME_ACCOUNTS: Record<string, string> = {
|
||||
* Get the expense account for a category, with entity-specific overrides.
|
||||
* Education (expense_education) differs: AB uses 7610, EF uses 6991.
|
||||
*/
|
||||
function getExpenseAccount(category: string, entityType: EntityType = 'enskild_firma'): string {
|
||||
function getExpenseAccount(category: string, entityType: EntityType): string {
|
||||
if (category === 'expense_education') {
|
||||
return entityType === 'aktiebolag' ? '7610' : '6991'
|
||||
// 7610 is a personnel cost: only a form with employees by default books
|
||||
// education there; the others take the general external-cost account.
|
||||
return byEntityType(entityType, {
|
||||
aktiebolag: '7610',
|
||||
enskild_firma: '6991',
|
||||
ideell_forening: '6991',
|
||||
})
|
||||
}
|
||||
return EXPENSE_ACCOUNTS[category] || '6991'
|
||||
}
|
||||
@@ -108,19 +112,14 @@ export function getCategoryAccountMapping(
|
||||
category: TransactionCategory,
|
||||
amount: number,
|
||||
isBusiness: boolean,
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
entityType: EntityType,
|
||||
vatTreatment?: VatTreatment
|
||||
): CategoryAccountMapping {
|
||||
// Private/owner transactions use entity-specific accounts
|
||||
// EF: 2013 for withdrawals (uttag), 2018 for deposits (insättningar)
|
||||
// AB: 2893 for both directions
|
||||
// AB: 2893 for both directions; ideell förening: 2890 (no owner)
|
||||
if (!isBusiness) {
|
||||
let privateAccount: string
|
||||
if (entityType === 'enskild_firma') {
|
||||
privateAccount = amount < 0 ? '2013' : '2018'
|
||||
} else {
|
||||
privateAccount = PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
|
||||
}
|
||||
const privateAccount = ownerSettlementAccount(entityType, amount < 0 ? 'withdrawal' : 'contribution')
|
||||
return {
|
||||
debitAccount: amount < 0 ? privateAccount : BANK_ACCOUNT,
|
||||
creditAccount: amount < 0 ? BANK_ACCOUNT : privateAccount,
|
||||
@@ -241,7 +240,7 @@ export function buildMappingResultFromCategory(
|
||||
category: TransactionCategory,
|
||||
transaction: Transaction,
|
||||
isBusiness: boolean,
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
entityType: EntityType,
|
||||
vatTreatment?: VatTreatment,
|
||||
vatAmountOverride?: number | null
|
||||
): MappingResult {
|
||||
@@ -404,10 +403,10 @@ export function getExpenseAccountForCategory(category: TransactionCategory): str
|
||||
*/
|
||||
export function getDefaultAccountForCategory(
|
||||
category: TransactionCategory,
|
||||
entityType: EntityType = 'enskild_firma'
|
||||
entityType: EntityType
|
||||
): string {
|
||||
if (category === 'private') {
|
||||
return PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
|
||||
return ownerSettlementAccount(entityType, 'withdrawal')
|
||||
}
|
||||
|
||||
if (category.startsWith('expense_')) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
VatJournalLine,
|
||||
} from '@/types'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { ownerSettlementAccount } from '@/lib/company/entity-type'
|
||||
|
||||
const log = createLogger('mapping-engine')
|
||||
|
||||
@@ -82,7 +83,7 @@ export async function evaluateMappingRules(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType,
|
||||
entityType: EntityType,
|
||||
settlementAccount?: string
|
||||
): Promise<MappingResult> {
|
||||
const bankAccount = settlementAccount || '1930'
|
||||
@@ -161,7 +162,7 @@ export async function evaluateMappingRules(
|
||||
*/
|
||||
function evaluateTemplateRules(
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType
|
||||
entityType: EntityType
|
||||
): MappingResult | null {
|
||||
const matches = findMatchingTemplates(transaction, entityType)
|
||||
if (matches.length === 0 || matches[0].confidence < 0.3) return null
|
||||
@@ -170,7 +171,7 @@ function evaluateTemplateRules(
|
||||
const result = buildMappingResultFromTemplate(
|
||||
best.template,
|
||||
transaction,
|
||||
entityType || 'enskild_firma'
|
||||
entityType
|
||||
)
|
||||
// Override the confidence with the auto-match confidence (not 1.0)
|
||||
result.confidence = best.confidence
|
||||
@@ -186,7 +187,7 @@ async function evaluateCounterpartyTemplates(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType
|
||||
entityType: EntityType
|
||||
): Promise<MappingResult | null> {
|
||||
try {
|
||||
const match = await findCounterpartyTemplate(supabase, companyId, transaction)
|
||||
@@ -198,7 +199,7 @@ async function evaluateCounterpartyTemplates(
|
||||
return buildMappingResultFromCounterpartyTemplate(
|
||||
match,
|
||||
transaction,
|
||||
entityType || 'enskild_firma'
|
||||
entityType
|
||||
)
|
||||
} catch {
|
||||
// Non-critical: fall through to next fallback
|
||||
@@ -303,7 +304,7 @@ function matchesRule(rule: MappingRule, transaction: Transaction): boolean {
|
||||
/**
|
||||
* Build a MappingResult from a matched rule
|
||||
*/
|
||||
function buildResult(rule: MappingRule, transaction: Transaction, entityType?: EntityType): MappingResult {
|
||||
function buildResult(rule: MappingRule, transaction: Transaction, entityType: EntityType): MappingResult {
|
||||
// VAT figures land on journal entry lines, which are always SEK, so they
|
||||
// are derived from the SEK value of the transaction. The LENIENT resolver
|
||||
// is deliberate: buildTransactionEntryLines resolves the gross with the
|
||||
@@ -353,7 +354,7 @@ function buildResult(rule: MappingRule, transaction: Transaction, entityType?: E
|
||||
|
||||
// If default_private, use entity-specific private account
|
||||
if (rule.default_private && isExpense) {
|
||||
debitAccount = entityType === 'aktiebolag' ? '2893' : '2013'
|
||||
debitAccount = ownerSettlementAccount(entityType, 'withdrawal')
|
||||
}
|
||||
|
||||
// Generate VAT lines if applicable
|
||||
|
||||
@@ -36,6 +36,7 @@ import { getVatRate } from '@/lib/bookkeeping/vat-entries'
|
||||
import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
|
||||
import { buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { templateAccountForForm } from '@/lib/company/entity-type'
|
||||
import {
|
||||
legacyTemplateDirection as legacyDirection,
|
||||
patternDirection,
|
||||
@@ -137,13 +138,11 @@ export function resolveTemplateAccountsForEntity(
|
||||
},
|
||||
entityType: EntityType | undefined,
|
||||
): { debitAccount?: string; creditAccount?: string } {
|
||||
if (entityType === 'aktiebolag') {
|
||||
return {
|
||||
debitAccount: template.debit_account_ab ?? template.debit_account,
|
||||
creditAccount: template.credit_account_ab ?? template.credit_account,
|
||||
}
|
||||
if (!entityType) return { debitAccount: template.debit_account, creditAccount: template.credit_account }
|
||||
return {
|
||||
debitAccount: templateAccountForForm(entityType, template.debit_account, template.debit_account_ab),
|
||||
creditAccount: templateAccountForForm(entityType, template.credit_account, template.credit_account_ab),
|
||||
}
|
||||
return { debitAccount: template.debit_account, creditAccount: template.credit_account }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mapEntityType } from '../entity-type-map'
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest'
|
||||
import { mapEntityType, mapSetupEntityType } from '../entity-type-map'
|
||||
|
||||
describe('mapEntityType', () => {
|
||||
it('maps the exact AB codes and labels to aktiebolag', () => {
|
||||
@@ -44,3 +44,30 @@ describe('mapEntityType', () => {
|
||||
expect(mapEntityType(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSetupEntityType: only creatable forms are prefilled', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('maps ideell förening only when the creation flag is on', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_IDEELL_FORENING_ENABLED', '')
|
||||
expect(mapSetupEntityType('Ideell förening')).toBeNull()
|
||||
expect(mapSetupEntityType('Aktiebolag')).toBe('aktiebolag')
|
||||
vi.stubEnv('NEXT_PUBLIC_IDEELL_FORENING_ENABLED', 'true')
|
||||
expect(mapSetupEntityType('Ideell förening')).toBe('ideell_forening')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapEntityType: ideell förening (issue #2072)', () => {
|
||||
it('maps the registry spelling of ideell förening', () => {
|
||||
expect(mapEntityType('Ideell förening')).toBe('ideell_forening')
|
||||
expect(mapEntityType('ideell forening')).toBe('ideell_forening')
|
||||
})
|
||||
|
||||
it('does not map other föreningar or stiftelser', () => {
|
||||
expect(mapEntityType('Ekonomisk förening')).toBeNull()
|
||||
expect(mapEntityType('Registrerat trossamfund')).toBeNull()
|
||||
expect(mapEntityType('Stiftelse')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EntityType } from '@/types'
|
||||
import { isEntityTypeCreatable } from '@/lib/company/entity-type'
|
||||
|
||||
/**
|
||||
* Explicit allow-lists for TIC/Bolagsverket `legalEntityType` → Accounted
|
||||
@@ -25,10 +26,36 @@ const ENSKILD_FIRMA_VALUES = new Set<string>([
|
||||
'enskild näringsidkare',
|
||||
])
|
||||
|
||||
/**
|
||||
* Ideell förening (issue #2072). Most föreningar carry an 8-series org number
|
||||
* issued by Skatteverket, so the Bolagsverket-backed lookup legitimately
|
||||
* misses them; this arm matters for the registered ones and for BankID
|
||||
* company roles. Ekonomisk förening, stiftelse and trossamfund are NOT
|
||||
* mapped: different equity, tax form and regelverk.
|
||||
*/
|
||||
const IDEELL_FORENING_VALUES = new Set<string>([
|
||||
'ideell förening',
|
||||
'ideell forening',
|
||||
'ideella föreningar',
|
||||
])
|
||||
|
||||
export function mapEntityType(ticType: string | null | undefined): EntityType | null {
|
||||
if (!ticType) return null
|
||||
const normalized = ticType.trim().toLowerCase()
|
||||
if (AKTIEBOLAG_VALUES.has(normalized)) return 'aktiebolag'
|
||||
if (ENSKILD_FIRMA_VALUES.has(normalized)) return 'enskild_firma'
|
||||
if (IDEELL_FORENING_VALUES.has(normalized)) return 'ideell_forening'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The form a registry lookup may PREFILL for automatic setup: mapEntityType
|
||||
* narrowed to forms this deployment can create. A form behind a feature flag
|
||||
* maps to null here so the onboarding journey falls through to the form
|
||||
* picker (which lists only creatable forms) instead of prefilling a value the
|
||||
* create path will refuse at the last step.
|
||||
*/
|
||||
export function mapSetupEntityType(ticType: string | null | undefined): EntityType | null {
|
||||
const mapped = mapEntityType(ticType)
|
||||
return mapped && isEntityTypeCreatable(mapped) ? mapped : null
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.' }
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { EntityType } from '@/types'
|
||||
import { flagEnabled } from '@/lib/env/public-flags'
|
||||
|
||||
/**
|
||||
* The one place that knows which legal forms Accounted books for and what
|
||||
* follows from each of them.
|
||||
*
|
||||
* Before this module the form was a binary flag spread over ~300 files:
|
||||
* `=== 'aktiebolag' ? A : B` ternaries and `?? 'enskild_firma'` /
|
||||
* `?? 'aktiebolag'` defaults. Widening the `EntityType` union compiled
|
||||
* everywhere and changed nothing, so a third form silently booked as an
|
||||
* enskild firma in the app and as an aktiebolag in bokslut and MCP. Every
|
||||
* form-dependent fact now goes through `byEntityType`, whose `Record` arms
|
||||
* make the compiler refuse the next widening until each site has an answer.
|
||||
*
|
||||
* Domain facts (issue #2072, DECISIONS.md 2026-09-08):
|
||||
* - Ideell förening closes its result to 2069 "Årets resultat" and carries
|
||||
* it to 2068 at the next year start, mirroring the AB 2099/2098 pair on the
|
||||
* BAS 2060-2069 group for föreningar.
|
||||
* - A förening has no owner: there are no egna uttag/insättningar (EF
|
||||
* 2013/2018) and no delägarskuld (AB 2893). Money settled with a member is
|
||||
* a plain short-term liability, 2890.
|
||||
* - A förening is a juridisk person, so BFL 3 kap does not force the
|
||||
* calendar year on it (the EF rule) and its default method is accrual.
|
||||
*/
|
||||
export const ENTITY_TYPES = [
|
||||
'enskild_firma',
|
||||
'aktiebolag',
|
||||
'ideell_forening',
|
||||
] as const satisfies readonly EntityType[]
|
||||
|
||||
// Compile-time proof that ENTITY_TYPES lists every member of the union.
|
||||
type MissingFromList = Exclude<EntityType, (typeof ENTITY_TYPES)[number]>
|
||||
const entityTypesAreExhaustive: MissingFromList extends never ? true : never = true
|
||||
void entityTypesAreExhaustive
|
||||
|
||||
/** Statutory Swedish names, kept in Swedish in both locales. */
|
||||
export const ENTITY_TYPE_LABELS_SV: Record<EntityType, string> = {
|
||||
enskild_firma: 'Enskild firma',
|
||||
aktiebolag: 'Aktiebolag',
|
||||
ideell_forening: 'Ideell förening',
|
||||
}
|
||||
|
||||
export class UnknownEntityTypeError extends Error {
|
||||
readonly code = 'COMPANY_ENTITY_TYPE_UNKNOWN'
|
||||
constructor(value: unknown) {
|
||||
super(
|
||||
`Unknown company entity_type ${JSON.stringify(value)}: expected one of ${ENTITY_TYPES.join(', ')}`,
|
||||
)
|
||||
this.name = 'UnknownEntityTypeError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isEntityType(value: unknown): value is EntityType {
|
||||
return typeof value === 'string' && (ENTITY_TYPES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Narrow a raw DB/JSON value; throws instead of defaulting. */
|
||||
export function parseEntityType(value: unknown): EntityType {
|
||||
if (isEntityType(value)) return value
|
||||
throw new UnknownEntityTypeError(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exhaustive dispatch on the legal form. `Record<EntityType, T>` makes every
|
||||
* arm mandatory at compile time; the runtime check catches a corrupt string
|
||||
* that slipped past the DB CHECK.
|
||||
*/
|
||||
export function byEntityType<T>(entityType: EntityType, arms: Record<EntityType, T>): T {
|
||||
if (!Object.prototype.hasOwnProperty.call(arms, entityType)) {
|
||||
throw new UnknownEntityTypeError(entityType)
|
||||
}
|
||||
return arms[entityType]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a company's legal form for a booking path. `hint` is whatever the
|
||||
* caller already loaded (usually `company_settings.entity_type`); when it is
|
||||
* missing or invalid the canonical `companies.entity_type` (NOT NULL, CHECKed)
|
||||
* is read. Never defaults: a wrong form books to the wrong equity account.
|
||||
*/
|
||||
export async function resolveCompanyEntityType(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
hint?: unknown,
|
||||
): Promise<EntityType> {
|
||||
if (isEntityType(hint)) return hint
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('entity_type')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Failed to load company entity type: ${error.message}`)
|
||||
return parseEntityType(data?.entity_type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation gate for forms still in beta. `NEXT_PUBLIC_` so the onboarding
|
||||
* picker and the server-side create paths read the same switch; the DB CHECK
|
||||
* accepts the value regardless, so flipping the flag never needs a migration.
|
||||
* The literal `process.env.NEXT_PUBLIC_...` spelling is what Next.js inlines
|
||||
* into client bundles; a computed key would read undefined in the browser.
|
||||
*/
|
||||
export const IDEELL_FORENING_FLAG = 'NEXT_PUBLIC_IDEELL_FORENING_ENABLED'
|
||||
|
||||
export function isEntityTypeCreatable(entityType: EntityType): boolean {
|
||||
return byEntityType(entityType, {
|
||||
enskild_firma: true,
|
||||
aktiebolag: true,
|
||||
ideell_forening: flagEnabled(process.env.NEXT_PUBLIC_IDEELL_FORENING_ENABLED),
|
||||
})
|
||||
}
|
||||
|
||||
/** Forms a user may pick right now (feature flags applied). */
|
||||
export function creatableEntityTypes(): EntityType[] {
|
||||
return ENTITY_TYPES.filter(isEntityTypeCreatable)
|
||||
}
|
||||
|
||||
// ── Domain facts ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ResultClosingAccounts {
|
||||
/** Account the year's net result is closed to. */
|
||||
closing: string
|
||||
closingName: string
|
||||
/**
|
||||
* Account the previous year's result is moved to at the next year start
|
||||
* (null when the form closes straight into an equity account, EF 2010).
|
||||
*/
|
||||
priorYearCarry: string | null
|
||||
}
|
||||
|
||||
export function resultClosingAccounts(entityType: EntityType): ResultClosingAccounts {
|
||||
return byEntityType<ResultClosingAccounts>(entityType, {
|
||||
enskild_firma: { closing: '2010', closingName: 'Eget kapital', priorYearCarry: null },
|
||||
aktiebolag: { closing: '2099', closingName: 'Årets resultat', priorYearCarry: '2098' },
|
||||
ideell_forening: { closing: '2069', closingName: 'Årets resultat', priorYearCarry: '2068' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Account for money settled with the owner (EF: egna uttag/insättningar, AB:
|
||||
* skuld till aktieägare). A förening has no owner; a member who pays or is
|
||||
* paid is a plain short-term counterparty on 2890.
|
||||
*/
|
||||
export function ownerSettlementAccount(
|
||||
entityType: EntityType,
|
||||
direction: 'withdrawal' | 'contribution',
|
||||
): string {
|
||||
return byEntityType(entityType, {
|
||||
enskild_firma: direction === 'withdrawal' ? '2013' : '2018',
|
||||
aktiebolag: '2893',
|
||||
ideell_forening: '2890',
|
||||
})
|
||||
}
|
||||
|
||||
/** Owner-side accounts a booking template may name in its base/AB columns. */
|
||||
const OWNER_SETTLEMENT_ACCOUNTS = new Set(['2013', '2018', '2893'])
|
||||
|
||||
/**
|
||||
* Resolve a booking template's account for the form. Templates carry a base
|
||||
* (enskild firma) account and an optional `_ab` override; an ideell förening
|
||||
* takes the base account (6991 for a course, 3100 for exempt revenue) except
|
||||
* that any owner account becomes the member settlement account, since a
|
||||
* förening has no egna uttag/insättningar and no delägarskuld.
|
||||
*/
|
||||
export function templateAccountForForm(
|
||||
entityType: EntityType,
|
||||
base: string | undefined,
|
||||
abOverride: string | undefined,
|
||||
): string | undefined {
|
||||
return byEntityType(entityType, {
|
||||
enskild_firma: base,
|
||||
aktiebolag: abOverride ?? base,
|
||||
ideell_forening:
|
||||
base && OWNER_SETTLEMENT_ACCOUNTS.has(base) ? ownerSettlementAccount('ideell_forening', 'withdrawal') : base,
|
||||
})
|
||||
}
|
||||
|
||||
/** Only an aktiebolag prepares an årsredovisning in Accounted today. */
|
||||
export function preparesArsredovisning(entityType: EntityType): boolean {
|
||||
return byEntityType(entityType, { enskild_firma: false, aktiebolag: true, ideell_forening: false })
|
||||
}
|
||||
|
||||
/** BFL 3 kap 1 §: a fysisk person (enskild firma) is bound to the calendar year. */
|
||||
export function fiscalYearLockedToCalendar(entityType: EntityType): boolean {
|
||||
return byEntityType(entityType, { enskild_firma: true, aktiebolag: false, ideell_forening: false })
|
||||
}
|
||||
|
||||
/** The org number is the owner's personnummer only for an enskild firma. */
|
||||
export function usesPersonnummerAsOrgNumber(entityType: EntityType): boolean {
|
||||
return byEntityType(entityType, { enskild_firma: true, aktiebolag: false, ideell_forening: false })
|
||||
}
|
||||
|
||||
export function defaultAccountingMethod(entityType: EntityType): 'accrual' | 'cash' {
|
||||
return byEntityType(entityType, { enskild_firma: 'cash', aktiebolag: 'accrual', ideell_forening: 'accrual' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified year-end regelverk label used by the accrual threshold logic
|
||||
* (K1: 5 000 kr per post may stay unperiodised). EF: BFNAR 2006:1; ideell
|
||||
* förening: BFNAR 2010:1; AB prepares under K2.
|
||||
*/
|
||||
export function simplifiedYearEndRegelverk(entityType: EntityType): 'K1' | 'K2' {
|
||||
return byEntityType(entityType, { enskild_firma: 'K1', aktiebolag: 'K2', ideell_forening: 'K1' })
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -3,6 +3,12 @@ import { saneIsoDateSchema } from '@/lib/invariants/zod'
|
||||
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import { deriveSwedishVatNumber } from '@/lib/vat/vat-number'
|
||||
import {
|
||||
ENTITY_TYPES,
|
||||
defaultAccountingMethod,
|
||||
fiscalYearLockedToCalendar,
|
||||
isEntityTypeCreatable,
|
||||
} from '@/lib/company/entity-type'
|
||||
import type { CreateCompanyInput } from '@/lib/company/create-company'
|
||||
|
||||
/**
|
||||
@@ -25,7 +31,7 @@ import type { CreateCompanyInput } from '@/lib/company/create-company'
|
||||
export const CompanySetupSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
entity_type: z.enum(['enskild_firma', 'aktiebolag']),
|
||||
entity_type: z.enum(ENTITY_TYPES),
|
||||
org_number: z.string().trim().min(1).max(20).optional(),
|
||||
vat_registered: z.boolean(),
|
||||
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(),
|
||||
@@ -59,6 +65,13 @@ export const CompanySetupSchema = z
|
||||
team_id: z.string().uuid().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!isEntityTypeCreatable(value.entity_type)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['entity_type'],
|
||||
message: `entity_type ${value.entity_type} is not enabled on this deployment yet.`,
|
||||
})
|
||||
}
|
||||
if (value.vat_registered && !value.moms_period) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
@@ -121,10 +134,10 @@ export type CompanySetupPlan =
|
||||
* left at this point is an invalid fiscal period (validatePeriodDuration).
|
||||
*/
|
||||
export function planCompanySetup(setup: CompanySetup): CompanySetupPlan {
|
||||
const isEf = setup.entity_type === 'enskild_firma'
|
||||
const calendarYearOnly = fiscalYearLockedToCalendar(setup.entity_type)
|
||||
const firstYear = setup.first_fiscal_year
|
||||
const startMonth = isEf ? 1 : (setup.fiscal_year_start_month ?? 1)
|
||||
const accountingMethod = setup.accounting_method ?? (isEf ? 'cash' : 'accrual')
|
||||
const startMonth = calendarYearOnly ? 1 : (setup.fiscal_year_start_month ?? 1)
|
||||
const accountingMethod = setup.accounting_method ?? defaultAccountingMethod(setup.entity_type)
|
||||
|
||||
const settings: Record<string, unknown> = {
|
||||
entity_type: setup.entity_type,
|
||||
@@ -136,7 +149,7 @@ export function planCompanySetup(setup: CompanySetup): CompanySetupPlan {
|
||||
accounting_method: accountingMethod,
|
||||
f_skatt: setup.f_skatt,
|
||||
// Enskild firma is calendar-year by law, with or without a first year.
|
||||
fiscal_year_start_month: isEf ? 1 : firstYear ? nextMonthAfter(firstYear.end) : startMonth,
|
||||
fiscal_year_start_month: calendarYearOnly ? 1 : firstYear ? nextMonthAfter(firstYear.end) : startMonth,
|
||||
...(setup.address_line1 ? { address_line1: setup.address_line1 } : {}),
|
||||
...(setup.postal_code ? { postal_code: setup.postal_code } : {}),
|
||||
...(setup.city ? { city: setup.city } : {}),
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('distributeOre', () => {
|
||||
|
||||
describe('buildCutoffLines: fordringar', () => {
|
||||
it('books the receivable against revenue and VILANDE output moms', () => {
|
||||
const { receivableLines } = buildCutoffLines([receivable()], [])
|
||||
const { receivableLines } = buildCutoffLines([receivable()], [], 'aktiebolag')
|
||||
|
||||
const debit = receivableLines.find((l) => l.debit_amount > 0)
|
||||
expect(debit?.account_number).toBe('1510')
|
||||
@@ -98,6 +98,7 @@ describe('buildCutoffLines: fordringar', () => {
|
||||
receivable({ id: 'c', outstanding: 106, vat: 6, vatTreatment: 'reduced_6' }),
|
||||
],
|
||||
[],
|
||||
'aktiebolag',
|
||||
)
|
||||
const totals = sum(receivableLines)
|
||||
expect(totals.debit).toBe(totals.credit)
|
||||
@@ -110,6 +111,7 @@ describe('buildCutoffLines: fordringar', () => {
|
||||
const { receivableLines } = buildCutoffLines(
|
||||
[receivable({ outstanding: 1000.01, vat: 200.003 })],
|
||||
[],
|
||||
'aktiebolag',
|
||||
)
|
||||
const totals = sum(receivableLines)
|
||||
expect(totals.debit).toBe(totals.credit)
|
||||
@@ -122,6 +124,7 @@ describe('buildCutoffLines: fordringar', () => {
|
||||
receivable({ id: 'b', outstanding: 1120, vat: 120, vatTreatment: 'reduced_12' }),
|
||||
],
|
||||
[],
|
||||
'aktiebolag',
|
||||
)
|
||||
expect(receivableLines.find((l) => l.account_number === VILANDE_OUTPUT_VAT_ACCOUNTS.standard_25)).toBeDefined()
|
||||
expect(receivableLines.find((l) => l.account_number === VILANDE_OUTPUT_VAT_ACCOUNTS.reduced_12)).toBeDefined()
|
||||
@@ -133,6 +136,7 @@ describe('buildCutoffLines: fordringar', () => {
|
||||
const { receivableLines } = buildCutoffLines(
|
||||
[receivable({ vatTreatment: 'export', outstanding: 5000, vat: 0 })],
|
||||
[],
|
||||
'aktiebolag',
|
||||
)
|
||||
expect(receivableLines.some((l) => l.account_number.startsWith('26'))).toBe(false)
|
||||
expect(receivableLines.find((l) => l.account_number === '3305')?.credit_amount).toBe(5000)
|
||||
@@ -147,6 +151,7 @@ describe('buildCutoffLines: fordringar', () => {
|
||||
const { receivableLines } = buildCutoffLines(
|
||||
[receivable({ vatTreatment: 'export', outstanding: 5000, vat: 100 })],
|
||||
[],
|
||||
'aktiebolag',
|
||||
)
|
||||
const totals = sum(receivableLines)
|
||||
expect(totals.debit).toBe(totals.credit)
|
||||
@@ -154,14 +159,14 @@ describe('buildCutoffLines: fordringar', () => {
|
||||
})
|
||||
|
||||
it('emits nothing when there is nothing outstanding', () => {
|
||||
expect(buildCutoffLines([], []).receivableLines).toEqual([])
|
||||
expect(buildCutoffLines([receivable({ outstanding: 0, vat: 0 })], []).receivableLines).toEqual([])
|
||||
expect(buildCutoffLines([], [], 'aktiebolag').receivableLines).toEqual([])
|
||||
expect(buildCutoffLines([receivable({ outstanding: 0, vat: 0 })], [], 'aktiebolag').receivableLines).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCutoffLines: skulder', () => {
|
||||
it('books the payable against expense and VILANDE input moms', () => {
|
||||
const { payableLines } = buildCutoffLines([], [payable()])
|
||||
const { payableLines } = buildCutoffLines([], [payable()], 'aktiebolag')
|
||||
|
||||
const credit = payableLines.find((l) => l.credit_amount > 0)
|
||||
expect(credit?.account_number).toBe('2440')
|
||||
@@ -188,6 +193,7 @@ describe('buildCutoffLines: skulder', () => {
|
||||
],
|
||||
}),
|
||||
],
|
||||
'aktiebolag',
|
||||
)
|
||||
const totals = sum(payableLines)
|
||||
expect(totals.debit).toBe(totals.credit)
|
||||
@@ -209,6 +215,7 @@ describe('buildCutoffLines: skulder', () => {
|
||||
],
|
||||
}),
|
||||
],
|
||||
'aktiebolag',
|
||||
)
|
||||
const totals = sum(payableLines)
|
||||
expect(totals.debit).toBe(totals.credit)
|
||||
@@ -216,7 +223,7 @@ describe('buildCutoffLines: skulder', () => {
|
||||
})
|
||||
|
||||
it('falls back to a generic expense account when item detail is missing', () => {
|
||||
const { payableLines } = buildCutoffLines([], [payable({ netByAccount: [] })])
|
||||
const { payableLines } = buildCutoffLines([], [payable({ netByAccount: [] })], 'aktiebolag')
|
||||
expect(payableLines.find((l) => l.account_number === '6990')?.debit_amount).toBe(1000)
|
||||
const totals = sum(payableLines)
|
||||
expect(totals.debit).toBe(totals.credit)
|
||||
@@ -226,6 +233,7 @@ describe('buildCutoffLines: skulder', () => {
|
||||
const lines = buildCutoffLines(
|
||||
[receivable({ outstanding: -1250, vat: -250 })],
|
||||
[payable({ outstanding: -1250, vat: -250 })],
|
||||
'aktiebolag',
|
||||
)
|
||||
expect(lines.receivableLines.find((line) => line.account_number === '1510')).toMatchObject({
|
||||
debit_amount: 0,
|
||||
@@ -250,7 +258,7 @@ describe('buildCutoffLines: skulder', () => {
|
||||
|
||||
describe('reverseLines', () => {
|
||||
it('swaps every debit and credit so the vändning nets to zero', () => {
|
||||
const { receivableLines } = buildCutoffLines([receivable()], [])
|
||||
const { receivableLines } = buildCutoffLines([receivable()], [], 'aktiebolag')
|
||||
const reversed = reverseLines(receivableLines)
|
||||
|
||||
const original = sum(receivableLines)
|
||||
@@ -374,7 +382,7 @@ describe('cut-off snapshot and posting inspection', () => {
|
||||
})
|
||||
|
||||
it('requires exact cut-off lines and exact next-period reversals', async () => {
|
||||
const lines = buildCutoffLines([receivable()], [payable()])
|
||||
const lines = buildCutoffLines([receivable()], [payable()], 'aktiebolag')
|
||||
const rows = [
|
||||
{
|
||||
id: 'ar', fiscal_period_id: 'fp-1',
|
||||
@@ -413,7 +421,7 @@ describe('cut-off snapshot and posting inspection', () => {
|
||||
})
|
||||
|
||||
it('treats a single stale immutable marker as a conflict', async () => {
|
||||
const lines = buildCutoffLines([receivable()], [])
|
||||
const lines = buildCutoffLines([receivable()], [], 'aktiebolag')
|
||||
const stale = lines.receivableLines.map((line) =>
|
||||
line.account_number === '1510' ? { ...line, debit_amount: 999 } : line,
|
||||
)
|
||||
@@ -439,7 +447,7 @@ describe('cut-off snapshot and posting inspection', () => {
|
||||
})
|
||||
|
||||
it('treats an otherwise exact marker on the wrong date as a conflict', async () => {
|
||||
const lines = buildCutoffLines([receivable()], [])
|
||||
const lines = buildCutoffLines([receivable()], [], 'aktiebolag')
|
||||
const status = await inspectKontantmetodCutoffPostings(
|
||||
makeJournalSupabase([{
|
||||
id: 'ar',
|
||||
@@ -456,7 +464,7 @@ describe('cut-off snapshot and posting inspection', () => {
|
||||
})
|
||||
|
||||
it('treats multiple exact markers as a duplicate conflict', async () => {
|
||||
const lines = buildCutoffLines([receivable()], [])
|
||||
const lines = buildCutoffLines([receivable()], [], 'aktiebolag')
|
||||
const rows = [
|
||||
{
|
||||
id: 'ar', fiscal_period_id: 'fp-1',
|
||||
@@ -487,7 +495,7 @@ describe('cut-off snapshot and posting inspection', () => {
|
||||
await expect(
|
||||
inspectKontantmetodCutoffPostings(
|
||||
makeJournalSupabase([], { message: 'connection lost' }),
|
||||
'co-1', 'fp-1', 'fp-2', '2026-12-31', buildCutoffLines([], []),
|
||||
'co-1', 'fp-1', 'fp-2', '2026-12-31', buildCutoffLines([], [], 'aktiebolag'),
|
||||
),
|
||||
).rejects.toThrow(/kunde inte kontrolleras/i)
|
||||
})
|
||||
@@ -626,7 +634,7 @@ describe('collectKontantmetodCutoff', () => {
|
||||
invoice_payments: paymentOf(17500),
|
||||
}) as never, 'co-1', '2026-01-01', '2026-12-31')
|
||||
expect(result.receivables).toEqual([])
|
||||
expect(buildCutoffLines(result.receivables, []).receivableLines).toEqual([])
|
||||
expect(buildCutoffLines(result.receivables, [], 'aktiebolag').receivableLines).toEqual([])
|
||||
})
|
||||
|
||||
it('carries only the customer residual on a part-paid ROT invoice, moms scaled by the customer share', async () => {
|
||||
@@ -639,7 +647,7 @@ describe('collectKontantmetodCutoff', () => {
|
||||
expect(result.receivables).toEqual([
|
||||
expect.objectContaining({ id: 'inv-rot', outstanding: 7500, vat: 2142.86 }),
|
||||
])
|
||||
const { receivableLines } = buildCutoffLines(result.receivables, [])
|
||||
const { receivableLines } = buildCutoffLines(result.receivables, [], 'aktiebolag')
|
||||
expect(receivableLines.find((l) => l.account_number === '1510')?.debit_amount).toBe(7500)
|
||||
expect(receivableLines.find((l) => l.account_number === '2618')?.credit_amount).toBe(2142.86)
|
||||
expect(receivableLines.find((l) => l.account_number === '3001')?.credit_amount).toBe(5357.14)
|
||||
@@ -684,7 +692,7 @@ describe('collectKontantmetodCutoff', () => {
|
||||
}) as never, 'co-1', '2026-01-01', '2026-12-31')
|
||||
expect(result.receivables.map((r) => r.outstanding)).toEqual([17500, -17500])
|
||||
expect(result.receivables.map((r) => r.vat)).toEqual([5000, -5000])
|
||||
expect(buildCutoffLines(result.receivables, []).receivableLines).toEqual([])
|
||||
expect(buildCutoffLines(result.receivables, [], 'aktiebolag').receivableLines).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a plain invoice with the same figures exactly as before', async () => {
|
||||
@@ -701,7 +709,7 @@ describe('collectKontantmetodCutoff', () => {
|
||||
expect(result.receivables).toEqual([
|
||||
expect.objectContaining({ id: 'inv-rot', outstanding: 7500, vat: 1500 }),
|
||||
])
|
||||
const { receivableLines } = buildCutoffLines(result.receivables, [])
|
||||
const { receivableLines } = buildCutoffLines(result.receivables, [], 'aktiebolag')
|
||||
expect(receivableLines.find((l) => l.account_number === '1510')?.debit_amount).toBe(7500)
|
||||
expect(receivableLines.find((l) => l.account_number === '3001')?.credit_amount).toBe(6000)
|
||||
expect(receivableLines.find((l) => l.account_number === '2618')?.credit_amount).toBe(1500)
|
||||
@@ -737,7 +745,7 @@ describe('collectKontantmetodCutoff', () => {
|
||||
supplierType: 'eu_business',
|
||||
}],
|
||||
})
|
||||
const lines = buildCutoffLines([], result.payables).payableLines
|
||||
const lines = buildCutoffLines([], result.payables, 'aktiebolag').payableLines
|
||||
expect(lines.find((line) => line.account_number === '2624')?.credit_amount).toBe(1104)
|
||||
expect(lines.find((line) => line.account_number === '2645')?.debit_amount).toBe(1104)
|
||||
expect(lines.find((line) => line.account_number === '4536')?.debit_amount).toBe(9200)
|
||||
@@ -799,7 +807,7 @@ describe('collectKontantmetodCutoff', () => {
|
||||
}) as never, 'co-1', '2026-01-01', '2026-12-31')
|
||||
expect(result.receivables.map((item) => item.outstanding)).toEqual([1250, -1250])
|
||||
expect(result.payables.map((item) => item.outstanding)).toEqual([1250, -1250])
|
||||
const lines = buildCutoffLines(result.receivables, result.payables)
|
||||
const lines = buildCutoffLines(result.receivables, result.payables, 'aktiebolag')
|
||||
expect(lines.receivableLines).toEqual([])
|
||||
expect(lines.payableLines).toEqual([])
|
||||
})
|
||||
@@ -849,6 +857,7 @@ describe('postKontantmetodCutoff', () => {
|
||||
periodEnd: '2026-12-31',
|
||||
receivables: [receivable()],
|
||||
payables: [],
|
||||
entityType: 'aktiebolag' as const,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -933,7 +942,7 @@ describe('postKontantmetodCutoff', () => {
|
||||
id: 'existing',
|
||||
fiscal_period_id: 'fp-1',
|
||||
description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable,
|
||||
lines: buildCutoffLines([receivable()], []).receivableLines,
|
||||
lines: buildCutoffLines([receivable()], [], 'aktiebolag').receivableLines,
|
||||
}]),
|
||||
'co-1',
|
||||
'user-1',
|
||||
@@ -944,7 +953,7 @@ describe('postKontantmetodCutoff', () => {
|
||||
})
|
||||
|
||||
it('resumes with the missing payable pair after a prior receivable pair succeeded', async () => {
|
||||
const receivableLines = buildCutoffLines([receivable()], []).receivableLines
|
||||
const receivableLines = buildCutoffLines([receivable()], [], 'aktiebolag').receivableLines
|
||||
const existingRows = [
|
||||
{
|
||||
id: 'ar', fiscal_period_id: 'fp-1',
|
||||
@@ -1066,6 +1075,7 @@ describe('buildCutoffLines: omvänd betalningsskyldighet', () => {
|
||||
}],
|
||||
netByAccount: [{ account: '6540', amount: 1000 }],
|
||||
})],
|
||||
'aktiebolag',
|
||||
)
|
||||
expect(payableLines.some((l) => l.account_number === VILANDE_INPUT_VAT_ACCOUNT)).toBe(false)
|
||||
expect(payableLines.find((l) => l.account_number === '2645')?.debit_amount).toBe(250)
|
||||
@@ -1079,7 +1089,7 @@ describe('buildCutoffLines: omvänd betalningsskyldighet', () => {
|
||||
})
|
||||
|
||||
it('still books vilande moms for ordinary (non-RC) supplier invoices', () => {
|
||||
const { payableLines } = buildCutoffLines([], [payable({ reverseCharge: false })])
|
||||
const { payableLines } = buildCutoffLines([], [payable({ reverseCharge: false })], 'aktiebolag')
|
||||
expect(payableLines.find((l) => l.account_number === VILANDE_INPUT_VAT_ACCOUNT)?.debit_amount).toBe(250)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,6 +66,26 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('generateResultAppropriation', () => {
|
||||
it('posts Dr 2069 / Cr 2068 for an ideell förening profit', async () => {
|
||||
results = [{ data: { entity_type: 'ideell_forening' }, error: null }, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2069', debit: 0, credit: 25000 }])
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
expect(entry).toEqual(FAKE_ENTRY)
|
||||
const input = vi.mocked(createJournalEntry).mock.calls[0][3] as {
|
||||
description: string
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
expect(input.description).toContain('2069 → 2068')
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2069', debit_amount: 25000, credit_amount: 0 })
|
||||
)
|
||||
expect(input.lines).toContainEqual(
|
||||
expect.objectContaining({ account_number: '2068', debit_amount: 0, credit_amount: 25000 })
|
||||
)
|
||||
})
|
||||
|
||||
it('posts Dr 2099 / Cr 2098 for a profit (AB)', async () => {
|
||||
results = [AB, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 100000 }])
|
||||
@@ -163,8 +183,8 @@ describe('generateResultAppropriation', () => {
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults missing company_settings to aktiebolag and posts', async () => {
|
||||
results = [NO_EXISTING /* settings missing */, NO_EXISTING, PERIOD]
|
||||
it('falls back to companies.entity_type when company_settings is missing and posts', async () => {
|
||||
results = [NO_EXISTING /* settings missing */, AB /* companies fallback */, NO_EXISTING, PERIOD]
|
||||
mockOpeningBalance([{ account_number: '2099', debit: 0, credit: 5000 }])
|
||||
|
||||
const entry = await generateResultAppropriation(makeClient() as never, 'c1', 'u1', 'p1')
|
||||
|
||||
@@ -714,7 +714,7 @@ describe('validateYearEndReadiness: kontantmetoden cut-off gate', () => {
|
||||
const expected = buildCutoffLines([{
|
||||
id: 'inv-1', reference: 'F-1', vatTreatment: 'standard_25',
|
||||
outstanding: 1250, vat: 250,
|
||||
}], [])
|
||||
}], [], 'aktiebolag')
|
||||
const markers = [
|
||||
{
|
||||
id: 'cutoff', company_id: 'company-1', fiscal_period_id: 'fp-1',
|
||||
|
||||
@@ -231,7 +231,7 @@ function signedLine(
|
||||
export function buildCutoffLines(
|
||||
receivables: CutoffReceivable[],
|
||||
payables: CutoffPayable[],
|
||||
entityType: EntityType = 'aktiebolag',
|
||||
entityType: EntityType,
|
||||
): CutoffLines {
|
||||
const receivableLines: CreateJournalEntryLineInput[] = []
|
||||
const payableLines: CreateJournalEntryLineInput[] = []
|
||||
@@ -959,7 +959,7 @@ export async function assessKontantmetodCutoff(
|
||||
companyId: string,
|
||||
period: { id: string; period_start: string; period_end: string },
|
||||
nextFiscalPeriodId: string,
|
||||
entityType: EntityType = 'aktiebolag',
|
||||
entityType: EntityType,
|
||||
): Promise<KontantmetodCutoffAssessment> {
|
||||
const collection = sortedCutoffCollection(await collectKontantmetodCutoff(
|
||||
supabase,
|
||||
@@ -1069,7 +1069,7 @@ export async function postKontantmetodCutoff(
|
||||
periodEnd: string
|
||||
receivables: CutoffReceivable[]
|
||||
payables: CutoffPayable[]
|
||||
entityType?: EntityType
|
||||
entityType: EntityType
|
||||
/** Refuse if any invoice lacked a vat_treatment (see CutoffCollection). */
|
||||
unknownVatTreatment?: string[]
|
||||
/** Refuse if any invoice carried moms on a zero-rate treatment. */
|
||||
|
||||
@@ -171,7 +171,9 @@ export function classifyHistoricalResultRepair(
|
||||
reason: Exclude<HistoricalResultRepairReason, 'ready'>,
|
||||
): HistoricalResultRepairAssessment => ({ ...base, status, reason, plan: null })
|
||||
|
||||
if ((snapshot.entityType ?? 'aktiebolag') !== 'aktiebolag') {
|
||||
// An unknown form is NOT assumed to be an aktiebolag: the repair only ever
|
||||
// applies to the 2099 -> 2098 chain, so anything else is skipped.
|
||||
if (snapshot.entityType !== 'aktiebolag') {
|
||||
return finish('skipped', 'non_aktiebolag')
|
||||
}
|
||||
if (snapshot.isClosed) return finish('skipped', 'period_closed')
|
||||
@@ -276,7 +278,7 @@ export async function assessHistoricalResultRepair(
|
||||
}
|
||||
|
||||
if (
|
||||
(baseSnapshot.entityType ?? 'aktiebolag') !== 'aktiebolag' ||
|
||||
baseSnapshot.entityType !== 'aktiebolag' ||
|
||||
baseSnapshot.isClosed ||
|
||||
baseSnapshot.lockedAt ||
|
||||
baseSnapshot.existingPostedAppropriation ||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { resolveCompanyEntityType, resultClosingAccounts } from '@/lib/company/entity-type'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { getOpeningBalances } from '@/lib/reports/opening-balances'
|
||||
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
|
||||
@@ -17,9 +18,13 @@ export interface ResultAppropriationPlan {
|
||||
periodName: string
|
||||
/** entry_date for the omföring: the new period's first day. */
|
||||
periodStart: string
|
||||
/** Net 2099 balance, credit-positive (a profit is > 0, a loss is < 0). */
|
||||
/** The form's "årets resultat" account (AB 2099, ideell förening 2069). */
|
||||
resultAccount: string
|
||||
/** Where last year's result is carried (AB 2098, ideell förening 2068). */
|
||||
priorResultAccount: string
|
||||
/** Net result-account IB balance, credit-positive (a profit is > 0, a loss is < 0). */
|
||||
net: number
|
||||
/** Absolute, öre-rounded amount that moves between 2099 and 2098. */
|
||||
/** Absolute, öre-rounded amount that moves between the two accounts. */
|
||||
amount: number
|
||||
direction: 'profit' | 'loss'
|
||||
/** Balanced lines for the omföring verifikat. */
|
||||
@@ -55,8 +60,14 @@ export async function planResultAppropriation(
|
||||
.select('entity_type')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const entityType = settings?.entity_type ?? 'aktiebolag'
|
||||
if (entityType !== 'aktiebolag') return null
|
||||
const entityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
|
||||
// Only forms that close into a dedicated "årets resultat" account carry it
|
||||
// forward: AB 2099 -> 2098, ideell förening 2069 -> 2068. An enskild firma
|
||||
// closes straight into 2010 and has nothing to reclassify.
|
||||
const accounts = resultClosingAccounts(entityType)
|
||||
if (!accounts.priorYearCarry) return null
|
||||
const resultAccount = accounts.closing
|
||||
const priorResultAccount = accounts.priorYearCarry
|
||||
|
||||
// Idempotency: never plan a second omföring for a period that already has a
|
||||
// LIVE one. Deliberately posted-only: a reversed omföring is storno-cancelled
|
||||
@@ -91,38 +102,38 @@ export async function planResultAppropriation(
|
||||
// aggregate of prior posted lines when none is set. credit − debit is positive
|
||||
// for a profit (2099 is credit-normal).
|
||||
const { balances } = await getOpeningBalances(supabase, companyId, period)
|
||||
const ib2099 = balances.get(RESULT_ACCOUNT)
|
||||
const net = ib2099 ? roundOre(ib2099.credit - ib2099.debit) : 0
|
||||
const ibResult = balances.get(resultAccount)
|
||||
const net = ibResult ? roundOre(ibResult.credit - ibResult.debit) : 0
|
||||
if (Math.abs(net) < ORE_TOLERANCE) return null
|
||||
|
||||
const amount = roundOre(Math.abs(net))
|
||||
const lines: CreateJournalEntryLineInput[] =
|
||||
net > 0
|
||||
? [
|
||||
// Profit: move the credit balance off 2099 onto 2098.
|
||||
// Profit: move the credit balance off the result account onto the carry.
|
||||
{
|
||||
account_number: RESULT_ACCOUNT,
|
||||
account_number: resultAccount,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: 'Omföring av föregående års resultat',
|
||||
},
|
||||
{
|
||||
account_number: PRIOR_RESULT_ACCOUNT,
|
||||
account_number: priorResultAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: 'Föregående års resultat',
|
||||
},
|
||||
]
|
||||
: [
|
||||
// Loss: move the debit balance off 2099 onto 2098.
|
||||
// Loss: move the debit balance off the result account onto the carry.
|
||||
{
|
||||
account_number: PRIOR_RESULT_ACCOUNT,
|
||||
account_number: priorResultAccount,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: 'Föregående års resultat',
|
||||
},
|
||||
{
|
||||
account_number: RESULT_ACCOUNT,
|
||||
account_number: resultAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: 'Omföring av föregående års resultat',
|
||||
@@ -133,6 +144,8 @@ export async function planResultAppropriation(
|
||||
periodId,
|
||||
periodName: period.name,
|
||||
periodStart: period.period_start,
|
||||
resultAccount,
|
||||
priorResultAccount,
|
||||
net,
|
||||
amount,
|
||||
direction: net > 0 ? 'profit' : 'loss',
|
||||
@@ -178,7 +191,7 @@ export async function generateResultAppropriation(
|
||||
const entry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: periodId,
|
||||
entry_date: plan.periodStart,
|
||||
description: `Omföring av föregående års resultat (${RESULT_ACCOUNT} → ${PRIOR_RESULT_ACCOUNT})`,
|
||||
description: `Omföring av föregående års resultat (${plan.resultAccount} → ${plan.priorResultAccount})`,
|
||||
source_type: 'result_appropriation',
|
||||
voucher_series: 'A',
|
||||
lines: plan.lines,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -27,6 +27,7 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { findPayslipLineForClaim } from '@/lib/salary/expense-claim-lines'
|
||||
import { roundOre, sumOre } from '@/lib/money'
|
||||
import { ownerSettlementAccount, parseEntityType } from '@/lib/company/entity-type'
|
||||
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
@@ -128,7 +129,7 @@ export async function registerExpenseClaim(
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
if (!company?.entity_type) return { ok: false, code: 'COMPANY_NOT_FOUND' }
|
||||
const ownerLiability = company.entity_type === 'enskild_firma' ? '2018' : '2893'
|
||||
const ownerLiability = ownerSettlementAccount(parseEntityType(company.entity_type), 'contribution')
|
||||
let claimantName = input.claimant_name?.trim() ?? ''
|
||||
let employeeId: string | null = null
|
||||
let liability: string = ownerLiability
|
||||
|
||||
+11
-3
@@ -27,20 +27,28 @@ export function isPersonPayer(choice: PayerChoice | null | undefined): choice is
|
||||
* the owner), so every writer that lets the name default must default to the
|
||||
* same string or one person shows up as two.
|
||||
*/
|
||||
import { isEntityType, ownerSettlementAccount } from '@/lib/company/entity-type'
|
||||
|
||||
export const OWNER_FALLBACK_NAME = 'Ägare'
|
||||
|
||||
export type ExpenseLiabilityAccount = '2893' | '2820' | '2018'
|
||||
export type ExpenseLiabilityAccount = '2893' | '2820' | '2018' | '2890'
|
||||
|
||||
/**
|
||||
* Liability account for an utlägg. An employee is always 2820 (kortfristiga
|
||||
* skulder till anställda). The owner's account follows the entity type: an AB
|
||||
* owner is a creditor (2893 skulder till närstående); an enskild firma owner
|
||||
* makes an egen insättning (2018), which is equity, not a debt.
|
||||
* makes an egen insättning (2018), which is equity, not a debt; a member of an
|
||||
* ideell förening is a plain short-term creditor (2890).
|
||||
*
|
||||
* Same resolver as lib/expenses/expense-claims-service.ts, which is the
|
||||
* authority at booking time; an unknown form here (a dialog rendering before
|
||||
* the company context loads) previews the AB account, never books it.
|
||||
*/
|
||||
export function resolveExpenseLiabilityAccount(
|
||||
entityType: string | null | undefined,
|
||||
payer: ExpensePayer,
|
||||
): ExpenseLiabilityAccount {
|
||||
if (payer === 'employee') return '2820'
|
||||
return entityType === 'enskild_firma' ? '2018' : '2893'
|
||||
if (!isEntityType(entityType)) return '2893'
|
||||
return ownerSettlementAccount(entityType, 'contribution') as ExpenseLiabilityAccount
|
||||
}
|
||||
|
||||
@@ -142,4 +142,10 @@ describe('toRedovisare12', () => {
|
||||
// Skatteverket reject it with its own message. See the module docblock.
|
||||
expect(toRedovisare12('5560125791', 'aktiebolag')).toBe('165560125791')
|
||||
})
|
||||
|
||||
it('prefixes 16 for an ideell förening like every other juridisk person', () => {
|
||||
// 8-series org numbers are issued by Skatteverket to föreningar; they
|
||||
// must never be read as a personnummer century.
|
||||
expect(toRedovisare12('814400-9464', 'ideell_forening')).toBe('168144009464')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* accepts an optional is_self_billed flag), so the two can never drift.
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
|
||||
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
@@ -306,7 +307,7 @@ export async function createSelfBilledSaleInvoice(
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const entityType = await resolveCompanyEntityType(supabase, companyId, settings?.entity_type)
|
||||
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
CompanyLookupOutcome,
|
||||
CompanySearchOutcome,
|
||||
} from '@/lib/company-lookup/fetch-company-lookup'
|
||||
import { mapEntityType } from '@/lib/company-lookup/entity-type-map'
|
||||
import { mapSetupEntityType } from '@/lib/company-lookup/entity-type-map'
|
||||
import { deriveSwedishVatNumber } from '@/lib/vat/vat-number'
|
||||
|
||||
/**
|
||||
@@ -263,7 +263,9 @@ function withOrgNumber(state: JourneyState, orgNumber: string): JourneyState {
|
||||
* advances past whatever the lookup already answered.
|
||||
*/
|
||||
function applyLookupFound(state: JourneyState, lookup: CompanyLookupResult): JourneyState {
|
||||
const mapped = mapEntityType(lookup.legalEntityType)
|
||||
// Only forms this deployment can create are prefilled; a flagged-off form
|
||||
// falls through to the picker instead of failing at the create step.
|
||||
const mapped = mapSetupEntityType(lookup.legalEntityType)
|
||||
const settings: Partial<CompanySettings> = {
|
||||
...state.settings,
|
||||
entity_type: mapped ?? state.settings.entity_type,
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ export const PACK_CATEGORIES = [
|
||||
] as const
|
||||
|
||||
/** Which entity types a pack applies to. Mirrors the `entity_type` CHECK. */
|
||||
export const PACK_ENTITY_TYPES = ['all', 'enskild_firma', 'aktiebolag'] as const
|
||||
export const PACK_ENTITY_TYPES = ['all', 'enskild_firma', 'aktiebolag', 'ideell_forening'] as const
|
||||
|
||||
/** Line roles. Drives the amount maths in `applyTemplate()`. */
|
||||
export const PACK_LINE_TYPES = ['business', 'vat', 'settlement'] as const
|
||||
|
||||
@@ -32,7 +32,7 @@ const collection = {
|
||||
}
|
||||
|
||||
function makePendingOp(overrides: Partial<PendingOperation> = {}): PendingOperation {
|
||||
const lines = buildCutoffLines(collection.receivables, collection.payables)
|
||||
const lines = buildCutoffLines(collection.receivables, collection.payables, 'aktiebolag')
|
||||
return {
|
||||
id: 'op-1', user_id: 'user-1', company_id: 'company-1',
|
||||
operation_type: 'post_kontantmetod_cutoff', status: 'pending', title: 'cut-off',
|
||||
@@ -103,7 +103,7 @@ beforeEach(() => {
|
||||
vi.setSystemTime(new Date('2027-02-01T12:00:00Z'))
|
||||
vi.mocked(assessKontantmetodCutoff).mockResolvedValue({
|
||||
collection,
|
||||
lines: buildCutoffLines(collection.receivables, collection.payables),
|
||||
lines: buildCutoffLines(collection.receivables, collection.payables, 'aktiebolag'),
|
||||
postings: {
|
||||
complete: false, hasAny: false, receivableEntryId: null,
|
||||
receivableReversalId: null, payableEntryId: null, payableReversalId: null,
|
||||
@@ -151,7 +151,7 @@ describe('commitPendingOperation: post_kontantmetod_cutoff', () => {
|
||||
...collection,
|
||||
receivables: [{ ...collection.receivables[0]!, outstanding: 1300 }],
|
||||
},
|
||||
lines: buildCutoffLines([], []),
|
||||
lines: buildCutoffLines([], [], 'aktiebolag'),
|
||||
postings: { complete: false, hasAny: false, receivableEntryId: null, receivableReversalId: null, payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
|
||||
})
|
||||
const result = await commitPendingOperation(
|
||||
@@ -165,7 +165,7 @@ describe('commitPendingOperation: post_kontantmetod_cutoff', () => {
|
||||
it('rejects a duplicate, locked period, wrong accounting method, and missing next period', async () => {
|
||||
vi.mocked(assessKontantmetodCutoff).mockResolvedValueOnce({
|
||||
collection,
|
||||
lines: buildCutoffLines(collection.receivables, []),
|
||||
lines: buildCutoffLines(collection.receivables, [], 'aktiebolag'),
|
||||
postings: { complete: true, hasAny: true, receivableEntryId: 'je-1', receivableReversalId: 'je-2', payableEntryId: null, payableReversalId: null, missing: [], duplicates: [] },
|
||||
})
|
||||
await expect(commitPendingOperation(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -361,7 +361,7 @@ const SETTINGS_VALUE_LABELS: Record<string, Record<string, string>> = {
|
||||
// company_settings CHECK allows 'accrual' | 'cash'; 'invoice' is the legacy spelling.
|
||||
accounting_method: { accrual: 'Faktureringsmetoden', invoice: 'Faktureringsmetoden', cash: 'Kontantmetoden' },
|
||||
moms_period: { monthly: 'Månad', quarterly: 'Kvartal', yearly: 'Helår', none: 'Ingen' },
|
||||
entity_type: { aktiebolag: 'Aktiebolag', enskild_firma: 'Enskild firma' },
|
||||
entity_type: { aktiebolag: 'Aktiebolag', enskild_firma: 'Enskild firma', ideell_forening: 'Ideell förening' },
|
||||
}
|
||||
|
||||
const PERIOD_FIELDS: Record<string, string> = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { EntityType } from '@/types'
|
||||
import { luhnCheckDigit, luhnValidate } from '@/lib/bankgiro/luhn'
|
||||
import { toRedovisare12 } from '@/lib/invariants/org-number'
|
||||
|
||||
@@ -50,7 +51,7 @@ const BALANCE_SNAPSHOT_KEY = 'skattekonto_balance_snapshot'
|
||||
*/
|
||||
export function generateSkattekontoOcr(
|
||||
orgOrPersonnummer: string,
|
||||
entityType: 'enskild_firma' | 'aktiebolag',
|
||||
entityType: EntityType,
|
||||
): string {
|
||||
const redovisare = toRedovisare12(orgOrPersonnummer, entityType)
|
||||
return redovisare + luhnCheckDigit(redovisare).toString()
|
||||
@@ -74,7 +75,7 @@ export async function resolveSkattekontoOcr(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
orgOrPersonnummer: string,
|
||||
entityType: 'enskild_firma' | 'aktiebolag',
|
||||
entityType: EntityType,
|
||||
): Promise<string> {
|
||||
const reported = await readReportedOcr(supabase, companyId)
|
||||
return reported ?? generateSkattekontoOcr(orgOrPersonnummer, entityType)
|
||||
|
||||
@@ -83,6 +83,34 @@ describe('VAT filing deadlines', () => {
|
||||
vat_filing_method: 'paper',
|
||||
}))[0]).toMatchObject({ day: 12, month: 6, year: 2027, period: '2026' })
|
||||
})
|
||||
|
||||
it('gives an ideell förening the juridisk person helårsmoms schedule, same as an AB', () => {
|
||||
const config = getConfig('moms_yearly')
|
||||
const ab = config.generateDates(2027, makeSettings({
|
||||
entity_type: 'aktiebolag',
|
||||
moms_period: 'yearly',
|
||||
vat_filing_method: 'paper',
|
||||
}))
|
||||
const forening = config.generateDates(2027, makeSettings({
|
||||
entity_type: 'ideell_forening',
|
||||
moms_period: 'yearly',
|
||||
vat_filing_method: 'paper',
|
||||
}))
|
||||
expect(forening).toHaveLength(ab.length)
|
||||
expect(forening[0]).toMatchObject({ day: 12, month: 6, year: 2027, period: '2026' })
|
||||
// A broken fiscal year is honoured (a förening is not calendar-locked).
|
||||
expect(config.generateDates(2027, makeSettings({
|
||||
entity_type: 'ideell_forening',
|
||||
moms_period: 'yearly',
|
||||
fiscal_year_start_month: 7,
|
||||
vat_filing_method: 'electronic',
|
||||
}))[0]).toMatchObject(config.generateDates(2027, makeSettings({
|
||||
entity_type: 'aktiebolag',
|
||||
moms_period: 'yearly',
|
||||
fiscal_year_start_month: 7,
|
||||
vat_filing_method: 'electronic',
|
||||
}))[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('monthly tax and employer deadlines', () => {
|
||||
|
||||
+12
-11
@@ -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[] = []
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { isEntityType } from '@/lib/company/entity-type'
|
||||
import type { TaxDeadlineType, DeadlineStatus } from '@/types'
|
||||
|
||||
const log = createLogger('deadline-generator')
|
||||
@@ -94,7 +95,7 @@ export function hasTaxRelevantFields(body: Record<string, unknown>): boolean {
|
||||
export function toDeadlineSettings(
|
||||
settings: Partial<CompanySettingsForDeadlines>,
|
||||
): CompanySettingsForDeadlines {
|
||||
if (settings.entity_type !== 'aktiebolag' && settings.entity_type !== 'enskild_firma') {
|
||||
if (!isEntityType(settings.entity_type)) {
|
||||
throw new Error('Company entity type is required to generate tax deadlines')
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('buildCounterpartySuggestion', () => {
|
||||
},
|
||||
undefined,
|
||||
'expense_other',
|
||||
'enskild_firma',
|
||||
)
|
||||
expect(account).toBe('6570')
|
||||
expect(() => account.startsWith('2')).not.toThrow()
|
||||
|
||||
@@ -70,7 +70,12 @@ function createQueueMockSupabase() {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
// The legal form is resolved from `companies` (never defaulted) before
|
||||
// mapping rules run; it is not part of the per-test queue.
|
||||
const next =
|
||||
table === 'companies'
|
||||
? { data: { entity_type: 'aktiebolag' }, error: null }
|
||||
: resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
if (prop === 'insert') {
|
||||
|
||||
@@ -23,20 +23,20 @@ describe('resolveQuickReviewDefaults', () => {
|
||||
|
||||
it('never returns undefined for the account, whatever the template omits', () => {
|
||||
const bare: ReviewTemplate = { id: 'counterparty:abc', name_sv: 'Fee' }
|
||||
const { account, vat } = resolveQuickReviewDefaults(bare, undefined, 'expense_other')
|
||||
expect(account).toBe(getDefaultAccountForCategory('expense_other'))
|
||||
const { account, vat } = resolveQuickReviewDefaults(bare, undefined, 'expense_other', 'enskild_firma')
|
||||
expect(account).toBe(getDefaultAccountForCategory('expense_other', 'enskild_firma'))
|
||||
expect(typeof account).toBe('string')
|
||||
expect(vat).toBe('none')
|
||||
})
|
||||
|
||||
it('returns an empty account rather than undefined when there is nothing at all', () => {
|
||||
expect(resolveQuickReviewDefaults(null, undefined, null)).toEqual({ account: '', vat: 'none' })
|
||||
expect(resolveQuickReviewDefaults({ id: 'counterparty:abc', name_sv: 'Fee' }, undefined, null))
|
||||
expect(resolveQuickReviewDefaults(null, undefined, null, 'enskild_firma')).toEqual({ account: '', vat: 'none' })
|
||||
expect(resolveQuickReviewDefaults({ id: 'counterparty:abc', name_sv: 'Fee' }, undefined, null, 'enskild_firma'))
|
||||
.toEqual({ account: '', vat: 'none' })
|
||||
})
|
||||
|
||||
it('seeds from the counterparty template accounts, not the category fallback', () => {
|
||||
const { account, vat } = resolveQuickReviewDefaults(counterparty, undefined, 'expense_other')
|
||||
const { account, vat } = resolveQuickReviewDefaults(counterparty, undefined, 'expense_other', 'enskild_firma')
|
||||
expect(account).toBe('6570')
|
||||
expect(vat).toBe('none')
|
||||
})
|
||||
@@ -46,6 +46,7 @@ describe('resolveQuickReviewDefaults', () => {
|
||||
{ ...counterparty, debit_account: '5420', vat_treatment: 'standard_25' },
|
||||
undefined,
|
||||
'expense_other',
|
||||
'enskild_firma',
|
||||
)
|
||||
expect(vat).toBe('standard_25')
|
||||
})
|
||||
@@ -58,15 +59,15 @@ describe('resolveQuickReviewDefaults', () => {
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
}
|
||||
const { account } = resolveQuickReviewDefaults(catalog, 'bank_fees', 'expense_other')
|
||||
const { account } = resolveQuickReviewDefaults(catalog, 'bank_fees', 'expense_other', 'enskild_firma')
|
||||
// Catalog templates are validated server-side by id; the form's account
|
||||
// field is not the source of truth for them.
|
||||
expect(account).toBe(getDefaultAccountForCategory('expense_other'))
|
||||
expect(account).toBe(getDefaultAccountForCategory('expense_other', 'enskild_firma'))
|
||||
})
|
||||
|
||||
it('falls back to the category defaults when no template is involved', () => {
|
||||
const { account, vat } = resolveQuickReviewDefaults(null, undefined, 'expense_other')
|
||||
expect(account).toBe(getDefaultAccountForCategory('expense_other'))
|
||||
const { account, vat } = resolveQuickReviewDefaults(null, undefined, 'expense_other', 'enskild_firma')
|
||||
expect(account).toBe(getDefaultAccountForCategory('expense_other', 'enskild_firma'))
|
||||
expect(vat === 'none' || typeof vat === 'string').toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user