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>
162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
/**
|
|
* Expense warnings for non-deductible or partially deductible items
|
|
* Based on Swedish tax law and Kammarrätten rulings
|
|
*/
|
|
|
|
export interface ExpenseWarning {
|
|
category: string
|
|
warningLevel: 'info' | 'warning' | 'danger'
|
|
message: string
|
|
legalBasis?: string
|
|
}
|
|
|
|
const warningPatterns: {
|
|
pattern: RegExp
|
|
warning: ExpenseWarning
|
|
}[] = [
|
|
{
|
|
pattern: /kläder|clothes|mode|fashion|outfit/i,
|
|
warning: {
|
|
category: 'Kläder',
|
|
warningLevel: 'danger',
|
|
message: 'Kläder är normalt inte avdragsgilla, även om de används i arbetet',
|
|
legalBasis: 'RÅ 1988 ref. 35',
|
|
},
|
|
},
|
|
{
|
|
pattern: /kosmetika|smink|makeup|hudvård|skincare|beauty/i,
|
|
warning: {
|
|
category: 'Kosmetika',
|
|
warningLevel: 'warning',
|
|
message: 'Kosmetika är normalt inte avdragsgillt. Undantag kan gälla för professionella artister.',
|
|
legalBasis: 'Skatteverkets ställningstagande',
|
|
},
|
|
},
|
|
{
|
|
pattern: /gym|träning|fitness|sport/i,
|
|
warning: {
|
|
category: 'Träning',
|
|
warningLevel: 'danger',
|
|
message: 'Gymkort och träningsavgifter är inte avdragsgilla som personlig kostnad',
|
|
legalBasis: 'IL 9 kap 2§',
|
|
},
|
|
},
|
|
{
|
|
pattern: /frisör|hår|salon|barber/i,
|
|
warning: {
|
|
category: 'Frisör',
|
|
warningLevel: 'warning',
|
|
message: 'Frisörbesök är normalt privata kostnader och inte avdragsgilla',
|
|
},
|
|
},
|
|
{
|
|
pattern: /restaurang|lunch|middag|dinner|café|fika/i,
|
|
warning: {
|
|
category: 'Representation',
|
|
warningLevel: 'info',
|
|
message: 'Måltider kan vara avdragsgilla som representation med max 300 kr per person (exkl. moms)',
|
|
legalBasis: 'IL 16 kap 2§',
|
|
},
|
|
},
|
|
{
|
|
pattern: /resa|flyg|flight|tåg|train|hotel|hotell/i,
|
|
warning: {
|
|
category: 'Resor',
|
|
warningLevel: 'info',
|
|
message: 'Resor kan vara avdragsgilla om de är nödvändiga för verksamheten. Dokumentera syftet!',
|
|
},
|
|
},
|
|
{
|
|
pattern: /presenter|gift|gåva/i,
|
|
warning: {
|
|
category: 'Gåvor',
|
|
warningLevel: 'warning',
|
|
message: 'Reklamgåvor är avdragsgilla upp till 300 kr per mottagare. Representationsgåvor max 180 kr.',
|
|
legalBasis: 'IL 16 kap 2§',
|
|
},
|
|
},
|
|
{
|
|
pattern: /mobil|telefon|phone|iphone|samsung/i,
|
|
warning: {
|
|
category: 'Telefon',
|
|
warningLevel: 'info',
|
|
message: 'Arbetstelefon är avdragsgillt. Vid blandad användning, endast den yrkesmässiga delen.',
|
|
},
|
|
},
|
|
{
|
|
pattern: /dator|laptop|computer|mac|ipad/i,
|
|
warning: {
|
|
category: 'Dator',
|
|
warningLevel: 'info',
|
|
message: 'Datorer för yrkesmässig användning är avdragsgilla. Vid blandad användning ska fördelning göras.',
|
|
},
|
|
},
|
|
]
|
|
|
|
/**
|
|
* Check if an expense description triggers any warnings
|
|
*/
|
|
export function checkExpenseWarnings(description: string): ExpenseWarning[] {
|
|
const warnings: ExpenseWarning[] = []
|
|
|
|
for (const { pattern, warning } of warningPatterns) {
|
|
if (pattern.test(description)) {
|
|
warnings.push(warning)
|
|
}
|
|
}
|
|
|
|
return warnings
|
|
}
|
|
|
|
/**
|
|
* Get category suggestions based on description
|
|
*/
|
|
export function suggestCategory(description: string): string | null {
|
|
const categoryPatterns: { pattern: RegExp; category: string }[] = [
|
|
{ pattern: /spotify|netflix|adobe|software|app store/i, category: 'expense_software' },
|
|
{ pattern: /kamera|camera|ljud|mikrofon|ring light|studio/i, category: 'expense_equipment' },
|
|
{ pattern: /flyg|tåg|hotel|taxi|uber/i, category: 'expense_travel' },
|
|
{ pattern: /facebook ads|google ads|instagram|marknadsföring|marketing/i, category: 'expense_marketing' },
|
|
{ pattern: /revisor|advokat|konsult|accountant|lawyer/i, category: 'expense_professional_services' },
|
|
{ pattern: /kurs|utbildning|course|workshop/i, category: 'expense_education' },
|
|
{ pattern: /kontor|office|skriv|hyra/i, category: 'expense_office' },
|
|
{ pattern: /bankavgift|bankfee|monthly fee|kontoavgift|serviceavgift/i, category: 'expense_bank_fees' },
|
|
{ pattern: /kortavgift|card fee|annual fee/i, category: 'expense_card_fees' },
|
|
{ pattern: /valutaväxling|currency|exchange|FX fee/i, category: 'expense_currency_exchange' },
|
|
]
|
|
|
|
for (const { pattern, category } of categoryPatterns) {
|
|
if (pattern.test(description)) {
|
|
return category
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Get display name for category
|
|
*/
|
|
export function getCategoryDisplayName(category: string): string {
|
|
const names: Record<string, string> = {
|
|
income_services: 'Tjänster',
|
|
income_products: 'Produkter',
|
|
income_other: 'Övriga intäkter',
|
|
expense_equipment: 'Utrustning',
|
|
expense_software: 'Programvara',
|
|
expense_travel: 'Resor',
|
|
expense_office: 'Kontor',
|
|
expense_marketing: 'Marknadsföring',
|
|
expense_professional_services: 'Konsulter',
|
|
expense_education: 'Utbildning',
|
|
expense_bank_fees: 'Bankavgift',
|
|
expense_card_fees: 'Kortavgift',
|
|
expense_currency_exchange: 'Valutaväxling',
|
|
expense_other: 'Övriga kostnader',
|
|
private: 'Privat',
|
|
uncategorized: 'Ej bokförd',
|
|
}
|
|
|
|
return names[category] || category
|
|
}
|