Per-line VAT rates: - Add generatePerRateLines() to group invoice items by vat_rate with separate revenue + VAT lines per rate group (invoice-entries.ts) - Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts) - PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices - Invoice create/review UI supports per-line rate selection - Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput Invoice document types (proforma, delivery note): - Add InvoiceDocumentType, document_type and converted_from_id to Invoice type - PDF hides prices for delivery notes, adds proforma notice - Email templates support all document types - mark-paid skips journal entries for non-invoice document types - Migration 031: invoice_document_type Accounting method support: - Add AccountingMethod type (accrual/cash) - Migration 032: add_accounting_method column to company_settings VAT declaration rewrite: - Rewrite to read directly from general ledger (26xx/3xxx account lines) instead of aggregating invoices/transactions/receipts - ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances Bank reconciliation: - Transaction ingest now pre-fetches unlinked GL lines and attempts auto-reconciliation during import - Add transaction.reconciled event type - Add ReconciliationMethod type and reconciliation_method on Transaction - Migration 030: bank_reconciliation - New reconciliation engine, API routes, and BankReconciliationView component Pagination (fetchAllRows): - New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit - Adopted in all report generators, SIE/SRU export, account list APIs Fiscal period validation: - New validate-period-duration.ts enforces max 18 months per BFL 3 kap. - Applied in period-service.ts and fiscal-periods API Account mapper simplification: - Remove Levenshtein/fuzzy matching, use exact account number match only Swedbank parser improvements: - Support abbreviated headers (Clnr, Bokfdag, Radnr) - Use Referens column as counterparty Chart of accounts management: - Add DELETE endpoint with system account and usage protection - PUT uses partial updates - New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager Tax deadline corrections: - Rewrite inkomstdeklaration_ab using Skatteverket lookup table - Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3 Onboarding first fiscal year: - Add first fiscal year toggle with date pickers and 18-month validation UI terminology: - Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout Report column fix: - Fix start_date/end_date to period_start/period_end in report queries Supplier invoice input: - CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept) Misc: - SIE import uses upsert for idempotent account creation - account-descriptions.ts falls back to BAS reference data - Add invoice_default_notes to CompanySettings - Update CLAUDE.md to reflect current project state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
206 lines
5.3 KiB
TypeScript
206 lines
5.3 KiB
TypeScript
/**
|
|
* Account Mapping Engine
|
|
*
|
|
* Maps accounts from an imported SIE file to the user's BAS chart of accounts.
|
|
* Uses exact account number matching only — no fuzzy/heuristic matching.
|
|
* This aligns with Swedish industry standard (e.g. Fortnox): exact match,
|
|
* create new, or let the user map manually.
|
|
*/
|
|
|
|
import type { BASAccount } from '@/types'
|
|
import type {
|
|
SIEAccount,
|
|
AccountMapping,
|
|
AccountMatchType,
|
|
SIEAccountMappingRecord,
|
|
} from './types'
|
|
|
|
/**
|
|
* Find the best matching BAS account for a source account.
|
|
* Only matches on exact account number — no fuzzy matching.
|
|
*/
|
|
function findBestMatch(
|
|
source: SIEAccount,
|
|
basAccounts: BASAccount[],
|
|
existingOverride?: AccountMapping
|
|
): AccountMapping | null {
|
|
// If there's a user override, use it
|
|
if (existingOverride) {
|
|
return {
|
|
...existingOverride,
|
|
isOverride: true,
|
|
}
|
|
}
|
|
|
|
// Exact account number match
|
|
const exactMatch = basAccounts.find(
|
|
(target) => source.number === target.account_number
|
|
)
|
|
|
|
if (exactMatch) {
|
|
return {
|
|
sourceAccount: source.number,
|
|
sourceName: source.name,
|
|
targetAccount: exactMatch.account_number,
|
|
targetName: exactMatch.account_name,
|
|
confidence: 1.0,
|
|
matchType: 'exact',
|
|
isOverride: false,
|
|
}
|
|
}
|
|
|
|
// No match found
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Suggest mappings for all source accounts
|
|
*/
|
|
export function suggestMappings(
|
|
sourceAccounts: SIEAccount[],
|
|
basAccounts: BASAccount[],
|
|
existingMappings?: SIEAccountMappingRecord[]
|
|
): AccountMapping[] {
|
|
// Convert existing mappings to a lookup map
|
|
const overrideMap = new Map<string, AccountMapping>()
|
|
if (existingMappings) {
|
|
for (const mapping of existingMappings) {
|
|
const basAccount = basAccounts.find((a) => a.account_number === mapping.target_account)
|
|
overrideMap.set(mapping.source_account, {
|
|
sourceAccount: mapping.source_account,
|
|
sourceName: mapping.source_name || '',
|
|
targetAccount: mapping.target_account,
|
|
targetName: basAccount?.account_name || mapping.target_account,
|
|
confidence: mapping.confidence,
|
|
matchType: mapping.match_type,
|
|
isOverride: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
const mappings: AccountMapping[] = []
|
|
|
|
for (const source of sourceAccounts) {
|
|
const existingOverride = overrideMap.get(source.number)
|
|
const mapping = findBestMatch(source, basAccounts, existingOverride)
|
|
|
|
if (mapping) {
|
|
mappings.push(mapping)
|
|
} else {
|
|
// No match found - add with null target
|
|
mappings.push({
|
|
sourceAccount: source.number,
|
|
sourceName: source.name,
|
|
targetAccount: '',
|
|
targetName: '',
|
|
confidence: 0,
|
|
matchType: 'manual',
|
|
isOverride: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort by confidence (low to high) so users see problematic mappings first
|
|
mappings.sort((a, b) => a.confidence - b.confidence)
|
|
|
|
return mappings
|
|
}
|
|
|
|
/**
|
|
* Validate that all accounts are mapped
|
|
*/
|
|
export function validateMappings(mappings: AccountMapping[]): {
|
|
valid: boolean
|
|
unmappedAccounts: string[]
|
|
lowConfidenceAccounts: string[]
|
|
} {
|
|
const unmapped = mappings.filter((m) => !m.targetAccount)
|
|
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.5)
|
|
|
|
return {
|
|
valid: unmapped.length === 0,
|
|
unmappedAccounts: unmapped.map((m) => m.sourceAccount),
|
|
lowConfidenceAccounts: lowConfidence.map((m) => m.sourceAccount),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get mapping statistics
|
|
*/
|
|
export function getMappingStats(mappings: AccountMapping[]): {
|
|
total: number
|
|
mapped: number
|
|
unmapped: number
|
|
exact: number
|
|
name: number
|
|
class: number
|
|
manual: number
|
|
lowConfidence: number
|
|
averageConfidence: number
|
|
} {
|
|
const total = mappings.length
|
|
const mapped = mappings.filter((m) => m.targetAccount).length
|
|
const unmapped = total - mapped
|
|
|
|
const exact = mappings.filter((m) => m.matchType === 'exact').length
|
|
const name = mappings.filter((m) => m.matchType === 'name').length
|
|
const classMatch = mappings.filter((m) => m.matchType === 'class').length
|
|
const manual = mappings.filter((m) => m.matchType === 'manual').length
|
|
|
|
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.5).length
|
|
|
|
const confidenceSum = mappings
|
|
.filter((m) => m.targetAccount)
|
|
.reduce((sum, m) => sum + m.confidence, 0)
|
|
const averageConfidence = mapped > 0 ? confidenceSum / mapped : 0
|
|
|
|
return {
|
|
total,
|
|
mapped,
|
|
unmapped,
|
|
exact,
|
|
name,
|
|
class: classMatch,
|
|
manual,
|
|
lowConfidence,
|
|
averageConfidence,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply a user override to the mappings
|
|
*/
|
|
export function applyMappingOverride(
|
|
mappings: AccountMapping[],
|
|
sourceAccount: string,
|
|
targetAccount: string,
|
|
targetName: string
|
|
): AccountMapping[] {
|
|
return mappings.map((m) => {
|
|
if (m.sourceAccount === sourceAccount) {
|
|
return {
|
|
...m,
|
|
targetAccount,
|
|
targetName,
|
|
confidence: 1.0,
|
|
matchType: 'manual' as AccountMatchType,
|
|
isOverride: true,
|
|
}
|
|
}
|
|
return m
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Convert mappings to a lookup map for quick access during import
|
|
*/
|
|
export function mappingsToMap(mappings: AccountMapping[]): Map<string, string> {
|
|
const map = new Map<string, string>()
|
|
for (const mapping of mappings) {
|
|
if (mapping.targetAccount) {
|
|
map.set(mapping.sourceAccount, mapping.targetAccount)
|
|
}
|
|
}
|
|
return map
|
|
}
|