23664e79cb
* feat: multi-series SIE import, reusable FiscalYearSelector, library templates in picker - SIE import preserves each voucher's source series (B/C/I/V/...), essential for Fortnox migrations where series carry semantic meaning (kundfakturor, inbetalningar, etc.). Target numbering still goes through next_voucher_number per series; source (series, number) is stored in the migration mapping for BFNAR 2013:2 audit trail. - Execute route reads company_settings.default_voucher_series as the fallback for vouchers arriving without a series (SIE4I). - Extract shared FiscalYearSelector component; adopt in /reports and /bookkeeping. - Transaction TemplatePicker now surfaces user-created library templates (company + team scope) alongside the static registry, with a helper to convert simple library templates into the BookingTemplate shape. - Exclude 8999 "Årets resultat" from income statement financial section and monthly breakdown so year-end closing entries don't cancel the net result. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: skip Bokio SIE regression when fixtures are absent /dev_docs is gitignored (contains anonymised customer exports), so the integration test can't find its input files in CI. Gate the suite on fixture presence so it still runs locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address Greptile review feedback - convertLibraryToBookingTemplate: default entity_applicability to 'all' when the source template has no entity_type, so TemplatePicker doesn't silently hide it for companies with a set entity type. - FiscalYearSelector: fire onReady in the no-company early-return branch so consumers (e.g. ReportsPage) don't get stuck in a loading skeleton while the company context is still hydrating. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
5.5 KiB
TypeScript
168 lines
5.5 KiB
TypeScript
import type { BookingTemplateCategory, BookingTemplateLibrary, BookingTemplateLibraryLine, VatTreatment } from '@/types'
|
||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||
|
||
/**
|
||
* Prefix for library template ids when they are mapped into the
|
||
* static BookingTemplate shape used by the transaction picker.
|
||
*/
|
||
export const LIBRARY_TEMPLATE_PREFIX = 'library:'
|
||
export function isLibraryTemplateId(id: string): boolean { return id.startsWith(LIBRARY_TEMPLATE_PREFIX) }
|
||
|
||
/**
|
||
* Category labels in Swedish for UI display.
|
||
*/
|
||
export const TEMPLATE_CATEGORY_LABELS: Record<BookingTemplateCategory, string> = {
|
||
eu_trade: 'EU-handel',
|
||
tax_account: 'Skattekonto',
|
||
private_transfer: 'Egna transaktioner',
|
||
salary: 'Lön',
|
||
representation: 'Representation',
|
||
year_end: 'Bokslut',
|
||
vat: 'Moms',
|
||
financial: 'Bank & finans',
|
||
other: 'Övrigt',
|
||
}
|
||
|
||
/**
|
||
* Convert a template's line pattern + total amount into form lines
|
||
* ready for the JournalEntryForm.
|
||
*
|
||
* The algorithm:
|
||
* 1. VAT lines: amount = totalAmount × vat_rate / (1 + vat_rate)
|
||
* 2. Settlement lines: amount = totalAmount (the full payment)
|
||
* 3. Business lines: amount = totalAmount × ratio (cost/revenue net of VAT handled separately)
|
||
*
|
||
* For simple two-line templates (no VAT), the ratio is typically 1.0
|
||
* on both sides and totalAmount is used directly.
|
||
*/
|
||
export function applyTemplate(
|
||
lines: BookingTemplateLibraryLine[],
|
||
totalAmount: number,
|
||
): FormLine[] {
|
||
const result: FormLine[] = []
|
||
|
||
for (const line of lines) {
|
||
let amount = 0
|
||
|
||
if (line.type === 'vat' && line.vat_rate) {
|
||
// VAT calculated on the total inclusive amount
|
||
amount = Math.round(totalAmount * line.vat_rate / (1 + line.vat_rate) * 100) / 100
|
||
} else if (line.type === 'settlement') {
|
||
amount = Math.round(totalAmount * (line.ratio ?? 1) * 100) / 100
|
||
} else {
|
||
// Business lines — use ratio (default 1.0)
|
||
amount = Math.round(totalAmount * (line.ratio ?? 1) * 100) / 100
|
||
}
|
||
|
||
result.push({
|
||
account_number: line.account,
|
||
debit_amount: line.side === 'debit' ? amount.toFixed(2) : '',
|
||
credit_amount: line.side === 'credit' ? amount.toFixed(2) : '',
|
||
line_description: line.label,
|
||
})
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Scope label for displaying where a template comes from.
|
||
*/
|
||
export function getTemplateScope(template: {
|
||
is_system: boolean
|
||
team_id: string | null
|
||
company_id: string | null
|
||
}): 'system' | 'team' | 'company' {
|
||
if (template.is_system) return 'system'
|
||
if (template.team_id) return 'team'
|
||
return 'company'
|
||
}
|
||
|
||
export const SCOPE_LABELS: Record<ReturnType<typeof getTemplateScope>, string> = {
|
||
system: 'Standard',
|
||
team: 'Team',
|
||
company: 'Företag',
|
||
}
|
||
|
||
function vatRateToTreatment(rate: number): VatTreatment | null {
|
||
if (rate === 0.25) return 'standard_25'
|
||
if (rate === 0.12) return 'reduced_12'
|
||
if (rate === 0.06) return 'reduced_6'
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Convert a user-created library template to the BookingTemplate shape the
|
||
* transaction TemplatePicker consumes.
|
||
*
|
||
* Only simple shapes (one business line + one settlement line, optionally
|
||
* one VAT line) are returned — complex multi-account templates cannot be
|
||
* expressed as a single debit/credit pair and must be applied via the full
|
||
* journal entry form instead.
|
||
*
|
||
* The id is prefixed with "library:" so downstream code can recognise a
|
||
* library template and look it up through the library APIs rather than the
|
||
* static registry.
|
||
*/
|
||
export function convertLibraryToBookingTemplate(
|
||
lib: BookingTemplateLibrary,
|
||
): BookingTemplate | null {
|
||
if (!Array.isArray(lib.lines)) return null
|
||
|
||
const business = lib.lines.filter((l) => l.type === 'business')
|
||
const settlement = lib.lines.filter((l) => l.type === 'settlement')
|
||
const vat = lib.lines.filter((l) => l.type === 'vat')
|
||
|
||
if (business.length !== 1 || settlement.length !== 1) return null
|
||
if (business[0].side === settlement[0].side) return null
|
||
|
||
const debitLine = business[0].side === 'debit' ? business[0] : settlement[0]
|
||
const creditLine = business[0].side === 'credit' ? business[0] : settlement[0]
|
||
|
||
const direction: 'expense' | 'income' = business[0].side === 'debit' ? 'expense' : 'income'
|
||
|
||
let vatTreatment: VatTreatment | null = null
|
||
let vatRate = 0
|
||
if (vat.length > 0) {
|
||
const inputVat = vat.find((v) => v.side === 'debit' && v.vat_rate)
|
||
?? vat.find((v) => v.vat_rate)
|
||
if (inputVat?.vat_rate) {
|
||
const treatment = vatRateToTreatment(inputVat.vat_rate)
|
||
if (treatment) {
|
||
vatTreatment = treatment
|
||
vatRate = inputVat.vat_rate
|
||
}
|
||
}
|
||
// Reverse-charge is recognised by the presence of 2614/2624/2634 (fictitious output VAT)
|
||
if (vat.some((v) => v.account === '2614' || v.account === '2624' || v.account === '2634')) {
|
||
vatTreatment = 'reverse_charge'
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: `${LIBRARY_TEMPLATE_PREFIX}${lib.id}`,
|
||
name_sv: lib.name,
|
||
name_en: lib.name,
|
||
group: 'financial',
|
||
direction,
|
||
entity_applicability: lib.entity_type ?? 'all',
|
||
debit_account: debitLine.account,
|
||
credit_account: creditLine.account,
|
||
vat_treatment: vatTreatment,
|
||
vat_rate: vatRate,
|
||
deductibility: 'full',
|
||
special_rules_sv: lib.description || undefined,
|
||
mcc_codes: [],
|
||
keywords: [],
|
||
risk_level: 'NONE',
|
||
requires_review: false,
|
||
impact_score: 0,
|
||
auto_match_confidence: 0,
|
||
default_private: false,
|
||
fallback_category: direction === 'expense' ? 'expense_other' : 'income_services',
|
||
description_sv: lib.description || '',
|
||
common: false,
|
||
}
|
||
}
|