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