refactor: consolidate extension system to general-only with manifest-driven architecture

- Remove all sector-specific extensions (construction, ecommerce, export,
  hotel, restaurant, tech) — only general-purpose extensions remain
- Move NE-bilaga and SRU export from extensions to core reports (lib/reports/)
- Move moms-box-mapping from extensions/export/shared to lib/vat/
- Replace per-extension API routes with catch-all dispatcher
  (app/api/extensions/ext/[...path]/route.ts)
- Add manifest.json for each extension with metadata, env vars, and deps
- Add api-routes.ts pattern for extension-defined API endpoints
- Add code generation scripts (generate-extension-registry, create-extension)
- Add extensions.config.json for opt-in extension loading
- Add extensions.schema.json for config validation
- Add email service interface with noop default (lib/email/service.ts)
- Add CI workflow (core-build.yml) to verify core builds with zero extensions
- Add migration 045: expand account_type CHECK for untaxed_reserves
- Update CLAUDE.md with comprehensive extension system documentation
- Update all report engines and bookkeeping services for new imports
- Clean up extensions.schema.json to only list existing extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-26 14:32:56 +01:00
co-authored by Claude Opus 4.6
parent 39e407644d
commit 03b569d708
289 changed files with 7325 additions and 27598 deletions
@@ -1,2 +0,0 @@
// Project Cost extension — Track costs per construction project
export const PROJECT_COST_EXTENSION_ID = 'construction/project-cost'
@@ -1,186 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateProjectStats,
calculateCategoryBreakdown,
filterByDateRange,
getBudgetStatus,
type CostEntry,
type RevenueEntry,
} from '../project-cost-calculator'
describe('calculateProjectStats', () => {
it('calculates basic project stats correctly', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 200000, date: '2025-03-01', category: 'materials' },
{ projectId: 'p1', amount: 100000, date: '2025-03-15', category: 'labor' },
]
const revenues: RevenueEntry[] = [
{ projectId: 'p1', amount: 500000, date: '2025-03-20' },
]
const stats = calculateProjectStats(costs, revenues, 400000)
expect(stats.totalCost).toBe(300000)
expect(stats.totalRevenue).toBe(500000)
// margin: (500000 - 300000) / 500000 * 100 = 40
expect(stats.margin).toBe(40)
// budgetUsed: 300000 / 400000 * 100 = 75
expect(stats.budgetUsed).toBe(75)
expect(stats.budgetStatus).toBe('ok')
})
it('returns margin 0 when revenue is zero', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 50000, date: '2025-01-10', category: 'materials' },
]
const revenues: RevenueEntry[] = []
const stats = calculateProjectStats(costs, revenues, 100000)
expect(stats.totalCost).toBe(50000)
expect(stats.totalRevenue).toBe(0)
expect(stats.margin).toBe(0)
})
it('returns negative margin when costs exceed revenue', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 150000, date: '2025-02-01', category: 'labor' },
]
const revenues: RevenueEntry[] = [
{ projectId: 'p1', amount: 100000, date: '2025-02-15' },
]
const stats = calculateProjectStats(costs, revenues, 200000)
// margin: (100000 - 150000) / 100000 * 100 = -50
expect(stats.margin).toBe(-50)
})
it('caps budgetUsed at 100 when costs exceed budget', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 210000, date: '2025-04-01', category: 'materials' },
]
const revenues: RevenueEntry[] = [
{ projectId: 'p1', amount: 300000, date: '2025-04-10' },
]
const stats = calculateProjectStats(costs, revenues, 200000)
// raw budgetUsed: 210000 / 200000 * 100 = 105, capped at 100
expect(stats.budgetUsed).toBe(100)
expect(stats.budgetStatus).toBe('danger')
})
it('returns zero stats for empty arrays', () => {
const stats = calculateProjectStats([], [], 100000)
expect(stats.totalCost).toBe(0)
expect(stats.totalRevenue).toBe(0)
expect(stats.margin).toBe(0)
expect(stats.budgetUsed).toBe(0)
expect(stats.budgetStatus).toBe('ok')
})
it('handles monetary rounding edge cases', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 33333.33, date: '2025-01-01', category: 'materials' },
{ projectId: 'p1', amount: 33333.33, date: '2025-01-02', category: 'labor' },
{ projectId: 'p1', amount: 33333.34, date: '2025-01-03', category: 'equipment' },
]
const revenues: RevenueEntry[] = [
{ projectId: 'p1', amount: 200000, date: '2025-01-15' },
]
const stats = calculateProjectStats(costs, revenues, 150000)
// totalCost: 33333.33 + 33333.33 + 33333.34 = 100000.00
expect(stats.totalCost).toBe(100000)
expect(stats.totalRevenue).toBe(200000)
// margin: (200000 - 100000) / 200000 * 100 = 50
expect(stats.margin).toBe(50)
})
})
describe('getBudgetStatus', () => {
it('returns ok when budget used is below 80%', () => {
expect(getBudgetStatus(0)).toBe('ok')
expect(getBudgetStatus(50)).toBe('ok')
expect(getBudgetStatus(79.99)).toBe('ok')
})
it('returns warning when budget used is between 80% and 99.99%', () => {
expect(getBudgetStatus(80)).toBe('warning')
expect(getBudgetStatus(85)).toBe('warning')
expect(getBudgetStatus(99.99)).toBe('warning')
})
it('returns danger when budget used is 100% or above', () => {
expect(getBudgetStatus(100)).toBe('danger')
expect(getBudgetStatus(105)).toBe('danger')
expect(getBudgetStatus(200)).toBe('danger')
})
})
describe('calculateCategoryBreakdown', () => {
it('breaks down costs by category with correct percentages', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 60000, date: '2025-01-01', category: 'materials' },
{ projectId: 'p1', amount: 30000, date: '2025-01-05', category: 'labor' },
{ projectId: 'p1', amount: 10000, date: '2025-01-10', category: 'equipment' },
]
const breakdown = calculateCategoryBreakdown(costs)
expect(breakdown).toHaveLength(3)
// Sorted by amount descending
expect(breakdown[0]).toEqual({ category: 'materials', amount: 60000, percent: 60 })
expect(breakdown[1]).toEqual({ category: 'labor', amount: 30000, percent: 30 })
expect(breakdown[2]).toEqual({ category: 'equipment', amount: 10000, percent: 10 })
})
it('returns single category as 100%', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 25000, date: '2025-01-01', category: 'labor' },
{ projectId: 'p1', amount: 75000, date: '2025-01-05', category: 'labor' },
]
const breakdown = calculateCategoryBreakdown(costs)
expect(breakdown).toHaveLength(1)
expect(breakdown[0]).toEqual({ category: 'labor', amount: 100000, percent: 100 })
})
it('returns empty array for empty costs', () => {
const breakdown = calculateCategoryBreakdown([])
expect(breakdown).toEqual([])
})
})
describe('filterByDateRange', () => {
const entries: CostEntry[] = [
{ projectId: 'p1', amount: 1000, date: '2025-01-15', category: 'materials' },
{ projectId: 'p1', amount: 2000, date: '2025-02-15', category: 'labor' },
{ projectId: 'p1', amount: 3000, date: '2025-03-15', category: 'equipment' },
]
it('includes entries within the date range', () => {
const filtered = filterByDateRange(entries, '2025-01-01', '2025-02-28')
expect(filtered).toHaveLength(2)
expect(filtered[0].date).toBe('2025-01-15')
expect(filtered[1].date).toBe('2025-02-15')
})
it('excludes entries outside the date range', () => {
const filtered = filterByDateRange(entries, '2025-04-01', '2025-04-30')
expect(filtered).toHaveLength(0)
})
it('includes entries on boundary dates', () => {
const filtered = filterByDateRange(entries, '2025-01-15', '2025-03-15')
expect(filtered).toHaveLength(3)
})
})
@@ -1,133 +0,0 @@
/**
* Pure calculation functions for the Project Cost extension.
*
* Tracks costs, revenues, margins, and budget usage per construction project.
* All monetary calculations use Math.round(x * 100) / 100 per project rules.
*/
export interface CostEntry {
projectId: string
amount: number
date: string
category: string
}
export interface RevenueEntry {
projectId: string
amount: number
date: string
}
export interface ProjectStats {
totalCost: number
totalRevenue: number
margin: number // (revenue - cost) / revenue * 100, or 0 if no revenue
budgetUsed: number // cost / budget * 100, capped at 100
budgetStatus: 'ok' | 'warning' | 'danger' // ok < 80%, warning >= 80%, danger >= 100%
}
export interface CategoryBreakdown {
category: string
amount: number
percent: number
}
/**
* Determine budget status based on percentage of budget used.
* - ok: less than 80%
* - warning: 80% to below 100%
* - danger: 100% or above
*/
export function getBudgetStatus(budgetUsedPct: number): 'ok' | 'warning' | 'danger' {
if (budgetUsedPct >= 100) return 'danger'
if (budgetUsedPct >= 80) return 'warning'
return 'ok'
}
/**
* Calculate project statistics from cost entries, revenue entries, and budget.
*
* - totalCost: sum of all cost entry amounts
* - totalRevenue: sum of all revenue entry amounts
* - margin: (revenue - cost) / revenue * 100, or 0 when revenue is zero
* - budgetUsed: cost / budget * 100, capped at 100; 0 if budget <= 0
* - budgetStatus: derived from budgetUsed via getBudgetStatus
*/
export function calculateProjectStats(
costs: CostEntry[],
revenues: RevenueEntry[],
budget: number
): ProjectStats {
const totalCost = Math.round(
costs.reduce((sum, c) => sum + c.amount, 0) * 100
) / 100
const totalRevenue = Math.round(
revenues.reduce((sum, r) => sum + r.amount, 0) * 100
) / 100
const margin = totalRevenue > 0
? Math.round(((totalRevenue - totalCost) / totalRevenue) * 10000) / 100
: 0
const rawBudgetUsed = budget > 0
? Math.round((totalCost / budget) * 10000) / 100
: 0
const budgetUsed = Math.min(rawBudgetUsed, 100)
const budgetStatus = getBudgetStatus(rawBudgetUsed)
return {
totalCost,
totalRevenue,
margin,
budgetUsed,
budgetStatus,
}
}
/**
* Calculate cost breakdown by category.
*
* Returns an array of categories sorted by amount descending,
* each with the category name, total amount, and percentage of total cost.
* Returns an empty array if there are no costs.
*/
export function calculateCategoryBreakdown(costs: CostEntry[]): CategoryBreakdown[] {
if (costs.length === 0) return []
const totalCost = Math.round(
costs.reduce((sum, c) => sum + c.amount, 0) * 100
) / 100
const categoryMap = new Map<string, number>()
for (const cost of costs) {
const current = categoryMap.get(cost.category) ?? 0
categoryMap.set(cost.category, current + cost.amount)
}
const breakdown: CategoryBreakdown[] = []
for (const [category, rawAmount] of categoryMap) {
const amount = Math.round(rawAmount * 100) / 100
const percent = totalCost > 0
? Math.round((amount / totalCost) * 10000) / 100
: 0
breakdown.push({ category, amount, percent })
}
breakdown.sort((a, b) => b.amount - a.amount)
return breakdown
}
/**
* Filter entries by date range (inclusive on both ends).
* Dates are compared as ISO date strings (YYYY-MM-DD).
*/
export function filterByDateRange<T extends { date: string }>(
entries: T[],
start: string,
end: string
): T[] {
return entries.filter(e => e.date >= start && e.date <= end)
}
@@ -1,2 +0,0 @@
// ROT Calculator extension — Calculate ROT tax deductions for construction work
export const ROT_CALCULATOR_EXTENSION_ID = 'construction/rot-calculator'
@@ -1,164 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateRotDeduction,
calculateCustomerQuotas,
filterJobsByYear,
generateRotCsvContent,
MAX_ROT_YEARLY,
ROT_RATE,
type RotJob,
} from '../rot-calculator'
describe('calculateRotDeduction', () => {
it('calculates 30% of labor as ROT deduction', () => {
const result = calculateRotDeduction(100000, 40000, 0)
// Labor = 100000 - 40000 = 60000
// ROT = 60000 * 0.30 = 18000
expect(result.labor).toBe(60000)
expect(result.rotDeduction).toBe(18000)
expect(result.customerPays).toBe(82000)
expect(result.remainingQuota).toBe(MAX_ROT_YEARLY - 18000)
})
it('caps ROT deduction at remaining yearly quota', () => {
// Customer has already used 45000 of 50000 quota
const result = calculateRotDeduction(100000, 40000, 45000)
// Labor = 60000, raw ROT = 18000, but only 5000 remaining
expect(result.rotDeduction).toBe(5000)
expect(result.customerPays).toBe(95000)
expect(result.remainingQuota).toBe(0)
})
it('returns zero deduction when labor is zero (material equals total)', () => {
const result = calculateRotDeduction(50000, 50000, 0)
expect(result.labor).toBe(0)
expect(result.rotDeduction).toBe(0)
expect(result.customerPays).toBe(50000)
expect(result.remainingQuota).toBe(MAX_ROT_YEARLY)
})
it('returns zero deduction when quota is already exhausted', () => {
const result = calculateRotDeduction(80000, 30000, MAX_ROT_YEARLY)
expect(result.labor).toBe(50000)
expect(result.rotDeduction).toBe(0)
expect(result.customerPays).toBe(80000)
expect(result.remainingQuota).toBe(0)
})
it('calculates customerPays as total minus rotDeduction', () => {
const result = calculateRotDeduction(75000, 25000, 0)
// Labor = 50000, ROT = 15000
expect(result.customerPays).toBe(75000 - result.rotDeduction)
expect(result.customerPays).toBe(60000)
})
it('handles monetary rounding correctly', () => {
// total=10001, material=3333 -> labor=6668
// ROT = 6668 * 0.30 = 2000.4
const result = calculateRotDeduction(10001, 3333, 0)
expect(result.labor).toBe(6668)
expect(result.rotDeduction).toBe(2000.4)
expect(result.customerPays).toBe(8000.6)
expect(result.remainingQuota).toBe(Math.round((MAX_ROT_YEARLY - 2000.4) * 100) / 100)
})
})
describe('calculateCustomerQuotas', () => {
const baseJob: RotJob = {
id: 'job-1',
customerId: 'cust-1',
total: 100000,
material: 40000,
labor: 60000,
rotDeduction: 18000,
date: '2025-03-15',
status: 'completed',
}
it('calculates used quota from completed jobs for a customer', () => {
const jobs: RotJob[] = [
{ ...baseJob, id: 'job-1', rotDeduction: 10000 },
{ ...baseJob, id: 'job-2', rotDeduction: 15000 },
]
const quotas = calculateCustomerQuotas(jobs, 2025)
expect(quotas.get('cust-1')).toBe(25000)
})
it('does not count draft jobs toward quota', () => {
const jobs: RotJob[] = [
{ ...baseJob, id: 'job-1', rotDeduction: 10000, status: 'completed' },
{ ...baseJob, id: 'job-2', rotDeduction: 15000, status: 'draft' },
]
const quotas = calculateCustomerQuotas(jobs, 2025)
expect(quotas.get('cust-1')).toBe(10000)
})
it('tracks multiple customers with different quotas', () => {
const jobs: RotJob[] = [
{ ...baseJob, id: 'job-1', customerId: 'cust-1', rotDeduction: 12000 },
{ ...baseJob, id: 'job-2', customerId: 'cust-2', rotDeduction: 8000 },
{ ...baseJob, id: 'job-3', customerId: 'cust-1', rotDeduction: 5000 },
]
const quotas = calculateCustomerQuotas(jobs, 2025)
expect(quotas.get('cust-1')).toBe(17000)
expect(quotas.get('cust-2')).toBe(8000)
})
})
describe('filterJobsByYear', () => {
it('returns only jobs from the specified year', () => {
const jobs: RotJob[] = [
{ id: '1', customerId: 'c1', total: 50000, material: 20000, labor: 30000, rotDeduction: 9000, date: '2025-06-01', status: 'completed' },
{ id: '2', customerId: 'c1', total: 60000, material: 25000, labor: 35000, rotDeduction: 10500, date: '2024-11-15', status: 'completed' },
{ id: '3', customerId: 'c1', total: 70000, material: 30000, labor: 40000, rotDeduction: 12000, date: '2025-12-31', status: 'completed' },
]
const filtered = filterJobsByYear(jobs, 2025)
expect(filtered).toHaveLength(2)
expect(filtered.map(j => j.id)).toEqual(['1', '3'])
})
})
describe('generateRotCsvContent', () => {
it('generates CSV with correct columns for completed jobs only', () => {
const jobs: RotJob[] = [
{ id: '1', customerId: 'c1', total: 80000, material: 30000, labor: 50000, rotDeduction: 15000, date: '2025-04-10', status: 'completed' },
{ id: '2', customerId: 'c2', total: 60000, material: 20000, labor: 40000, rotDeduction: 12000, date: '2025-05-20', status: 'draft' },
{ id: '3', customerId: 'c1', total: 40000, material: 15000, labor: 25000, rotDeduction: 7500, date: '2025-06-15', status: 'completed' },
]
const customers = new Map([
['c1', { name: 'Anna Svensson', personalNumber: '198501011234' }],
['c2', { name: 'Erik Johansson', personalNumber: '199003025678' }],
])
const csv = generateRotCsvContent(jobs, customers)
const lines = csv.split('\n')
expect(lines[0]).toBe('PersonalNumber,CustomerName,Labor,RotDeduction,Date')
// Draft job (id=2) should be excluded
expect(lines).toHaveLength(3)
expect(lines[1]).toBe('198501011234,Anna Svensson,50000,15000,2025-04-10')
expect(lines[2]).toBe('198501011234,Anna Svensson,25000,7500,2025-06-15')
})
})
describe('constants', () => {
it('has correct ROT rate and yearly maximum', () => {
expect(ROT_RATE).toBe(0.30)
expect(MAX_ROT_YEARLY).toBe(50000)
})
})
@@ -1,118 +0,0 @@
/**
* ROT (Repairs, Conversion, Extension) tax deduction calculator.
*
* ROT deduction allows Swedish homeowners to deduct 30% of labor costs
* for home renovation work, up to SEK 50 000 per person per year.
*
* All monetary values use Math.round(x * 100) / 100 to avoid
* floating-point precision issues.
*/
export const MAX_ROT_YEARLY = 50000
export const ROT_RATE = 0.30
export interface RotJob {
id: string
customerId: string
total: number
material: number
labor: number
rotDeduction: number
date: string
status: 'draft' | 'completed'
}
export interface RotCalculation {
labor: number
rotDeduction: number
customerPays: number
remainingQuota: number
}
export interface CustomerQuota {
customerId: string
usedQuota: number
remainingQuota: number
}
/**
* Calculate ROT deduction for a job given the customer's already-used quota.
*
* The deduction is 30% of labor (total - material), capped by the
* remaining yearly quota (MAX_ROT_YEARLY - usedQuota).
*/
export function calculateRotDeduction(
total: number,
material: number,
usedQuota: number
): RotCalculation {
const labor = Math.round((total - material) * 100) / 100
const remaining = Math.max(MAX_ROT_YEARLY - usedQuota, 0)
const rawDeduction = Math.round(labor * ROT_RATE * 100) / 100
const rotDeduction = Math.round(Math.min(rawDeduction, remaining) * 100) / 100
const customerPays = Math.round((total - rotDeduction) * 100) / 100
const remainingQuota = Math.round((remaining - rotDeduction) * 100) / 100
return {
labor,
rotDeduction,
customerPays,
remainingQuota,
}
}
/**
* Calculate per-customer used quota for a given year.
* Only completed jobs count toward the quota.
*/
export function calculateCustomerQuotas(
jobs: RotJob[],
year: number
): Map<string, number> {
const yearJobs = filterJobsByYear(jobs, year).filter(
j => j.status === 'completed'
)
const quotas = new Map<string, number>()
for (const job of yearJobs) {
const current = quotas.get(job.customerId) ?? 0
quotas.set(
job.customerId,
Math.round((current + job.rotDeduction) * 100) / 100
)
}
return quotas
}
/**
* Filter jobs whose date falls within the specified year.
*/
export function filterJobsByYear(jobs: RotJob[], year: number): RotJob[] {
const yearStr = String(year)
return jobs.filter(j => j.date.startsWith(yearStr))
}
/**
* Generate CSV content for Skatteverket ROT deduction reporting.
* Only completed jobs are included.
*
* Columns: PersonalNumber, CustomerName, Labor, RotDeduction, Date
*/
export function generateRotCsvContent(
jobs: RotJob[],
customers: Map<string, { name: string; personalNumber: string }>
): string {
const header = 'PersonalNumber,CustomerName,Labor,RotDeduction,Date'
const completedJobs = jobs.filter(j => j.status === 'completed')
const rows = completedJobs.map(job => {
const customer = customers.get(job.customerId)
const personalNumber = customer?.personalNumber ?? ''
const customerName = customer?.name ?? ''
return `${personalNumber},${customerName},${job.labor},${job.rotDeduction},${job.date}`
})
return [header, ...rows].join('\n')
}
@@ -1,2 +0,0 @@
// Multichannel Revenue extension — Track revenue across sales channels
export const MULTICHANNEL_REVENUE_EXTENSION_ID = 'ecommerce/multichannel-revenue'
@@ -1,211 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateChannelSummary,
calculateOverallAOV,
findDuplicate,
calculateChannelGrowth,
buildMonthlyComparison,
filterEntriesByRange,
type RevenueEntry,
} from '../multichannel-calculator'
describe('calculateChannelSummary', () => {
it('groups entries by channel and calculates totals with AOV', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
{ id: '2', month: '2025-02', channel: 'Shopify', revenue: 60000, orderCount: 250 },
{ id: '3', month: '2025-01', channel: 'Amazon', revenue: 30000, orderCount: 150 },
{ id: '4', month: '2025-02', channel: 'Amazon', revenue: 35000, orderCount: 180 },
]
const result = calculateChannelSummary(entries)
expect(result).toHaveLength(2)
const shopify = result.find(s => s.channel === 'Shopify')!
expect(shopify.totalRevenue).toBe(110000)
expect(shopify.totalOrders).toBe(450)
// 110000 / 450 = 244.444... -> 244.44
expect(shopify.aov).toBe(244.44)
const amazon = result.find(s => s.channel === 'Amazon')!
expect(amazon.totalRevenue).toBe(65000)
expect(amazon.totalOrders).toBe(330)
// 65000 / 330 = 196.969696... -> 196.97
expect(amazon.aov).toBe(196.97)
})
it('returns AOV of 0 when a channel has zero orders', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Wholesale', revenue: 0, orderCount: 0 },
]
const result = calculateChannelSummary(entries)
expect(result).toHaveLength(1)
expect(result[0].aov).toBe(0)
expect(result[0].totalOrders).toBe(0)
})
})
describe('calculateOverallAOV', () => {
it('calculates overall AOV across all channels', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
{ id: '2', month: '2025-01', channel: 'Amazon', revenue: 30000, orderCount: 100 },
]
// Total revenue: 80000, Total orders: 300
// 80000 / 300 = 266.666... -> 266.67
const result = calculateOverallAOV(entries)
expect(result).toBe(266.67)
})
it('returns 0 for empty entries', () => {
const result = calculateOverallAOV([])
expect(result).toBe(0)
})
})
describe('findDuplicate', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
{ id: '2', month: '2025-02', channel: 'Amazon', revenue: 30000, orderCount: 150 },
]
it('finds an existing entry for same month and channel', () => {
const dup = findDuplicate(entries, '2025-01', 'Shopify')
expect(dup).toBeDefined()
expect(dup!.id).toBe('1')
})
it('returns undefined when no duplicate exists', () => {
const dup = findDuplicate(entries, '2025-03', 'Shopify')
expect(dup).toBeUndefined()
})
})
describe('calculateChannelGrowth', () => {
it('calculates positive growth percentage', () => {
const previous: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 40000, orderCount: 100 },
]
const current: RevenueEntry[] = [
{ id: '2', month: '2025-02', channel: 'Shopify', revenue: 50000, orderCount: 120 },
]
const result = calculateChannelGrowth(current, previous)
expect(result).toHaveLength(1)
expect(result[0].channel).toBe('Shopify')
expect(result[0].currentRevenue).toBe(50000)
expect(result[0].previousRevenue).toBe(40000)
// (50000 - 40000) / 40000 * 100 = 25
expect(result[0].growthPct).toBe(25)
})
it('calculates negative growth percentage', () => {
const previous: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Amazon', revenue: 60000, orderCount: 200 },
]
const current: RevenueEntry[] = [
{ id: '2', month: '2025-02', channel: 'Amazon', revenue: 45000, orderCount: 150 },
]
const result = calculateChannelGrowth(current, previous)
expect(result).toHaveLength(1)
expect(result[0].growthPct).toBe(-25)
})
it('returns null growthPct for new channel with no previous data', () => {
const previous: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 40000, orderCount: 100 },
]
const current: RevenueEntry[] = [
{ id: '2', month: '2025-02', channel: 'Shopify', revenue: 50000, orderCount: 120 },
{ id: '3', month: '2025-02', channel: 'TikTok Shop', revenue: 10000, orderCount: 50 },
]
const result = calculateChannelGrowth(current, previous)
const tiktok = result.find(r => r.channel === 'TikTok Shop')!
expect(tiktok.currentRevenue).toBe(10000)
expect(tiktok.previousRevenue).toBe(0)
expect(tiktok.growthPct).toBeNull()
})
})
describe('buildMonthlyComparison', () => {
it('builds comparison table with 2 channels across 3 months', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
{ id: '2', month: '2025-01', channel: 'Amazon', revenue: 30000, orderCount: 100 },
{ id: '3', month: '2025-02', channel: 'Shopify', revenue: 55000, orderCount: 220 },
{ id: '4', month: '2025-02', channel: 'Amazon', revenue: 32000, orderCount: 110 },
{ id: '5', month: '2025-03', channel: 'Shopify', revenue: 60000, orderCount: 240 },
{ id: '6', month: '2025-03', channel: 'Amazon', revenue: 35000, orderCount: 130 },
]
const channelNames = ['Shopify', 'Amazon']
const result = buildMonthlyComparison(entries, channelNames)
expect(result).toHaveLength(3)
// Months should be sorted ascending
expect(result[0].month).toBe('2025-01')
expect(result[1].month).toBe('2025-02')
expect(result[2].month).toBe('2025-03')
// January
expect(result[0].channels['Shopify']).toBe(50000)
expect(result[0].channels['Amazon']).toBe(30000)
expect(result[0].total).toBe(80000)
// March
expect(result[2].channels['Shopify']).toBe(60000)
expect(result[2].channels['Amazon']).toBe(35000)
expect(result[2].total).toBe(95000)
})
})
describe('filterEntriesByRange', () => {
it('filters entries by YYYY-MM date range inclusive', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2024-11', channel: 'Shopify', revenue: 40000, orderCount: 100 },
{ id: '2', month: '2024-12', channel: 'Shopify', revenue: 45000, orderCount: 110 },
{ id: '3', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 120 },
{ id: '4', month: '2025-02', channel: 'Shopify', revenue: 55000, orderCount: 130 },
{ id: '5', month: '2025-03', channel: 'Shopify', revenue: 60000, orderCount: 140 },
]
const result = filterEntriesByRange(entries, '2024-12', '2025-02')
expect(result).toHaveLength(3)
expect(result.map(e => e.month)).toEqual(['2024-12', '2025-01', '2025-02'])
})
})
describe('monetary rounding', () => {
it('applies Math.round(x * 100) / 100 for all monetary outputs', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 33333.33, orderCount: 7 },
{ id: '2', month: '2025-01', channel: 'Amazon', revenue: 16666.67, orderCount: 3 },
]
// Overall AOV: 50000 / 10 = 5000
expect(calculateOverallAOV(entries)).toBe(5000)
// Per-channel AOV
const summaries = calculateChannelSummary(entries)
const shopify = summaries.find(s => s.channel === 'Shopify')!
// 33333.33 / 7 = 4761.9042857... -> 4761.9
expect(shopify.aov).toBe(4761.9)
expect(shopify.totalRevenue).toBe(33333.33)
const amazon = summaries.find(s => s.channel === 'Amazon')!
// 16666.67 / 3 = 5555.5566... -> 5555.56
expect(amazon.aov).toBe(5555.56)
expect(amazon.totalRevenue).toBe(16666.67)
})
})
@@ -1,200 +0,0 @@
/**
* Pure calculation functions for the Multichannel Revenue extension.
*
* Aggregates revenue data across sales channels (e.g. Shopify, Amazon,
* physical store) and computes per-channel summaries, growth rates,
* and monthly comparison tables.
*
* All monetary values use Math.round(x * 100) / 100 for precision.
*/
export interface RevenueEntry {
id: string
month: string // YYYY-MM
channel: string
revenue: number
orderCount: number
}
export interface ChannelSummary {
channel: string
totalRevenue: number
totalOrders: number
aov: number // revenue / orders, or 0
}
export interface ChannelGrowth {
channel: string
currentRevenue: number
previousRevenue: number
growthPct: number | null // null if no previous data
}
export interface MonthlyComparison {
month: string
channels: Record<string, number> // channel name -> revenue
total: number
}
/**
* Calculate per-channel summary with AOV (Average Order Value).
* Groups entries by channel and computes totals.
*/
export function calculateChannelSummary(entries: RevenueEntry[]): ChannelSummary[] {
const byChannel = new Map<string, { revenue: number; orders: number }>()
for (const entry of entries) {
const existing = byChannel.get(entry.channel)
if (existing) {
existing.revenue += entry.revenue
existing.orders += entry.orderCount
} else {
byChannel.set(entry.channel, {
revenue: entry.revenue,
orders: entry.orderCount,
})
}
}
const summaries: ChannelSummary[] = []
for (const [channel, data] of byChannel) {
const totalRevenue = Math.round(data.revenue * 100) / 100
const totalOrders = data.orders
const aov = totalOrders > 0
? Math.round((totalRevenue / totalOrders) * 100) / 100
: 0
summaries.push({ channel, totalRevenue, totalOrders, aov })
}
return summaries
}
/**
* Calculate overall AOV across all channels.
* Returns 0 if there are no orders.
*/
export function calculateOverallAOV(entries: RevenueEntry[]): number {
const totalRevenue = entries.reduce((sum, e) => sum + e.revenue, 0)
const totalOrders = entries.reduce((sum, e) => sum + e.orderCount, 0)
if (totalOrders === 0) return 0
return Math.round((totalRevenue / totalOrders) * 100) / 100
}
/**
* Check for duplicate entry (same month + channel).
* Returns the first matching entry or undefined.
*/
export function findDuplicate(
entries: RevenueEntry[],
month: string,
channel: string
): RevenueEntry | undefined {
return entries.find(e => e.month === month && e.channel === channel)
}
/**
* Calculate channel growth between current and previous period entries.
* A channel present only in currentEntries gets growthPct: null.
*/
export function calculateChannelGrowth(
currentEntries: RevenueEntry[],
previousEntries: RevenueEntry[]
): ChannelGrowth[] {
const currentByChannel = new Map<string, number>()
for (const entry of currentEntries) {
currentByChannel.set(
entry.channel,
(currentByChannel.get(entry.channel) ?? 0) + entry.revenue
)
}
const previousByChannel = new Map<string, number>()
for (const entry of previousEntries) {
previousByChannel.set(
entry.channel,
(previousByChannel.get(entry.channel) ?? 0) + entry.revenue
)
}
const results: ChannelGrowth[] = []
for (const [channel, currentRevenue] of currentByChannel) {
const rounded = Math.round(currentRevenue * 100) / 100
const previousRevenue = previousByChannel.get(channel)
if (previousRevenue === undefined || previousRevenue === 0) {
results.push({
channel,
currentRevenue: rounded,
previousRevenue: 0,
growthPct: null,
})
} else {
const prevRounded = Math.round(previousRevenue * 100) / 100
const growthPct = Math.round(
((rounded - prevRounded) / prevRounded) * 10000
) / 100
results.push({
channel,
currentRevenue: rounded,
previousRevenue: prevRounded,
growthPct,
})
}
}
return results
}
/**
* Build monthly comparison table.
* Each row contains per-channel revenue and a total for that month.
* Months are sorted in ascending order.
*/
export function buildMonthlyComparison(
entries: RevenueEntry[],
channelNames: string[]
): MonthlyComparison[] {
const monthMap = new Map<string, Record<string, number>>()
for (const entry of entries) {
if (!monthMap.has(entry.month)) {
const channels: Record<string, number> = {}
for (const name of channelNames) {
channels[name] = 0
}
monthMap.set(entry.month, channels)
}
const channels = monthMap.get(entry.month)!
channels[entry.channel] = Math.round(
((channels[entry.channel] ?? 0) + entry.revenue) * 100
) / 100
}
const months = Array.from(monthMap.keys()).sort()
return months.map(month => {
const channels = monthMap.get(month)!
const total = Math.round(
Object.values(channels).reduce((sum, v) => sum + v, 0) * 100
) / 100
return { month, channels, total }
})
}
/**
* Filter entries by date range using the month field (YYYY-MM).
* Inclusive on both ends: startDate <= month <= endDate.
*/
export function filterEntriesByRange(
entries: RevenueEntry[],
startDate: string,
endDate: string
): RevenueEntry[] {
return entries.filter(e => e.month >= startDate && e.month <= endDate)
}
@@ -1,2 +0,0 @@
// Shopify Import extension — Import orders and transactions from Shopify
export const SHOPIFY_IMPORT_EXTENSION_ID = 'ecommerce/shopify-import'
@@ -1,167 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateShopifyStats,
calculatePaymentBreakdown,
calculateFulfillmentBreakdown,
calculateMonthlyVat,
filterOrdersByDateRange,
parseCsvNumber,
type ShopifyOrder,
} from '../shopify-calculator'
function makeOrder(overrides: Partial<ShopifyOrder> = {}): ShopifyOrder {
return {
id: 'order-1',
createdAt: '2025-03-15T10:00:00Z',
total: 1250,
subtotal: 1000,
shipping: 0,
taxes: 250,
paymentMethod: 'Shopify Payments',
fulfillmentStatus: 'fulfilled',
...overrides,
}
}
describe('calculateShopifyStats', () => {
it('calculates order count, revenue, and AOV correctly', () => {
const orders = [
makeOrder({ id: '1', total: 500, subtotal: 400, taxes: 100 }),
makeOrder({ id: '2', total: 1500, subtotal: 1200, taxes: 300 }),
]
const stats = calculateShopifyStats(orders)
expect(stats.orderCount).toBe(2)
expect(stats.totalRevenue).toBe(2000)
expect(stats.aov).toBe(1000)
expect(stats.totalTaxes).toBe(400)
expect(stats.totalSubtotal).toBe(1600)
})
it('returns all zeros for empty orders', () => {
const stats = calculateShopifyStats([])
expect(stats.orderCount).toBe(0)
expect(stats.totalRevenue).toBe(0)
expect(stats.aov).toBe(0)
expect(stats.totalTaxes).toBe(0)
expect(stats.totalSubtotal).toBe(0)
expect(stats.avgVatRate).toBe(0)
})
it('calculates average VAT rate correctly', () => {
const orders = [
makeOrder({ id: '1', subtotal: 800, taxes: 200 }),
makeOrder({ id: '2', subtotal: 1200, taxes: 300 }),
]
const stats = calculateShopifyStats(orders)
// Total taxes: 500, total subtotal: 2000 -> 500/2000*100 = 25
expect(stats.avgVatRate).toBe(25)
})
it('returns avgVatRate 0 when subtotal is zero', () => {
const orders = [
makeOrder({ id: '1', total: 0, subtotal: 0, taxes: 0 }),
]
const stats = calculateShopifyStats(orders)
expect(stats.avgVatRate).toBe(0)
})
})
describe('calculatePaymentBreakdown', () => {
it('groups orders by payment method with count and total', () => {
const orders = [
makeOrder({ id: '1', total: 500, paymentMethod: 'Klarna' }),
makeOrder({ id: '2', total: 800, paymentMethod: 'Shopify Payments' }),
makeOrder({ id: '3', total: 300, paymentMethod: 'Klarna' }),
makeOrder({ id: '4', total: 200, paymentMethod: 'PayPal' }),
]
const breakdown = calculatePaymentBreakdown(orders)
expect(breakdown).toEqual([
{ method: 'Klarna', count: 2, total: 800 },
{ method: 'PayPal', count: 1, total: 200 },
{ method: 'Shopify Payments', count: 1, total: 800 },
])
})
})
describe('calculateFulfillmentBreakdown', () => {
it('groups orders by fulfillment status', () => {
const orders = [
makeOrder({ id: '1', fulfillmentStatus: 'fulfilled' }),
makeOrder({ id: '2', fulfillmentStatus: 'unfulfilled' }),
makeOrder({ id: '3', fulfillmentStatus: 'fulfilled' }),
makeOrder({ id: '4', fulfillmentStatus: 'partial' }),
makeOrder({ id: '5', fulfillmentStatus: 'unfulfilled' }),
]
const breakdown = calculateFulfillmentBreakdown(orders)
expect(breakdown).toEqual([
{ status: 'fulfilled', count: 2 },
{ status: 'partial', count: 1 },
{ status: 'unfulfilled', count: 2 },
])
})
})
describe('calculateMonthlyVat', () => {
it('calculates monthly VAT breakdown sorted chronologically', () => {
const orders = [
makeOrder({ id: '1', createdAt: '2025-01-10T10:00:00Z', subtotal: 800, taxes: 200 }),
makeOrder({ id: '2', createdAt: '2025-01-20T10:00:00Z', subtotal: 400, taxes: 100 }),
makeOrder({ id: '3', createdAt: '2025-03-05T10:00:00Z', subtotal: 1000, taxes: 250 }),
]
const monthly = calculateMonthlyVat(orders)
expect(monthly).toEqual([
{ month: '2025-01', taxes: 300, subtotal: 1200, vatRate: 25 },
{ month: '2025-03', taxes: 250, subtotal: 1000, vatRate: 25 },
])
})
})
describe('filterOrdersByDateRange', () => {
it('includes orders within the date range and excludes those outside', () => {
const orders = [
makeOrder({ id: '1', createdAt: '2025-01-01T08:00:00Z' }),
makeOrder({ id: '2', createdAt: '2025-01-15T12:00:00Z' }),
makeOrder({ id: '3', createdAt: '2025-01-31T23:59:59Z' }),
makeOrder({ id: '4', createdAt: '2025-02-01T00:00:00Z' }),
makeOrder({ id: '5', createdAt: '2024-12-31T23:59:59Z' }),
]
const filtered = filterOrdersByDateRange(orders, '2025-01-01', '2025-01-31')
expect(filtered.map(o => o.id)).toEqual(['1', '2', '3'])
})
})
describe('parseCsvNumber', () => {
it('parses Swedish-style number with spaces and comma decimal', () => {
expect(parseCsvNumber('1 234,56')).toBe(1234.56)
})
it('parses standard dot-decimal notation', () => {
expect(parseCsvNumber('1234.56')).toBe(1234.56)
})
it('returns 0 for empty string', () => {
expect(parseCsvNumber('')).toBe(0)
})
it('rounds monetary values to two decimal places', () => {
// 1234.567 should round to 1234.57
expect(parseCsvNumber('1234.567')).toBe(1234.57)
// 99.999 should round to 100.00
expect(parseCsvNumber('99.999')).toBe(100)
})
})
@@ -1,199 +0,0 @@
/**
* Pure calculation functions for Shopify order import data.
*
* Calculates aggregate statistics, payment breakdowns, fulfillment
* breakdowns, and monthly VAT summaries from imported Shopify orders.
*
* All monetary values use Math.round(x * 100) / 100 for precision.
*/
export interface ShopifyOrder {
id: string
createdAt: string
total: number
subtotal: number
shipping: number
taxes: number
paymentMethod: string
fulfillmentStatus: string
}
export interface ShopifyStats {
orderCount: number
totalRevenue: number
aov: number
totalTaxes: number
totalSubtotal: number
avgVatRate: number
}
export interface PaymentBreakdown {
method: string
count: number
total: number
}
export interface FulfillmentBreakdown {
status: string
count: number
}
export interface MonthlyVat {
month: string
taxes: number
subtotal: number
vatRate: number
}
/**
* Calculate overall statistics from a list of Shopify orders.
* AOV is totalRevenue / orderCount, or 0 if no orders.
* avgVatRate is (totalTaxes / totalSubtotal) * 100, or 0 if subtotal is zero.
*/
export function calculateShopifyStats(orders: ShopifyOrder[]): ShopifyStats {
if (orders.length === 0) {
return {
orderCount: 0,
totalRevenue: 0,
aov: 0,
totalTaxes: 0,
totalSubtotal: 0,
avgVatRate: 0,
}
}
const totalRevenue = orders.reduce((sum, o) => sum + o.total, 0)
const totalTaxes = orders.reduce((sum, o) => sum + o.taxes, 0)
const totalSubtotal = orders.reduce((sum, o) => sum + o.subtotal, 0)
const aov = Math.round((totalRevenue / orders.length) * 100) / 100
const avgVatRate = totalSubtotal > 0
? Math.round((totalTaxes / totalSubtotal) * 10000) / 100
: 0
return {
orderCount: orders.length,
totalRevenue: Math.round(totalRevenue * 100) / 100,
aov,
totalTaxes: Math.round(totalTaxes * 100) / 100,
totalSubtotal: Math.round(totalSubtotal * 100) / 100,
avgVatRate,
}
}
/**
* Group orders by payment method and calculate count and total per method.
* Returns sorted alphabetically by method name.
*/
export function calculatePaymentBreakdown(orders: ShopifyOrder[]): PaymentBreakdown[] {
const map = new Map<string, { count: number; total: number }>()
for (const order of orders) {
const entry = map.get(order.paymentMethod)
if (entry) {
entry.count += 1
entry.total += order.total
} else {
map.set(order.paymentMethod, { count: 1, total: order.total })
}
}
return Array.from(map.entries())
.map(([method, { count, total }]) => ({
method,
count,
total: Math.round(total * 100) / 100,
}))
.sort((a, b) => a.method.localeCompare(b.method))
}
/**
* Group orders by fulfillment status and calculate count per status.
* Returns sorted alphabetically by status.
*/
export function calculateFulfillmentBreakdown(orders: ShopifyOrder[]): FulfillmentBreakdown[] {
const map = new Map<string, number>()
for (const order of orders) {
map.set(order.fulfillmentStatus, (map.get(order.fulfillmentStatus) ?? 0) + 1)
}
return Array.from(map.entries())
.map(([status, count]) => ({ status, count }))
.sort((a, b) => a.status.localeCompare(b.status))
}
/**
* Calculate monthly VAT breakdown from orders.
* Groups by YYYY-MM derived from createdAt, calculates taxes, subtotal,
* and effective VAT rate per month. Returns sorted chronologically.
*/
export function calculateMonthlyVat(orders: ShopifyOrder[]): MonthlyVat[] {
const map = new Map<string, { taxes: number; subtotal: number }>()
for (const order of orders) {
const month = order.createdAt.slice(0, 7) // YYYY-MM
const entry = map.get(month)
if (entry) {
entry.taxes += order.taxes
entry.subtotal += order.subtotal
} else {
map.set(month, { taxes: order.taxes, subtotal: order.subtotal })
}
}
return Array.from(map.entries())
.map(([month, { taxes, subtotal }]) => ({
month,
taxes: Math.round(taxes * 100) / 100,
subtotal: Math.round(subtotal * 100) / 100,
vatRate: subtotal > 0
? Math.round((taxes / subtotal) * 10000) / 100
: 0,
}))
.sort((a, b) => a.month.localeCompare(b.month))
}
/**
* Filter orders whose createdAt falls within [from, to] inclusive.
* Comparison is done on the date portion (YYYY-MM-DD) of createdAt.
*/
export function filterOrdersByDateRange(
orders: ShopifyOrder[],
from: string,
to: string
): ShopifyOrder[] {
return orders.filter(o => {
const date = o.createdAt.slice(0, 10)
return date >= from && date <= to
})
}
/**
* Parse a numeric value from a CSV string.
* Handles Swedish-style formatting with spaces as thousands separators
* and commas as decimal separators (e.g. "1 234,56" -> 1234.56).
* Also handles standard dot-decimal notation ("1234.56" -> 1234.56).
* Returns 0 for empty or unparseable strings.
*/
export function parseCsvNumber(value: string): number {
if (!value || value.trim() === '') {
return 0
}
// Remove whitespace (thousands separators)
let cleaned = value.replace(/\s/g, '')
// If the string contains a comma, treat it as a decimal separator
// (Swedish CSV convention)
if (cleaned.includes(',')) {
cleaned = cleaned.replace(',', '.')
}
const result = parseFloat(cleaned)
if (isNaN(result)) {
return 0
}
return Math.round(result * 100) / 100
}
@@ -1,18 +0,0 @@
import type { Extension } from '@/lib/extensions/types'
/**
* Multi-Currency Receivables Manager / Valutafordringar Extension
*
* Dashboard showing foreign currency exposure from open receivables.
* Calculates unrealized FX gains/losses using current Riksbanken rates
* compared to booking rates.
*
* Shows realized FX gains (account 3960) and losses (account 7960) per
* period, with monthly trend analysis. Provides period-end revaluation
* preview for informational purposes (does not create journal entries).
*/
export const currencyReceivablesExtension: Extension = {
id: 'currency-receivables',
name: 'Valutafordringar',
version: '1.0.0',
}
@@ -1,465 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
generateReceivablesReport,
type ReceivableInvoice,
type ReceivableCustomer,
type GLLine,
type ExchangeRateInfo,
type ReceivablesOptions,
} from '../receivables-engine'
// ── Fixtures ────────────────────────────────────────────────
function makeInvoice(overrides: Partial<ReceivableInvoice> = {}): ReceivableInvoice {
return {
id: 'inv-1',
invoice_number: 'F2026-001',
invoice_date: '2026-01-15',
due_date: '2026-02-15',
status: 'sent',
currency: 'EUR',
total: 10000,
total_sek: 114200,
exchange_rate: 11.42,
customer_id: 'cust-1',
...overrides,
}
}
function makeCustomer(overrides: Partial<ReceivableCustomer> = {}): ReceivableCustomer {
return {
id: 'cust-1',
name: 'Müller GmbH',
country: 'DE',
...overrides,
}
}
function makeRate(overrides: Partial<ExchangeRateInfo> = {}): ExchangeRateInfo {
return {
currency: 'EUR',
rate: 11.50,
date: '2026-03-15',
...overrides,
}
}
function makeGLLine(overrides: Partial<GLLine> = {}): GLLine {
return {
account_number: '3960',
debit: 0,
credit: 0,
entry_date: '2026-01-31',
...overrides,
}
}
const BASE_OPTIONS: ReceivablesOptions = {
invoices: [],
customers: [],
currentRates: [makeRate()],
realizedFXLines: [],
referenceDate: '2026-03-15',
year: 2026,
}
// ── Basic report tests ──────────────────────────────────────
describe('generateReceivablesReport', () => {
it('generates empty report when no invoices', () => {
const report = generateReceivablesReport(BASE_OPTIONS)
expect(report.receivables).toHaveLength(0)
expect(report.exposureByCurrency).toHaveLength(0)
expect(report.totals.receivableCount).toBe(0)
expect(report.totals.currencyCount).toBe(0)
expect(report.totals.totalUnrealizedGainLoss).toBe(0)
expect(report.referenceDate).toBe('2026-03-15')
})
it('calculates unrealized gain when rate increases', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({
total: 10000,
total_sek: 114200, // Booked at 11.42
exchange_rate: 11.42,
})],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })], // Current rate higher
})
expect(report.receivables).toHaveLength(1)
const r = report.receivables[0]
expect(r.bookedSekAmount).toBe(114200)
expect(r.currentSekAmount).toBe(115000) // 10000 × 11.50
expect(r.unrealizedGainLoss).toBe(800) // 115000 - 114200
expect(r.bookedRate).toBe(11.42)
expect(r.currentRate).toBe(11.50)
})
it('calculates unrealized loss when rate decreases', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({
total: 10000,
total_sek: 114200,
exchange_rate: 11.42,
})],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.00 })], // Current rate lower
})
const r = report.receivables[0]
expect(r.currentSekAmount).toBe(110000)
expect(r.unrealizedGainLoss).toBe(-4200) // 110000 - 114200
})
it('aggregates exposure by currency', () => {
const invoices = [
makeInvoice({ id: 'inv-1', total: 5000, total_sek: 57100, exchange_rate: 11.42 }),
makeInvoice({ id: 'inv-2', total: 3000, total_sek: 34260, exchange_rate: 11.42 }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
expect(report.exposureByCurrency).toHaveLength(1)
const exp = report.exposureByCurrency[0]
expect(exp.currency).toBe('EUR')
expect(exp.totalForeignAmount).toBe(8000)
expect(exp.bookedSekValue).toBe(91360) // 57100 + 34260
expect(exp.currentSekValue).toBe(92000) // 8000 × 11.50
expect(exp.invoiceCount).toBe(2)
expect(exp.averageBookedRate).toBe(11.42) // 91360 / 8000
expect(exp.currentRate).toBe(11.50)
})
it('handles multiple currencies', () => {
const invoices = [
makeInvoice({ id: 'inv-1', currency: 'EUR', total: 5000, total_sek: 57100 }),
makeInvoice({ id: 'inv-2', currency: 'USD', total: 8000, total_sek: 84000, customer_id: 'cust-2' }),
]
const customers = [
makeCustomer({ id: 'cust-1' }),
makeCustomer({ id: 'cust-2', name: 'Smith Inc', country: 'US' }),
]
const rates = [
makeRate({ currency: 'EUR', rate: 11.50 }),
makeRate({ currency: 'USD', rate: 10.60 }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices,
customers,
currentRates: rates,
})
expect(report.exposureByCurrency).toHaveLength(2)
expect(report.totals.currencyCount).toBe(2)
expect(report.totals.receivableCount).toBe(2)
const eurExp = report.exposureByCurrency.find(e => e.currency === 'EUR')!
const usdExp = report.exposureByCurrency.find(e => e.currency === 'USD')!
expect(eurExp.currentSekValue).toBe(57500) // 5000 × 11.50
expect(usdExp.currentSekValue).toBe(84800) // 8000 × 10.60
})
// ── Filtering ─────────────────────────────────────────────
it('excludes paid invoices', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ status: 'paid' })],
customers: [makeCustomer()],
})
expect(report.receivables).toHaveLength(0)
})
it('excludes SEK invoices', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ currency: 'SEK', total: 10000, total_sek: 10000 })],
customers: [makeCustomer()],
})
expect(report.receivables).toHaveLength(0)
})
it('excludes zero-amount invoices', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 0 })],
customers: [makeCustomer()],
})
expect(report.receivables).toHaveLength(0)
})
it('includes overdue invoices', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ status: 'overdue' })],
customers: [makeCustomer()],
})
expect(report.receivables).toHaveLength(1)
})
// ── Booked value fallback ─────────────────────────────────
it('falls back to total × exchange_rate when total_sek is null', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 5000, total_sek: null, exchange_rate: 11.42 })],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
const r = report.receivables[0]
expect(r.bookedSekAmount).toBe(57100) // 5000 × 11.42
expect(r.currentSekAmount).toBe(57500) // 5000 × 11.50
expect(r.unrealizedGainLoss).toBe(400)
})
it('uses 0 booked value when no exchange rate info', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 5000, total_sek: null, exchange_rate: null })],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
const r = report.receivables[0]
expect(r.bookedSekAmount).toBe(0)
expect(r.currentSekAmount).toBe(57500)
})
// ── Days outstanding ──────────────────────────────────────
it('calculates days outstanding correctly', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ invoice_date: '2026-03-01' })],
customers: [makeCustomer()],
referenceDate: '2026-03-15',
})
expect(report.receivables[0].daysOutstanding).toBe(14)
})
// ── Realized FX from GL ───────────────────────────────────
it('calculates realized FX gains from account 3960 credits', () => {
const lines: GLLine[] = [
makeGLLine({ account_number: '3960', credit: 5000, debit: 0, entry_date: '2026-01-31' }),
makeGLLine({ account_number: '3960', credit: 3000, debit: 0, entry_date: '2026-02-28' }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
realizedFXLines: lines,
})
expect(report.realizedGainLoss.gains).toBe(8000)
expect(report.realizedGainLoss.losses).toBe(0)
expect(report.realizedGainLoss.net).toBe(8000)
})
it('calculates realized FX losses from account 7960 debits', () => {
const lines: GLLine[] = [
makeGLLine({ account_number: '7960', debit: 3200, credit: 0, entry_date: '2026-01-31' }),
makeGLLine({ account_number: '7960', debit: 1800, credit: 0, entry_date: '2026-02-28' }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
realizedFXLines: lines,
})
expect(report.realizedGainLoss.gains).toBe(0)
expect(report.realizedGainLoss.losses).toBe(5000)
expect(report.realizedGainLoss.net).toBe(-5000)
})
it('calculates net realized FX with both gains and losses', () => {
const lines: GLLine[] = [
makeGLLine({ account_number: '3960', credit: 8500, debit: 0, entry_date: '2026-01-31' }),
makeGLLine({ account_number: '7960', debit: 3200, credit: 0, entry_date: '2026-01-31' }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
realizedFXLines: lines,
})
expect(report.realizedGainLoss.gains).toBe(8500)
expect(report.realizedGainLoss.losses).toBe(3200)
expect(report.realizedGainLoss.net).toBe(5300)
})
// ── Monthly trend ─────────────────────────────────────────
it('generates monthly trend with all 12 months', () => {
const report = generateReceivablesReport(BASE_OPTIONS)
expect(report.monthlyTrend).toHaveLength(12)
expect(report.monthlyTrend[0].month).toBe('2026-01')
expect(report.monthlyTrend[11].month).toBe('2026-12')
})
it('distributes realized FX to correct months', () => {
const lines: GLLine[] = [
makeGLLine({ account_number: '3960', credit: 5000, entry_date: '2026-01-15' }),
makeGLLine({ account_number: '7960', debit: 2000, entry_date: '2026-01-20' }),
makeGLLine({ account_number: '3960', credit: 8000, entry_date: '2026-03-10' }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
realizedFXLines: lines,
})
const jan = report.monthlyTrend.find(t => t.month === '2026-01')!
expect(jan.realizedGains).toBe(5000)
expect(jan.realizedLosses).toBe(2000)
expect(jan.netRealized).toBe(3000)
const feb = report.monthlyTrend.find(t => t.month === '2026-02')!
expect(feb.realizedGains).toBe(0)
expect(feb.realizedLosses).toBe(0)
expect(feb.netRealized).toBe(0)
const mar = report.monthlyTrend.find(t => t.month === '2026-03')!
expect(mar.realizedGains).toBe(8000)
expect(mar.realizedLosses).toBe(0)
expect(mar.netRealized).toBe(8000)
})
// ── Revaluation preview ───────────────────────────────────
it('shows revaluation preview with net gain', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 10000, total_sek: 114200 })],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
expect(report.revalPreview.totalUnrealizedGainLoss).toBe(800)
expect(report.revalPreview.gains).toBe(800)
expect(report.revalPreview.losses).toBe(0)
})
it('shows revaluation preview with net loss', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 10000, total_sek: 114200 })],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.00 })],
})
expect(report.revalPreview.totalUnrealizedGainLoss).toBe(-4200)
expect(report.revalPreview.gains).toBe(0)
expect(report.revalPreview.losses).toBe(4200)
})
// ── Customer data ─────────────────────────────────────────
it('uses customer name and country from customer lookup', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ name: 'Acme Corp', country: 'FI' })],
})
expect(report.receivables[0].customerName).toBe('Acme Corp')
expect(report.receivables[0].customerCountry).toBe('FI')
})
it('falls back to unknown customer when not found', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ customer_id: 'unknown' })],
customers: [makeCustomer()], // cust-1, not 'unknown'
})
expect(report.receivables[0].customerName).toBe('Okänd kund')
})
// ── Totals ────────────────────────────────────────────────
it('calculates correct totals across all receivables', () => {
const invoices = [
makeInvoice({ id: 'inv-1', currency: 'EUR', total: 5000, total_sek: 57100 }),
makeInvoice({ id: 'inv-2', currency: 'USD', total: 3000, total_sek: 31500, customer_id: 'cust-2' }),
]
const customers = [
makeCustomer({ id: 'cust-1' }),
makeCustomer({ id: 'cust-2', name: 'Smith Inc', country: 'US' }),
]
const rates = [
makeRate({ currency: 'EUR', rate: 11.50 }),
makeRate({ currency: 'USD', rate: 10.60 }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices,
customers,
currentRates: rates,
})
// EUR: 5000 × 11.50 = 57500, USD: 3000 × 10.60 = 31800
expect(report.totals.bookedSekValue).toBe(88600) // 57100 + 31500
expect(report.totals.currentSekValue).toBe(89300) // 57500 + 31800
expect(report.totals.totalUnrealizedGainLoss).toBe(700) // 89300 - 88600
expect(report.totals.receivableCount).toBe(2)
expect(report.totals.currencyCount).toBe(2)
})
// ── Period info ───────────────────────────────────────────
it('includes year and exchange rate info', () => {
const rates = [
makeRate({ currency: 'EUR', rate: 11.50 }),
makeRate({ currency: 'USD', rate: 10.60 }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
currentRates: rates,
})
expect(report.realizedGainLoss.year).toBe(2026)
expect(report.exchangeRates).toHaveLength(2)
expect(report.exchangeRates.find(r => r.currency === 'EUR')?.rate).toBe(11.50)
})
// ── Sorting ───────────────────────────────────────────────
it('sorts receivables by absolute unrealized gain/loss descending', () => {
const invoices = [
makeInvoice({ id: 'inv-1', total: 1000, total_sek: 11420 }), // gain: 80
makeInvoice({ id: 'inv-2', total: 5000, total_sek: 57100, customer_id: 'cust-1' }), // gain: 400
makeInvoice({ id: 'inv-3', total: 2000, total_sek: 22840, customer_id: 'cust-1' }), // gain: 160
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
expect(report.receivables[0].foreignAmount).toBe(5000) // largest gain
expect(report.receivables[1].foreignAmount).toBe(2000)
expect(report.receivables[2].foreignAmount).toBe(1000) // smallest gain
})
})
@@ -1,359 +0,0 @@
/**
* Multi-Currency Receivables Engine
*
* Pure-function engine that calculates foreign currency exposure from
* open receivables, unrealized FX gains/losses vs. current Riksbanken
* rates, and realized FX gains/losses from GL accounts 3960/7960.
*
* This extension is READ-ONLY — it does not create journal entries.
* The revaluation preview is informational only.
*/
// ── Types ──────────────────────────────────────────────────────
/** Minimal invoice data needed by the engine */
export interface ReceivableInvoice {
id: string
invoice_number: string
invoice_date: string
due_date: string
status: string
currency: string
total: number
total_sek: number | null
exchange_rate: number | null
customer_id: string
}
/** Minimal customer data */
export interface ReceivableCustomer {
id: string
name: string
country: string
}
/** GL line for realized FX */
export interface GLLine {
account_number: string
debit: number
credit: number
entry_date: string
}
/** Current exchange rate */
export interface ExchangeRateInfo {
currency: string
rate: number
date: string
}
/** Engine options */
export interface ReceivablesOptions {
/** Open (unpaid) foreign-currency invoices */
invoices: ReceivableInvoice[]
/** Customers for those invoices */
customers: ReceivableCustomer[]
/** Current exchange rates from Riksbanken */
currentRates: ExchangeRateInfo[]
/** GL lines on accounts 3960 and 7960 for the selected year */
realizedFXLines: GLLine[]
/** Reference date (for days outstanding calculation) */
referenceDate: string
/** Selected year for realized FX */
year: number
}
// ── Output types ───────────────────────────────────────────────
export interface CurrencyExposure {
currency: string
totalForeignAmount: number
bookedSekValue: number
currentSekValue: number
unrealizedGainLoss: number
invoiceCount: number
averageBookedRate: number
currentRate: number
}
export interface ForeignReceivable {
invoiceId: string
invoiceNumber: string
customerName: string
customerCountry: string
currency: string
foreignAmount: number
bookedSekAmount: number
bookedRate: number
currentSekAmount: number
currentRate: number
unrealizedGainLoss: number
invoiceDate: string
dueDate: string
daysOutstanding: number
}
export interface MonthlyFXTrend {
month: string // 'YYYY-MM'
realizedGains: number
realizedLosses: number
netRealized: number
}
export interface RevalPreview {
totalUnrealizedGainLoss: number
gains: number // Positive: amount for account 3969
losses: number // Positive: amount for account 7969
}
export interface ReceivablesReport {
referenceDate: string
exchangeRates: ExchangeRateInfo[]
exposureByCurrency: CurrencyExposure[]
receivables: ForeignReceivable[]
realizedGainLoss: {
year: number
gains: number
losses: number
net: number
}
monthlyTrend: MonthlyFXTrend[]
revalPreview: RevalPreview
totals: {
bookedSekValue: number
currentSekValue: number
totalUnrealizedGainLoss: number
receivableCount: number
currencyCount: number
}
}
// ── Constants ──────────────────────────────────────────────────
const ACCOUNT_FX_GAINS = '3960'
const ACCOUNT_FX_LOSSES = '7960'
const OPEN_STATUSES = ['sent', 'overdue']
// ── Engine ─────────────────────────────────────────────────────
export function generateReceivablesReport(options: ReceivablesOptions): ReceivablesReport {
const { invoices, customers, currentRates, realizedFXLines, referenceDate, year } = options
const rateMap = new Map<string, ExchangeRateInfo>()
for (const r of currentRates) {
rateMap.set(r.currency.toUpperCase(), r)
}
const customerMap = new Map<string, ReceivableCustomer>()
for (const c of customers) {
customerMap.set(c.id, c)
}
// Filter to open foreign-currency invoices
const foreignInvoices = invoices.filter(inv =>
OPEN_STATUSES.includes(inv.status) &&
inv.currency !== 'SEK' &&
inv.total > 0
)
// Build receivable details
const receivables: ForeignReceivable[] = []
for (const inv of foreignInvoices) {
const customer = customerMap.get(inv.customer_id)
const rateInfo = rateMap.get(inv.currency.toUpperCase())
const currentRate = rateInfo?.rate ?? 0
const bookedSekAmount = getBookedSekAmount(inv)
const bookedRate = inv.total > 0 ? bookedSekAmount / inv.total : 0
const currentSekAmount = round2(inv.total * currentRate)
const unrealizedGainLoss = round2(currentSekAmount - bookedSekAmount)
const daysOutstanding = daysBetween(inv.invoice_date, referenceDate)
receivables.push({
invoiceId: inv.id,
invoiceNumber: inv.invoice_number,
customerName: customer?.name ?? 'Okänd kund',
customerCountry: customer?.country ?? '',
currency: inv.currency,
foreignAmount: inv.total,
bookedSekAmount,
bookedRate: round4(bookedRate),
currentSekAmount,
currentRate: round4(currentRate),
unrealizedGainLoss,
invoiceDate: inv.invoice_date,
dueDate: inv.due_date,
daysOutstanding,
})
}
// Sort by unrealized gain/loss (largest absolute first)
receivables.sort((a, b) => Math.abs(b.unrealizedGainLoss) - Math.abs(a.unrealizedGainLoss))
// Aggregate by currency
const exposureMap = new Map<string, {
totalForeign: number
bookedSek: number
currentSek: number
count: number
}>()
for (const r of receivables) {
const existing = exposureMap.get(r.currency) || { totalForeign: 0, bookedSek: 0, currentSek: 0, count: 0 }
existing.totalForeign += r.foreignAmount
existing.bookedSek += r.bookedSekAmount
existing.currentSek += r.currentSekAmount
existing.count += 1
exposureMap.set(r.currency, existing)
}
const exposureByCurrency: CurrencyExposure[] = []
for (const [currency, agg] of exposureMap.entries()) {
const rateInfo = rateMap.get(currency.toUpperCase())
exposureByCurrency.push({
currency,
totalForeignAmount: round2(agg.totalForeign),
bookedSekValue: round2(agg.bookedSek),
currentSekValue: round2(agg.currentSek),
unrealizedGainLoss: round2(agg.currentSek - agg.bookedSek),
invoiceCount: agg.count,
averageBookedRate: agg.totalForeign > 0 ? round4(agg.bookedSek / agg.totalForeign) : 0,
currentRate: rateInfo?.rate ?? 0,
})
}
// Sort exposures by absolute unrealized gain/loss descending
exposureByCurrency.sort((a, b) => Math.abs(b.unrealizedGainLoss) - Math.abs(a.unrealizedGainLoss))
// Calculate realized FX from GL
const { gains, losses } = calculateRealizedFX(realizedFXLines)
// Monthly trend
const monthlyTrend = calculateMonthlyTrend(realizedFXLines, year)
// Revaluation preview
const totalBooked = receivables.reduce((sum, r) => sum + r.bookedSekAmount, 0)
const totalCurrent = receivables.reduce((sum, r) => sum + r.currentSekAmount, 0)
const totalUnrealized = round2(totalCurrent - totalBooked)
const revalPreview: RevalPreview = {
totalUnrealizedGainLoss: totalUnrealized,
gains: round2(Math.max(0, totalUnrealized)),
losses: round2(Math.abs(Math.min(0, totalUnrealized))),
}
const currencies = new Set(receivables.map(r => r.currency))
return {
referenceDate,
exchangeRates: currentRates,
exposureByCurrency,
receivables,
realizedGainLoss: {
year,
gains: round2(gains),
losses: round2(losses),
net: round2(gains - losses),
},
monthlyTrend,
revalPreview,
totals: {
bookedSekValue: round2(totalBooked),
currentSekValue: round2(totalCurrent),
totalUnrealizedGainLoss: totalUnrealized,
receivableCount: receivables.length,
currencyCount: currencies.size,
},
}
}
// ── Helpers ────────────────────────────────────────────────────
function getBookedSekAmount(inv: ReceivableInvoice): number {
// Prefer the explicitly stored SEK amount
if (inv.total_sek !== null && inv.total_sek !== undefined) {
return round2(inv.total_sek)
}
// Fallback: total × exchange_rate at invoice date
if (inv.exchange_rate !== null && inv.exchange_rate !== undefined) {
return round2(inv.total * inv.exchange_rate)
}
// No rate info — return 0 (will show as warning in UI)
return 0
}
function calculateRealizedFX(lines: GLLine[]): { gains: number; losses: number } {
let gains = 0
let losses = 0
for (const line of lines) {
if (line.account_number === ACCOUNT_FX_GAINS) {
// FX gains are booked as credits on 3960
gains += line.credit - line.debit
} else if (line.account_number === ACCOUNT_FX_LOSSES) {
// FX losses are booked as debits on 7960
losses += line.debit - line.credit
}
}
return { gains: Math.max(0, gains), losses: Math.max(0, losses) }
}
function calculateMonthlyTrend(lines: GLLine[], year: number): MonthlyFXTrend[] {
const monthData = new Map<string, { gains: number; losses: number }>()
// Initialize all 12 months
for (let m = 1; m <= 12; m++) {
const key = `${year}-${String(m).padStart(2, '0')}`
monthData.set(key, { gains: 0, losses: 0 })
}
for (const line of lines) {
const monthKey = line.entry_date.substring(0, 7) // 'YYYY-MM'
const data = monthData.get(monthKey)
if (!data) continue
if (line.account_number === ACCOUNT_FX_GAINS) {
data.gains += line.credit - line.debit
} else if (line.account_number === ACCOUNT_FX_LOSSES) {
data.losses += line.debit - line.credit
}
}
const trend: MonthlyFXTrend[] = []
for (const [month, data] of monthData.entries()) {
const gains = Math.max(0, round2(data.gains))
const losses = Math.max(0, round2(data.losses))
trend.push({
month,
realizedGains: gains,
realizedLosses: losses,
netRealized: round2(gains - losses),
})
}
// Sort chronologically
trend.sort((a, b) => a.month.localeCompare(b.month))
return trend
}
function daysBetween(dateStr: string, refDateStr: string): number {
const d1 = new Date(dateStr)
const d2 = new Date(refDateStr)
const diffMs = d2.getTime() - d1.getTime()
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)))
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
function round4(n: number): number {
return Math.round(n * 10000) / 10000
}
-18
View File
@@ -1,18 +0,0 @@
import type { Extension } from '@/lib/extensions/types'
/**
* EU Sales List / Periodisk Sammanställning Extension
*
* Generates the mandatory EC Sales List (periodisk sammanställning) report
* for Skatteverket. Aggregates intra-community B2B sales by customer VAT
* number, separating goods (box 35) from services (box 39).
*
* Outputs downloadable CSV/XML files for upload to Skatteverket's e-service.
* Validates customer VAT numbers via VIES and cross-checks against
* momsdeklaration box totals.
*/
export const euSalesListExtension: Extension = {
id: 'eu-sales-list',
name: 'Periodisk sammanställning',
version: '1.0.0',
}
@@ -1,140 +0,0 @@
import { describe, it, expect } from 'vitest'
import { generateCSV, generateCSVFilename } from '../csv-generator'
import type { ECSalesListReport } from '../eu-sales-list-engine'
function makeReport(overrides: Partial<ECSalesListReport> = {}): ECSalesListReport {
return {
period: { year: 2026, quarter: 1 },
filingType: 'quarterly',
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
lines: [
{
customerVatNumber: 'DE123456789',
customerName: 'Acme GmbH',
customerCountry: 'DE',
customerId: 'cust-1',
goodsAmount: 50000,
servicesAmount: 30000,
triangulationAmount: 0,
invoiceCount: 3,
},
],
totals: { goods: 50000, services: 30000, triangulation: 0, total: 80000 },
warnings: [],
crossCheck: null,
invoiceCount: 3,
customerCount: 1,
...overrides,
}
}
describe('generateCSV', () => {
it('starts with UTF-8 BOM', () => {
const csv = generateCSV(makeReport())
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
it('uses semicolons as delimiter', () => {
const csv = generateCSV(makeReport())
const headerLine = csv.split('\r\n')[0].replace('\uFEFF', '')
expect(headerLine).toContain(';')
expect(headerLine).not.toContain(',')
})
it('has correct header columns', () => {
const csv = generateCSV(makeReport())
const header = csv.split('\r\n')[0].replace('\uFEFF', '')
expect(header).toBe('Land;VAT-nummer;Varuförsäljning (SEK);Tjänsteförsäljning (SEK);Trepartshandel (SEK)')
})
it('includes customer data rows', () => {
const csv = generateCSV(makeReport())
const lines = csv.split('\r\n')
expect(lines[1]).toBe('DE;DE123456789;50000;30000;0')
})
it('rounds amounts to whole SEK', () => {
const report = makeReport({
lines: [{
customerVatNumber: 'DE123',
customerName: 'Test',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 12345.67,
servicesAmount: 89012.34,
triangulationAmount: 0,
invoiceCount: 1,
}],
totals: { goods: 12345.67, services: 89012.34, triangulation: 0, total: 101358.01 },
})
const csv = generateCSV(report)
const dataLine = csv.split('\r\n')[1]
expect(dataLine).toBe('DE;DE123;12346;89012;0')
})
it('includes summary row', () => {
const csv = generateCSV(makeReport())
const lines = csv.split('\r\n')
const summaryIndex = lines.findIndex(l => l.startsWith('Summa'))
expect(summaryIndex).toBeGreaterThan(0)
expect(lines[summaryIndex]).toBe('Summa;;50000;30000;0')
})
it('uses CRLF line endings', () => {
const csv = generateCSV(makeReport())
expect(csv).toContain('\r\n')
})
it('handles multiple customers', () => {
const report = makeReport({
lines: [
{
customerVatNumber: 'DE111',
customerName: 'A',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 10000,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 1,
},
{
customerVatNumber: 'FR222',
customerName: 'B',
customerCountry: 'FR',
customerId: 'c-2',
goodsAmount: 0,
servicesAmount: 20000,
triangulationAmount: 0,
invoiceCount: 2,
},
],
})
const csv = generateCSV(report)
const lines = csv.split('\r\n')
expect(lines[1]).toBe('DE;DE111;10000;0;0')
expect(lines[2]).toBe('FR;FR222;0;20000;0')
})
})
describe('generateCSVFilename', () => {
it('generates quarterly filename', () => {
const filename = generateCSVFilename(makeReport())
expect(filename).toBe('PS_SE556677889901_2026-Q1.csv')
})
it('generates monthly filename', () => {
const report = makeReport({ period: { year: 2026, month: 3 }, filingType: 'monthly' })
const filename = generateCSVFilename(report)
expect(filename).toBe('PS_SE556677889901_2026-03.csv')
})
it('strips spaces from VAT number', () => {
const report = makeReport({ reporterVatNumber: 'SE 5566 7788 9901' })
const filename = generateCSVFilename(report)
expect(filename).toBe('PS_SE556677889901_2026-Q1.csv')
})
})
@@ -1,559 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
generateECSalesListReport,
getMonthPeriod,
getQuarterPeriod,
getFilingDeadline,
daysUntilDeadline,
type ECSalesListInvoice,
type ECSalesListCustomer,
type GLAccountTotal,
} from '../eu-sales-list-engine'
// ── Test fixtures ─────────────────────────────────────────────
function makeInvoice(overrides: Partial<ECSalesListInvoice> = {}): ECSalesListInvoice {
return {
id: 'inv-1',
invoice_number: 'F2026-001',
invoice_date: '2026-01-15',
status: 'sent',
currency: 'EUR',
total: 10000,
total_sek: 112000,
subtotal: 10000,
subtotal_sek: 112000,
vat_treatment: 'reverse_charge',
moms_ruta: '35',
document_type: 'invoice',
credited_invoice_id: null,
customer_id: 'cust-1',
...overrides,
}
}
function makeCustomer(overrides: Partial<ECSalesListCustomer> = {}): ECSalesListCustomer {
return {
id: 'cust-1',
name: 'Acme GmbH',
country: 'DE',
customer_type: 'eu_business',
vat_number: 'DE123456789',
vat_number_validated: true,
...overrides,
}
}
const BASE_OPTIONS = {
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
year: 2026,
month: 1 as number | undefined,
quarter: undefined as number | undefined,
}
// ── Report generation tests ───────────────────────────────────
describe('generateECSalesListReport', () => {
it('generates empty report when no invoices', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [],
customers: [],
})
expect(report.lines).toHaveLength(0)
expect(report.totals.goods).toBe(0)
expect(report.totals.services).toBe(0)
expect(report.totals.total).toBe(0)
expect(report.invoiceCount).toBe(0)
expect(report.customerCount).toBe(0)
expect(report.warnings).toHaveLength(0)
})
it('aggregates goods invoice correctly (moms_ruta 35)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: '35', subtotal_sek: 50000 })],
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(1)
expect(report.lines[0].goodsAmount).toBe(50000)
expect(report.lines[0].servicesAmount).toBe(0)
expect(report.totals.goods).toBe(50000)
expect(report.invoiceCount).toBe(1)
})
it('aggregates service invoice correctly (moms_ruta 39)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: '39', subtotal_sek: 30000 })],
customers: [makeCustomer()],
})
expect(report.lines[0].servicesAmount).toBe(30000)
expect(report.lines[0].goodsAmount).toBe(0)
expect(report.totals.services).toBe(30000)
})
it('aggregates triangulation invoice correctly (moms_ruta 38)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: '38', subtotal_sek: 20000 })],
customers: [makeCustomer()],
})
expect(report.lines[0].triangulationAmount).toBe(20000)
expect(report.totals.triangulation).toBe(20000)
})
it('defaults to services when moms_ruta is null', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: null, subtotal_sek: 15000 })],
customers: [makeCustomer()],
})
expect(report.lines[0].servicesAmount).toBe(15000)
expect(report.lines[0].goodsAmount).toBe(0)
})
it('groups multiple invoices by customer VAT number', () => {
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 10000, moms_ruta: '35' }),
makeInvoice({ id: 'inv-2', subtotal_sek: 20000, moms_ruta: '35' }),
makeInvoice({ id: 'inv-3', subtotal_sek: 5000, moms_ruta: '39' }),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(1)
expect(report.lines[0].goodsAmount).toBe(30000)
expect(report.lines[0].servicesAmount).toBe(5000)
expect(report.lines[0].invoiceCount).toBe(3)
})
it('separates different customers into different lines', () => {
const invoices = [
makeInvoice({ id: 'inv-1', customer_id: 'cust-1', subtotal_sek: 10000 }),
makeInvoice({ id: 'inv-2', customer_id: 'cust-2', subtotal_sek: 20000 }),
]
const customers = [
makeCustomer({ id: 'cust-1', vat_number: 'DE111111111', country: 'DE' }),
makeCustomer({ id: 'cust-2', vat_number: 'FR222222222', country: 'FR', name: 'Fromage SARL' }),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers,
})
expect(report.lines).toHaveLength(2)
expect(report.customerCount).toBe(2)
// Sorted by country, so DE comes before FR
expect(report.lines[0].customerCountry).toBe('DE')
expect(report.lines[1].customerCountry).toBe('FR')
})
it('handles credit notes (subtracts from customer total)', () => {
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 50000, moms_ruta: '35' }),
makeInvoice({
id: 'inv-2',
subtotal_sek: 10000,
moms_ruta: '35',
credited_invoice_id: 'inv-1',
}),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
})
expect(report.lines[0].goodsAmount).toBe(40000) // 50000 - 10000
expect(report.invoiceCount).toBe(2)
})
it('uses subtotal (not total) for amounts', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 80000, total_sek: 100000, moms_ruta: '35' })],
customers: [makeCustomer()],
})
expect(report.lines[0].goodsAmount).toBe(80000)
})
it('falls back to subtotal when subtotal_sek is null (SEK invoices)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: null, subtotal: 45000, moms_ruta: '35' })],
customers: [makeCustomer()],
})
expect(report.lines[0].goodsAmount).toBe(45000)
})
// ── Filtering tests ──────────────────────────────────────
it('excludes draft invoices', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ status: 'draft' })],
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(0)
expect(report.invoiceCount).toBe(0)
})
it('excludes non-reverse-charge invoices', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ vat_treatment: 'standard_25' })],
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(0)
})
it('includes paid and overdue invoices', () => {
const invoices = [
makeInvoice({ id: 'inv-1', status: 'paid', subtotal_sek: 10000 }),
makeInvoice({ id: 'inv-2', status: 'overdue', subtotal_sek: 20000 }),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
})
expect(report.invoiceCount).toBe(2)
})
it('excludes proforma documents without credit note link', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ document_type: 'proforma', credited_invoice_id: null })],
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(0)
})
it('includes credit notes even with non-invoice document type', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({
document_type: 'credit_note',
credited_invoice_id: 'inv-original',
subtotal_sek: 5000,
moms_ruta: '35',
})],
customers: [makeCustomer()],
})
// Credit note creates a line with negative amount
expect(report.lines).toHaveLength(1)
expect(report.lines[0].goodsAmount).toBe(-5000)
})
// ── Warning tests ────────────────────────────────────────
it('warns on missing VAT number (error severity)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ vat_number: null })],
})
expect(report.warnings).toHaveLength(1)
expect(report.warnings[0].type).toBe('missing_vat_number')
expect(report.warnings[0].severity).toBe('error')
// Invoice is excluded from lines when VAT number missing
expect(report.lines).toHaveLength(0)
})
it('warns on unvalidated VAT number (warning severity)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ vat_number_validated: false })],
})
const unvalidatedWarnings = report.warnings.filter(w => w.type === 'unvalidated_vat_number')
expect(unvalidatedWarnings).toHaveLength(1)
expect(unvalidatedWarnings[0].severity).toBe('warning')
// Invoice is still included in report
expect(report.lines).toHaveLength(1)
})
it('warns on non-EU country', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ country: 'US' })],
})
expect(report.warnings).toHaveLength(1)
expect(report.warnings[0].type).toBe('non_eu_country')
expect(report.lines).toHaveLength(0)
})
it('excludes Sweden (SE) as non-intra-community', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ country: 'SE' })],
})
expect(report.warnings).toHaveLength(1)
expect(report.warnings[0].type).toBe('non_eu_country')
expect(report.lines).toHaveLength(0)
})
// ── Cross-check tests ────────────────────────────────────
it('cross-check passes when GL matches report totals', () => {
const glTotals: GLAccountTotal[] = [
{ account_number: '3108', credit: 50000 },
{ account_number: '3308', credit: 30000 },
]
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 50000, moms_ruta: '35' }),
makeInvoice({ id: 'inv-2', subtotal_sek: 30000, moms_ruta: '39' }),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
glTotals,
})
expect(report.crossCheck).not.toBeNull()
expect(report.crossCheck!.box35Match).toBe(true)
expect(report.crossCheck!.box39Match).toBe(true)
})
it('cross-check fails when GL does not match', () => {
const glTotals: GLAccountTotal[] = [
{ account_number: '3108', credit: 99999 },
{ account_number: '3308', credit: 30000 },
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 50000, moms_ruta: '35' })],
customers: [makeCustomer()],
glTotals,
})
expect(report.crossCheck!.box35Match).toBe(false)
const mismatchWarnings = report.warnings.filter(w => w.type === 'cross_check_mismatch')
expect(mismatchWarnings.length).toBeGreaterThan(0)
})
it('cross-check allows 1 SEK rounding tolerance', () => {
const glTotals: GLAccountTotal[] = [
{ account_number: '3108', credit: 50000.50 },
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 50000, moms_ruta: '35' })],
customers: [makeCustomer()],
glTotals,
})
expect(report.crossCheck!.box35Match).toBe(true)
})
it('cross-check sums multiple accounts for box 35 (3108 + 3521)', () => {
const glTotals: GLAccountTotal[] = [
{ account_number: '3108', credit: 40000 },
{ account_number: '3521', credit: 10000 },
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 50000, moms_ruta: '35' })],
customers: [makeCustomer()],
glTotals,
})
expect(report.crossCheck!.box35Match).toBe(true)
expect(report.crossCheck!.box35GLTotal).toBe(50000)
})
it('skips cross-check when no GL data provided', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer()],
})
expect(report.crossCheck).toBeNull()
})
// ── Period and metadata tests ────────────────────────────
it('sets filingType to monthly when month is provided', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [],
customers: [],
month: 3,
quarter: undefined,
})
expect(report.filingType).toBe('monthly')
expect(report.period.month).toBe(3)
})
it('sets filingType to quarterly when quarter is provided', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [],
customers: [],
month: undefined,
quarter: 2,
})
expect(report.filingType).toBe('quarterly')
expect(report.period.quarter).toBe(2)
})
it('includes reporter info in report', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [],
customers: [],
})
expect(report.reporterVatNumber).toBe('SE556677889901')
expect(report.reporterName).toBe('Test AB')
})
// ── Monetary precision tests ─────────────────────────────
it('rounds amounts to 2 decimal places', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 33333.335, moms_ruta: '35' })],
customers: [makeCustomer()],
})
expect(report.lines[0].goodsAmount).toBe(33333.34)
})
})
// ── Period helper tests ───────────────────────────────────────
describe('getMonthPeriod', () => {
it('returns correct start and end for January', () => {
const { start, end } = getMonthPeriod(2026, 1)
expect(start).toBe('2026-01-01')
expect(end).toBe('2026-01-31')
})
it('handles February in a non-leap year', () => {
const { start, end } = getMonthPeriod(2027, 2)
expect(start).toBe('2027-02-01')
expect(end).toBe('2027-02-28')
})
it('handles February in a leap year', () => {
const { start, end } = getMonthPeriod(2028, 2)
expect(start).toBe('2028-02-01')
expect(end).toBe('2028-02-29')
})
it('returns correct dates for December', () => {
const { start, end } = getMonthPeriod(2026, 12)
expect(start).toBe('2026-12-01')
expect(end).toBe('2026-12-31')
})
})
describe('getQuarterPeriod', () => {
it('returns Q1 dates', () => {
const { start, end } = getQuarterPeriod(2026, 1)
expect(start).toBe('2026-01-01')
expect(end).toBe('2026-03-31')
})
it('returns Q2 dates', () => {
const { start, end } = getQuarterPeriod(2026, 2)
expect(start).toBe('2026-04-01')
expect(end).toBe('2026-06-30')
})
it('returns Q3 dates', () => {
const { start, end } = getQuarterPeriod(2026, 3)
expect(start).toBe('2026-07-01')
expect(end).toBe('2026-09-30')
})
it('returns Q4 dates', () => {
const { start, end } = getQuarterPeriod(2026, 4)
expect(start).toBe('2026-10-01')
expect(end).toBe('2026-12-31')
})
})
describe('getFilingDeadline', () => {
it('returns 25th of following month for monthly', () => {
expect(getFilingDeadline(2026, 1)).toBe('2026-02-25')
expect(getFilingDeadline(2026, 6)).toBe('2026-07-25')
})
it('rolls over to next year for December', () => {
expect(getFilingDeadline(2026, 12)).toBe('2027-01-25')
})
it('returns correct deadline for quarterly', () => {
expect(getFilingDeadline(2026, undefined, 1)).toBe('2026-04-25')
expect(getFilingDeadline(2026, undefined, 2)).toBe('2026-07-25')
expect(getFilingDeadline(2026, undefined, 3)).toBe('2026-10-25')
})
it('rolls over to next year for Q4', () => {
expect(getFilingDeadline(2026, undefined, 4)).toBe('2027-01-25')
})
it('throws when neither month nor quarter provided', () => {
expect(() => getFilingDeadline(2026)).toThrow()
})
})
describe('daysUntilDeadline', () => {
it('returns positive number for future deadlines', () => {
const future = new Date()
future.setDate(future.getDate() + 10)
const dateStr = future.toISOString().slice(0, 10)
expect(daysUntilDeadline(dateStr)).toBe(10)
})
it('returns 0 for today', () => {
const today = new Date().toISOString().slice(0, 10)
expect(daysUntilDeadline(today)).toBe(0)
})
it('returns negative number for past deadlines', () => {
const past = new Date()
past.setDate(past.getDate() - 5)
const dateStr = past.toISOString().slice(0, 10)
expect(daysUntilDeadline(dateStr)).toBe(-5)
})
})
@@ -1,189 +0,0 @@
import { describe, it, expect } from 'vitest'
import { generateSKVXml, generateXMLFilename } from '../skv-xml-generator'
import type { ECSalesListReport } from '../eu-sales-list-engine'
function makeReport(overrides: Partial<ECSalesListReport> = {}): ECSalesListReport {
return {
period: { year: 2026, quarter: 1 },
filingType: 'quarterly',
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
lines: [
{
customerVatNumber: 'DE123456789',
customerName: 'Acme GmbH',
customerCountry: 'DE',
customerId: 'cust-1',
goodsAmount: 50000,
servicesAmount: 30000,
triangulationAmount: 0,
invoiceCount: 3,
},
],
totals: { goods: 50000, services: 30000, triangulation: 0, total: 80000 },
warnings: [],
crossCheck: null,
invoiceCount: 3,
customerCount: 1,
...overrides,
}
}
describe('generateSKVXml', () => {
it('starts with XML declaration', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/)
})
it('wraps content in KVPS root element', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<KVPS>')
expect(xml).toContain('</KVPS>')
})
it('includes reporter VAT number', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<Momsregistreringsnummer>SE556677889901</Momsregistreringsnummer>')
})
it('includes reporter name', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<Namn>Test AB</Namn>')
})
it('includes quarterly period info', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<Ar>2026</Ar>')
expect(xml).toContain('<Kvartal>1</Kvartal>')
expect(xml).toContain('<Redovisningstyp>Kvartal</Redovisningstyp>')
})
it('includes monthly period info', () => {
const report = makeReport({ period: { year: 2026, month: 3 }, filingType: 'monthly' })
const xml = generateSKVXml(report)
expect(xml).toContain('<Manad>03</Manad>')
expect(xml).toContain('<Redovisningstyp>Manad</Redovisningstyp>')
})
it('includes customer line with goods and services', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<KopareVATnr>DE123456789</KopareVATnr>')
expect(xml).toContain('<KopareLand>DE</KopareLand>')
expect(xml).toContain('<VarorBeloppSEK>50000</VarorBeloppSEK>')
expect(xml).toContain('<TjansterBeloppSEK>30000</TjansterBeloppSEK>')
})
it('omits zero amount elements', () => {
const xml = generateSKVXml(makeReport())
// Triangulation is 0, should not appear in line
expect(xml).not.toContain('<TriangelhandelBeloppSEK>0</TriangelhandelBeloppSEK>')
})
it('includes triangulation when non-zero', () => {
const report = makeReport({
lines: [{
customerVatNumber: 'DE123',
customerName: 'Test',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 0,
servicesAmount: 0,
triangulationAmount: 15000,
invoiceCount: 1,
}],
})
const xml = generateSKVXml(report)
expect(xml).toContain('<TriangelhandelBeloppSEK>15000</TriangelhandelBeloppSEK>')
})
it('skips lines with all zero amounts', () => {
const report = makeReport({
lines: [
{
customerVatNumber: 'DE111',
customerName: 'Zero',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 0,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 0,
},
{
customerVatNumber: 'FR222',
customerName: 'NonZero',
customerCountry: 'FR',
customerId: 'c-2',
goodsAmount: 10000,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 1,
},
],
})
const xml = generateSKVXml(report)
expect(xml).not.toContain('DE111')
expect(xml).toContain('FR222')
})
it('includes totals section', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<VarorTotaltSEK>50000</VarorTotaltSEK>')
expect(xml).toContain('<TjansterTotaltSEK>30000</TjansterTotaltSEK>')
expect(xml).toContain('<TotaltSEK>80000</TotaltSEK>')
})
it('rounds amounts to whole SEK', () => {
const report = makeReport({
lines: [{
customerVatNumber: 'DE123',
customerName: 'Test',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 12345.67,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 1,
}],
totals: { goods: 12345.67, services: 0, triangulation: 0, total: 12345.67 },
})
const xml = generateSKVXml(report)
expect(xml).toContain('<VarorBeloppSEK>12346</VarorBeloppSEK>')
})
it('escapes XML special characters in names', () => {
const report = makeReport({
reporterName: 'Foo & Bar <AB>',
lines: [{
customerVatNumber: 'DE123',
customerName: 'Test',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 1000,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 1,
}],
})
const xml = generateSKVXml(report)
expect(xml).toContain('Foo &amp; Bar &lt;AB&gt;')
expect(xml).not.toContain('Foo & Bar <AB>')
})
})
describe('generateXMLFilename', () => {
it('generates quarterly filename', () => {
const filename = generateXMLFilename(makeReport())
expect(filename).toBe('KVPS_SE556677889901_2026-Q1.xml')
})
it('generates monthly filename', () => {
const report = makeReport({ period: { year: 2026, month: 11 }, filingType: 'monthly' })
const filename = generateXMLFilename(report)
expect(filename).toBe('KVPS_SE556677889901_2026-11.xml')
})
})
@@ -1,65 +0,0 @@
/**
* EU Sales List CSV Generator
*
* Generates a semicolon-separated CSV file (UTF-8 with BOM) for the
* periodisk sammanställning report. Compatible with Excel and Skatteverket's
* import tools.
*
* Format: Semicolon-delimited, UTF-8 BOM, whole SEK amounts.
*/
import type { ECSalesListReport } from './eu-sales-list-engine'
/** UTF-8 BOM for Excel compatibility */
const UTF8_BOM = '\uFEFF'
/**
* Generate a CSV string for the EC Sales List report.
*
* Columns:
* Land;VAT-nummer;Varuförsäljning (SEK);Tjänsteförsäljning (SEK);Trepartshandel (SEK)
*
* Amounts are rounded to whole SEK (öre removed) as required by Skatteverket.
*/
export function generateCSV(report: ECSalesListReport): string {
const header = 'Land;VAT-nummer;Varuförsäljning (SEK);Tjänsteförsäljning (SEK);Trepartshandel (SEK)'
const rows = report.lines.map(line => {
const goods = Math.round(line.goodsAmount)
const services = Math.round(line.servicesAmount)
const triangulation = Math.round(line.triangulationAmount)
return `${line.customerCountry};${line.customerVatNumber};${goods};${services};${triangulation}`
})
// Summary row
const totalGoods = Math.round(report.totals.goods)
const totalServices = Math.round(report.totals.services)
const totalTriangulation = Math.round(report.totals.triangulation)
rows.push('')
rows.push(`Summa;;${totalGoods};${totalServices};${totalTriangulation}`)
return UTF8_BOM + [header, ...rows].join('\r\n') + '\r\n'
}
/**
* Generate a filename for the CSV download.
*
* Format: PS_<VAT>_<period>.csv
* Example: PS_SE556677889901_2026-Q1.csv or PS_SE556677889901_2026-03.csv
*/
export function generateCSVFilename(report: ECSalesListReport): string {
const vat = report.reporterVatNumber.replace(/\s/g, '')
const period = formatPeriod(report)
return `PS_${vat}_${period}.csv`
}
function formatPeriod(report: ECSalesListReport): string {
const { year, month, quarter } = report.period
if (month !== undefined) {
return `${year}-${String(month).padStart(2, '0')}`
}
if (quarter !== undefined) {
return `${year}-Q${quarter}`
}
return `${year}`
}
@@ -1,453 +0,0 @@
/**
* EU Sales List / Periodisk Sammanställning Engine
*
* Core business logic for generating EC Sales List reports.
* Pure functions — no Supabase, no React, no side effects.
*
* Aggregates intra-community B2B sales by customer VAT number,
* separating goods (account 3108, box 35) from services (account 3308, box 39).
*
* Reference: Skatteverket SKV 5740
* Filing: Monthly for goods, quarterly for services
* Deadline: 25th of the month following the reporting period
*/
import { isEUCountry, toCountryCode } from '@/extensions/export/shared/eu-countries'
// ── Types ────────────────────────────────────────────────────
/** Invoice data needed for EC Sales List generation */
export interface ECSalesListInvoice {
id: string
invoice_number: string
invoice_date: string
status: string
currency: string
total: number
total_sek: number | null
subtotal: number
subtotal_sek: number | null
vat_treatment: string
moms_ruta: string | null
document_type: string
credited_invoice_id: string | null
customer_id: string
}
/** Customer data needed for EC Sales List */
export interface ECSalesListCustomer {
id: string
name: string
country: string
customer_type: string
vat_number: string | null
vat_number_validated: boolean
}
/** Journal entry line data for cross-checking */
export interface GLAccountTotal {
account_number: string
credit: number
}
/** Aggregated line in the EC Sales List report */
export interface ECSalesListLine {
customerVatNumber: string
customerName: string
customerCountry: string
customerId: string
goodsAmount: number
servicesAmount: number
triangulationAmount: number
invoiceCount: number
}
/** Warning about data quality */
export interface ECSalesListWarning {
type:
| 'missing_vat_number'
| 'unvalidated_vat_number'
| 'missing_moms_ruta'
| 'non_eu_country'
| 'cross_check_mismatch'
severity: 'error' | 'warning'
invoiceId?: string
invoiceNumber?: string
customerId?: string
customerName?: string
message: string
}
/** Cross-check result comparing report totals vs GL */
export interface CrossCheckResult {
box35Match: boolean
box35ReportTotal: number
box35GLTotal: number
box39Match: boolean
box39ReportTotal: number
box39GLTotal: number
}
/** Complete EC Sales List report */
export interface ECSalesListReport {
period: {
year: number
month?: number
quarter?: number
}
filingType: 'monthly' | 'quarterly'
reporterVatNumber: string
reporterName: string
lines: ECSalesListLine[]
totals: {
goods: number
services: number
triangulation: number
total: number
}
warnings: ECSalesListWarning[]
crossCheck: CrossCheckResult | null
invoiceCount: number
customerCount: number
}
/** Options for generating the report */
export interface GenerateReportOptions {
invoices: ECSalesListInvoice[]
customers: ECSalesListCustomer[]
glTotals?: GLAccountTotal[]
reporterVatNumber: string
reporterName: string
year: number
month?: number
quarter?: number
}
// ── Revenue account classification ─────────────────────────
/** Accounts that represent EU goods sales (box 35) */
const GOODS_ACCOUNTS = ['3108', '3521']
/** Accounts that represent EU service sales (box 39) */
const SERVICE_ACCOUNTS = ['3308']
/** Accounts for triangular trade (box 38) */
const TRIANGULATION_ACCOUNTS = ['3109']
// ── Core engine ──────────────────────────────────────────────
/**
* Generate an EC Sales List report from invoice and customer data.
*
* Steps:
* 1. Filter invoices to EU B2B reverse charge only
* 2. Join with customers
* 3. Validate data quality (VAT numbers, country codes)
* 4. Group by customer VAT number
* 5. Classify as goods or services based on moms_ruta / vat_treatment
* 6. Cross-check against GL account totals if provided
*/
export function generateECSalesListReport(options: GenerateReportOptions): ECSalesListReport {
const {
invoices,
customers,
glTotals,
reporterVatNumber,
reporterName,
year,
month,
quarter,
} = options
const warnings: ECSalesListWarning[] = []
const customerMap = new Map(customers.map(c => [c.id, c]))
// Step 1: Filter to relevant invoices
const relevantInvoices = invoices.filter(inv => {
// Only sent, paid, or overdue invoices (not drafts)
if (!['sent', 'paid', 'overdue'].includes(inv.status)) return false
// Only actual invoices (not proforma/delivery notes) — except credit notes
if (inv.document_type !== 'invoice' && inv.credited_invoice_id === null) return false
// Must be reverse charge (EU B2B)
if (inv.vat_treatment !== 'reverse_charge') return false
return true
})
// Step 2: Build aggregation map (keyed by customer VAT number)
const aggregation = new Map<string, ECSalesListLine>()
for (const invoice of relevantInvoices) {
const customer = customerMap.get(invoice.customer_id)
if (!customer) continue
// Validate: customer should be in an EU country (not Sweden)
if (!isEUCountry(customer.country)) {
warnings.push({
type: 'non_eu_country',
severity: 'warning',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerId: customer.id,
customerName: customer.name,
message: `Faktura ${invoice.invoice_number} till ${customer.name} har omvänd skattskyldighet men kunden är i ${customer.country} (ej EU).`,
})
continue
}
// Validate: VAT number must exist
if (!customer.vat_number) {
warnings.push({
type: 'missing_vat_number',
severity: 'error',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerId: customer.id,
customerName: customer.name,
message: `Faktura ${invoice.invoice_number} till ${customer.name} saknar momsregistreringsnummer (VAT-nummer). Krävs för periodisk sammanställning.`,
})
continue
}
// Validate: VAT number should be validated via VIES
if (!customer.vat_number_validated) {
warnings.push({
type: 'unvalidated_vat_number',
severity: 'warning',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerId: customer.id,
customerName: customer.name,
message: `VAT-nummer ${customer.vat_number} för ${customer.name} har inte validerats via VIES.`,
})
}
// Determine amount in SEK (use total_sek if available, else total for SEK invoices)
const amountSek = getAmountSek(invoice)
// Determine if this is a credit note (negative amount)
const isCreditNote = invoice.credited_invoice_id !== null
const effectiveAmount = isCreditNote ? -Math.abs(amountSek) : amountSek
// Classify: goods (box 35) or services (box 39)
const classification = classifyInvoice(invoice)
// Normalize country to ISO code for consistent output
const countryCode = toCountryCode(customer.country)
// Get or create aggregation line
const vatNumber = customer.vat_number
const key = vatNumber
if (!aggregation.has(key)) {
aggregation.set(key, {
customerVatNumber: vatNumber,
customerName: customer.name,
customerCountry: countryCode,
customerId: customer.id,
goodsAmount: 0,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 0,
})
}
const line = aggregation.get(key)!
line.invoiceCount++
if (classification === 'triangulation') {
line.triangulationAmount = round2(line.triangulationAmount + effectiveAmount)
} else if (classification === 'goods') {
line.goodsAmount = round2(line.goodsAmount + effectiveAmount)
} else {
line.servicesAmount = round2(line.servicesAmount + effectiveAmount)
}
}
// Step 3: Build sorted output
const lines = Array.from(aggregation.values())
.sort((a, b) => a.customerCountry.localeCompare(b.customerCountry) || a.customerVatNumber.localeCompare(b.customerVatNumber))
// Step 4: Calculate totals
const totals = {
goods: round2(lines.reduce((sum, l) => sum + l.goodsAmount, 0)),
services: round2(lines.reduce((sum, l) => sum + l.servicesAmount, 0)),
triangulation: round2(lines.reduce((sum, l) => sum + l.triangulationAmount, 0)),
total: 0,
}
totals.total = round2(totals.goods + totals.services + totals.triangulation)
// Step 5: Cross-check against GL if data provided
let crossCheck: CrossCheckResult | null = null
if (glTotals && glTotals.length > 0) {
crossCheck = performCrossCheck(totals, glTotals, warnings)
}
return {
period: { year, month, quarter },
filingType: month !== undefined ? 'monthly' : 'quarterly',
reporterVatNumber,
reporterName,
lines,
totals,
warnings,
crossCheck,
invoiceCount: relevantInvoices.length,
customerCount: lines.length,
}
}
// ── Classification helpers ───────────────────────────────────
type InvoiceClassification = 'goods' | 'services' | 'triangulation'
/**
* Classify an invoice as goods, services, or triangulation.
*
* Uses moms_ruta as primary signal (most reliable, set during invoice creation).
* Falls back to vat_treatment if moms_ruta is not set.
*
* Box 35 = goods to EU (account 3108)
* Box 38 = triangular trade (account 3109)
* Box 39 = services to EU (account 3308)
*/
function classifyInvoice(invoice: ECSalesListInvoice): InvoiceClassification {
const ruta = invoice.moms_ruta
if (ruta === '35') return 'goods'
if (ruta === '38') return 'triangulation'
if (ruta === '39') return 'services'
// Fallback: EU B2B reverse charge defaults to services (box 39)
// since the current VAT rules assign '39' for all reverse charge.
// Goods classification requires explicit moms_ruta = '35'.
return 'services'
}
// ── Cross-check ──────────────────────────────────────────────
/**
* Compare report totals against GL account credit totals.
*
* Box 35 total should match credit sum on accounts 3108 + 3521
* Box 39 total should match credit sum on account 3308
*
* A tolerance of 1 SEK is allowed for rounding differences.
*/
function performCrossCheck(
totals: { goods: number; services: number },
glTotals: GLAccountTotal[],
warnings: ECSalesListWarning[]
): CrossCheckResult {
const glMap = new Map(glTotals.map(t => [t.account_number, t.credit]))
const box35GL = round2(
GOODS_ACCOUNTS.reduce((sum, acc) => sum + (glMap.get(acc) ?? 0), 0)
)
const box39GL = round2(
SERVICE_ACCOUNTS.reduce((sum, acc) => sum + (glMap.get(acc) ?? 0), 0)
)
const TOLERANCE = 1 // Allow 1 SEK rounding difference
const box35Match = Math.abs(totals.goods - box35GL) <= TOLERANCE
const box39Match = Math.abs(totals.services - box39GL) <= TOLERANCE
if (!box35Match) {
warnings.push({
type: 'cross_check_mismatch',
severity: 'warning',
message: `Ruta 35 (varuförsäljning EU): rapporten visar ${totals.goods} SEK men huvudboken visar ${box35GL} SEK (differens ${round2(totals.goods - box35GL)} SEK).`,
})
}
if (!box39Match) {
warnings.push({
type: 'cross_check_mismatch',
severity: 'warning',
message: `Ruta 39 (tjänsteförsäljning EU): rapporten visar ${totals.services} SEK men huvudboken visar ${box39GL} SEK (differens ${round2(totals.services - box39GL)} SEK).`,
})
}
return {
box35Match,
box35ReportTotal: totals.goods,
box35GLTotal: box35GL,
box39Match,
box39ReportTotal: totals.services,
box39GLTotal: box39GL,
}
}
// ── Amount helpers ───────────────────────────────────────────
/**
* Get the SEK amount for an invoice.
* Uses subtotal_sek (excluding VAT) for the EC Sales List,
* since reverse charge invoices have 0 VAT and subtotal === total.
* Falls back to subtotal if SEK amounts are not populated.
*/
function getAmountSek(invoice: ECSalesListInvoice): number {
// For reverse charge invoices, subtotal_sek is the correct base
if (invoice.subtotal_sek !== null) return round2(invoice.subtotal_sek)
// If no SEK conversion exists, the invoice is already in SEK
return round2(invoice.subtotal)
}
/** Round to 2 decimal places (monetary standard) */
function round2(value: number): number {
return Math.round(value * 100) / 100
}
// ── Period helpers ───────────────────────────────────────────
/** Get the start and end dates for a monthly period */
export function getMonthPeriod(year: number, month: number): { start: string; end: string } {
const start = `${year}-${String(month).padStart(2, '0')}-01`
const lastDay = new Date(year, month, 0).getDate()
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
return { start, end }
}
/** Get the start and end dates for a quarterly period */
export function getQuarterPeriod(year: number, quarter: number): { start: string; end: string } {
const startMonth = (quarter - 1) * 3 + 1
const endMonth = startMonth + 2
const start = `${year}-${String(startMonth).padStart(2, '0')}-01`
const lastDay = new Date(year, endMonth, 0).getDate()
const end = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
return { start, end }
}
/** Get the filing deadline for a period (25th of the month after period end) */
export function getFilingDeadline(year: number, month?: number, quarter?: number): string {
let deadlineMonth: number
let deadlineYear = year
if (month !== undefined) {
// Monthly filing: due 25th of the following month
deadlineMonth = month + 1
if (deadlineMonth > 12) {
deadlineMonth = 1
deadlineYear++
}
} else if (quarter !== undefined) {
// Quarterly filing: due 25th of the month after quarter end
deadlineMonth = quarter * 3 + 1
if (deadlineMonth > 12) {
deadlineMonth = 1
deadlineYear++
}
} else {
throw new Error('Either month or quarter must be provided')
}
return `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-25`
}
/** Calculate days remaining until a deadline */
export function daysUntilDeadline(deadline: string): number {
const deadlineDate = new Date(deadline)
const today = new Date()
today.setHours(0, 0, 0, 0)
deadlineDate.setHours(0, 0, 0, 0)
const diffMs = deadlineDate.getTime() - today.getTime()
return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
}
@@ -1,127 +0,0 @@
/**
* EU Sales List XML Generator (SKV 5740 format)
*
* Generates XML compatible with Skatteverket's e-filing system for
* periodisk sammanställning (EC Sales List / recapitulative statement).
*
* Reference: Skatteverket SKV 5740, KVPS XML schema
* Filing: Monthly for goods, quarterly for services
*
* The XML structure follows Skatteverket's KVPS (Kvartalsvis Periodisk
* Sammanställning) format with elements for reporter info, period, and
* per-customer goods/services/triangulation amounts in whole SEK.
*/
import type { ECSalesListReport } from './eu-sales-list-engine'
/**
* Generate SKV-compatible XML for the EC Sales List report.
*
* Structure:
* <KVPS>
* <Avsandare> — reporter/sender information
* <Period> — reporting period
* <Rad> — one per customer VAT number
* <KopareVATnr> — buyer VAT number
* <KopareLand> — buyer country code
* <VarorBeloppSEK> — goods amount (box 35)
* <TjansterBeloppSEK> — services amount (box 39)
* <TriangelhandelBeloppSEK> — triangulation (box 38)
* </Rad>
* </KVPS>
*
* All amounts are rounded to whole SEK (no decimals).
*/
export function generateSKVXml(report: ECSalesListReport): string {
const lines: string[] = []
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<KVPS>')
// Reporter/sender info
lines.push(' <Avsandare>')
lines.push(` <Momsregistreringsnummer>${escapeXml(report.reporterVatNumber)}</Momsregistreringsnummer>`)
lines.push(` <Namn>${escapeXml(report.reporterName)}</Namn>`)
lines.push(' </Avsandare>')
// Period info
lines.push(' <Period>')
lines.push(` <Ar>${report.period.year}</Ar>`)
if (report.period.month !== undefined) {
lines.push(` <Manad>${String(report.period.month).padStart(2, '0')}</Manad>`)
}
if (report.period.quarter !== undefined) {
lines.push(` <Kvartal>${report.period.quarter}</Kvartal>`)
}
lines.push(` <Redovisningstyp>${report.filingType === 'monthly' ? 'Manad' : 'Kvartal'}</Redovisningstyp>`)
lines.push(' </Period>')
// Customer lines
for (const line of report.lines) {
const goods = Math.round(line.goodsAmount)
const services = Math.round(line.servicesAmount)
const triangulation = Math.round(line.triangulationAmount)
// Skip lines with all zero amounts
if (goods === 0 && services === 0 && triangulation === 0) continue
lines.push(' <Rad>')
lines.push(` <KopareVATnr>${escapeXml(line.customerVatNumber)}</KopareVATnr>`)
lines.push(` <KopareLand>${escapeXml(line.customerCountry)}</KopareLand>`)
if (goods !== 0) {
lines.push(` <VarorBeloppSEK>${goods}</VarorBeloppSEK>`)
}
if (services !== 0) {
lines.push(` <TjansterBeloppSEK>${services}</TjansterBeloppSEK>`)
}
if (triangulation !== 0) {
lines.push(` <TriangelhandelBeloppSEK>${triangulation}</TriangelhandelBeloppSEK>`)
}
lines.push(' </Rad>')
}
// Totals
lines.push(' <Summa>')
lines.push(` <VarorTotaltSEK>${Math.round(report.totals.goods)}</VarorTotaltSEK>`)
lines.push(` <TjansterTotaltSEK>${Math.round(report.totals.services)}</TjansterTotaltSEK>`)
lines.push(` <TriangelhandelTotaltSEK>${Math.round(report.totals.triangulation)}</TriangelhandelTotaltSEK>`)
lines.push(` <TotaltSEK>${Math.round(report.totals.total)}</TotaltSEK>`)
lines.push(' </Summa>')
lines.push('</KVPS>')
return lines.join('\n') + '\n'
}
/**
* Generate a filename for the XML download.
*
* Format: KVPS_<VAT>_<period>.xml
* Example: KVPS_SE556677889901_2026-Q1.xml
*/
export function generateXMLFilename(report: ECSalesListReport): string {
const vat = report.reporterVatNumber.replace(/\s/g, '')
const period = formatPeriod(report)
return `KVPS_${vat}_${period}.xml`
}
function formatPeriod(report: ECSalesListReport): string {
const { year, month, quarter } = report.period
if (month !== undefined) {
return `${year}-${String(month).padStart(2, '0')}`
}
if (quarter !== undefined) {
return `${year}-Q${quarter}`
}
return `${year}`
}
/** Escape special XML characters to prevent injection */
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
-17
View File
@@ -1,17 +0,0 @@
import type { Extension } from '@/lib/extensions/types'
/**
* Intrastat Generator Extension
*
* Generates monthly Intrastat dispatch declarations for reporting to SCB
* (Statistics Sweden). Manages product metadata (CN commodity codes, weights,
* country of origin) and aggregates EU goods dispatches.
*
* Outputs SCB IDEP.web compatible CSV files. Monitors the SEK 12M dispatch
* threshold and alerts when the reporting obligation is triggered.
*/
export const intrastatExtension: Extension = {
id: 'intrastat',
name: 'Intrastat-generator',
version: '1.0.0',
}
@@ -1,417 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
generateIntrastatReport,
getIntrastatDeadline,
type IntrastatInvoice,
type IntrastatCustomer,
type IntrastatInvoiceItem,
type ProductMetadata,
type IntrastatOptions,
} from '../intrastat-engine'
// ── Fixtures ────────────────────────────────────────────────
function makeInvoice(overrides: Partial<IntrastatInvoice> = {}): IntrastatInvoice {
return {
id: 'inv-1',
invoice_number: 'F2026-001',
invoice_date: '2026-01-15',
status: 'sent',
vat_treatment: 'reverse_charge',
moms_ruta: '35',
currency: 'EUR',
total_sek: 100000,
subtotal_sek: 100000,
subtotal: 9000,
document_type: 'invoice',
credited_invoice_id: null,
customer_id: 'cust-1',
...overrides,
}
}
function makeCustomer(overrides: Partial<IntrastatCustomer> = {}): IntrastatCustomer {
return {
id: 'cust-1',
name: 'Acme GmbH',
country: 'DE',
vat_number: 'DE123456789',
...overrides,
}
}
function makeItem(overrides: Partial<IntrastatInvoiceItem> = {}): IntrastatInvoiceItem {
return {
id: 'item-1',
invoice_id: 'inv-1',
description: 'Stålbalk M8',
quantity: 100,
unit_price: 90,
total: 9000,
total_sek: 100000,
...overrides,
}
}
function makeProduct(overrides: Partial<ProductMetadata> = {}): ProductMetadata {
return {
productId: 'stålbalk m8',
cnCode: '72163100',
description: 'Stålbalk M8',
netWeightKg: 45.5,
countryOfOrigin: 'SE',
supplementaryUnit: null,
supplementaryUnitType: null,
...overrides,
}
}
const BASE_OPTIONS: IntrastatOptions = {
invoices: [],
invoiceItems: [],
customers: [],
products: [],
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
year: 2026,
month: 1,
}
// ── Basic report tests ──────────────────────────────────────
describe('generateIntrastatReport', () => {
it('generates empty report when no invoices', () => {
const report = generateIntrastatReport(BASE_OPTIONS)
expect(report.lines).toHaveLength(0)
expect(report.totals.invoicedValue).toBe(0)
expect(report.totals.netMass).toBe(0)
expect(report.invoiceCount).toBe(0)
expect(report.flowType).toBe('dispatch')
})
it('generates line from matched invoice + product', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(1)
expect(report.lines[0].cnCode).toBe('72163100')
expect(report.lines[0].partnerCountry).toBe('DE')
expect(report.lines[0].countryOfOrigin).toBe('SE')
expect(report.lines[0].invoicedValue).toBe(100000)
expect(report.lines[0].netMass).toBe(4550) // 45.5 kg × 100 units
expect(report.lines[0].partnerVatId).toBe('DE123456789')
})
it('aggregates multiple invoices with same CN code + country', () => {
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 50000, total_sek: 50000 }),
makeInvoice({ id: 'inv-2', subtotal_sek: 30000, total_sek: 30000 }),
]
const items = [
makeItem({ id: 'item-1', invoice_id: 'inv-1', total_sek: 50000, quantity: 50 }),
makeItem({ id: 'item-2', invoice_id: 'inv-2', total_sek: 30000, quantity: 30 }),
]
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices,
invoiceItems: items,
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(1)
expect(report.lines[0].invoicedValue).toBe(80000)
expect(report.lines[0].netMass).toBe(3640) // 45.5 × (50 + 30)
expect(report.invoiceCount).toBe(2)
})
it('separates lines by different partner countries', () => {
const invoices = [
makeInvoice({ id: 'inv-1', customer_id: 'cust-1' }),
makeInvoice({ id: 'inv-2', customer_id: 'cust-2' }),
]
const items = [
makeItem({ id: 'item-1', invoice_id: 'inv-1', quantity: 10 }),
makeItem({ id: 'item-2', invoice_id: 'inv-2', quantity: 20 }),
]
const customers = [
makeCustomer({ id: 'cust-1', country: 'DE' }),
makeCustomer({ id: 'cust-2', country: 'FI', vat_number: 'FI12345678', name: 'Finnish Co' }),
]
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices,
invoiceItems: items,
customers,
products: [makeProduct()],
})
expect(report.lines).toHaveLength(2)
expect(report.lines[0].partnerCountry).toBe('DE')
expect(report.lines[1].partnerCountry).toBe('FI')
})
it('separates lines by different CN codes', () => {
const items = [
makeItem({ id: 'item-1', invoice_id: 'inv-1', description: 'Stålbalk M8', quantity: 10, total_sek: 50000 }),
makeItem({ id: 'item-2', invoice_id: 'inv-1', description: 'Ventil DN50', quantity: 5, total_sek: 50000 }),
]
const products = [
makeProduct({ productId: 'stålbalk m8', cnCode: '72163100', description: 'Stålbalk M8' }),
makeProduct({ productId: 'ventil dn50', cnCode: '84818019', description: 'Ventil DN50', netWeightKg: 2.3 }),
]
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: items,
customers: [makeCustomer()],
products,
})
expect(report.lines).toHaveLength(2)
const codes = report.lines.map(l => l.cnCode).sort()
expect(codes).toEqual(['72163100', '84818019'])
})
// ── Credit notes ──────────────────────────────────────────
it('handles credit notes (subtracts from totals)', () => {
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 100000, total_sek: 100000 }),
makeInvoice({
id: 'inv-2',
subtotal_sek: 20000,
total_sek: 20000,
credited_invoice_id: 'inv-1',
}),
]
const items = [
makeItem({ id: 'item-1', invoice_id: 'inv-1', total_sek: 100000, quantity: 100 }),
makeItem({ id: 'item-2', invoice_id: 'inv-2', total_sek: 20000, quantity: 20 }),
]
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices,
invoiceItems: items,
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines[0].invoicedValue).toBe(80000)
expect(report.lines[0].netMass).toBe(3640) // 45.5 × (100 - 20)
})
// ── Filtering ─────────────────────────────────────────────
it('excludes non-reverse-charge invoices', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ vat_treatment: 'standard_25' })],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
it('excludes services (moms_ruta 39)', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: '39' })],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
it('excludes non-EU countries', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer({ country: 'US' })],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
it('excludes Sweden', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer({ country: 'SE' })],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
it('excludes draft invoices', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ status: 'draft' })],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
// ── Warnings ──────────────────────────────────────────────
it('warns when product has no CN code', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct({ cnCode: null })],
})
const cnWarnings = report.warnings.filter(w => w.type === 'missing_cn_code')
expect(cnWarnings.length).toBeGreaterThan(0)
})
it('warns when product has no weight', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct({ netWeightKg: null })],
})
const weightWarnings = report.warnings.filter(w => w.type === 'missing_weight')
expect(weightWarnings.length).toBeGreaterThan(0)
})
it('warns when no product match found for invoice line', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem({ description: 'Unknown Product XYZ' })],
customers: [makeCustomer()],
products: [makeProduct()], // won't match 'Unknown Product XYZ'
})
const cnWarnings = report.warnings.filter(w => w.type === 'missing_cn_code')
expect(cnWarnings.length).toBeGreaterThan(0)
})
// ── Threshold ─────────────────────────────────────────────
it('calculates threshold status with prior cumulative', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 500000 })],
invoiceItems: [makeItem({ total_sek: 500000 })],
customers: [makeCustomer()],
products: [makeProduct()],
priorCumulativeValue: 11_000_000,
})
expect(report.thresholdStatus.cumulativeValue).toBe(11_500_000)
expect(report.thresholdStatus.isObligated).toBe(false)
expect(report.thresholdStatus.percentageUsed).toBeCloseTo(95.83, 1)
})
it('flags when threshold is exceeded', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 2_000_000 })],
invoiceItems: [makeItem({ total_sek: 2_000_000 })],
customers: [makeCustomer()],
products: [makeProduct()],
priorCumulativeValue: 11_000_000,
})
expect(report.thresholdStatus.isObligated).toBe(true)
const thresholdWarnings = report.warnings.filter(w => w.type === 'threshold_exceeded')
expect(thresholdWarnings).toHaveLength(1)
expect(thresholdWarnings[0].severity).toBe('error')
})
it('warns when threshold is approaching (80%+)', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 100000 })],
invoiceItems: [makeItem({ total_sek: 100000 })],
customers: [makeCustomer()],
products: [makeProduct()],
priorCumulativeValue: 10_000_000,
})
const approaching = report.warnings.filter(w => w.type === 'threshold_approaching')
expect(approaching).toHaveLength(1)
expect(approaching[0].severity).toBe('warning')
})
// ── Default values ────────────────────────────────────────
it('uses custom default transaction nature and delivery terms', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
defaultTransactionNature: '31',
defaultDeliveryTerms: 'DAP',
})
expect(report.lines[0].transactionNature).toBe('31')
expect(report.lines[0].deliveryTerms).toBe('DAP')
})
it('defaults to transaction nature 11 and FCA', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines[0].transactionNature).toBe('11')
expect(report.lines[0].deliveryTerms).toBe('FCA')
})
// ── Period info ───────────────────────────────────────────
it('includes period and reporter info', () => {
const report = generateIntrastatReport(BASE_OPTIONS)
expect(report.period).toEqual({ year: 2026, month: 1 })
expect(report.reporterVatNumber).toBe('SE556677889901')
expect(report.reporterName).toBe('Test AB')
})
})
// ── Deadline tests ──────────────────────────────────────────
describe('getIntrastatDeadline', () => {
it('returns 14th of following month', () => {
expect(getIntrastatDeadline(2026, 1)).toBe('2026-02-14')
expect(getIntrastatDeadline(2026, 6)).toBe('2026-07-14')
})
it('rolls over to next year for December', () => {
expect(getIntrastatDeadline(2026, 12)).toBe('2027-01-14')
})
})
@@ -1,203 +0,0 @@
import { describe, it, expect } from 'vitest'
import { generateSCBCsv, generateSCBFilename } from '../scb-csv-generator'
import type { IntrastatReport, IntrastatLine } from '../intrastat-engine'
// ── Fixtures ────────────────────────────────────────────────
function makeLine(overrides: Partial<IntrastatLine> = {}): IntrastatLine {
return {
cnCode: '72163100',
partnerCountry: 'DE',
countryOfOrigin: 'SE',
transactionNature: '11',
deliveryTerms: 'FCA',
invoicedValue: 100000,
netMass: 4550,
supplementaryUnit: null,
supplementaryUnitType: null,
partnerVatId: 'DE123456789',
...overrides,
}
}
function makeReport(overrides: Partial<IntrastatReport> = {}): IntrastatReport {
return {
period: { year: 2026, month: 1 },
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
flowType: 'dispatch',
lines: [makeLine()],
totals: { invoicedValue: 100000, netMass: 4550, lineCount: 1 },
thresholdStatus: {
cumulativeValue: 100000,
threshold: 12000000,
isObligated: false,
percentageUsed: 0.83,
},
warnings: [],
invoiceCount: 1,
...overrides,
}
}
// ── CSV generation ──────────────────────────────────────────
describe('generateSCBCsv', () => {
it('starts with UTF-8 BOM', () => {
const csv = generateSCBCsv(makeReport())
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
it('has correct header row', () => {
const csv = generateSCBCsv(makeReport())
const lines = csv.replace('\uFEFF', '').split('\r\n')
expect(lines[0]).toBe(
'CN-kod;Partnerland;Ursprungsland;Transaktionstyp;Leveransvillkor;Fakturerat värde (SEK);Nettovikt (kg);Kompletterande enhet;Partner-VAT'
)
})
it('uses semicolons as delimiter', () => {
const csv = generateSCBCsv(makeReport())
const lines = csv.replace('\uFEFF', '').split('\r\n')
// Header has 9 columns = 8 semicolons
expect(lines[0].split(';')).toHaveLength(9)
// Data row also has 9 columns
expect(lines[1].split(';')).toHaveLength(9)
})
it('uses CRLF line endings', () => {
const csv = generateSCBCsv(makeReport())
expect(csv).toContain('\r\n')
// Should not contain lone LF without preceding CR
const withoutCRLF = csv.replace(/\r\n/g, '')
expect(withoutCRLF).not.toContain('\n')
})
it('ends with CRLF', () => {
const csv = generateSCBCsv(makeReport())
expect(csv.endsWith('\r\n')).toBe(true)
})
it('renders data row with correct values', () => {
const csv = generateSCBCsv(makeReport())
const lines = csv.replace('\uFEFF', '').split('\r\n')
const cols = lines[1].split(';')
expect(cols[0]).toBe('72163100') // CN-kod
expect(cols[1]).toBe('DE') // Partnerland
expect(cols[2]).toBe('SE') // Ursprungsland
expect(cols[3]).toBe('11') // Transaktionstyp
expect(cols[4]).toBe('FCA') // Leveransvillkor
expect(cols[5]).toBe('100000') // Fakturerat värde
expect(cols[6]).toBe('4550') // Nettovikt
expect(cols[7]).toBe('') // Kompletterande enhet (null)
expect(cols[8]).toBe('DE123456789') // Partner-VAT
})
it('rounds invoiced value to whole SEK', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ invoicedValue: 123456.78 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const value = lines[1].split(';')[5]
expect(value).toBe('123457') // Rounded up
})
it('formats integer mass without decimals', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ netMass: 4550 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const mass = lines[1].split(';')[6]
expect(mass).toBe('4550')
})
it('formats fractional mass with up to 3 decimal places', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ netMass: 45.123 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const mass = lines[1].split(';')[6]
expect(mass).toBe('45.123')
})
it('removes trailing zeros from mass', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ netMass: 45.1 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const mass = lines[1].split(';')[6]
expect(mass).toBe('45.1')
})
it('rounds mass to max 3 decimal places', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ netMass: 45.12345 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const mass = lines[1].split(';')[6]
expect(mass).toBe('45.123')
})
it('includes supplementary unit when present', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ supplementaryUnit: 150 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const suppUnit = lines[1].split(';')[7]
expect(suppUnit).toBe('150')
})
it('rounds supplementary unit to whole number', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ supplementaryUnit: 150.7 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const suppUnit = lines[1].split(';')[7]
expect(suppUnit).toBe('151')
})
it('renders multiple data rows', () => {
const report = makeReport({
lines: [
makeLine({ cnCode: '72163100', partnerCountry: 'DE' }),
makeLine({ cnCode: '84818019', partnerCountry: 'FI', partnerVatId: 'FI12345678' }),
],
})
const csv = generateSCBCsv(report)
const lines = csv.replace('\uFEFF', '').split('\r\n').filter(l => l.length > 0)
expect(lines).toHaveLength(3) // header + 2 data rows
expect(lines[1].split(';')[0]).toBe('72163100')
expect(lines[2].split(';')[0]).toBe('84818019')
})
it('generates empty CSV with only header when no lines', () => {
const csv = generateSCBCsv(makeReport({ lines: [] }))
const lines = csv.replace('\uFEFF', '').split('\r\n').filter(l => l.length > 0)
expect(lines).toHaveLength(1) // header only
expect(lines[0]).toContain('CN-kod')
})
})
// ── Filename generation ─────────────────────────────────────
describe('generateSCBFilename', () => {
it('generates correct filename', () => {
const filename = generateSCBFilename(makeReport())
expect(filename).toBe('INTRASTAT_SE556677889901_2026-01.csv')
})
it('pads single-digit month', () => {
const filename = generateSCBFilename(makeReport({ period: { year: 2026, month: 3 } }))
expect(filename).toBe('INTRASTAT_SE556677889901_2026-03.csv')
})
it('does not pad double-digit month', () => {
const filename = generateSCBFilename(makeReport({ period: { year: 2026, month: 12 } }))
expect(filename).toBe('INTRASTAT_SE556677889901_2026-12.csv')
})
it('strips whitespace from VAT number', () => {
const filename = generateSCBFilename(makeReport({ reporterVatNumber: 'SE 5566 7788 9901' }))
expect(filename).toBe('INTRASTAT_SE556677889901_2026-01.csv')
})
})
@@ -1,466 +0,0 @@
/**
* Intrastat Generator Engine
*
* Aggregates EU B2B goods sales into Intrastat declaration lines
* for reporting to SCB (Statistiska Centralbyrån) via IDEP.web.
*
* Pure functions — no Supabase, no React, no side effects.
*
* Only covers dispatches (utförsel) — goods sent from Sweden to
* other EU member states. Services are excluded from Intrastat.
*
* Threshold: SEK 12,000,000 cumulative dispatches over 12 months
* determines mandatory reporting obligation.
*
* Reference: SCB Intrastat guidelines, Combined Nomenclature (CN)
*/
import { isEUCountry, toCountryCode } from '@/extensions/export/shared/eu-countries'
// ── Types ────────────────────────────────────────────────────
/** Invoice data needed for Intrastat */
export interface IntrastatInvoice {
id: string
invoice_number: string
invoice_date: string
status: string
vat_treatment: string
moms_ruta: string | null
currency: string
total_sek: number | null
subtotal_sek: number | null
subtotal: number
document_type: string
credited_invoice_id: string | null
customer_id: string
}
/** Customer data for Intrastat */
export interface IntrastatCustomer {
id: string
name: string
country: string
vat_number: string | null
}
/** Product metadata stored in extension_data */
export interface ProductMetadata {
productId: string
cnCode: string | null
description: string
netWeightKg: number | null
countryOfOrigin: string
supplementaryUnit: number | null
supplementaryUnitType: string | null
}
/** Invoice line item for matching to products */
export interface IntrastatInvoiceItem {
id: string
invoice_id: string
description: string
quantity: number
unit_price: number
total: number
total_sek: number | null
}
/** Aggregated Intrastat declaration line */
export interface IntrastatLine {
cnCode: string
partnerCountry: string
countryOfOrigin: string
transactionNature: string
deliveryTerms: string
invoicedValue: number
netMass: number
supplementaryUnit: number | null
supplementaryUnitType: string | null
partnerVatId: string
}
/** Warning about data quality */
export interface IntrastatWarning {
type:
| 'missing_cn_code'
| 'missing_weight'
| 'missing_origin'
| 'threshold_approaching'
| 'threshold_exceeded'
severity: 'error' | 'warning'
invoiceId?: string
invoiceNumber?: string
productId?: string
message: string
}
/** Threshold monitoring status */
export interface ThresholdStatus {
cumulativeValue: number
threshold: number
isObligated: boolean
percentageUsed: number
}
/** Complete Intrastat report */
export interface IntrastatReport {
period: { year: number; month: number }
reporterVatNumber: string
reporterName: string
flowType: 'dispatch'
lines: IntrastatLine[]
totals: {
invoicedValue: number
netMass: number
lineCount: number
}
thresholdStatus: ThresholdStatus
warnings: IntrastatWarning[]
invoiceCount: number
}
/** Options for generating the Intrastat report */
export interface IntrastatOptions {
invoices: IntrastatInvoice[]
invoiceItems: IntrastatInvoiceItem[]
customers: IntrastatCustomer[]
products: ProductMetadata[]
reporterVatNumber: string
reporterName: string
year: number
month: number
defaultTransactionNature?: string
defaultDeliveryTerms?: string
/** Cumulative dispatch value from prior months in the rolling 12-month window */
priorCumulativeValue?: number
}
// ── Constants ───────────────────────────────────────────────
/** SCB Intrastat reporting threshold in SEK (12 million) */
const INTRASTAT_THRESHOLD = 12_000_000
/** Goods revenue accounts that trigger Intrastat reporting */
const GOODS_ACCOUNTS_RUTA = ['35']
// ── Core engine ──────────────────────────────────────────────
export function generateIntrastatReport(options: IntrastatOptions): IntrastatReport {
const {
invoices,
invoiceItems,
customers,
products,
reporterVatNumber,
reporterName,
year,
month,
defaultTransactionNature = '11',
defaultDeliveryTerms = 'FCA',
priorCumulativeValue = 0,
} = options
const warnings: IntrastatWarning[] = []
const customerMap = new Map(customers.map(c => [c.id, c]))
const productMap = buildProductMap(products)
// Step 1: Filter to EU B2B goods invoices
const relevantInvoices = invoices.filter(inv => {
if (!['sent', 'paid', 'overdue'].includes(inv.status)) return false
if (inv.document_type !== 'invoice' && inv.credited_invoice_id === null) return false
if (inv.vat_treatment !== 'reverse_charge') return false
// Only goods (moms_ruta 35) — services are excluded from Intrastat
if (inv.moms_ruta && !GOODS_ACCOUNTS_RUTA.includes(inv.moms_ruta)) return false
// Verify customer is in EU (not Sweden)
const customer = customerMap.get(inv.customer_id)
if (!customer || !isEUCountry(customer.country)) return false
return true
})
// Step 2: Build items index by invoice_id
const itemsByInvoice = new Map<string, IntrastatInvoiceItem[]>()
for (const item of invoiceItems) {
const list = itemsByInvoice.get(item.invoice_id) ?? []
list.push(item)
itemsByInvoice.set(item.invoice_id, list)
}
// Step 3: Build aggregation map
// Key: cnCode|partnerCountry|countryOfOrigin|transactionNature|deliveryTerms
const aggregation = new Map<string, IntrastatLine>()
for (const invoice of relevantInvoices) {
const customer = customerMap.get(invoice.customer_id)!
const partnerCountryCode = toCountryCode(customer.country)
const isCreditNote = invoice.credited_invoice_id !== null
const items = itemsByInvoice.get(invoice.id) ?? []
if (items.length === 0) {
// No line items — use invoice-level amount with unknown product
const amountSek = getInvoiceAmountSek(invoice)
const effectiveAmount = isCreditNote ? -Math.abs(amountSek) : amountSek
warnings.push({
type: 'missing_cn_code',
severity: 'error',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Faktura ${invoice.invoice_number} saknar fakturarader — kan inte tilldela CN-kod.`,
})
const key = buildAggKey('00000000', partnerCountryCode, 'SE', defaultTransactionNature, defaultDeliveryTerms)
addToAggregation(aggregation, key, {
cnCode: '00000000',
partnerCountry: partnerCountryCode,
countryOfOrigin: 'SE',
transactionNature: defaultTransactionNature,
deliveryTerms: defaultDeliveryTerms,
invoicedValue: effectiveAmount,
netMass: 0,
supplementaryUnit: null,
supplementaryUnitType: null,
partnerVatId: customer.vat_number ?? '',
})
continue
}
// Process each line item
for (const item of items) {
const product = matchProduct(item.description, productMap)
const amountSek = item.total_sek ?? item.total
const effectiveAmount = isCreditNote ? -Math.abs(amountSek) : amountSek
if (!product) {
// No product metadata found — use defaults and warn
warnings.push({
type: 'missing_cn_code',
severity: 'error',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Faktura ${invoice.invoice_number}, rad "${item.description}" — ingen matchande produkt med CN-kod hittad.`,
})
const key = buildAggKey('00000000', partnerCountryCode, 'SE', defaultTransactionNature, defaultDeliveryTerms)
addToAggregation(aggregation, key, {
cnCode: '00000000',
partnerCountry: partnerCountryCode,
countryOfOrigin: 'SE',
transactionNature: defaultTransactionNature,
deliveryTerms: defaultDeliveryTerms,
invoicedValue: effectiveAmount,
netMass: 0,
supplementaryUnit: null,
supplementaryUnitType: null,
partnerVatId: customer.vat_number ?? '',
})
continue
}
// Validate product metadata
if (!product.cnCode) {
warnings.push({
type: 'missing_cn_code',
severity: 'error',
productId: product.productId,
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Produkt "${product.description}" saknar CN-kod.`,
})
}
if (product.netWeightKg === null) {
warnings.push({
type: 'missing_weight',
severity: 'warning',
productId: product.productId,
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Produkt "${product.description}" saknar nettovikt.`,
})
}
const cnCode = product.cnCode ?? '00000000'
const origin = product.countryOfOrigin || 'SE'
const mass = (product.netWeightKg ?? 0) * item.quantity
const suppUnit = product.supplementaryUnit !== null
? product.supplementaryUnit * item.quantity
: null
const key = buildAggKey(cnCode, partnerCountryCode, origin, defaultTransactionNature, defaultDeliveryTerms)
addToAggregation(aggregation, key, {
cnCode,
partnerCountry: partnerCountryCode,
countryOfOrigin: origin,
transactionNature: defaultTransactionNature,
deliveryTerms: defaultDeliveryTerms,
invoicedValue: effectiveAmount,
netMass: isCreditNote ? -Math.abs(mass) : mass,
supplementaryUnit: suppUnit !== null ? (isCreditNote ? -Math.abs(suppUnit) : suppUnit) : null,
supplementaryUnitType: product.supplementaryUnitType,
partnerVatId: customer.vat_number ?? '',
})
}
}
// Step 4: Build sorted output
const lines = Array.from(aggregation.values())
.map(line => ({
...line,
invoicedValue: round2(line.invoicedValue),
netMass: round3(line.netMass),
}))
.sort((a, b) =>
a.cnCode.localeCompare(b.cnCode) ||
a.partnerCountry.localeCompare(b.partnerCountry)
)
// Step 5: Calculate totals
const totals = {
invoicedValue: round2(lines.reduce((sum, l) => sum + l.invoicedValue, 0)),
netMass: round3(lines.reduce((sum, l) => sum + l.netMass, 0)),
lineCount: lines.length,
}
// Step 6: Threshold status
const cumulativeValue = round2(priorCumulativeValue + totals.invoicedValue)
const percentageUsed = INTRASTAT_THRESHOLD > 0
? round2((cumulativeValue / INTRASTAT_THRESHOLD) * 100)
: 0
const thresholdStatus: ThresholdStatus = {
cumulativeValue,
threshold: INTRASTAT_THRESHOLD,
isObligated: cumulativeValue >= INTRASTAT_THRESHOLD,
percentageUsed,
}
if (percentageUsed >= 100) {
warnings.push({
type: 'threshold_exceeded',
severity: 'error',
message: `Tröskelvärdet för Intrastat (${formatSEK(INTRASTAT_THRESHOLD)} SEK) har överskridits. Rapportering till SCB är obligatorisk.`,
})
} else if (percentageUsed >= 80) {
warnings.push({
type: 'threshold_approaching',
severity: 'warning',
message: `Ackumulerad utförsel är ${formatSEK(cumulativeValue)} SEK (${percentageUsed}% av tröskelvärdet). Rapporteringsskyldighet kan uppstå snart.`,
})
}
return {
period: { year, month },
reporterVatNumber,
reporterName,
flowType: 'dispatch',
lines,
totals,
thresholdStatus,
warnings,
invoiceCount: relevantInvoices.length,
}
}
// ── Product matching ────────────────────────────────────────
/**
* Build a map for fast product lookup by description (case-insensitive).
* Products are matched by exact description or by productId.
*/
function buildProductMap(products: ProductMetadata[]): Map<string, ProductMetadata> {
const map = new Map<string, ProductMetadata>()
for (const product of products) {
map.set(product.productId.toLowerCase(), product)
if (product.description) {
map.set(product.description.toLowerCase(), product)
}
}
return map
}
/**
* Match an invoice line description to a product.
* Tries exact match first, then checks if description starts with a product key.
*/
function matchProduct(description: string, productMap: Map<string, ProductMetadata>): ProductMetadata | null {
const lower = description.toLowerCase().trim()
// Exact match
if (productMap.has(lower)) return productMap.get(lower)!
// Prefix match (invoice description may contain extra details)
for (const [key, product] of productMap) {
if (lower.startsWith(key) || key.startsWith(lower)) {
return product
}
}
return null
}
// ── Aggregation helpers ─────────────────────────────────────
function buildAggKey(
cnCode: string,
partnerCountry: string,
origin: string,
transactionNature: string,
deliveryTerms: string,
): string {
return `${cnCode}|${partnerCountry}|${origin}|${transactionNature}|${deliveryTerms}`
}
function addToAggregation(
map: Map<string, IntrastatLine>,
key: string,
line: IntrastatLine,
): void {
const existing = map.get(key)
if (existing) {
existing.invoicedValue += line.invoicedValue
existing.netMass += line.netMass
if (existing.supplementaryUnit !== null && line.supplementaryUnit !== null) {
existing.supplementaryUnit += line.supplementaryUnit
}
// Keep the first partner VAT ID encountered
} else {
map.set(key, { ...line })
}
}
// ── Amount helpers ──────────────────────────────────────────
function getInvoiceAmountSek(invoice: IntrastatInvoice): number {
if (invoice.subtotal_sek !== null) return round2(invoice.subtotal_sek)
return round2(invoice.subtotal)
}
function round2(value: number): number {
return Math.round(value * 100) / 100
}
function round3(value: number): number {
return Math.round(value * 1000) / 1000
}
function formatSEK(amount: number): string {
return Math.round(amount).toLocaleString('sv-SE')
}
// ── Period helpers ───────────────────────────────────────────
/**
* Get the Intrastat filing deadline for a month.
* The deadline is the 10th business day of the following month.
* For simplicity, we approximate as the 14th of the following month.
*/
export function getIntrastatDeadline(year: number, month: number): string {
let deadlineMonth = month + 1
let deadlineYear = year
if (deadlineMonth > 12) {
deadlineMonth = 1
deadlineYear++
}
return `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-14`
}
@@ -1,74 +0,0 @@
/**
* Intrastat SCB CSV Generator
*
* Generates a semicolon-separated CSV file (UTF-8 with BOM)
* compatible with SCB's IDEP.web upload for Intrastat declarations.
*
* Format: Semicolon-delimited, UTF-8 BOM, whole SEK amounts,
* net mass in kg with up to 3 decimal places.
*
* Reference: SCB IDEP.web filformat, Intrastat utförsel
*/
import type { IntrastatReport } from './intrastat-engine'
const UTF8_BOM = '\uFEFF'
/**
* Generate an IDEP.web-compatible CSV for the Intrastat report.
*
* Columns (matching SCB IDEP.web format):
* CN-kod;Partnerland;Ursprungsland;Transaktionstyp;Leveransvillkor;
* Fakturerat värde (SEK);Nettovikt (kg);Kompletterande enhet;Partner-VAT
*/
export function generateSCBCsv(report: IntrastatReport): string {
const header = [
'CN-kod',
'Partnerland',
'Ursprungsland',
'Transaktionstyp',
'Leveransvillkor',
'Fakturerat värde (SEK)',
'Nettovikt (kg)',
'Kompletterande enhet',
'Partner-VAT',
].join(';')
const rows = report.lines.map(line => {
const value = Math.round(line.invoicedValue)
const mass = roundMass(line.netMass)
const suppUnit = line.supplementaryUnit !== null ? String(Math.round(line.supplementaryUnit)) : ''
return [
line.cnCode,
line.partnerCountry,
line.countryOfOrigin,
line.transactionNature,
line.deliveryTerms,
String(value),
String(mass),
suppUnit,
line.partnerVatId,
].join(';')
})
return UTF8_BOM + [header, ...rows].join('\r\n') + '\r\n'
}
/**
* Generate a filename for the IDEP.web CSV download.
*
* Format: INTRASTAT_<VAT>_<YYYY>-<MM>.csv
*/
export function generateSCBFilename(report: IntrastatReport): string {
const vat = report.reporterVatNumber.replace(/\s/g, '')
const period = `${report.period.year}-${String(report.period.month).padStart(2, '0')}`
return `INTRASTAT_${vat}_${period}.csv`
}
/** Round mass to max 3 decimal places, removing trailing zeros */
function roundMass(kg: number): string {
const rounded = Math.round(kg * 1000) / 1000
if (rounded === Math.floor(rounded)) return String(rounded)
return rounded.toFixed(3).replace(/0+$/, '')
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Re-export from core. EU country data now lives in lib/vat/eu-countries.ts.
* This file exists for backward compatibility with export extensions.
*/
export {
type EUCountry,
EU_COUNTRIES,
EU_COUNTRY_CODES_EXCL_SE,
EU_COUNTRY_CODES,
isEUCountry,
isEUCountryIncludingSE,
getEUCountry,
getVatPrefix,
toCountryCode,
} from '@/lib/vat/eu-countries'
@@ -1,132 +0,0 @@
/**
* Momsdeklaration box mapping for Swedish VAT returns.
*
* Maps BAS revenue accounts to the correct box (ruta) in the
* momsdeklaration filed with Skatteverket. Used by:
* - Export VAT Monitor (full box overview)
* - EU Sales List (cross-validation against box 35/39)
*
* Reference: Skatteverket momsdeklaration (SKV 4700)
* https://www.skatteverket.se/foretag/moms/deklareramoms/fyllaimomsdeklarationen
*/
/** Momsdeklaration box number */
export type MomsBox =
| '05' // Momspliktig forsaljning (taxable sales)
| '06' // Momspliktiga uttag (taxable withdrawals)
| '07' // Vinstmarginalbeskattning (margin scheme)
| '08' // Hyresinkomster frivillig beskattning (rental)
| '10' // Utgaende moms 25%
| '11' // Utgaende moms 12%
| '12' // Utgaende moms 6%
| '20' // Inkop varor fran EU
| '21' // Inkop tjanster fran EU
| '22' // Inkop tjanster utanfor EU
| '23' // Inkop varor Sverige omvand skattskyldighet
| '24' // Inkop tjanster Sverige omvand skattskyldighet
| '30' // Utgaende moms inkop 25%
| '31' // Utgaende moms inkop 12%
| '32' // Utgaende moms inkop 6%
| '35' // Varuforssaljning till annat EU-land
| '36' // Varuforssaljning utanfor EU (export)
| '37' // Mellanmans inkop trepartshandel
| '38' // Mellanmans forsaljning trepartshandel
| '39' // Tjansteforssaljning EU (huvudregeln)
| '40' // Ovrig forsaljning av tjanster utomlands
| '41' // Forsaljning omvand skattskyldighet Sverige
| '42' // Ovrig forsaljning m.m.
| '48' // Ingaende moms att dra av
| '49' // Moms att betala eller fa tillbaka
| '50' // Importbeskattningsunderlag
| '60' // Importmoms 25%
| '61' // Importmoms 12%
| '62' // Importmoms 6%
/** Map BAS revenue account to momsdeklaration box */
export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
// Domestic revenue (taxable) → Box 05
'3001': '05', // Forsaljning varor/tjanster 25%
'3002': '05', // Forsaljning varor/tjanster 12%
'3003': '05', // Forsaljning varor/tjanster 6%
// EU goods (reverse charge, VAT-free) → Box 35
'3108': '35', // Forsaljning varor till annat EU-land
'3521': '35', // Fakturerade frakter EU (follows goods treatment)
// Non-EU goods export (zero-rated) → Box 36
'3105': '36', // Forsaljning varor export utanfor EU
'3522': '36', // Fakturerade frakter export
// Triangular trade → Box 38
'3109': '38', // Mellanmans forsaljning trepartshandel
// EU services (reverse charge, main rule) → Box 39
'3308': '39', // Forsaljning tjanster EU
// Non-EU services → Box 40
'3305': '40', // Forsaljning tjanster export utanfor EU
// Output VAT → Boxes 10, 11, 12
'2611': '10', // Utgaende moms 25%
'2621': '11', // Utgaende moms 12%
'2631': '12', // Utgaende moms 6%
// Input VAT → Box 48
'2641': '48', // Ingaende moms
'2645': '48', // Beraknad ingaende moms (EU forvarv)
}
/** Swedish labels for each momsdeklaration box */
export const BOX_LABELS: Record<MomsBox, string> = {
'05': 'Momspliktig försäljning',
'06': 'Momspliktiga uttag',
'07': 'Vinstmarginalbeskattning',
'08': 'Hyresinkomster (frivillig beskattning)',
'10': 'Utgående moms 25%',
'11': 'Utgående moms 12%',
'12': 'Utgående moms 6%',
'20': 'Inköp varor från EU',
'21': 'Inköp tjänster från EU',
'22': 'Inköp tjänster utanför EU',
'23': 'Inköp varor Sverige (omvänd skattskyldighet)',
'24': 'Inköp tjänster Sverige (omvänd skattskyldighet)',
'30': 'Utgående moms på inköp 25%',
'31': 'Utgående moms på inköp 12%',
'32': 'Utgående moms på inköp 6%',
'35': 'Varuförsäljning till annat EU-land',
'36': 'Varuförsäljning utanför EU (export)',
'37': 'Mellanmans inköp vid trepartshandel',
'38': 'Mellanmans försäljning vid trepartshandel',
'39': 'Tjänsteförsäljning till EU (huvudregeln)',
'40': 'Övrig försäljning av tjänster utomlands',
'41': 'Försäljning med omvänd skattskyldighet (Sverige)',
'42': 'Övrig försäljning m.m.',
'48': 'Ingående moms att dra av',
'49': 'Moms att betala eller få tillbaka',
'50': 'Beskattningsunderlag vid import',
'60': 'Importmoms 25%',
'61': 'Importmoms 12%',
'62': 'Importmoms 6%',
}
/** Get the momsdeklaration box for a BAS account number */
export function getBoxForAccount(accountNumber: string): MomsBox | undefined {
return ACCOUNT_TO_BOX[accountNumber]
}
/** Get the Swedish label for a momsdeklaration box */
export function getBoxLabel(box: MomsBox): string {
return BOX_LABELS[box]
}
/** Boxes that represent VAT-exempt export/EU sales (no output VAT) */
export const EXPORT_BOXES: MomsBox[] = ['35', '36', '38', '39', '40']
/** Boxes that represent taxable domestic sales (have output VAT) */
export const DOMESTIC_BOXES: MomsBox[] = ['05', '06', '07', '08']
/** Boxes that represent output VAT */
export const OUTPUT_VAT_BOXES: MomsBox[] = ['10', '11', '12']
/** Boxes that represent input VAT */
export const INPUT_VAT_BOXES: MomsBox[] = ['48']
-17
View File
@@ -1,17 +0,0 @@
import type { Extension } from '@/lib/extensions/types'
/**
* Export VAT Monitor / Exportmoms-monitor Extension
*
* Dashboard that maps GL revenue accounts to Swedish momsdeklaration boxes
* (05, 35, 36, 39, 40). Shows revenue breakdown by destination type:
* domestic, EU B2B (reverse charge), and non-EU export.
*
* Validates VAT treatment consistency and flags potential errors before
* the user files their momsdeklaration.
*/
export const vatMonitorExtension: Extension = {
id: 'vat-monitor',
name: 'Exportmoms-monitor',
version: '1.0.0',
}
@@ -1,477 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
generateVatMonitorReport,
type GLLine,
type VatMonitorInvoice,
type VatMonitorCustomer,
type VatMonitorOptions,
} from '../vat-monitor-engine'
// ── Helpers ─────────────────────────────────────────────────
/** Create a credit GL line (revenue is credit-side) */
function creditLine(account: string, amount: number): GLLine {
return { account_number: account, debit_amount: 0, credit_amount: amount }
}
/** Create a debit GL line (input VAT is debit-side) */
function debitLine(account: string, amount: number): GLLine {
return { account_number: account, debit_amount: amount, credit_amount: 0 }
}
function makeInvoice(overrides: Partial<VatMonitorInvoice> = {}): VatMonitorInvoice {
return {
id: 'inv-1',
invoice_number: 'F2026-001',
vat_treatment: 'reverse_charge',
moms_ruta: '35',
customer_id: 'cust-1',
...overrides,
}
}
function makeCustomer(overrides: Partial<VatMonitorCustomer> = {}): VatMonitorCustomer {
return {
id: 'cust-1',
name: 'Acme GmbH',
country: 'DE',
vat_number: 'DE123456789',
vat_number_validated: true,
...overrides,
}
}
const BASE_OPTIONS: VatMonitorOptions = {
glLines: [],
year: 2026,
month: 1,
}
// ── Box mapping tests ───────────────────────────────────────
describe('box mapping', () => {
it('maps domestic revenue (3001) to box 05', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 100000)],
})
const box05 = report.boxes.find(b => b.boxNumber === '05')
expect(box05).toBeDefined()
expect(box05!.amount).toBe(100000)
})
it('maps EU goods (3108) to box 35', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 50000)],
})
const box35 = report.boxes.find(b => b.boxNumber === '35')
expect(box35).toBeDefined()
expect(box35!.amount).toBe(50000)
})
it('maps EU freight (3521) to box 35 alongside 3108', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 40000), creditLine('3521', 10000)],
})
const box35 = report.boxes.find(b => b.boxNumber === '35')
expect(box35!.amount).toBe(50000)
expect(box35!.accounts).toContain('3108')
expect(box35!.accounts).toContain('3521')
})
it('maps export goods (3105) to box 36', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3105', 80000)],
})
const box36 = report.boxes.find(b => b.boxNumber === '36')
expect(box36!.amount).toBe(80000)
})
it('maps export freight (3522) to box 36', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3522', 15000)],
})
const box36 = report.boxes.find(b => b.boxNumber === '36')
expect(box36!.amount).toBe(15000)
})
it('maps triangulation (3109) to box 38', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3109', 25000)],
})
const box38 = report.boxes.find(b => b.boxNumber === '38')
expect(box38!.amount).toBe(25000)
})
it('maps EU services (3308) to box 39', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3308', 30000)],
})
const box39 = report.boxes.find(b => b.boxNumber === '39')
expect(box39!.amount).toBe(30000)
})
it('maps export services (3305) to box 40', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3305', 20000)],
})
const box40 = report.boxes.find(b => b.boxNumber === '40')
expect(box40!.amount).toBe(20000)
})
it('maps output VAT (2611) to box 10', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('2611', 25000)],
})
const box10 = report.boxes.find(b => b.boxNumber === '10')
expect(box10!.amount).toBe(25000)
})
it('maps input VAT (2641) to box 48', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [debitLine('2641', 15000)],
})
const box48 = report.boxes.find(b => b.boxNumber === '48')
expect(box48!.amount).toBe(15000)
})
})
// ── Revenue breakdown tests ─────────────────────────────────
describe('revenue breakdown', () => {
it('calculates correct breakdown for mixed revenue', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('3001', 200000), // domestic
creditLine('3108', 100000), // EU goods
creditLine('3308', 50000), // EU services
creditLine('3105', 75000), // export goods
creditLine('3305', 25000), // export services
],
})
const rb = report.revenueBreakdown
expect(rb.totalRevenue).toBe(450000)
expect(rb.domestic.amount).toBe(200000)
expect(rb.euGoods.amount).toBe(100000)
expect(rb.euServices.amount).toBe(50000)
expect(rb.exportGoods.amount).toBe(75000)
expect(rb.exportServices.amount).toBe(25000)
})
it('calculates correct percentages', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('3001', 50000),
creditLine('3108', 50000),
],
})
expect(report.revenueBreakdown.domestic.percentage).toBe(50)
expect(report.revenueBreakdown.euGoods.percentage).toBe(50)
})
it('returns 0% when no revenue', () => {
const report = generateVatMonitorReport(BASE_OPTIONS)
expect(report.revenueBreakdown.totalRevenue).toBe(0)
expect(report.revenueBreakdown.domestic.percentage).toBe(0)
})
it('only domestic sales — export categories are zero', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 100000)],
})
expect(report.revenueBreakdown.domestic.amount).toBe(100000)
expect(report.revenueBreakdown.euGoods.amount).toBe(0)
expect(report.revenueBreakdown.exportGoods.amount).toBe(0)
})
})
// ── VAT summary tests ───────────────────────────────────────
describe('VAT summary', () => {
it('calculates net VAT: output - input = box 49', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('2611', 50000), // output 25%
creditLine('2621', 12000), // output 12%
creditLine('2631', 3000), // output 6%
debitLine('2641', 30000), // input VAT
],
})
expect(report.vatSummary.outputVat25).toBe(50000)
expect(report.vatSummary.outputVat12).toBe(12000)
expect(report.vatSummary.outputVat6).toBe(3000)
expect(report.vatSummary.totalOutputVat).toBe(65000)
expect(report.vatSummary.inputVat).toBe(30000)
expect(report.vatSummary.netVat).toBe(35000)
expect(report.vatSummary.isRefund).toBe(false)
})
it('identifies refund when input > output', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('2611', 10000),
debitLine('2641', 50000),
],
})
expect(report.vatSummary.netVat).toBe(-40000)
expect(report.vatSummary.isRefund).toBe(true)
})
it('box 49 appears in boxes list', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('2611', 25000),
debitLine('2641', 10000),
],
})
const box49 = report.boxes.find(b => b.boxNumber === '49')
expect(box49).toBeDefined()
expect(box49!.amount).toBe(15000)
expect(box49!.label).toContain('betala')
})
})
// ── Invoice validation tests ────────────────────────────────
describe('invoice validation', () => {
it('warns when reverse_charge invoice has customer without VAT number', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 10000)],
invoices: [makeInvoice({ vat_treatment: 'reverse_charge' })],
customers: [makeCustomer({ vat_number: null })],
})
const missingVat = report.warnings.filter(w => w.type === 'missing_vat_number')
expect(missingVat).toHaveLength(1)
expect(missingVat[0].severity).toBe('error')
})
it('warns when reverse_charge invoice has unvalidated VAT number', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 10000)],
invoices: [makeInvoice({ vat_treatment: 'reverse_charge' })],
customers: [makeCustomer({ vat_number_validated: false })],
})
const unvalidated = report.warnings.filter(w => w.type === 'unvalidated_vat_number')
expect(unvalidated).toHaveLength(1)
expect(unvalidated[0].severity).toBe('warning')
})
it('warns when moms_ruta does not match vat_treatment', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [],
invoices: [makeInvoice({ vat_treatment: 'standard_25', moms_ruta: '35' })],
customers: [makeCustomer()],
})
const mismatch = report.warnings.filter(w => w.type === 'vat_treatment_mismatch')
expect(mismatch).toHaveLength(1)
})
it('no warning when moms_ruta matches reverse_charge (35, 38, or 39)', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [],
invoices: [
makeInvoice({ id: 'i1', vat_treatment: 'reverse_charge', moms_ruta: '35' }),
makeInvoice({ id: 'i2', vat_treatment: 'reverse_charge', moms_ruta: '39' }),
makeInvoice({ id: 'i3', vat_treatment: 'reverse_charge', moms_ruta: '38' }),
],
customers: [makeCustomer()],
})
const mismatch = report.warnings.filter(w => w.type === 'vat_treatment_mismatch')
expect(mismatch).toHaveLength(0)
})
it('no warning when moms_ruta matches export (36 or 40)', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [],
invoices: [
makeInvoice({ id: 'i1', vat_treatment: 'export', moms_ruta: '36' }),
makeInvoice({ id: 'i2', vat_treatment: 'export', moms_ruta: '40' }),
],
customers: [makeCustomer()],
})
const mismatch = report.warnings.filter(w => w.type === 'vat_treatment_mismatch')
expect(mismatch).toHaveLength(0)
})
it('no validation warnings when no invoices provided', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 10000)],
})
expect(report.warnings).toHaveLength(0)
})
})
// ── Period comparison tests ─────────────────────────────────
describe('period comparison', () => {
it('calculates delta between current and previous period', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 200000)],
previousGlLines: [creditLine('3001', 150000)],
})
expect(report.comparison).not.toBeNull()
expect(report.comparison!.domestic.current).toBe(200000)
expect(report.comparison!.domestic.previous).toBe(150000)
expect(report.comparison!.domestic.change).toBe(50000)
expect(report.comparison!.domestic.changePercent).toBeCloseTo(33.33, 1)
})
it('returns null changePercent when previous is zero', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 50000)],
previousGlLines: [],
})
expect(report.comparison!.euGoods.changePercent).toBeNull()
})
it('shows negative delta when revenue decreased', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 80000)],
previousGlLines: [creditLine('3001', 100000)],
})
expect(report.comparison!.domestic.change).toBe(-20000)
expect(report.comparison!.domestic.changePercent).toBe(-20)
})
it('no comparison when previousGlLines not provided', () => {
const report = generateVatMonitorReport(BASE_OPTIONS)
expect(report.comparison).toBeNull()
})
it('includes netVat comparison', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('2611', 50000), debitLine('2641', 20000)],
previousGlLines: [creditLine('2611', 40000), debitLine('2641', 25000)],
})
expect(report.comparison!.netVat.current).toBe(30000)
expect(report.comparison!.netVat.previous).toBe(15000)
expect(report.comparison!.netVat.change).toBe(15000)
})
})
// ── Empty / edge case tests ─────────────────────────────────
describe('edge cases', () => {
it('generates empty report with no GL data', () => {
const report = generateVatMonitorReport(BASE_OPTIONS)
expect(report.boxes).toHaveLength(1) // Only box 49 (net = 0)
expect(report.revenueBreakdown.totalRevenue).toBe(0)
expect(report.vatSummary.netVat).toBe(0)
expect(report.warnings).toHaveLength(0)
})
it('ignores irrelevant accounts', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('1510', 100000), // accounts receivable — not relevant
creditLine('3001', 50000), // domestic revenue — relevant
],
})
expect(report.revenueBreakdown.totalRevenue).toBe(50000)
})
it('handles mixed debit/credit on same account', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('3001', 100000),
{ account_number: '3001', debit_amount: 10000, credit_amount: 0 }, // credit note reversal
],
})
expect(report.revenueBreakdown.domestic.amount).toBe(90000)
})
it('rounds amounts to 2 decimal places', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 33333.335)],
})
expect(report.revenueBreakdown.domestic.amount).toBe(33333.34)
})
it('boxes are sorted by box number', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('2641', 0), // We need debit line for 2641
debitLine('2641', 10000),
creditLine('2611', 25000),
creditLine('3001', 100000),
creditLine('3108', 50000),
],
})
const boxNumbers = report.boxes.map(b => b.boxNumber)
const sorted = [...boxNumbers].sort()
expect(boxNumbers).toEqual(sorted)
})
it('includes period info in report', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
year: 2026,
month: 3,
quarter: undefined,
})
expect(report.period.year).toBe(2026)
expect(report.period.month).toBe(3)
})
})
@@ -1,453 +0,0 @@
/**
* Export VAT Monitor Engine
*
* Analyses journal entry data to produce a momsdeklaration preview
* focused on export and intra-community trade. Maps BAS accounts to
* momsdeklaration boxes, breaks down revenue by destination type,
* and validates invoice-to-account consistency.
*
* Pure functions — no Supabase, no React, no side effects.
*
* Complements the core VatDeclaration report by adding:
* - Revenue breakdown by destination (domestic / EU / non-EU)
* - Invoice-level validation (vat_treatment vs account)
* - Period-over-period comparison
*
* Reference: Skatteverket momsdeklaration (SKV 4700)
*/
import {
ACCOUNT_TO_BOX,
BOX_LABELS,
type MomsBox,
} from '@/extensions/export/shared/moms-box-mapping'
// ── Types ────────────────────────────────────────────────────
/** Journal entry line data pre-fetched from GL */
export interface GLLine {
account_number: string
debit_amount: number
credit_amount: number
}
/** Invoice data for validation (subset of core Invoice type) */
export interface VatMonitorInvoice {
id: string
invoice_number: string
vat_treatment: string
moms_ruta: string | null
customer_id: string
}
/** Customer data for validation */
export interface VatMonitorCustomer {
id: string
name: string
country: string
vat_number: string | null
vat_number_validated: boolean
}
/** A single momsdeklaration box result */
export interface VatBoxData {
boxNumber: MomsBox
label: string
amount: number
accounts: string[]
}
/** Revenue breakdown by destination type */
export interface RevenueBreakdown {
domestic: { amount: number; percentage: number }
euGoods: { amount: number; percentage: number }
euServices: { amount: number; percentage: number }
exportGoods: { amount: number; percentage: number }
exportServices: { amount: number; percentage: number }
triangular: { amount: number; percentage: number }
totalRevenue: number
}
/** Warning about VAT data quality */
export interface VatMonitorWarning {
type:
| 'vat_treatment_mismatch'
| 'missing_vat_number'
| 'unvalidated_vat_number'
| 'wrong_account'
severity: 'error' | 'warning'
invoiceId?: string
invoiceNumber?: string
customerName?: string
message: string
}
/** Period comparison delta for a destination type */
export interface PeriodDelta {
current: number
previous: number
change: number
changePercent: number | null
}
/** Complete VAT Monitor report */
export interface VatMonitorReport {
period: { year: number; month?: number; quarter?: number }
boxes: VatBoxData[]
revenueBreakdown: RevenueBreakdown
vatSummary: {
outputVat25: number
outputVat12: number
outputVat6: number
totalOutputVat: number
inputVat: number
netVat: number
isRefund: boolean
}
warnings: VatMonitorWarning[]
comparison: PeriodComparison | null
}
/** Period-over-period comparison */
export interface PeriodComparison {
domestic: PeriodDelta
euGoods: PeriodDelta
euServices: PeriodDelta
exportGoods: PeriodDelta
exportServices: PeriodDelta
triangular: PeriodDelta
totalRevenue: PeriodDelta
netVat: PeriodDelta
}
/** Options for generating the VAT Monitor report */
export interface VatMonitorOptions {
glLines: GLLine[]
invoices?: VatMonitorInvoice[]
customers?: VatMonitorCustomer[]
year: number
month?: number
quarter?: number
previousGlLines?: GLLine[]
}
// ── Revenue account groups ──────────────────────────────────
const DOMESTIC_ACCOUNTS = ['3001', '3002', '3003']
const EU_GOODS_ACCOUNTS = ['3108', '3521']
const EU_SERVICES_ACCOUNTS = ['3308']
const EXPORT_GOODS_ACCOUNTS = ['3105', '3522']
const EXPORT_SERVICES_ACCOUNTS = ['3305']
const TRIANGULATION_ACCOUNTS = ['3109']
const OUTPUT_VAT_ACCOUNTS: Record<string, string> = {
'2611': '25%',
'2621': '12%',
'2631': '6%',
}
const INPUT_VAT_ACCOUNTS = ['2641', '2645']
// All accounts this engine cares about
const ALL_ACCOUNTS = [
...DOMESTIC_ACCOUNTS,
...EU_GOODS_ACCOUNTS,
...EU_SERVICES_ACCOUNTS,
...EXPORT_GOODS_ACCOUNTS,
...EXPORT_SERVICES_ACCOUNTS,
...TRIANGULATION_ACCOUNTS,
...Object.keys(OUTPUT_VAT_ACCOUNTS),
...INPUT_VAT_ACCOUNTS,
]
// ── Expected vat_treatment for revenue account groups ───────
const ACCOUNT_EXPECTED_VAT_TREATMENT: Record<string, string[]> = {
'3001': ['standard_25'],
'3002': ['reduced_12'],
'3003': ['reduced_6'],
'3108': ['reverse_charge'],
'3521': ['reverse_charge'],
'3109': ['reverse_charge'],
'3308': ['reverse_charge'],
'3105': ['export'],
'3522': ['export'],
'3305': ['export'],
}
// ── Core engine ──────────────────────────────────────────────
/**
* Generate a VAT Monitor report from pre-fetched GL data.
*/
export function generateVatMonitorReport(options: VatMonitorOptions): VatMonitorReport {
const { glLines, invoices, customers, year, month, quarter, previousGlLines } = options
const warnings: VatMonitorWarning[] = []
// Step 1: Aggregate GL lines into account credit balances
const accountBalances = aggregateGLLines(glLines)
// Step 2: Map accounts to boxes
const boxes = buildBoxes(accountBalances)
// Step 3: Calculate revenue breakdown
const revenueBreakdown = calculateRevenueBreakdown(accountBalances)
// Step 4: Calculate VAT summary
const vatSummary = calculateVatSummary(accountBalances)
// Step 5: Validate invoices if provided
if (invoices && customers) {
validateInvoices(invoices, customers, warnings)
}
// Step 6: Calculate period comparison if previous data provided
let comparison: PeriodComparison | null = null
if (previousGlLines) {
const prevBalances = aggregateGLLines(previousGlLines)
const prevBreakdown = calculateRevenueBreakdown(prevBalances)
const prevVat = calculateVatSummary(prevBalances)
comparison = buildComparison(revenueBreakdown, prevBreakdown, vatSummary.netVat, prevVat.netVat)
}
return {
period: { year, month, quarter },
boxes,
revenueBreakdown,
vatSummary,
warnings,
comparison,
}
}
// ── GL aggregation ──────────────────────────────────────────
/**
* Aggregate GL lines into net balances per account.
* Revenue accounts: credit balance (credit - debit).
* VAT accounts: see sign per account type.
*/
function aggregateGLLines(lines: GLLine[]): Map<string, number> {
const balances = new Map<string, number>()
for (const line of lines) {
if (!ALL_ACCOUNTS.includes(line.account_number)) continue
const credit = Number(line.credit_amount) || 0
const debit = Number(line.debit_amount) || 0
// Input VAT is debit-side, all others are credit-side
const isInputVat = INPUT_VAT_ACCOUNTS.includes(line.account_number)
const balance = isInputVat ? (debit - credit) : (credit - debit)
balances.set(
line.account_number,
round2((balances.get(line.account_number) ?? 0) + balance),
)
}
return balances
}
/** Sum balances for a set of accounts */
function sumAccounts(balances: Map<string, number>, accounts: string[]): number {
return round2(accounts.reduce((sum, acc) => sum + (balances.get(acc) ?? 0), 0))
}
// ── Box building ────────────────────────────────────────────
function buildBoxes(balances: Map<string, number>): VatBoxData[] {
// Group accounts by box
const boxAccounts = new Map<MomsBox, string[]>()
const boxAmounts = new Map<MomsBox, number>()
for (const [account, balance] of balances) {
const box = ACCOUNT_TO_BOX[account]
if (!box) continue
if (!boxAccounts.has(box)) boxAccounts.set(box, [])
boxAccounts.get(box)!.push(account)
boxAmounts.set(box, round2((boxAmounts.get(box) ?? 0) + balance))
}
// Build sorted box list
const boxes: VatBoxData[] = []
for (const [box, amount] of boxAmounts) {
boxes.push({
boxNumber: box,
label: BOX_LABELS[box],
amount: round2(amount),
accounts: boxAccounts.get(box) ?? [],
})
}
// Add box 49 (net VAT = output - input)
const outputVat = sumAccounts(balances, Object.keys(OUTPUT_VAT_ACCOUNTS))
const inputVat = sumAccounts(balances, INPUT_VAT_ACCOUNTS)
const netVat = round2(outputVat - inputVat)
boxes.push({
boxNumber: '49',
label: BOX_LABELS['49'],
amount: netVat,
accounts: [...Object.keys(OUTPUT_VAT_ACCOUNTS), ...INPUT_VAT_ACCOUNTS],
})
// Sort by box number
boxes.sort((a, b) => a.boxNumber.localeCompare(b.boxNumber))
return boxes
}
// ── Revenue breakdown ───────────────────────────────────────
function calculateRevenueBreakdown(balances: Map<string, number>): RevenueBreakdown {
const domestic = sumAccounts(balances, DOMESTIC_ACCOUNTS)
const euGoods = sumAccounts(balances, EU_GOODS_ACCOUNTS)
const euServices = sumAccounts(balances, EU_SERVICES_ACCOUNTS)
const exportGoods = sumAccounts(balances, EXPORT_GOODS_ACCOUNTS)
const exportServices = sumAccounts(balances, EXPORT_SERVICES_ACCOUNTS)
const triangular = sumAccounts(balances, TRIANGULATION_ACCOUNTS)
const totalRevenue = round2(domestic + euGoods + euServices + exportGoods + exportServices + triangular)
const pct = (amount: number) => totalRevenue > 0 ? round2((amount / totalRevenue) * 100) : 0
return {
domestic: { amount: domestic, percentage: pct(domestic) },
euGoods: { amount: euGoods, percentage: pct(euGoods) },
euServices: { amount: euServices, percentage: pct(euServices) },
exportGoods: { amount: exportGoods, percentage: pct(exportGoods) },
exportServices: { amount: exportServices, percentage: pct(exportServices) },
triangular: { amount: triangular, percentage: pct(triangular) },
totalRevenue,
}
}
// ── VAT summary ─────────────────────────────────────────────
function calculateVatSummary(balances: Map<string, number>) {
const outputVat25 = balances.get('2611') ?? 0
const outputVat12 = balances.get('2621') ?? 0
const outputVat6 = balances.get('2631') ?? 0
const totalOutputVat = round2(outputVat25 + outputVat12 + outputVat6)
const inputVat = sumAccounts(balances, INPUT_VAT_ACCOUNTS)
const netVat = round2(totalOutputVat - inputVat)
return {
outputVat25: round2(outputVat25),
outputVat12: round2(outputVat12),
outputVat6: round2(outputVat6),
totalOutputVat,
inputVat: round2(inputVat),
netVat,
isRefund: netVat < 0,
}
}
// ── Invoice validation ──────────────────────────────────────
function validateInvoices(
invoices: VatMonitorInvoice[],
customers: VatMonitorCustomer[],
warnings: VatMonitorWarning[],
) {
const customerMap = new Map(customers.map(c => [c.id, c]))
for (const invoice of invoices) {
const customer = customerMap.get(invoice.customer_id)
// Check: reverse_charge invoices should have EU customer with VAT number
if (invoice.vat_treatment === 'reverse_charge' && customer) {
if (!customer.vat_number) {
warnings.push({
type: 'missing_vat_number',
severity: 'error',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerName: customer.name,
message: `Faktura ${invoice.invoice_number} (${customer.name}) har omvänd skattskyldighet men kunden saknar VAT-nummer.`,
})
} else if (!customer.vat_number_validated) {
warnings.push({
type: 'unvalidated_vat_number',
severity: 'warning',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerName: customer.name,
message: `Faktura ${invoice.invoice_number} (${customer.name}): VAT-nummer ${customer.vat_number} har inte validerats via VIES.`,
})
}
}
// Check: moms_ruta consistency with vat_treatment
if (invoice.moms_ruta && invoice.vat_treatment) {
const expectedBoxes = getExpectedBoxesForTreatment(invoice.vat_treatment)
if (expectedBoxes.length > 0 && !expectedBoxes.includes(invoice.moms_ruta)) {
warnings.push({
type: 'vat_treatment_mismatch',
severity: 'warning',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Faktura ${invoice.invoice_number} har momsbehandling "${invoice.vat_treatment}" men moms_ruta "${invoice.moms_ruta}" (förväntat: ${expectedBoxes.join('/')}).`,
})
}
}
}
}
/** Get expected momsdeklaration boxes for a vat_treatment value */
function getExpectedBoxesForTreatment(vatTreatment: string): string[] {
switch (vatTreatment) {
case 'standard_25':
case 'reduced_12':
case 'reduced_6':
return ['05']
case 'reverse_charge':
return ['35', '38', '39']
case 'export':
return ['36', '40']
default:
return []
}
}
// ── Period comparison ───────────────────────────────────────
function buildComparison(
current: RevenueBreakdown,
previous: RevenueBreakdown,
currentNetVat: number,
previousNetVat: number,
): PeriodComparison {
return {
domestic: makeDelta(current.domestic.amount, previous.domestic.amount),
euGoods: makeDelta(current.euGoods.amount, previous.euGoods.amount),
euServices: makeDelta(current.euServices.amount, previous.euServices.amount),
exportGoods: makeDelta(current.exportGoods.amount, previous.exportGoods.amount),
exportServices: makeDelta(current.exportServices.amount, previous.exportServices.amount),
triangular: makeDelta(current.triangular.amount, previous.triangular.amount),
totalRevenue: makeDelta(current.totalRevenue, previous.totalRevenue),
netVat: makeDelta(currentNetVat, previousNetVat),
}
}
function makeDelta(current: number, previous: number): PeriodDelta {
const change = round2(current - previous)
const changePercent = previous !== 0 ? round2((change / Math.abs(previous)) * 100) : null
return { current, previous, change, changePercent }
}
// ── Helpers ─────────────────────────────────────────────────
function round2(value: number): number {
return Math.round(value * 100) / 100
}
// ── Exported constants for API route ────────────────────────
/** All BAS accounts that the VAT Monitor needs from GL */
export const VAT_MONITOR_ACCOUNTS = ALL_ACCOUNTS
@@ -0,0 +1,161 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import type { CategorizationSuggestion } from './categorizer'
import { categorizeTransactions, getSettings, saveSettings } from './index'
// ============================================================
// /suggestions — GET: fetch stored suggestions
// ============================================================
async function handleGetSuggestions(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const idsParam = searchParams.get('transaction_ids')
if (!idsParam) {
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
}
const transactionIds = idsParam.split(',').filter(Boolean).slice(0, 50)
// Read stored suggestions from extension_data
const keys = transactionIds.map((id) => `suggestion:${id}`)
const { data: records } = await supabase
.from('extension_data')
.select('key, value')
.eq('user_id', userId)
.eq('extension_id', 'ai-categorization')
.in('key', keys)
const suggestions: Record<string, CategorizationSuggestion> = {}
if (records) {
for (const record of records) {
const txId = record.key.replace('suggestion:', '')
suggestions[txId] = record.value as unknown as CategorizationSuggestion
}
}
return NextResponse.json({ suggestions })
}
// ============================================================
// /suggestions — POST: trigger on-demand categorization
// ============================================================
async function handlePostSuggestions(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const body = await request.json()
const { transaction_ids } = body
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
}
const ids = transaction_ids.slice(0, 50)
try {
const suggestions = await categorizeTransactions(userId, ids)
// Group by transaction ID
const grouped: Record<string, CategorizationSuggestion> = {}
for (const s of suggestions) {
grouped[s.transactionId] = s
}
return NextResponse.json({ suggestions: grouped })
} catch (error) {
console.error('[ai-categorization] On-demand categorization failed:', error)
return NextResponse.json(
{ error: 'AI categorization failed' },
{ status: 500 }
)
}
}
// ============================================================
// /settings — GET: get current settings
// ============================================================
async function handleGetSettings(
_request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const settings = await getSettings(userId)
return NextResponse.json({ data: settings })
}
// ============================================================
// /settings — PUT/PATCH: update settings
// ============================================================
async function handleUpdateSettings(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const body = await request.json()
// Validate setting keys
const allowedKeys = [
'autoSuggestEnabled',
'confidenceThreshold',
'providerModel',
]
const filtered: Record<string, unknown> = {}
for (const key of allowedKeys) {
if (key in body) {
filtered[key] = body[key]
}
}
if (Object.keys(filtered).length === 0) {
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
}
const settings = await saveSettings(userId, filtered)
return NextResponse.json({ data: settings })
}
// ============================================================
// Route definitions
// ============================================================
export const aiCategorizationApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/suggestions',
handler: handleGetSuggestions,
},
{
method: 'POST',
path: '/suggestions',
handler: handlePostSuggestions,
},
{
method: 'GET',
path: '/settings',
handler: handleGetSettings,
},
{
method: 'PUT',
path: '/settings',
handler: handleUpdateSettings,
},
{
method: 'PATCH',
path: '/settings',
handler: handleUpdateSettings,
},
]
+26 -1
View File
@@ -1,4 +1,5 @@
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { aiCategorizationApiRoutes } from './api-routes'
import type { EventPayload } from '@/lib/events/types'
import type { Transaction, EntityType } from '@/types'
import {
@@ -10,7 +11,6 @@ import {
type AccountUsageEntry,
type MerchantHistoryEntry,
} from './categorizer'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
// ============================================================
@@ -287,6 +287,7 @@ async function buildEnrichedContext(
// Find candidate templates via embedding search
// Use a representative subset of transactions to find candidates
const { findSimilarTemplates } = await import('./lib/template-embeddings')
const representativeTransactions = transactions.slice(0, 5)
const candidateMap = new Map<string, BookingTemplate>()
@@ -356,6 +357,8 @@ export const aiCategorizationExtension: Extension = {
id: 'ai-categorization',
name: 'AI Kategorisering',
version: '1.0.0',
sector: 'general',
apiRoutes: aiCategorizationApiRoutes,
eventHandlers: [
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
],
@@ -363,6 +366,28 @@ export const aiCategorizationExtension: Extension = {
label: 'AI Kategorisering',
path: '/settings/extensions/ai-categorization',
},
services: {
findSimilarTemplates: async (...args: unknown[]) => {
const { findSimilarTemplates } = await import('./lib/template-embeddings')
return findSimilarTemplates(
args[0] as Transaction,
args[1] as EntityType | undefined,
args[2] as number | undefined,
args[3] as string | undefined
)
},
seedAllTemplateEmbeddings: async () => {
const { seedAllTemplateEmbeddings } = await import('./lib/template-embeddings')
return seedAllTemplateEmbeddings()
},
getSchemaVersion: async () => {
const { getSchemaVersion } = await import('./lib/template-embeddings')
return getSchemaVersion()
},
categorizeTransactions: async (...args: unknown[]) => {
return categorizeTransactions(args[0] as string, args[1] as string[])
},
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { makeTransaction, createMockSupabase } from '@/tests/helpers'
import { BOOKING_TEMPLATES } from '@/lib/bookkeeping/booking-templates'
// Mock server-only (no-op in tests)
vi.mock('server-only', () => ({}))
// Mock OpenAI Embeddings
vi.mock('@langchain/openai', () => {
class MockOpenAIEmbeddings {
embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1))
embedDocuments = vi.fn().mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => new Array(1536).fill(0.1)))
)
}
return { OpenAIEmbeddings: MockOpenAIEmbeddings }
})
// Mock Supabase
const { supabase: mockSupabase, mockResult } = createMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn().mockResolvedValue(mockSupabase),
}))
describe('template-embeddings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('buildEmbeddingText', () => {
it('includes all relevant fields for a template', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
const text = buildEmbeddingText(template)
// Should include Swedish and English name
expect(text).toContain('Lokalhyra')
expect(text).toContain('Office rent')
// Should include description
expect(text).toContain(template.description_sv)
// Should include keywords
expect(text).toContain('hyra')
expect(text).toContain('lokal')
// Should include group
expect(text).toContain('premises')
// Should include direction
expect(text).toContain('utgift')
// Should include accounts
expect(text).toContain('5010')
expect(text).toContain('1930')
})
it('includes VAT treatment when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
const text = buildEmbeddingText(template)
expect(text).toContain('standard_25')
expect(text).toContain('25%')
})
it('includes special rules when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
const text = buildEmbeddingText(template)
expect(text).toContain(template.special_rules_sv!)
})
it('includes MCC codes when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
const text = buildEmbeddingText(template)
expect(text).toContain('4900')
})
it('includes deductibility note for non-full deductibility', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.deductibility === 'non_deductible')!
const text = buildEmbeddingText(template)
expect(text).toContain('non_deductible')
})
it('generates text for all templates without error', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
for (const template of BOOKING_TEMPLATES) {
const text = buildEmbeddingText(template)
expect(text.length).toBeGreaterThan(10)
}
})
})
describe('buildTransactionQueryText', () => {
it('combines description, merchant, and direction', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
mcc_code: 5815,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('SPOTIFY PREMIUM')
expect(text).toContain('Spotify')
expect(text).toContain('MCC 5815')
expect(text).toContain('utgift')
})
it('marks positive amounts as income', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'Inbetalning',
amount: 5000,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('intäkt')
})
it('handles null merchant_name and mcc_code', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'Some payment',
merchant_name: null,
mcc_code: null,
amount: -100,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('Some payment')
expect(text).toContain('utgift')
expect(text).not.toContain('MCC')
})
it('prepends user description when provided', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SWE REST 4521 STHLM',
merchant_name: 'Unknown',
amount: -450,
})
const text = buildTransactionQueryText(tx, 'business lunch with client')
// User description should appear first
expect(text.indexOf('business lunch with client')).toBe(0)
// Transaction data should still be present
expect(text).toContain('SWE REST 4521 STHLM')
expect(text).toContain('Unknown')
expect(text).toContain('utgift')
})
it('behaves identically when userDescription is undefined', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
})
const withoutDesc = buildTransactionQueryText(tx)
const withUndefined = buildTransactionQueryText(tx, undefined)
expect(withoutDesc).toBe(withUndefined)
})
})
describe('getSchemaVersion', () => {
it('returns a consistent hash string', async () => {
const { getSchemaVersion } = await import('../template-embeddings')
const v1 = getSchemaVersion()
const v2 = getSchemaVersion()
expect(v1).toBe(v2)
expect(v1).toHaveLength(12)
expect(v1).toMatch(/^[a-f0-9]+$/)
})
})
describe('findSimilarTemplates', () => {
it('returns empty array on RPC error (graceful fallback)', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
// Mock staleness check
mockResult({ data: { schema_version: 'test' }, error: null })
const tx = makeTransaction({
description: 'SPOTIFY',
amount: -109,
})
// The mock will return error for the RPC call
mockResult({ data: null, error: { message: 'RPC failed' } })
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
})
it('returns empty array when no embeddings exist', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
mockResult({ data: [], error: null })
const tx = makeTransaction({
description: 'Random purchase',
amount: -50,
})
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
})
})
})
@@ -0,0 +1,265 @@
/**
* Template Embeddings Module
*
* SERVER-ONLY: Uses OpenAI embeddings and Supabase service client.
*
* Provides semantic search over booking templates using pgvector.
* Templates are pre-embedded and stored in the database. Transaction
* text is embedded at query time and compared via cosine similarity.
*/
import 'server-only'
import { OpenAIEmbeddings } from '@langchain/openai'
import {
BOOKING_TEMPLATES,
getTemplateById,
type BookingTemplate,
type TemplateMatch,
} from '@/lib/bookkeeping/booking-templates'
import type { Transaction, EntityType } from '@/types'
import { createHash } from 'crypto'
// ============================================================
// Constants
// ============================================================
export const EMBEDDING_MODEL = 'text-embedding-3-small'
const EMBEDDING_LOGIC_VERSION = '1'
const MATCH_COUNT = 5
const MATCH_THRESHOLD = 0.5
/**
* Schema version is a hash of the model + embedding logic version.
* Bump EMBEDDING_LOGIC_VERSION when buildEmbeddingText changes.
*/
export function getSchemaVersion(): string {
return createHash('sha256')
.update(`${EMBEDDING_MODEL}:${EMBEDDING_LOGIC_VERSION}`)
.digest('hex')
.slice(0, 12)
}
// ============================================================
// Embedding Text Builders
// ============================================================
/**
* Build a rich text representation of a template for embedding.
* Includes all semantically relevant fields.
*/
export function buildEmbeddingText(template: BookingTemplate): string {
const parts: string[] = []
parts.push(`${template.name_sv} (${template.name_en})`)
parts.push(template.description_sv)
if (template.keywords.length > 0) {
parts.push(`Nyckelord: ${template.keywords.join(', ')}`)
}
parts.push(`Grupp: ${template.group}`)
parts.push(`Typ: ${template.direction === 'expense' ? 'utgift' : template.direction === 'income' ? 'intäkt' : 'överföring'}`)
parts.push(`Konton: ${template.debit_account} (debet) / ${template.credit_account} (kredit)`)
if (template.vat_treatment) {
parts.push(`Moms: ${template.vat_treatment} (${template.vat_rate * 100}%)`)
}
if (template.special_rules_sv) {
parts.push(`Regler: ${template.special_rules_sv}`)
}
if (template.mcc_codes.length > 0) {
parts.push(`MCC-koder: ${template.mcc_codes.join(', ')}`)
}
if (template.deductibility !== 'full') {
parts.push(`Avdragsrätt: ${template.deductibility}`)
}
return parts.join('. ')
}
/**
* Build query text from a transaction for embedding search.
* When userDescription is provided, it is prepended so it dominates
* the semantic search (user intent > raw bank text).
*/
export function buildTransactionQueryText(
transaction: Transaction,
userDescription?: string
): string {
const parts: string[] = []
if (userDescription) {
parts.push(userDescription)
}
if (transaction.description) {
parts.push(transaction.description)
}
if (transaction.merchant_name) {
parts.push(transaction.merchant_name)
}
if (transaction.mcc_code) {
parts.push(`MCC ${transaction.mcc_code}`)
}
parts.push(transaction.amount < 0 ? 'utgift' : 'intäkt')
return parts.join(' — ')
}
// ============================================================
// Embeddings Client
// ============================================================
let embeddingsInstance: OpenAIEmbeddings | null = null
function getEmbeddingsClient(): OpenAIEmbeddings {
if (!embeddingsInstance) {
embeddingsInstance = new OpenAIEmbeddings({
modelName: EMBEDDING_MODEL,
openAIApiKey: process.env.OPENAI_API_KEY,
})
}
return embeddingsInstance
}
// ============================================================
// Seed All Template Embeddings
// ============================================================
export async function seedAllTemplateEmbeddings(): Promise<{
seeded: number
errors: string[]
}> {
const { createServiceClient } = await import('@/lib/supabase/server')
const supabase = await createServiceClient()
const embeddings = getEmbeddingsClient()
const schemaVersion = getSchemaVersion()
const errors: string[] = []
// Build texts for all templates
const texts = BOOKING_TEMPLATES.map((t) => buildEmbeddingText(t))
// Batch embed all texts
let vectors: number[][]
try {
vectors = await embeddings.embedDocuments(texts)
} catch (error) {
return { seeded: 0, errors: [`Embedding generation failed: ${error}`] }
}
// Upsert each template embedding
let seeded = 0
for (let i = 0; i < BOOKING_TEMPLATES.length; i++) {
const template = BOOKING_TEMPLATES[i]
const { error } = await supabase
.from('booking_template_embeddings')
.upsert(
{
template_id: template.id,
embedding: JSON.stringify(vectors[i]),
embedding_text: texts[i],
model: EMBEDDING_MODEL,
schema_version: schemaVersion,
},
{ onConflict: 'template_id' }
)
if (error) {
errors.push(`Failed to upsert ${template.id}: ${error.message}`)
} else {
seeded++
}
}
return { seeded, errors }
}
// ============================================================
// Find Similar Templates (Semantic Search)
// ============================================================
let stalenessWarned = false
export async function findSimilarTemplates(
transaction: Transaction,
entityType?: EntityType,
matchCount: number = MATCH_COUNT,
userDescription?: string
): Promise<TemplateMatch[]> {
try {
const { createServiceClient } = await import('@/lib/supabase/server')
const supabase = await createServiceClient()
const embeddings = getEmbeddingsClient()
// Check schema version staleness on first call
if (!stalenessWarned) {
const { data: sample } = await supabase
.from('booking_template_embeddings')
.select('schema_version')
.limit(1)
.single()
if (sample && sample.schema_version !== getSchemaVersion()) {
console.warn(
`[template-embeddings] Schema version mismatch: DB has "${sample.schema_version}", current is "${getSchemaVersion()}". Re-seed embeddings.`
)
}
stalenessWarned = true
}
// Embed the transaction query text
const queryText = buildTransactionQueryText(transaction, userDescription)
const queryVector = await embeddings.embedQuery(queryText)
// Request extra results to account for post-filtering
const requestCount = matchCount + 10
const { data, error } = await supabase.rpc('match_booking_templates', {
query_embedding: JSON.stringify(queryVector),
match_count: requestCount,
match_threshold: MATCH_THRESHOLD,
})
if (error || !data) {
console.error('[template-embeddings] RPC error:', error)
return []
}
// Map RPC results to TemplateMatch[], filtering by entity type and direction
const isExpense = transaction.amount < 0
const isIncome = transaction.amount > 0
const results: TemplateMatch[] = []
for (const row of data as { template_id: string; similarity: number }[]) {
const template = getTemplateById(row.template_id)
if (!template) continue
// Filter by entity applicability
if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) {
continue
}
// Filter by direction
if (template.direction === 'expense' && !isExpense) continue
if (template.direction === 'income' && !isIncome) continue
results.push({
template,
confidence: Math.round(row.similarity * 100) / 100,
})
if (results.length >= matchCount) break
}
return results
} catch (error) {
console.error('[template-embeddings] findSimilarTemplates failed:', error)
return []
}
}
@@ -0,0 +1,19 @@
{
"id": "ai-categorization",
"sector": "general",
"exportName": "aiCategorizationExtension",
"entryPoint": "@/extensions/general/ai-categorization",
"workspace": "@/components/extensions/general/AiCategorizationWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@anthropic-ai/sdk", "@langchain/openai"],
"definition": {
"name": "AI-kategorisering",
"category": "operations",
"icon": "Sparkles",
"dataPattern": "core",
"readsCoreTables": ["transactions"],
"description": "AI-drivna kategoriförslag för transaktioner",
"longDescription": "Använder AI för att automatiskt föreslå BAS-kontokategorier för dina banktransaktioner. Lär sig från dina tidigare bokföringsval."
}
}
+587
View File
@@ -0,0 +1,587 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import { generateChatResponse, streamChatResponse } from '@/extensions/general/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
// Simple in-memory rate limiting (per user)
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
function checkRateLimit(userId: string): boolean {
const now = Date.now()
const limit = rateLimitMap.get(userId)
if (!limit || now > limit.resetTime) {
rateLimitMap.set(userId, {
count: 1,
resetTime: now + 60000, // 1 minute window
})
return true
}
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
return false
}
limit.count++
return true
}
// ============================================================
// POST / — Send a message and get a response
// ============================================================
async function handlePostChat(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
// Rate limiting
if (!checkRateLimit(userId)) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please wait a moment.' },
{ status: 429 }
)
}
try {
const body: ChatRequest = await request.json()
const { message, session_id } = body
if (!message || typeof message !== 'string' || message.trim().length === 0) {
return NextResponse.json(
{ error: 'Message is required' },
{ status: 400 }
)
}
let sessionId = session_id
// Create new session if not provided
if (!sessionId) {
const { data: newSession, error: sessionError } = await supabase
.from('chat_sessions')
.insert({
user_id: userId,
title: message.slice(0, 100), // Use first 100 chars of message as title
})
.select()
.single()
if (sessionError) {
console.error('Error creating session:', sessionError)
return NextResponse.json(
{ error: 'Failed to create chat session' },
{ status: 500 }
)
}
sessionId = newSession.id
} else {
// Verify session belongs to user
const { data: existingSession } = await supabase
.from('chat_sessions')
.select('id')
.eq('id', sessionId)
.eq('user_id', userId)
.single()
if (!existingSession) {
return NextResponse.json(
{ error: 'Session not found' },
{ status: 404 }
)
}
}
// Save user message
const { error: userMsgError } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'user',
content: message.trim(),
sources: [],
})
.select()
.single()
if (userMsgError) {
console.error('Error saving user message:', userMsgError)
return NextResponse.json(
{ error: 'Failed to save message' },
{ status: 500 }
)
}
// Get conversation history
const { data: history } = await supabase
.from('chat_messages')
.select('role, content')
.eq('session_id', sessionId)
.order('created_at', { ascending: true })
.limit(CHATBOT_CONFIG.maxHistoryMessages)
const conversationHistory = (history || []) as ChatMessage[]
// Generate AI response
const result = await generateChatResponse(message.trim(), conversationHistory)
// Save assistant message
const { data: assistantMessage, error: assistantMsgError } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'assistant',
content: result.content,
sources: result.sources,
})
.select()
.single()
if (assistantMsgError) {
console.error('Error saving assistant message:', assistantMsgError)
return NextResponse.json(
{ error: 'Failed to save response' },
{ status: 500 }
)
}
return NextResponse.json({
message: assistantMessage,
session_id: sessionId,
})
} catch (err) {
console.error('Chat error:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
{ status: 500 }
)
}
}
// ============================================================
// POST /stream — Streaming chat response via Server-Sent Events
// ============================================================
async function handlePostStream(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
if (!checkRateLimit(userId)) {
return new Response(
JSON.stringify({ error: 'Rate limit exceeded' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } }
)
}
try {
const body: ChatRequest = await request.json()
const { message, session_id } = body
if (!message || typeof message !== 'string' || message.trim().length === 0) {
return new Response(
JSON.stringify({ error: 'Message is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
let sessionId = session_id
// Create new session if not provided
if (!sessionId) {
const { data: newSession, error: sessionError } = await supabase
.from('chat_sessions')
.insert({
user_id: userId,
title: message.slice(0, 100),
})
.select()
.single()
if (sessionError) {
return new Response(
JSON.stringify({ error: 'Failed to create session' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
sessionId = newSession.id
} else {
// Verify session belongs to user
const { data: existingSession } = await supabase
.from('chat_sessions')
.select('id')
.eq('id', sessionId)
.eq('user_id', userId)
.single()
if (!existingSession) {
return new Response(
JSON.stringify({ error: 'Session not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
)
}
}
// Save user message
await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'user',
content: message.trim(),
sources: [],
})
// Get conversation history
const { data: history } = await supabase
.from('chat_messages')
.select('role, content')
.eq('session_id', sessionId)
.order('created_at', { ascending: true })
.limit(CHATBOT_CONFIG.maxHistoryMessages)
const conversationHistory = (history || []) as ChatMessage[]
// Create streaming response
const encoder = new TextEncoder()
let fullContent = ''
let sources: SourceReference[] = []
const stream = new ReadableStream({
async start(controller) {
try {
// Send session ID first
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
)
// Stream the response
for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) {
if (chunk.type === 'content') {
fullContent += chunk.data as string
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: chunk.data })}\n\n`)
)
} else if (chunk.type === 'sources') {
sources = chunk.data as SourceReference[]
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: chunk.data })}\n\n`)
)
}
}
// Save the complete assistant message
const { data: savedMessage } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'assistant',
content: fullContent,
sources,
})
.select()
.single()
// Send done signal with message ID
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
)
controller.close()
} catch (error) {
console.error('Streaming error:', error)
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
)
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (err) {
console.error('Stream setup error:', err)
return new Response(
JSON.stringify({ error: 'Failed to setup stream' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
}
// ============================================================
// GET /sessions — List all chat sessions for the user
// ============================================================
async function handleGetSessions(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20')
const offset = parseInt(searchParams.get('offset') || '0')
const { data, error, count } = await supabase
.from('chat_sessions')
.select('*', { count: 'exact' })
.eq('user_id', userId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (error) {
console.error('Error fetching sessions:', error)
return NextResponse.json(
{ error: 'Failed to fetch sessions' },
{ status: 500 }
)
}
return NextResponse.json({ data, count })
}
// ============================================================
// POST /sessions — Create a new chat session
// ============================================================
async function handlePostSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
try {
const body = await request.json()
const { title } = body
const { data, error } = await supabase
.from('chat_sessions')
.insert({
user_id: userId,
title: title || null,
})
.select()
.single()
if (error) {
console.error('Error creating session:', error)
return NextResponse.json(
{ error: 'Failed to create session' },
{ status: 500 }
)
}
return NextResponse.json({ data })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create session' },
{ status: 400 }
)
}
}
// ============================================================
// GET /sessions/:id — Get a single chat session with messages
// ============================================================
async function handleGetSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
}
// Get session with messages
const { data: session, error: sessionError } = await supabase
.from('chat_sessions')
.select('*')
.eq('id', id)
.eq('user_id', userId)
.single()
if (sessionError || !session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
// Get messages
const { data: messages, error: messagesError } = await supabase
.from('chat_messages')
.select('*')
.eq('session_id', id)
.order('created_at', { ascending: true })
if (messagesError) {
console.error('Error fetching messages:', messagesError)
return NextResponse.json(
{ error: 'Failed to fetch messages' },
{ status: 500 }
)
}
return NextResponse.json({
session,
messages: messages || [],
})
}
// ============================================================
// PATCH /sessions/:id — Update a chat session (e.g., rename)
// ============================================================
async function handlePatchSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
}
try {
const body = await request.json()
const { title } = body
const { data, error } = await supabase
.from('chat_sessions')
.update({ title })
.eq('id', id)
.eq('user_id', userId)
.select()
.single()
if (error) {
console.error('Error updating session:', error)
return NextResponse.json(
{ error: 'Failed to update session' },
{ status: 500 }
)
}
if (!data) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
return NextResponse.json({ data })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update session' },
{ status: 400 }
)
}
}
// ============================================================
// DELETE /sessions/:id — Delete a chat session and its messages
// ============================================================
async function handleDeleteSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
}
// Delete session (messages will cascade delete due to FK)
const { error } = await supabase
.from('chat_sessions')
.delete()
.eq('id', id)
.eq('user_id', userId)
if (error) {
console.error('Error deleting session:', error)
return NextResponse.json(
{ error: 'Failed to delete session' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
}
// ============================================================
// Route definitions
// ============================================================
export const aiChatApiRoutes: ApiRouteDefinition[] = [
{
method: 'POST',
path: '/',
handler: handlePostChat,
},
{
method: 'POST',
path: '/stream',
handler: handlePostStream,
},
{
method: 'GET',
path: '/sessions',
handler: handleGetSessions,
},
{
method: 'POST',
path: '/sessions',
handler: handlePostSession,
},
{
method: 'GET',
path: '/sessions/:id',
handler: handleGetSession,
},
{
method: 'PATCH',
path: '/sessions/:id',
handler: handlePatchSession,
},
{
method: 'DELETE',
path: '/sessions/:id',
handler: handleDeleteSession,
},
]
+3
View File
@@ -1,4 +1,5 @@
import type { Extension } from '@/lib/extensions/types'
import { aiChatApiRoutes } from './api-routes'
/**
* AI Chat Extension
@@ -15,6 +16,8 @@ export const aiChatExtension: Extension = {
id: 'ai-chat',
name: 'AI-assistent',
version: '1.0.0',
sector: 'general',
apiRoutes: aiChatApiRoutes,
settingsPanel: {
label: 'AI-assistent',
path: '/settings/extensions/ai-chat',
+25
View File
@@ -0,0 +1,25 @@
{
"id": "ai-chat",
"sector": "general",
"exportName": "aiChatExtension",
"entryPoint": "@/extensions/general/ai-chat",
"workspace": "@/components/extensions/general/AiChatWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai"],
"definition": {
"name": "AI-assistent",
"category": "operations",
"icon": "MessageSquare",
"dataPattern": "manual",
"hasOwnData": true,
"description": "AI-assistent för skatte- och bokföringsfrågor",
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.",
"quickAction": {
"label": "AI-assistent",
"description": "Fråga om bokföring",
"icon": "MessageSquare",
"event": "open-ai-chat"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"id": "calendar",
"sector": "general",
"exportName": "calendarExtension",
"entryPoint": "@/extensions/general/calendar",
"workspace": "@/components/extensions/general/CalendarWorkspace",
"requiredEnvVars": [],
"optionalEnvVars": [],
"npmDependencies": ["ics"],
"definition": {
"name": "Kalender",
"category": "operations",
"icon": "Calendar",
"dataPattern": "core",
"readsCoreTables": ["invoices", "deadlines", "customers"],
"description": "Fullstandig kalendervy med manads-, vecko- och dagsvisning",
"longDescription": "Se alla fakturadatum och deadlines i en interaktiv kalender med manads-, vecko- och dagsvy."
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { Extension } from '@/lib/extensions/types'
import { registerEmailService } from '@/lib/email/service'
import { ResendEmailService } from './lib/resend-service'
// Register the Resend implementation immediately when this extension is loaded
registerEmailService(new ResendEmailService())
export const emailExtension: Extension = {
id: 'email',
name: 'E-post (Resend)',
version: '1.0.0',
}
@@ -0,0 +1,220 @@
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { formatCurrency, formatDate } from '@/lib/utils'
function getDocumentLabel(invoice: Invoice): string {
if (invoice.credited_invoice_id) return 'Kreditfaktura'
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return 'Proformafaktura'
if (docType === 'delivery_note') return 'Följesedel'
return 'Faktura'
}
export interface InvoiceEmailData {
invoice: Invoice
customer: Customer
company: CompanySettings
}
/**
* Generate HTML email for sending an invoice
*/
export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const documentType = getDocumentLabel(invoice)
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
return `
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${documentType} ${invoice.invoice_number}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<!-- Header -->
<div style="margin-bottom: 30px;">
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: #111;">
${documentType} från ${company.company_name}
</h1>
<p style="margin: 0; color: #666; font-size: 14px;">
${documentType}nummer: ${invoice.invoice_number}
</p>
</div>
<!-- Greeting -->
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">
Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},
</p>
<p style="margin: 0;">
${isCreditNote
? `Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.`
: `Tack för ditt förtroende! Bifogat hittar du din faktura.`
}
</p>
</div>
<!-- Invoice Summary Box -->
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${documentType}nummer:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${invoice.invoice_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${documentType}datum:</td>
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Förfallodatum:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: ${isCreditNote ? '#333' : '#e11d48'};">
${formatDate(invoice.due_date)}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">Att betala:</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${isCreditNote ? '#059669' : '#111'};">
${formatCurrency(invoice.total, invoice.currency)}
</td>
</tr>
</table>
</div>
<!-- Payment Details -->
${!hidePayment ? `
<div style="margin-bottom: 30px;">
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: #111;">
Betalningsinformation
</h2>
<table style="width: 100%; border-collapse: collapse;">
${company.bank_name ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px; width: 140px;">Bank:</td>
<td style="padding: 6px 0;">${company.bank_name}</td>
</tr>
` : ''}
${company.clearing_number && company.account_number ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">Kontonummer:</td>
<td style="padding: 6px 0;">${company.clearing_number}-${company.account_number}</td>
</tr>
` : ''}
${company.iban ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">IBAN:</td>
<td style="padding: 6px 0;">${company.iban}</td>
</tr>
` : ''}
${company.bic ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">BIC/SWIFT:</td>
<td style="padding: 6px 0;">${company.bic}</td>
</tr>
` : ''}
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">Meddelande:</td>
<td style="padding: 6px 0; font-weight: 500;">${invoice.invoice_number}</td>
</tr>
</table>
</div>
` : ''}
<!-- Footer -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
<strong>${company.company_name}</strong>
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
Org.nr: ${company.org_number}
${company.vat_number ? ` | VAT: ${company.vat_number}` : ''}
${company.f_skatt ? ' | Innehar F-skattsedel' : ''}
</p>
` : ''}
</div>
</div>
</body>
</html>
`
}
/**
* Generate plain text email for sending an invoice
*/
export function generateInvoiceEmailText(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const documentType = getDocumentLabel(invoice)
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
let text = `${documentType} från ${company.company_name}\n`
text += `${documentType}nummer: ${invoice.invoice_number}\n\n`
text += `Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},\n\n`
if (isCreditNote) {
text += `Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.\n\n`
} else {
text += `Tack för ditt förtroende! Bifogat hittar du din faktura.\n\n`
}
text += `${documentType}sammanfattning:\n`
text += `---\n`
text += `${documentType}nummer: ${invoice.invoice_number}\n`
text += `${documentType}datum: ${formatDate(invoice.invoice_date)}\n`
text += `Förfallodatum: ${formatDate(invoice.due_date)}\n`
text += `Att betala: ${formatCurrency(invoice.total, invoice.currency)}\n`
text += `---\n\n`
if (!hidePayment) {
text += `Betalningsinformation:\n`
if (company.bank_name) text += `Bank: ${company.bank_name}\n`
if (company.clearing_number && company.account_number) {
text += `Kontonummer: ${company.clearing_number}-${company.account_number}\n`
}
if (company.iban) text += `IBAN: ${company.iban}\n`
if (company.bic) text += `BIC/SWIFT: ${company.bic}\n`
text += `Meddelande: ${invoice.invoice_number}\n\n`
}
text += `Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.\n\n`
text += `Med vänliga hälsningar,\n`
text += `${company.company_name}\n`
if (company.org_number) {
text += `\nOrg.nr: ${company.org_number}`
if (company.vat_number) text += ` | VAT: ${company.vat_number}`
if (company.f_skatt) text += ` | Innehar F-skattsedel`
text += `\n`
}
return text
}
/**
* Generate email subject for an invoice
*/
export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
const { invoice, company } = data
const documentType = getDocumentLabel(invoice)
return `${documentType} ${invoice.invoice_number} från ${company.company_name}`
}
@@ -0,0 +1,280 @@
import type { Invoice, Customer, CompanySettings } from '@/types'
import { formatCurrency, formatDate } from '@/lib/utils'
export interface ReminderEmailData {
invoice: Invoice
customer: Customer
company: CompanySettings
reminderLevel: 1 | 2 | 3
daysOverdue: number
actionUrl: string // URL for customer to mark as paid or dispute
}
// Reminder level configurations
const REMINDER_CONFIG = {
1: {
title: 'Vänlig påminnelse',
tone: 'friendly',
daysAfterDue: 15
},
2: {
title: 'Andra påminnelsen',
tone: 'firm',
daysAfterDue: 30
},
3: {
title: 'Slutlig påminnelse',
tone: 'urgent',
daysAfterDue: 45
}
} as const
/**
* Generate HTML email for payment reminder
*/
export function generateReminderEmailHtml(data: ReminderEmailData): string {
const { invoice, customer, company, reminderLevel, daysOverdue, actionUrl } = data
const config = REMINDER_CONFIG[reminderLevel]
// Different styling based on urgency
const headerColor = reminderLevel === 3 ? '#dc2626' : reminderLevel === 2 ? '#ea580c' : '#2563eb'
const buttonColor = reminderLevel === 3 ? '#dc2626' : reminderLevel === 2 ? '#ea580c' : '#2563eb'
return `
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${config.title} - Faktura ${invoice.invoice_number}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f9fafb;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="background: white; border-radius: 12px; padding: 40px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: ${headerColor}15; color: ${headerColor}; padding: 8px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
${config.title}
</div>
</div>
<!-- Title -->
<h1 style="margin: 0 0 20px 0; font-size: 22px; font-weight: 600; color: #111; text-align: center;">
Faktura ${invoice.invoice_number} förföll för ${daysOverdue} dagar sedan
</h1>
<!-- Greeting and Message -->
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">
Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},
</p>
${reminderLevel === 1 ? `
<p style="margin: 0 0 15px 0;">
Vi vill påminna dig om att faktura ${invoice.invoice_number} förföll till betalning den ${formatDate(invoice.due_date)}.
Om du redan har betalat kan du bortse från denna påminnelse.
</p>
` : reminderLevel === 2 ? `
<p style="margin: 0 0 15px 0;">
Trots vår tidigare påminnelse har vi ännu inte mottagit betalning för faktura ${invoice.invoice_number}
som förföll den ${formatDate(invoice.due_date)}.
</p>
<p style="margin: 0 0 15px 0;">
Vi ber dig vänligen att omgående reglera detta belopp för att undvika ytterligare åtgärder.
</p>
` : `
<p style="margin: 0 0 15px 0; color: #dc2626; font-weight: 500;">
Detta är vår slutliga påminnelse gällande faktura ${invoice.invoice_number}.
</p>
<p style="margin: 0 0 15px 0;">
Fakturan förföll till betalning den ${formatDate(invoice.due_date)} och vi har ännu inte mottagit betalning
trots tidigare påminnelser.
</p>
<p style="margin: 0 0 15px 0;">
Om betalning inte inkommer inom 7 dagar kommer ärendet att överlämnas för vidare hantering.
</p>
`}
</div>
<!-- Invoice Summary Box -->
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px; border-left: 4px solid ${headerColor};">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Fakturanummer:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${invoice.invoice_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Fakturadatum:</td>
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Förfallodatum:</td>
<td style="padding: 8px 0; text-align: right; color: #dc2626; font-weight: 500;">
${formatDate(invoice.due_date)}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">Belopp att betala:</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${headerColor};">
${formatCurrency(invoice.total, invoice.currency)}
</td>
</tr>
</table>
</div>
<!-- Payment Details -->
<div style="margin-bottom: 30px;">
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: #111;">
Betalningsinformation
</h2>
<table style="width: 100%; border-collapse: collapse;">
${company.bank_name ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px; width: 140px;">Bank:</td>
<td style="padding: 6px 0;">${company.bank_name}</td>
</tr>
` : ''}
${company.clearing_number && company.account_number ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">Kontonummer:</td>
<td style="padding: 6px 0;">${company.clearing_number}-${company.account_number}</td>
</tr>
` : ''}
${company.iban ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">IBAN:</td>
<td style="padding: 6px 0;">${company.iban}</td>
</tr>
` : ''}
${company.bic ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">BIC/SWIFT:</td>
<td style="padding: 6px 0;">${company.bic}</td>
</tr>
` : ''}
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">Meddelande:</td>
<td style="padding: 6px 0; font-weight: 500;">${invoice.invoice_number}</td>
</tr>
</table>
</div>
<!-- Action Buttons -->
<div style="text-align: center; margin-bottom: 30px;">
<p style="margin: 0 0 20px 0; color: #666; font-size: 14px;">
Har du redan betalat eller har frågor om fakturan?
</p>
<a href="${actionUrl}" style="display: inline-block; background: ${buttonColor}; color: white; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 500; font-size: 14px;">
Hantera faktura
</a>
</div>
<!-- Footer -->
<div style="padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
Har du frågor? Svara direkt på detta mejl så hjälper vi dig.
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
<strong>${company.company_name}</strong>
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
Org.nr: ${company.org_number}
${company.vat_number ? ` | VAT: ${company.vat_number}` : ''}
</p>
` : ''}
</div>
</div>
<!-- Unsubscribe note -->
<p style="text-align: center; margin-top: 20px; color: #999; font-size: 12px;">
Detta är ett automatiskt meddelande angående en obetald faktura.
</p>
</div>
</body>
</html>
`
}
/**
* Generate plain text email for payment reminder
*/
export function generateReminderEmailText(data: ReminderEmailData): string {
const { invoice, customer, company, reminderLevel, daysOverdue, actionUrl } = data
const config = REMINDER_CONFIG[reminderLevel]
let text = `${config.title.toUpperCase()}\n`
text += `Faktura ${invoice.invoice_number} förföll för ${daysOverdue} dagar sedan\n`
text += `=`.repeat(50) + `\n\n`
text += `Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},\n\n`
if (reminderLevel === 1) {
text += `Vi vill påminna dig om att faktura ${invoice.invoice_number} förföll till betalning den ${formatDate(invoice.due_date)}.\n`
text += `Om du redan har betalat kan du bortse från denna påminnelse.\n\n`
} else if (reminderLevel === 2) {
text += `Trots vår tidigare påminnelse har vi ännu inte mottagit betalning för faktura ${invoice.invoice_number} som förföll den ${formatDate(invoice.due_date)}.\n\n`
text += `Vi ber dig vänligen att omgående reglera detta belopp för att undvika ytterligare åtgärder.\n\n`
} else {
text += `DETTA ÄR VÅR SLUTLIGA PÅMINNELSE\n\n`
text += `Fakturan förföll till betalning den ${formatDate(invoice.due_date)} och vi har ännu inte mottagit betalning trots tidigare påminnelser.\n\n`
text += `Om betalning inte inkommer inom 7 dagar kommer ärendet att överlämnas för vidare hantering.\n\n`
}
text += `Fakturasammanfattning:\n`
text += `-`.repeat(30) + `\n`
text += `Fakturanummer: ${invoice.invoice_number}\n`
text += `Fakturadatum: ${formatDate(invoice.invoice_date)}\n`
text += `Förfallodatum: ${formatDate(invoice.due_date)}\n`
text += `Belopp att betala: ${formatCurrency(invoice.total, invoice.currency)}\n`
text += `-`.repeat(30) + `\n\n`
text += `Betalningsinformation:\n`
if (company.bank_name) text += `Bank: ${company.bank_name}\n`
if (company.clearing_number && company.account_number) {
text += `Kontonummer: ${company.clearing_number}-${company.account_number}\n`
}
if (company.iban) text += `IBAN: ${company.iban}\n`
if (company.bic) text += `BIC/SWIFT: ${company.bic}\n`
text += `Meddelande: ${invoice.invoice_number}\n\n`
text += `Har du redan betalat eller har frågor om fakturan?\n`
text += `Hantera faktura: ${actionUrl}\n\n`
text += `Har du frågor? Svara direkt på detta mejl så hjälper vi dig.\n\n`
text += `Med vänliga hälsningar,\n`
text += `${company.company_name}\n`
if (company.org_number) {
text += `\nOrg.nr: ${company.org_number}`
if (company.vat_number) text += ` | VAT: ${company.vat_number}`
text += `\n`
}
return text
}
/**
* Generate email subject for payment reminder
*/
export function generateReminderEmailSubject(data: ReminderEmailData): string {
const { invoice, reminderLevel } = data
const config = REMINDER_CONFIG[reminderLevel]
return `${config.title}: Faktura ${invoice.invoice_number} - ${formatCurrency(invoice.total, invoice.currency)}`
}
/**
* Get the number of days after due date for each reminder level
*/
export function getReminderDaysConfig(): Record<1 | 2 | 3, number> {
return {
1: REMINDER_CONFIG[1].daysAfterDue,
2: REMINDER_CONFIG[2].daysAfterDue,
3: REMINDER_CONFIG[3].daysAfterDue
}
}
@@ -0,0 +1,79 @@
/**
* Resend Email Service Implementation
*
* Implements EmailService using the Resend API.
*/
import { Resend } from 'resend'
import { createLogger } from '@/lib/logger'
import type { EmailService, SendEmailOptions, SendEmailResult } from '@/lib/email/service'
const log = createLogger('email')
const DEFAULT_FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'noreply@localhost'
let resendClient: Resend | null = null
function getResendClient(): Resend {
if (!resendClient) {
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY is not configured')
}
resendClient = new Resend(process.env.RESEND_API_KEY)
}
return resendClient
}
function isResendConfigured(): boolean {
return !!process.env.RESEND_API_KEY && !!process.env.RESEND_FROM_EMAIL && process.env.RESEND_FROM_EMAIL !== 'noreply@localhost'
}
export class ResendEmailService implements EmailService {
async sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const { to, subject, html, text, replyTo, fromName, attachments } = options
if (!this.isConfigured()) {
return { success: false, error: 'Email service is not configured' }
}
const from = fromName
? `${fromName} via ERP Base <${DEFAULT_FROM_EMAIL}>`
: `ERP Base <${DEFAULT_FROM_EMAIL}>`
try {
const resend = getResendClient()
const response = await resend.emails.send({
from,
to: Array.isArray(to) ? to : [to],
subject,
html,
text,
replyTo,
attachments: attachments?.map(att => ({
filename: att.filename,
content: typeof att.content === 'string'
? Buffer.from(att.content, 'base64')
: att.content,
content_type: att.contentType,
})),
})
if (response.error) {
log.error('Resend error:', response.error)
return { success: false, error: response.error.message }
}
return { success: true, messageId: response.data?.id }
} catch (error) {
log.error('Failed to send email:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
isConfigured(): boolean {
return isResendConfigured()
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"id": "email",
"sector": "general",
"exportName": "emailExtension",
"entryPoint": "@/extensions/general/email",
"workspace": null,
"requiredEnvVars": ["RESEND_API_KEY", "RESEND_FROM_EMAIL"],
"optionalEnvVars": [],
"npmDependencies": ["resend"],
"definition": {
"name": "E-post (Resend)",
"category": "operations",
"icon": "Mail",
"dataPattern": "core",
"readsCoreTables": ["invoices", "customers", "company_settings"],
"description": "Skicka fakturor och påminnelser via e-post",
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
}
}
@@ -0,0 +1,20 @@
{
"id": "enable-banking",
"sector": "general",
"exportName": "enableBankingExtension",
"entryPoint": "@/extensions/general/enable-banking",
"workspace": "@/components/extensions/general/EnableBankingWorkspace",
"requiredEnvVars": ["ENABLE_BANKING_APP_ID", "ENABLE_BANKING_PRIVATE_KEY"],
"optionalEnvVars": ["ENABLE_BANKING_SANDBOX"],
"npmDependencies": [],
"definition": {
"name": "Bankintegration (PSD2)",
"category": "import",
"icon": "Landmark",
"dataPattern": "manual",
"hasOwnData": true,
"description": "Automatisk banktransaktionssynk via PSD2",
"longDescription": "Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.",
"subscriptionNotice": "Denna integration kräver ett aktivt Enable Banking-abonnemang. Utan abonnemang kommer bankintegration inte att fungera."
}
}
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import { matchSupplier } from './lib/supplier-matcher'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { InvoiceInboxSettings } from './types'
import { invoiceInboxApiRoutes } from './api-routes'
// ============================================================
// Settings
@@ -210,6 +211,8 @@ export const invoiceInboxExtension: Extension = {
id: 'invoice-inbox',
name: 'Invoice Inbox',
version: '1.0.0',
sector: 'general',
apiRoutes: invoiceInboxApiRoutes,
eventHandlers: [
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
],
@@ -0,0 +1,19 @@
{
"id": "invoice-inbox",
"sector": "general",
"exportName": "invoiceInboxExtension",
"entryPoint": "@/extensions/general/invoice-inbox",
"workspace": "@/components/extensions/general/DocumentInboxWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@anthropic-ai/sdk"],
"definition": {
"name": "Dokumentinkorg",
"category": "import",
"icon": "Inbox",
"dataPattern": "manual",
"hasOwnData": true,
"description": "Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost",
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument."
}
}
@@ -117,7 +117,7 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
return
}
const vapidResponse = await fetch('/api/extensions/push-notifications/subscribe')
const vapidResponse = await fetch('/api/extensions/ext/push-notifications/subscribe')
const { vapidPublicKey } = await vapidResponse.json()
if (!vapidPublicKey) {
@@ -130,7 +130,7 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
const response = await fetch('/api/extensions/push-notifications/subscribe', {
const response = await fetch('/api/extensions/ext/push-notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
@@ -162,7 +162,7 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
if (subscription) {
await subscription.unsubscribe()
await fetch('/api/extensions/push-notifications/subscribe', {
await fetch('/api/extensions/ext/push-notifications/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
@@ -63,7 +63,7 @@ export function PushPrompt({ onSubscribed, onDismissed }: PushPromptProps) {
}
// Get VAPID public key
const vapidResponse = await fetch('/api/extensions/push-notifications/subscribe')
const vapidResponse = await fetch('/api/extensions/ext/push-notifications/subscribe')
const { vapidPublicKey } = await vapidResponse.json()
if (!vapidPublicKey) {
@@ -78,7 +78,7 @@ export function PushPrompt({ onSubscribed, onDismissed }: PushPromptProps) {
})
// Send subscription to server
const response = await fetch('/api/extensions/push-notifications/subscribe', {
const response = await fetch('/api/extensions/ext/push-notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
@@ -0,0 +1,215 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import { getVapidPublicKey } from './notification-sender'
import { getSettings, saveSettings } from './index'
// ============================================================
// /subscribe — GET: get VAPID public key for client-side subscription
// ============================================================
async function handleGetSubscribe(
_request: Request,
_ctx?: ExtensionContext
): Promise<Response> {
const vapidKey = getVapidPublicKey()
if (!vapidKey) {
return NextResponse.json(
{ error: 'Push notifications not configured' },
{ status: 500 }
)
}
return NextResponse.json({ vapidPublicKey: vapidKey })
}
// ============================================================
// /subscribe — POST: save a new push subscription
// ============================================================
async function handlePostSubscribe(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const body = await request.json()
const { endpoint, keys } = body
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return NextResponse.json(
{ error: 'Invalid subscription data' },
{ status: 400 }
)
}
// Get user agent for debugging
const userAgent = request.headers.get('user-agent') || null
// Upsert subscription (update if endpoint exists)
const { data, error } = await supabase
.from('push_subscriptions')
.upsert(
{
user_id: userId,
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
user_agent: userAgent,
is_active: true,
last_used_at: new Date().toISOString(),
},
{
onConflict: 'user_id,endpoint',
}
)
.select()
.single()
if (error) {
console.error('Error saving subscription:', error)
return NextResponse.json(
{ error: 'Failed to save subscription' },
{ status: 500 }
)
}
// Also ensure notification settings exist with defaults
await supabase
.from('notification_settings')
.upsert(
{
user_id: userId,
tax_deadlines_enabled: true,
invoice_reminders_enabled: true,
push_enabled: true,
email_enabled: true,
quiet_start: '21:00',
quiet_end: '08:00',
},
{
onConflict: 'user_id',
ignoreDuplicates: true,
}
)
return NextResponse.json({ success: true, id: data.id })
}
// ============================================================
// /subscribe — DELETE: remove a push subscription
// ============================================================
async function handleDeleteSubscribe(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const body = await request.json()
const { endpoint } = body
if (!endpoint) {
return NextResponse.json(
{ error: 'Endpoint is required' },
{ status: 400 }
)
}
const { error } = await supabase
.from('push_subscriptions')
.delete()
.eq('user_id', userId)
.eq('endpoint', endpoint)
if (error) {
return NextResponse.json(
{ error: 'Failed to remove subscription' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
}
// ============================================================
// /settings — GET: get current notification settings
// ============================================================
async function handleGetSettings(
_request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const settings = await getSettings(userId)
return NextResponse.json({ data: settings })
}
// ============================================================
// /settings — PUT: update notification settings
// ============================================================
async function handleUpdateSettings(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const body = await request.json()
const allowedKeys = [
'periodLockedEnabled',
'periodYearClosedEnabled',
'invoiceSentEnabled',
'receiptExtractedEnabled',
'receiptMatchedEnabled',
]
const filtered: Record<string, unknown> = {}
for (const key of allowedKeys) {
if (key in body) {
filtered[key] = body[key]
}
}
if (Object.keys(filtered).length === 0) {
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
}
const settings = await saveSettings(userId, filtered)
return NextResponse.json({ data: settings })
}
// ============================================================
// Route definitions
// ============================================================
export const pushNotificationsApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/subscribe',
handler: handleGetSubscribe,
},
{
method: 'POST',
path: '/subscribe',
handler: handlePostSubscribe,
},
{
method: 'DELETE',
path: '/subscribe',
handler: handleDeleteSubscribe,
},
{
method: 'GET',
path: '/settings',
handler: handleGetSettings,
},
{
method: 'PUT',
path: '/settings',
handler: handleUpdateSettings,
},
]
@@ -11,6 +11,7 @@
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import { pushNotificationsApiRoutes } from './api-routes'
import { sendNotificationToUser } from './notification-sender'
import {
createPeriodLockedPayload,
@@ -243,6 +244,8 @@ export const pushNotificationsExtension: Extension = {
id: 'push-notifications',
name: 'Push-notiser',
version: '1.0.0',
sector: 'general',
apiRoutes: pushNotificationsApiRoutes,
eventHandlers: [
{ eventType: 'period.locked', handler: handlePeriodLocked },
{ eventType: 'period.year_closed', handler: handleYearClosed },
@@ -0,0 +1,19 @@
{
"id": "push-notifications",
"sector": "general",
"exportName": "pushNotificationsExtension",
"entryPoint": "@/extensions/general/push-notifications",
"workspace": "@/components/extensions/general/PushNotificationsWorkspace",
"requiredEnvVars": ["VAPID_PRIVATE_KEY", "NEXT_PUBLIC_VAPID_PUBLIC_KEY"],
"optionalEnvVars": ["VAPID_SUBJECT"],
"npmDependencies": ["web-push"],
"definition": {
"name": "Push-notiser",
"category": "operations",
"icon": "Bell",
"dataPattern": "core",
"readsCoreTables": ["journal_entries", "invoices", "receipts"],
"description": "Händelsenotiser för bokföringsaktiviteter",
"longDescription": "Få push-notiser direkt i webbläsaren när viktiga händelser sker — nya fakturor, förfallna betalningar, slutförda bokföringar med mera."
}
}
@@ -0,0 +1,864 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import type { ConfirmReceiptInput, Receipt, ReceiptLineItem, Transaction } from '@/types'
import { analyzeReceipt } from './lib/receipt-analyzer'
import { processLineItems } from './lib/receipt-categorizer'
import { findTransactionMatches } from './lib/receipt-matcher'
import { getSettings, saveSettings } from './index'
// ============================================================
// / — GET: list receipts
// ============================================================
async function handleListReceipts(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50', 10)
const offset = parseInt(searchParams.get('offset') || '0', 10)
let query = supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*)
`, { count: 'exact' })
.eq('user_id', userId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (status) {
query = query.eq('status', status)
}
const { data, error, count } = await query
if (error) {
console.error('Receipts fetch error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({
data,
count,
limit,
offset,
})
}
// ============================================================
// /upload — POST: upload receipt image and extract data
// ============================================================
async function handleUpload(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
try {
const formData = await request.formData()
const imageFile = formData.get('image') as File | null
if (!imageFile) {
return NextResponse.json({ error: 'No image file provided' }, { status: 400 })
}
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!validTypes.includes(imageFile.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
{ status: 400 }
)
}
// Convert file to base64
const arrayBuffer = await imageFile.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
// Generate unique filename
const ext = imageFile.type.split('/')[1]
const filename = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('receipts')
.upload(filename, arrayBuffer, {
contentType: imageFile.type,
cacheControl: '3600',
})
if (uploadError) {
console.error('Storage upload error:', uploadError)
return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 })
}
// WORM archive copy (non-blocking -- receipt flow continues even if this fails)
let wormDocumentId: string | null = null
try {
const { uploadDocument } = await import('@/lib/core/documents/document-service')
const wormDoc = await uploadDocument(supabase, userId, {
name: imageFile.name,
buffer: arrayBuffer,
type: imageFile.type,
}, { upload_source: 'camera' })
wormDocumentId = wormDoc.id
} catch (archiveErr) {
console.error('[receipt-upload] WORM archive copy failed:', archiveErr)
}
// Get public URL
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
const imageUrl = urlData.publicUrl
// Create receipt record with pending status
const { data: receipt, error: insertError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: imageUrl,
status: 'processing',
document_id: wormDocumentId,
})
.select()
.single()
if (insertError) {
console.error('Receipt insert error:', insertError)
return NextResponse.json({ error: 'Failed to create receipt record' }, { status: 500 })
}
// Analyze receipt with Claude Vision
try {
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const extraction = await analyzeReceipt(base64, mimeType)
// Process and categorize line items
const processedLineItems = processLineItems(extraction.lineItems)
// Update receipt with extracted data
const { error: updateError } = await supabase
.from('receipts')
.update({
status: 'extracted',
extraction_confidence: extraction.confidence,
merchant_name: extraction.merchant.name,
merchant_org_number: extraction.merchant.orgNumber,
merchant_vat_number: extraction.merchant.vatNumber,
receipt_date: extraction.receipt.date,
receipt_time: extraction.receipt.time,
total_amount: extraction.totals.total,
currency: extraction.receipt.currency,
vat_amount: extraction.totals.vatAmount,
is_restaurant: extraction.flags.isRestaurant,
is_systembolaget: extraction.flags.isSystembolaget,
is_foreign_merchant: extraction.flags.isForeignMerchant,
raw_extraction: extraction,
})
.eq('id', receipt.id)
if (updateError) {
console.error('Receipt update error:', updateError)
}
// Insert line items
if (processedLineItems.length > 0) {
const lineItemsToInsert = processedLineItems.map((item, index) => ({
receipt_id: receipt.id,
description: item.description,
quantity: item.quantity,
unit_price: item.unitPrice,
line_total: item.lineTotal,
vat_rate: item.vatRate,
vat_amount: item.vatRate && item.lineTotal ? (item.lineTotal * item.vatRate) / (100 + item.vatRate) : null,
extraction_confidence: item.confidence,
suggested_category: item.suggestedCategory,
category: item.category,
bas_account: item.basAccount,
sort_order: index,
}))
const { error: lineItemsError } = await supabase
.from('receipt_line_items')
.insert(lineItemsToInsert)
if (lineItemsError) {
console.error('Line items insert error:', lineItemsError)
}
}
// Fetch the complete receipt with line items
const { data: completeReceipt, error: fetchError } = await supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*)
`)
.eq('id', receipt.id)
.single()
if (fetchError) {
console.error('Fetch error:', fetchError)
return NextResponse.json({
data: {
id: receipt.id,
status: 'extracted',
extraction,
},
})
}
// Emit receipt.extracted event
const { eventBus } = await import('@/lib/events/bus')
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt: completeReceipt,
documentId: wormDocumentId,
confidence: extraction.confidence,
userId,
},
})
return NextResponse.json({ data: completeReceipt })
} catch (analysisError) {
console.error('Receipt analysis error:', analysisError)
// Update receipt with error status
await supabase
.from('receipts')
.update({
status: 'error',
raw_extraction: {
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
},
})
.eq('id', receipt.id)
return NextResponse.json(
{
error: 'Failed to analyze receipt',
receiptId: receipt.id,
},
{ status: 500 }
)
}
} catch (error) {
console.error('Upload error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Upload failed' },
{ status: 500 }
)
}
}
// ============================================================
// /queue — GET: unmatched receipts and queue statistics
// ============================================================
async function handleGetQueue(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
// Parse query params
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const unmatched = searchParams.get('unmatched') === 'true'
// Build query
let query = supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*)
`)
.eq('user_id', userId)
.order('created_at', { ascending: false })
if (status) {
query = query.eq('status', status)
}
if (unmatched) {
query = query.is('matched_transaction_id', null)
}
const { data: receipts, error: receiptsError } = await query
if (receiptsError) {
console.error('Receipts fetch error:', receiptsError)
return NextResponse.json({ error: 'Failed to fetch receipts' }, { status: 500 })
}
// Get counts for queue summary
const { count: unmatchedReceiptsCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('status', 'confirmed')
.is('matched_transaction_id', null)
const { count: pendingReviewCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('status', 'extracted')
const { count: unmatchedTransactionsCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.lt('amount', 0)
.is('receipt_id', null)
// Calculate streak (days with at least one categorized transaction)
const { data: recentActivity } = await supabase
.from('receipts')
.select('created_at')
.eq('user_id', userId)
.eq('status', 'confirmed')
.order('created_at', { ascending: false })
.limit(30)
let streakCount = 0
if (recentActivity && recentActivity.length > 0) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const activityDates = new Set(
recentActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
)
const checkDate = new Date(today)
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
streakCount++
checkDate.setDate(checkDate.getDate() - 1)
}
}
return NextResponse.json({
data: {
receipts,
summary: {
unmatched_receipts_count: unmatchedReceiptsCount || 0,
unmatched_transactions_count: unmatchedTransactionsCount || 0,
pending_review_count: pendingReviewCount || 0,
streak_count: streakCount,
},
},
})
}
// ============================================================
// /:id — GET: single receipt with line items
// ============================================================
async function handleGetReceipt(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
const { data, error } = await supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*),
matched_transaction:transactions(*)
`)
.eq('id', id)
.eq('user_id', userId)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
// ============================================================
// /:id — DELETE: delete receipt and line items
// ============================================================
async function handleDeleteReceipt(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
// Get receipt to find image URL for cleanup
const { data: receipt } = await supabase
.from('receipts')
.select('image_url, matched_transaction_id')
.eq('id', id)
.eq('user_id', userId)
.single()
if (!receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
// Unlink from transaction if matched
if (receipt.matched_transaction_id) {
await supabase
.from('transactions')
.update({ receipt_id: null })
.eq('id', receipt.matched_transaction_id)
}
// Delete receipt (line items are cascade deleted)
const { error } = await supabase
.from('receipts')
.delete()
.eq('id', id)
.eq('user_id', userId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Optionally delete image from storage
if (receipt.image_url) {
try {
const urlParts = receipt.image_url.split('/receipts/')
if (urlParts[1]) {
await supabase.storage.from('receipts').remove([urlParts[1]])
}
} catch {
// Ignore storage cleanup errors
}
}
return NextResponse.json({ success: true })
}
// ============================================================
// /:id/confirm — POST: confirm line item classifications
// ============================================================
async function handleConfirm(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
// Verify receipt ownership
const { data: receipt, error: fetchError } = await supabase
.from('receipts')
.select('*')
.eq('id', id)
.eq('user_id', userId)
.single()
if (fetchError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
const body: ConfirmReceiptInput = await request.json()
// Update line items with classifications
if (body.line_items && body.line_items.length > 0) {
for (const item of body.line_items) {
const { error: updateError } = await supabase
.from('receipt_line_items')
.update({
is_business: item.is_business,
category: item.category || null,
bas_account: item.bas_account || null,
})
.eq('id', item.id)
.eq('receipt_id', id)
if (updateError) {
console.error('Line item update error:', updateError)
}
}
}
// Build receipt update
const receiptUpdate: Record<string, unknown> = {
status: 'confirmed',
}
// Add restaurant representation data if provided
if (body.representation_persons !== undefined) {
receiptUpdate.representation_persons = body.representation_persons
}
if (body.representation_purpose !== undefined) {
receiptUpdate.representation_purpose = body.representation_purpose
}
// Link to transaction if provided
if (body.matched_transaction_id) {
// Verify transaction ownership
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id')
.eq('id', body.matched_transaction_id)
.eq('user_id', userId)
.single()
if (!txError && transaction) {
receiptUpdate.matched_transaction_id = body.matched_transaction_id
// Also update the transaction with the receipt link
await supabase
.from('transactions')
.update({ receipt_id: id })
.eq('id', body.matched_transaction_id)
}
}
// Update the receipt
const { data: updatedReceipt, error: updateError } = await supabase
.from('receipts')
.update(receiptUpdate)
.eq('id', id)
.select(`
*,
line_items:receipt_line_items(*)
`)
.single()
if (updateError) {
console.error('Receipt update error:', updateError)
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
}
// Calculate business/private totals from line items
const lineItems = ((updatedReceipt as unknown as Receipt).line_items || []) as ReceiptLineItem[]
let businessTotal = 0
let privateTotal = 0
for (const item of lineItems) {
if (item.is_business === true) {
businessTotal += item.line_total
} else if (item.is_business === false) {
privateTotal += item.line_total
}
}
// Emit receipt.confirmed event
const { eventBus } = await import('@/lib/events/bus')
await eventBus.emit({
type: 'receipt.confirmed',
payload: {
receipt: updatedReceipt as unknown as Receipt,
businessTotal: Math.round(businessTotal * 100) / 100,
privateTotal: Math.round(privateTotal * 100) / 100,
userId,
},
})
return NextResponse.json({ data: updatedReceipt })
}
// ============================================================
// /:id/match — POST: find potential transaction matches
// ============================================================
async function handleFindMatches(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
// Fetch receipt
const { data: receipt, error: receiptError } = await supabase
.from('receipts')
.select('*')
.eq('id', id)
.eq('user_id', userId)
.single()
if (receiptError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
// Get date range for transaction search (+/-7 days from receipt date)
const receiptDate = receipt.receipt_date ? new Date(receipt.receipt_date) : new Date()
const startDate = new Date(receiptDate)
startDate.setDate(startDate.getDate() - 7)
const endDate = new Date(receiptDate)
endDate.setDate(endDate.getDate() + 7)
// Fetch unmatched transactions in date range
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('receipt_id', null)
.lt('amount', 0) // Only expenses
.gte('date', startDate.toISOString().split('T')[0])
.lte('date', endDate.toISOString().split('T')[0])
.order('date', { ascending: false })
if (txError) {
console.error('Transaction fetch error:', txError)
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
// Find matches
const matches = findTransactionMatches(
receipt as unknown as Receipt,
transactions as Transaction[]
)
return NextResponse.json({
data: {
receipt_id: id,
matches: matches.slice(0, 5), // Return top 5 matches
},
})
}
// ============================================================
// /:id/match — PATCH: link receipt to a specific transaction
// ============================================================
async function handleLinkMatch(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
const body = await request.json()
const { transaction_id, match_confidence } = body
if (!transaction_id) {
return NextResponse.json({ error: 'transaction_id is required' }, { status: 400 })
}
// Verify receipt ownership
const { data: receipt, error: receiptError } = await supabase
.from('receipts')
.select('*, line_items:receipt_line_items(*)')
.eq('id', id)
.eq('user_id', userId)
.single()
if (receiptError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
// Verify transaction ownership
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('id', transaction_id)
.eq('user_id', userId)
.single()
if (txError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Update receipt with match
const { error: updateReceiptError } = await supabase
.from('receipts')
.update({
matched_transaction_id: transaction_id,
match_confidence: match_confidence || null,
})
.eq('id', id)
if (updateReceiptError) {
console.error('Receipt update error:', updateReceiptError)
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
}
// Update transaction with receipt link
const { error: updateTxError } = await supabase
.from('transactions')
.update({ receipt_id: id })
.eq('id', transaction_id)
if (updateTxError) {
console.error('Transaction update error:', updateTxError)
}
// Emit receipt.matched event
const { eventBus } = await import('@/lib/events/bus')
await eventBus.emit({
type: 'receipt.matched',
payload: {
receipt: receipt as unknown as Receipt,
transaction: transaction as Transaction,
confidence: match_confidence || 0,
autoMatched: false,
userId,
},
})
return NextResponse.json({
data: {
receipt_id: id,
transaction_id,
matched: true,
},
})
}
// ============================================================
// /settings — GET: get current settings
// ============================================================
async function handleGetSettings(
_request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const settings = await getSettings(userId)
return NextResponse.json({ data: settings })
}
// ============================================================
// /settings — PUT: update settings
// ============================================================
async function handleUpdateSettings(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const body = await request.json()
// Validate setting keys
const allowedKeys = [
'autoOcrEnabled',
'autoMatchEnabled',
'autoMatchThreshold',
'ocrConfidenceThreshold',
]
const filtered: Record<string, unknown> = {}
for (const key of allowedKeys) {
if (key in body) {
filtered[key] = body[key]
}
}
if (Object.keys(filtered).length === 0) {
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
}
const settings = await saveSettings(userId, filtered)
return NextResponse.json({ data: settings })
}
// ============================================================
// Route definitions
// ============================================================
export const receiptOcrApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/',
handler: handleListReceipts,
},
{
method: 'POST',
path: '/upload',
handler: handleUpload,
},
{
method: 'GET',
path: '/queue',
handler: handleGetQueue,
},
{
method: 'GET',
path: '/:id',
handler: handleGetReceipt,
},
{
method: 'DELETE',
path: '/:id',
handler: handleDeleteReceipt,
},
{
method: 'POST',
path: '/:id/confirm',
handler: handleConfirm,
},
{
method: 'POST',
path: '/:id/match',
handler: handleFindMatches,
},
{
method: 'PATCH',
path: '/:id/match',
handler: handleLinkMatch,
},
{
method: 'GET',
path: '/settings',
handler: handleGetSettings,
},
{
method: 'PUT',
path: '/settings',
handler: handleUpdateSettings,
},
]
@@ -34,7 +34,7 @@ export default function TransactionMatcher({
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/match`, {
const response = await fetch(`/api/extensions/ext/receipt-ocr/${receipt.id}/match`, {
method: 'POST',
})
const data = await response.json()
+3
View File
@@ -1,6 +1,7 @@
import { analyzeReceipt } from './lib/receipt-analyzer'
import { processLineItems } from './lib/receipt-categorizer'
import { autoMatchReceipts } from './lib/receipt-matcher'
import { receiptOcrApiRoutes } from './api-routes'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { Receipt } from '@/types'
@@ -310,6 +311,8 @@ export const receiptOcrExtension: Extension = {
id: 'receipt-ocr',
name: 'Receipt OCR',
version: '1.0.0',
sector: 'general',
apiRoutes: receiptOcrApiRoutes,
eventHandlers: [
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
@@ -0,0 +1,25 @@
{
"id": "receipt-ocr",
"sector": "general",
"exportName": "receiptOcrExtension",
"entryPoint": "@/extensions/general/receipt-ocr",
"workspace": "@/components/extensions/general/ReceiptOcrWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@anthropic-ai/sdk"],
"definition": {
"name": "Kvittoscanning",
"category": "import",
"icon": "Camera",
"dataPattern": "manual",
"hasOwnData": true,
"description": "Skanna kvitton och extrahera data automatiskt",
"quickAction": {
"label": "Skanna kvitto",
"description": "Fotografera & spara",
"icon": "Camera",
"href": "/receipts/scan"
},
"longDescription": "Ladda upp kvittofoton och låt systemet automatiskt extrahera leverantör, belopp, moms och datum. Sparar tid och minskar manuell inmatning."
}
}
@@ -42,7 +42,7 @@ export default function ReceiptsPage() {
try {
const [receiptsRes, queueRes] = await Promise.all([
fetch('/api/extensions/receipt-ocr'),
fetch('/api/extensions/receipt-ocr/queue'),
fetch('/api/extensions/ext/receipt-ocr/queue'),
])
const [receiptsData, queueData] = await Promise.all([
@@ -109,7 +109,7 @@ export default function ReceiptsPage() {
}) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${selectedReceipt.id}/confirm`, {
const response = await fetch(`/api/extensions/ext/receipt-ocr/${selectedReceipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
@@ -129,7 +129,7 @@ export default function ReceiptsPage() {
const handleMatchReceipt = async (transactionId: string, confidence: number) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${selectedReceipt.id}/match`, {
const response = await fetch(`/api/extensions/ext/receipt-ocr/${selectedReceipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
@@ -36,7 +36,7 @@ export default function ScanReceiptPage() {
formData.append('image', blob, 'receipt.jpg')
// Upload and analyze
const response = await fetch('/api/extensions/receipt-ocr/upload', {
const response = await fetch('/api/extensions/ext/receipt-ocr/upload', {
method: 'POST',
body: formData,
})
@@ -70,7 +70,7 @@ export default function ScanReceiptPage() {
}) => {
if (!receipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/confirm`, {
const response = await fetch(`/api/extensions/ext/receipt-ocr/${receipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
@@ -90,7 +90,7 @@ export default function ScanReceiptPage() {
const handleMatch = async (transactionId: string, confidence: number) => {
if (!receipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/match`, {
const response = await fetch(`/api/extensions/ext/receipt-ocr/${receipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
@@ -0,0 +1,19 @@
{
"id": "user-description-match",
"sector": "general",
"exportName": "userDescriptionMatchExtension",
"entryPoint": "@/extensions/general/user-description-match",
"workspace": "@/components/extensions/general/UserDescriptionMatchWorkspace",
"requiredEnvVars": [],
"optionalEnvVars": [],
"npmDependencies": [],
"definition": {
"name": "Beskrivningsmatchning",
"category": "operations",
"icon": "TextSearch",
"dataPattern": "core",
"readsCoreTables": ["transactions", "mapping_rules"],
"description": "Matcha transaktioner med egna beskrivningar",
"longDescription": "Beskriv vad en transaktion gäller med egna ord och få smarta bokföringsförslag. Systemet lär sig av dina beskrivningar och applicerar automatiskt på framtida transaktioner från samma leverantör."
}
}
-2
View File
@@ -1,2 +0,0 @@
// Occupancy extension — Track and report hotel room occupancy rates
export const OCCUPANCY_EXTENSION_ID = 'hotel/occupancy'
@@ -1,178 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateOccupancyKPIs,
validateOccupancyEntry,
computePreviousPeriod,
filterEntriesByRange,
getOccupancyColor,
type DailyOccupancyEntry,
} from '../occupancy-calculator'
describe('calculateOccupancyKPIs', () => {
it('calculates basic occupancy percentage', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 80, roomsOutOfOrder: 2 },
{ date: '2025-01-02', roomsOccupied: 90, roomsOutOfOrder: 2 },
{ date: '2025-01-03', roomsOccupied: 70, roomsOutOfOrder: 3 },
]
const result = calculateOccupancyKPIs(entries, 100)
// totalOccupied = 240, totalCapacity = 300
// 240 / 300 * 100 = 80
expect(result.occupancyPct).toBe(80)
expect(result.totalOccupied).toBe(240)
expect(result.daysWithData).toBe(3)
})
it('calculates average occupied rooms', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 40, roomsOutOfOrder: 0 },
{ date: '2025-01-02', roomsOccupied: 50, roomsOutOfOrder: 0 },
{ date: '2025-01-03', roomsOccupied: 60, roomsOutOfOrder: 0 },
]
const result = calculateOccupancyKPIs(entries, 100)
// 150 / 3 = 50.0
expect(result.avgOccupied).toBe(50)
})
it('calculates average out-of-order rooms with 1 decimal', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 50, roomsOutOfOrder: 3 },
{ date: '2025-01-02', roomsOccupied: 50, roomsOutOfOrder: 5 },
{ date: '2025-01-03', roomsOccupied: 50, roomsOutOfOrder: 2 },
]
const result = calculateOccupancyKPIs(entries, 100)
// totalOOO = 10, 10 / 3 = 3.333... -> 3.3
expect(result.avgOutOfOrder).toBe(3.3)
expect(result.totalOutOfOrder).toBe(10)
})
it('calculates average available rooms = totalRooms - occupied - OOO', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 60, roomsOutOfOrder: 5 },
{ date: '2025-01-02', roomsOccupied: 70, roomsOutOfOrder: 10 },
]
const result = calculateOccupancyKPIs(entries, 100)
// totalCapacity = 200, totalOccupied = 130, totalOOO = 15
// available = (200 - 130 - 15) / 2 = 55 / 2 = 27.5
expect(result.avgAvailable).toBe(27.5)
})
it('returns all zeros for empty entries', () => {
const result = calculateOccupancyKPIs([], 100)
expect(result.occupancyPct).toBe(0)
expect(result.avgOccupied).toBe(0)
expect(result.avgOutOfOrder).toBe(0)
expect(result.avgAvailable).toBe(0)
expect(result.totalOccupied).toBe(0)
expect(result.totalOutOfOrder).toBe(0)
expect(result.daysWithData).toBe(0)
})
it('handles rounding edge cases for percentages and averages', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 33, roomsOutOfOrder: 1 },
{ date: '2025-01-02', roomsOccupied: 33, roomsOutOfOrder: 1 },
{ date: '2025-01-03', roomsOccupied: 34, roomsOutOfOrder: 1 },
]
const result = calculateOccupancyKPIs(entries, 100)
// totalOccupied = 100, totalCapacity = 300
// 100 / 300 * 100 = 33.333... -> Math.round(33.333... * 10000) / 100 = 33.33 (not 33.34)
expect(result.occupancyPct).toBe(33.33)
// avgOccupied = 100 / 3 = 33.333... -> Math.round(33.333... * 10) / 10 = 33.3
expect(result.avgOccupied).toBe(33.3)
// avgOOO = 3 / 3 = 1.0
expect(result.avgOutOfOrder).toBe(1)
// avgAvailable = (300 - 100 - 3) / 3 = 197 / 3 = 65.666... -> 65.7
expect(result.avgAvailable).toBe(65.7)
})
})
describe('validateOccupancyEntry', () => {
it('returns null for a valid entry (occupied + OOO <= total)', () => {
expect(validateOccupancyEntry(80, 10, 100)).toBeNull()
})
it('returns error when occupied + OOO exceeds total rooms', () => {
const result = validateOccupancyEntry(90, 20, 100)
expect(result).toBe('Occupied (90) + out of order (20) exceeds total rooms (100)')
})
it('returns null when both occupied and OOO are zero', () => {
expect(validateOccupancyEntry(0, 0, 100)).toBeNull()
})
it('returns null when occupied + OOO exactly equals total rooms', () => {
expect(validateOccupancyEntry(80, 20, 100)).toBeNull()
})
})
describe('computePreviousPeriod', () => {
it('computes previous period of the same length immediately before', () => {
// 2025-01-11 to 2025-01-20 = 10 days
const prev = computePreviousPeriod('2025-01-11', '2025-01-20')
expect(prev.start).toBe('2025-01-01')
expect(prev.end).toBe('2025-01-10')
})
it('handles month boundary crossing', () => {
// 2025-02-01 to 2025-02-28 = 28 days
const prev = computePreviousPeriod('2025-02-01', '2025-02-28')
expect(prev.start).toBe('2025-01-04')
expect(prev.end).toBe('2025-01-31')
})
})
describe('filterEntriesByRange', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-05', roomsOccupied: 50, roomsOutOfOrder: 2 },
{ date: '2025-01-10', roomsOccupied: 60, roomsOutOfOrder: 3 },
{ date: '2025-01-15', roomsOccupied: 70, roomsOutOfOrder: 1 },
{ date: '2025-01-20', roomsOccupied: 80, roomsOutOfOrder: 0 },
{ date: '2025-02-01', roomsOccupied: 90, roomsOutOfOrder: 5 },
]
it('includes boundary dates and excludes out-of-range entries', () => {
const filtered = filterEntriesByRange(entries, '2025-01-10', '2025-01-20')
expect(filtered).toHaveLength(3)
expect(filtered.map(e => e.date)).toEqual([
'2025-01-10',
'2025-01-15',
'2025-01-20',
])
})
})
describe('getOccupancyColor', () => {
it('returns green for >= 80%', () => {
expect(getOccupancyColor(80)).toBe('bg-green-500')
expect(getOccupancyColor(100)).toBe('bg-green-500')
})
it('returns yellow for 50-79%', () => {
expect(getOccupancyColor(50)).toBe('bg-yellow-500')
expect(getOccupancyColor(79)).toBe('bg-yellow-500')
})
it('returns red for 1-49%', () => {
expect(getOccupancyColor(1)).toBe('bg-red-500')
expect(getOccupancyColor(49)).toBe('bg-red-500')
})
it('returns muted for 0%', () => {
expect(getOccupancyColor(0)).toBe('bg-muted')
})
})
@@ -1,151 +0,0 @@
/**
* Pure calculation functions for hotel occupancy KPIs.
*
* Occupancy % = (totalOccupied / (totalRooms * days)) * 100
*
* All percentages use Math.round(x * 10000) / 100 (2 decimals).
* All 1-decimal averages use Math.round(x * 10) / 10.
*/
export interface DailyOccupancyEntry {
date: string
roomsOccupied: number
roomsOutOfOrder: number
reason?: string
}
export interface OccupancyKPIs {
/** totalOccupied / (totalRooms * days) * 100, rounded to 2 decimals */
occupancyPct: number
/** totalOccupied / days, rounded to 1 decimal */
avgOccupied: number
/** totalOOO / days, rounded to 1 decimal */
avgOutOfOrder: number
/** (totalRooms * days - occupied - OOO) / days, rounded to 1 decimal */
avgAvailable: number
totalOccupied: number
totalOutOfOrder: number
daysWithData: number
}
/**
* Calculate occupancy KPIs from daily entries and total room count.
* Returns all-zero KPIs when entries is empty or totalRooms is 0.
*/
export function calculateOccupancyKPIs(
entries: DailyOccupancyEntry[],
totalRooms: number
): OccupancyKPIs {
const days = entries.length
if (days === 0 || totalRooms <= 0) {
return {
occupancyPct: 0,
avgOccupied: 0,
avgOutOfOrder: 0,
avgAvailable: 0,
totalOccupied: 0,
totalOutOfOrder: 0,
daysWithData: 0,
}
}
const totalOccupied = entries.reduce((sum, e) => sum + e.roomsOccupied, 0)
const totalOutOfOrder = entries.reduce((sum, e) => sum + e.roomsOutOfOrder, 0)
const totalCapacity = totalRooms * days
const occupancyPct = Math.round((totalOccupied / totalCapacity) * 10000) / 100
const avgOccupied = Math.round((totalOccupied / days) * 10) / 10
const avgOutOfOrder = Math.round((totalOutOfOrder / days) * 10) / 10
const avgAvailable =
Math.round(((totalCapacity - totalOccupied - totalOutOfOrder) / days) * 10) / 10
return {
occupancyPct,
avgOccupied,
avgOutOfOrder,
avgAvailable,
totalOccupied,
totalOutOfOrder,
daysWithData: days,
}
}
/**
* Validate that occupied + outOfOrder does not exceed totalRooms.
* Returns an error message string, or null if valid.
*/
export function validateOccupancyEntry(
occupied: number,
outOfOrder: number,
totalRooms: number
): string | null {
if (occupied < 0 || outOfOrder < 0) {
return 'Values cannot be negative'
}
if (occupied + outOfOrder > totalRooms) {
return `Occupied (${occupied}) + out of order (${outOfOrder}) exceeds total rooms (${totalRooms})`
}
return null
}
/**
* Compute the previous period of the same length, immediately before
* the given start date.
*
* For example, if start=2025-01-11 and end=2025-01-20 (10 days),
* the previous period is 2025-01-01 to 2025-01-10.
*/
export function computePreviousPeriod(
start: string,
end: string
): { start: string; end: string } {
const startDate = new Date(start + 'T00:00:00')
const endDate = new Date(end + 'T00:00:00')
// Duration in milliseconds (inclusive: add 1 day)
const durationMs = endDate.getTime() - startDate.getTime() + 24 * 60 * 60 * 1000
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
const prevStart = new Date(prevEnd.getTime() - durationMs + 24 * 60 * 60 * 1000)
return {
start: formatDate(prevStart),
end: formatDate(prevEnd),
}
}
/**
* Filter entries whose date falls within [start, end] inclusive.
*/
export function filterEntriesByRange(
entries: DailyOccupancyEntry[],
start: string,
end: string
): DailyOccupancyEntry[] {
return entries.filter((e) => e.date >= start && e.date <= end)
}
/**
* Get a Tailwind color class for a calendar heatmap cell based on occupancy percentage.
*
* - >= 80%: green (high occupancy)
* - 50-79%: yellow (moderate)
* - 1-49%: red (low occupancy)
* - 0%: muted (empty)
*/
export function getOccupancyColor(pct: number): string {
if (pct >= 80) return 'bg-green-500'
if (pct >= 50) return 'bg-yellow-500'
if (pct >= 1) return 'bg-red-500'
return 'bg-muted'
}
function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
-2
View File
@@ -1,2 +0,0 @@
// RevPAR extension — Revenue Per Available Room calculation for hotels
export const REVPAR_EXTENSION_ID = 'hotel/revpar'
@@ -1,227 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateRevparKPIs,
calculateMonthlyRevparTrend,
computePreviousPeriod,
filterEntriesByRange,
type DailyRevparEntry,
} from '../revpar-calculator'
describe('calculateRevparKPIs', () => {
it('calculates basic RevPAR correctly', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 80, roomRevenue: 120000 },
{ date: '2025-01-02', roomsSold: 90, roomRevenue: 135000 },
]
const result = calculateRevparKPIs(entries, 100)
// totalRevenue = 255000, availableRoomNights = 100 * 2 = 200
// revpar = 255000 / 200 = 1275
expect(result.revpar).toBe(1275)
expect(result.totalRevenue).toBe(255000)
expect(result.daysWithData).toBe(2)
})
it('calculates ADR as revenue divided by rooms sold', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 50, roomRevenue: 75000 },
{ date: '2025-01-02', roomsSold: 60, roomRevenue: 96000 },
]
const result = calculateRevparKPIs(entries, 100)
// totalRevenue = 171000, totalRoomsSold = 110
// adr = 171000 / 110 = 1554.545454... -> 1554.55
expect(result.adr).toBe(1554.55)
expect(result.totalRoomsSold).toBe(110)
})
it('calculates occupancy percentage', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 75, roomRevenue: 100000 },
{ date: '2025-01-02', roomsSold: 85, roomRevenue: 110000 },
{ date: '2025-01-03', roomsSold: 80, roomRevenue: 105000 },
]
const result = calculateRevparKPIs(entries, 100)
// totalRoomsSold = 240, availableRoomNights = 100 * 3 = 300
// occupancyPct = (240 / 300) * 100 = 80.00
expect(result.occupancyPct).toBe(80)
})
it('returns ADR = 0 when zero rooms sold', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 0, roomRevenue: 0 },
{ date: '2025-01-02', roomsSold: 0, roomRevenue: 0 },
]
const result = calculateRevparKPIs(entries, 100)
expect(result.adr).toBe(0)
expect(result.revpar).toBe(0)
expect(result.occupancyPct).toBe(0)
expect(result.totalRoomsSold).toBe(0)
})
it('returns all zeros when totalRooms is zero', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 50, roomRevenue: 75000 },
]
const result = calculateRevparKPIs(entries, 0)
expect(result.revpar).toBe(0)
expect(result.adr).toBe(0)
expect(result.occupancyPct).toBe(0)
expect(result.totalRevenue).toBe(0)
expect(result.totalRoomsSold).toBe(0)
expect(result.daysWithData).toBe(0)
})
it('returns all zeros for empty entries', () => {
const result = calculateRevparKPIs([], 100)
expect(result.revpar).toBe(0)
expect(result.adr).toBe(0)
expect(result.occupancyPct).toBe(0)
expect(result.totalRevenue).toBe(0)
expect(result.totalRoomsSold).toBe(0)
expect(result.daysWithData).toBe(0)
})
it('rounds monetary values correctly', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 33, roomRevenue: 49999.99 },
{ date: '2025-01-02', roomsSold: 33, roomRevenue: 49999.99 },
{ date: '2025-01-03', roomsSold: 33, roomRevenue: 49999.99 },
]
const result = calculateRevparKPIs(entries, 50)
// totalRevenue = 149999.97, availableRoomNights = 50 * 3 = 150
// revpar = 149999.97 / 150 = 999.9998 -> 1000.00
expect(result.revpar).toBe(1000)
expect(result.totalRevenue).toBe(149999.97)
// adr = 149999.97 / 99 = 1515.1512... -> 1515.15
expect(result.adr).toBe(1515.15)
// occupancyPct = (99 / 150) * 100 = 66.00
expect(result.occupancyPct).toBe(66)
})
})
describe('calculateMonthlyRevparTrend', () => {
it('groups entries by month and calculates per-month KPIs', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-05', roomsSold: 80, roomRevenue: 100000 },
{ date: '2025-01-15', roomsSold: 90, roomRevenue: 120000 },
{ date: '2025-02-10', roomsSold: 70, roomRevenue: 85000 },
{ date: '2025-03-20', roomsSold: 95, roomRevenue: 140000 },
]
const trend = calculateMonthlyRevparTrend(entries, 100)
expect(trend).toHaveLength(3)
expect(trend[0].month).toBe('2025-01')
expect(trend[1].month).toBe('2025-02')
expect(trend[2].month).toBe('2025-03')
// January: revenue = 220000, rooms sold = 170, days = 2, available = 200
// revpar = 220000 / 200 = 1100
expect(trend[0].revpar).toBe(1100)
// adr = 220000 / 170 = 1294.117647... -> 1294.12
expect(trend[0].adr).toBe(1294.12)
// occupancyPct = (170 / 200) * 100 = 85.00
expect(trend[0].occupancyPct).toBe(85)
// February: revenue = 85000, rooms sold = 70, days = 1, available = 100
expect(trend[1].revpar).toBe(850)
// adr = 85000 / 70 = 1214.285714... -> 1214.29
expect(trend[1].adr).toBe(1214.29)
expect(trend[1].occupancyPct).toBe(70)
// March: revenue = 140000, rooms sold = 95, days = 1, available = 100
expect(trend[2].revpar).toBe(1400)
// adr = 140000 / 95 = 1473.684210... -> 1473.68
expect(trend[2].adr).toBe(1473.68)
expect(trend[2].occupancyPct).toBe(95)
})
it('returns empty array for empty entries', () => {
expect(calculateMonthlyRevparTrend([], 100)).toEqual([])
})
it('returns empty array when totalRooms is zero', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 50, roomRevenue: 75000 },
]
expect(calculateMonthlyRevparTrend(entries, 0)).toEqual([])
})
})
describe('computePreviousPeriod', () => {
it('computes previous period for January (wraps to previous year)', () => {
const prev = computePreviousPeriod('2025-01-01', '2025-01-31')
// 31 days in range. Previous period ends 2024-12-31, starts 2024-12-01.
expect(prev.start).toBe('2024-12-01')
expect(prev.end).toBe('2024-12-31')
})
it('computes previous period for same-month range', () => {
const prev = computePreviousPeriod('2025-06-01', '2025-06-30')
// June 1-30 = 30 days inclusive (29-day span).
// Previous ends May 31, starts May 31 - 29 = May 2.
// May 2-31 = 30 days inclusive — same length.
expect(prev.start).toBe('2025-05-02')
expect(prev.end).toBe('2025-05-31')
})
it('computes previous period for a 7-day range', () => {
const prev = computePreviousPeriod('2025-03-10', '2025-03-16')
// March 10-16 = 7 days inclusive (6-day span).
// Previous ends March 9, starts March 9 - 6 = March 3.
// March 3-9 = 7 days inclusive — same length.
expect(prev.start).toBe('2025-03-03')
expect(prev.end).toBe('2025-03-09')
})
})
describe('filterEntriesByRange', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 80, roomRevenue: 100000 },
{ date: '2025-01-15', roomsSold: 90, roomRevenue: 120000 },
{ date: '2025-01-31', roomsSold: 85, roomRevenue: 110000 },
{ date: '2025-02-01', roomsSold: 70, roomRevenue: 85000 },
{ date: '2025-02-15', roomsSold: 75, roomRevenue: 95000 },
]
it('filters entries within date range inclusive', () => {
const filtered = filterEntriesByRange(entries, '2025-01-01', '2025-01-31')
expect(filtered).toHaveLength(3)
expect(filtered.map(e => e.date)).toEqual([
'2025-01-01',
'2025-01-15',
'2025-01-31',
])
})
it('excludes entries outside the range', () => {
const filtered = filterEntriesByRange(entries, '2025-02-01', '2025-02-28')
expect(filtered).toHaveLength(2)
expect(filtered.map(e => e.date)).toEqual(['2025-02-01', '2025-02-15'])
})
it('returns empty array when no entries match', () => {
const filtered = filterEntriesByRange(entries, '2025-06-01', '2025-06-30')
expect(filtered).toHaveLength(0)
})
})
@@ -1,166 +0,0 @@
/**
* Calculate RevPAR (Revenue Per Available Room) and related hotel KPIs.
*
* RevPAR = Total Room Revenue / Total Available Room-Nights
* ADR = Total Room Revenue / Total Rooms Sold
* Occ% = Total Rooms Sold / Total Available Room-Nights * 100
*
* Pure calculation functions — no side effects, no DB access.
*/
export interface DailyRevparEntry {
date: string
roomsSold: number
roomRevenue: number
}
export interface RevparKPIs {
revpar: number
adr: number
occupancyPct: number
totalRevenue: number
totalRoomsSold: number
daysWithData: number
}
export interface MonthlyRevparTrend {
month: string
revpar: number
adr: number
occupancyPct: number
}
/**
* Calculate RevPAR KPIs from daily entries.
*
* - revpar: totalRevenue / (totalRooms * daysWithData)
* - adr: totalRevenue / totalRoomsSold
* - occupancyPct: totalRoomsSold / (totalRooms * daysWithData) * 100
*/
export function calculateRevparKPIs(
entries: DailyRevparEntry[],
totalRooms: number
): RevparKPIs {
if (totalRooms <= 0 || entries.length === 0) {
return {
revpar: 0,
adr: 0,
occupancyPct: 0,
totalRevenue: 0,
totalRoomsSold: 0,
daysWithData: 0,
}
}
const daysWithData = entries.length
const totalRevenue = entries.reduce((sum, e) => sum + e.roomRevenue, 0)
const totalRoomsSold = entries.reduce((sum, e) => sum + e.roomsSold, 0)
const availableRoomNights = totalRooms * daysWithData
const revpar = Math.round((totalRevenue / availableRoomNights) * 100) / 100
const adr =
totalRoomsSold > 0
? Math.round((totalRevenue / totalRoomsSold) * 100) / 100
: 0
const occupancyPct =
Math.round((totalRoomsSold / availableRoomNights) * 10000) / 100
return {
revpar,
adr,
occupancyPct,
totalRevenue: Math.round(totalRevenue * 100) / 100,
totalRoomsSold,
daysWithData,
}
}
/**
* Calculate monthly trend with RevPAR, ADR, and occupancy for each month.
*
* Groups entries by YYYY-MM and calculates KPIs per group.
* Returns results sorted chronologically.
*/
export function calculateMonthlyRevparTrend(
entries: DailyRevparEntry[],
totalRooms: number
): MonthlyRevparTrend[] {
if (totalRooms <= 0 || entries.length === 0) {
return []
}
const byMonth = new Map<string, DailyRevparEntry[]>()
for (const entry of entries) {
const month = entry.date.slice(0, 7) // YYYY-MM
const group = byMonth.get(month)
if (group) {
group.push(entry)
} else {
byMonth.set(month, [entry])
}
}
const months = Array.from(byMonth.keys()).sort()
return months.map((month) => {
const monthEntries = byMonth.get(month)!
const kpis = calculateRevparKPIs(monthEntries, totalRooms)
return {
month,
revpar: kpis.revpar,
adr: kpis.adr,
occupancyPct: kpis.occupancyPct,
}
})
}
/**
* Compute previous period date range of equal length, immediately before
* the given range.
*
* For example, 2025-01-01 to 2025-01-31 (31 days) produces
* 2024-12-01 to 2024-12-31.
*/
export function computePreviousPeriod(
start: string,
end: string
): { start: string; end: string } {
const startDate = new Date(start + 'T00:00:00')
const endDate = new Date(end + 'T00:00:00')
const durationMs = endDate.getTime() - startDate.getTime()
const durationDays = Math.round(durationMs / (1000 * 60 * 60 * 24))
// Previous period ends the day before the current start
const prevEnd = new Date(startDate.getTime())
prevEnd.setDate(prevEnd.getDate() - 1)
// Previous period starts (durationDays) days before prevEnd
const prevStart = new Date(prevEnd.getTime())
prevStart.setDate(prevStart.getDate() - durationDays)
return {
start: formatDate(prevStart),
end: formatDate(prevEnd),
}
}
/**
* Filter entries to those whose date falls within [start, end] inclusive.
*/
export function filterEntriesByRange(
entries: DailyRevparEntry[],
start: string,
end: string
): DailyRevparEntry[] {
return entries.filter((e) => e.date >= start && e.date <= end)
}
function formatDate(d: Date): string {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
-297
View File
@@ -1,297 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { NEDeclaration } from '@/extensions/ne-bilaga/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function NEDeclarationView({ periodId }: { periodId: string }) {
const [data, setData] = useState<NEDeclaration | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchDeclaration = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/extensions/ne-bilaga?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta NE-bilaga')
} finally {
setLoading(false)
}
}
const downloadSRU = () => {
window.open(`/api/extensions/ne-bilaga?period_id=${periodId}&format=sru`, '_blank')
}
// NE ruta labels
const rutaLabels: Record<string, string> = {
R1: 'Försäljning med moms (25%)',
R2: 'Momsfria intäkter',
R3: 'Bil/bostadsförmån',
R4: 'Ränteintäkter',
R5: 'Varuinköp',
R6: 'Övriga kostnader',
R7: 'Lönekostnader',
R8: 'Räntekostnader',
R9: 'Avskrivningar fastighet',
R10: 'Avskrivningar övriga tillgångar',
R11: 'Årets resultat',
}
// Categorize rutor
const revenueRutor = ['R1', 'R2', 'R3', 'R4'] as const
const expenseRutor = ['R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
return (
<div className="space-y-4">
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">NE-bilaga (Enskild firma)</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
NE-bilagan används för att deklarera resultat från enskild näringsverksamhet.
Uppgifterna hämtas från bokföringen för valt räkenskapsår.
</p>
<div className="flex gap-2">
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta NE-bilaga'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
</Button>
)}
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-orange-800">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>
{data.companyInfo.companyName}
</CardTitle>
<Badge className="bg-blue-100 text-blue-800">
{data.fiscalYear.name}
</Badge>
</div>
{data.companyInfo.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.companyInfo.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* Revenue section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Intäkter</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{revenueRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa intäkter</td>
<td className="py-2 text-right">
{formatAmount(
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
)} kr
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Expenses section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Kostnader</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{expenseRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
isExpense
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa kostnader</td>
<td className="py-2 text-right">
-{formatAmount(
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
)} kr
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Result */}
<Card className="border-2">
<CardContent className="py-4">
<div className="flex justify-between items-center">
<div>
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
<span className="font-bold text-xl">Årets resultat</span>
</div>
<span
className={`text-2xl font-bold ${
data.rutor.R11 >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatAmount(data.rutor.R11)} kr
</span>
</div>
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Hämta NE-bilaga&quot; för att generera deklarationsunderlaget.
</CardContent>
</Card>
)}
</div>
)
}
function NEDeclarationRow({
ruta,
label,
amount,
accounts,
isExpense,
}: {
ruta: string
label: string
amount: number
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
isExpense?: boolean
}) {
const [expanded, setExpanded] = useState(false)
// Don't show rows with zero values
if (amount === 0 && accounts.length === 0) return null
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
{label}
{accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right">
{isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr
</td>
</tr>
{expanded && accounts.length > 0 && (
<tr>
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">
{isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
@@ -1,122 +0,0 @@
import { describe, it, expect } from 'vitest'
import { NE_ACCOUNT_MAPPINGS } from '../ne-engine'
import type { NEAccountMapping } from '@/extensions/ne-bilaga/types'
/**
* Helper to check if an account falls into a specific ruta
*/
function findRutaForAccount(accountNumber: string): string | null {
for (const mapping of NE_ACCOUNT_MAPPINGS) {
for (const range of mapping.accountRanges) {
if (accountNumber >= range.start && accountNumber <= range.end) {
if (range.exclude && range.exclude.includes(accountNumber)) {
continue
}
return mapping.ruta
}
}
}
return null
}
describe('NE Account Mappings', () => {
describe('R1 - Försäljning med moms', () => {
it('includes standard revenue accounts 3001-3003', () => {
expect(findRutaForAccount('3001')).toBe('R1')
expect(findRutaForAccount('3002')).toBe('R1')
expect(findRutaForAccount('3003')).toBe('R1')
})
it('excludes 3100 (momsfria intäkter)', () => {
expect(findRutaForAccount('3100')).not.toBe('R1')
})
it('includes 3500 (Fakturerade kostnader)', () => {
expect(findRutaForAccount('3500')).toBe('R1')
})
it('includes 3500-3599 range', () => {
expect(findRutaForAccount('3510')).toBe('R1')
expect(findRutaForAccount('3599')).toBe('R1')
})
it('includes 3700-3799 (Lämnade rabatter)', () => {
expect(findRutaForAccount('3700')).toBe('R1')
expect(findRutaForAccount('3731')).toBe('R1')
expect(findRutaForAccount('3799')).toBe('R1')
})
})
describe('R2 - Momsfria intäkter', () => {
it('includes 3100', () => {
expect(findRutaForAccount('3100')).toBe('R2')
})
it('includes 3900 (Övriga rörelseintäkter)', () => {
expect(findRutaForAccount('3900')).toBe('R2')
})
it('includes 3910 (Hyresintäkter)', () => {
expect(findRutaForAccount('3910')).toBe('R2')
})
it('includes 3920 (Provisionsintäkter)', () => {
expect(findRutaForAccount('3920')).toBe('R2')
})
it('includes 3950 (Återvunna kundfordringar)', () => {
expect(findRutaForAccount('3950')).toBe('R2')
})
it('includes 3960 (Valutakursvinster)', () => {
expect(findRutaForAccount('3960')).toBe('R2')
})
it('includes 3970-3980 range', () => {
expect(findRutaForAccount('3970')).toBe('R2')
expect(findRutaForAccount('3980')).toBe('R2')
})
it('includes 3981-3999 range', () => {
expect(findRutaForAccount('3981')).toBe('R2')
expect(findRutaForAccount('3990')).toBe('R2')
expect(findRutaForAccount('3999')).toBe('R2')
})
})
describe('accounts are not silently dropped', () => {
it('3500 is mapped (not dropped)', () => {
expect(findRutaForAccount('3500')).not.toBeNull()
})
it('3700 is mapped (not dropped)', () => {
expect(findRutaForAccount('3700')).not.toBeNull()
})
it('3910 is mapped (not dropped)', () => {
expect(findRutaForAccount('3910')).not.toBeNull()
})
it('3960 is mapped (not dropped)', () => {
expect(findRutaForAccount('3960')).not.toBeNull()
})
it('3990 is mapped (not dropped)', () => {
expect(findRutaForAccount('3990')).not.toBeNull()
})
})
describe('no overlap between R1 and R2', () => {
it('3100 is in R2 not R1', () => {
expect(findRutaForAccount('3100')).toBe('R2')
})
it('3001 is in R1 not R2', () => {
expect(findRutaForAccount('3001')).toBe('R1')
})
it('3900 is in R2 not R1', () => {
expect(findRutaForAccount('3900')).toBe('R2')
})
})
})
-23
View File
@@ -1,23 +0,0 @@
import type { Extension } from '@/lib/extensions/types'
/**
* NE-bilaga Extension
*
* Generates NE-bilaga (Näringsverksamhet) for enskild firma.
* Maps BAS account balances to NE declaration rutor (R1-R11)
* for tax reporting to Skatteverket.
*
* Only relevant for EF entity type. Hidden for AB.
*/
export const neBilagaExtension: Extension = {
id: 'ne-bilaga',
name: 'NE-bilaga',
version: '1.0.0',
reportTypes: [
{
id: 'ne-bilaga',
name: 'NE-bilaga',
description: 'NE-bilaga (Näringsverksamhet) för enskild firma',
},
],
}
-8
View File
@@ -1,8 +0,0 @@
/**
* Re-export from core. NE engine now lives in lib/reports/ne-bilaga/ne-engine.ts.
*/
export {
NE_ACCOUNT_MAPPINGS,
generateNEDeclaration,
getNEDeclarationTotals,
} from '@/lib/reports/ne-bilaga/ne-engine'
-11
View File
@@ -1,11 +0,0 @@
/**
* Re-export from core. NE-bilaga types now live in lib/reports/ne-bilaga/types.ts.
*/
export {
type NEDeclarationRutor,
type NEAccountMapping,
type NEDeclaration,
type SRURecord,
type SRUFile,
NE_RUTA_LABELS,
} from '@/lib/reports/ne-bilaga/types'
@@ -1,3 +0,0 @@
// Earnings Per Liter extension — Pattern B (manual data entry)
// Tracks alcohol volume sold and calculates revenue per liter.
export const EARNINGS_PER_LITER_EXTENSION_ID = 'restaurant/earnings-per-liter'
@@ -1,82 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateEarningsPerLiter,
type LiterEntry,
} from '../earnings-calculator'
describe('calculateEarningsPerLiter', () => {
const period = { start: '2025-01-01', end: '2025-01-31' }
it('calculates earnings per liter correctly', () => {
const entries: LiterEntry[] = [
{ date: '2025-01-05', liters: 50, type: 'wine' },
{ date: '2025-01-15', liters: 30, type: 'spirits' },
{ date: '2025-01-20', liters: 120, type: 'beer' },
]
const result = calculateEarningsPerLiter(100000, entries, period.start, period.end)
expect(result.totalRevenue).toBe(100000)
expect(result.totalLiters).toBe(200)
expect(result.earningsPerLiter).toBe(500)
expect(result.period).toEqual(period)
})
it('returns 0 earnings per liter when no liters sold', () => {
const entries: LiterEntry[] = []
const result = calculateEarningsPerLiter(50000, entries, period.start, period.end)
expect(result.totalRevenue).toBe(50000)
expect(result.totalLiters).toBe(0)
expect(result.earningsPerLiter).toBe(0)
})
it('filters entries by date range correctly', () => {
const entries: LiterEntry[] = [
{ date: '2025-01-10', liters: 40, type: 'wine' },
{ date: '2025-02-15', liters: 999, type: 'beer' }, // Outside period
{ date: '2024-12-31', liters: 888, type: 'spirits' }, // Outside period
]
const result = calculateEarningsPerLiter(20000, entries, period.start, period.end)
expect(result.totalLiters).toBe(40)
expect(result.earningsPerLiter).toBe(500)
})
it('handles monetary rounding correctly', () => {
const entries: LiterEntry[] = [
{ date: '2025-01-10', liters: 3, type: 'spirits' },
]
const result = calculateEarningsPerLiter(10000, entries, period.start, period.end)
// 10000 / 3 = 3333.333... -> 3333.33
expect(result.earningsPerLiter).toBe(3333.33)
})
it('handles liter rounding correctly', () => {
const entries: LiterEntry[] = [
{ date: '2025-01-05', liters: 1.555, type: 'wine' },
{ date: '2025-01-10', liters: 2.777, type: 'beer' },
]
const result = calculateEarningsPerLiter(5000, entries, period.start, period.end)
// 1.555 + 2.777 = 4.332 -> rounded to 4.33
expect(result.totalLiters).toBe(4.33)
})
it('includes boundary dates in the period', () => {
const entries: LiterEntry[] = [
{ date: '2025-01-01', liters: 10, type: 'wine' },
{ date: '2025-01-31', liters: 20, type: 'beer' },
]
const result = calculateEarningsPerLiter(9000, entries, period.start, period.end)
expect(result.totalLiters).toBe(30)
expect(result.earningsPerLiter).toBe(300)
})
})
@@ -1,40 +0,0 @@
/**
* Calculate earnings per liter of alcohol sold.
* Earnings Per Liter = Alcohol Revenue / Total Liters Sold
*/
export interface LiterEntry {
date: string
liters: number
type: string // 'spirits' | 'wine' | 'beer'
}
export interface EarningsPerLiterResult {
totalRevenue: number
totalLiters: number
earningsPerLiter: number
period: { start: string; end: string }
}
export function calculateEarningsPerLiter(
revenue: number,
entries: LiterEntry[],
periodStart: string,
periodEnd: string
): EarningsPerLiterResult {
const periodEntries = entries.filter(
e => e.date >= periodStart && e.date <= periodEnd
)
const totalLiters = periodEntries.reduce((sum, e) => sum + e.liters, 0)
const earningsPerLiter = totalLiters > 0
? Math.round((revenue / totalLiters) * 100) / 100
: 0
return {
totalRevenue: Math.round(revenue * 100) / 100,
totalLiters: Math.round(totalLiters * 100) / 100,
earningsPerLiter,
period: { start: periodStart, end: periodEnd },
}
}
-4
View File
@@ -1,4 +0,0 @@
// Food Cost % extension — Pattern A (core data only)
// Reads food purchase accounts (4000-series) and food revenue (3000-series)
// from journal_entry_lines to calculate food cost percentage.
export const FOOD_COST_EXTENSION_ID = 'restaurant/food-cost'
@@ -1,114 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateFoodCost,
type JournalLineData,
} from '../food-cost-calculator'
describe('calculateFoodCost', () => {
const period = { start: '2025-01-01', end: '2025-01-31' }
it('calculates food cost percentage correctly', () => {
const lines: JournalLineData[] = [
// Food purchases (4010 - varuinkop livsmedel)
{ account_number: '4010', debit_amount: 40000, credit_amount: 0, entry_date: '2025-01-15' },
// Food revenue (3001 - intakter 25%)
{ account_number: '3001', debit_amount: 0, credit_amount: 100000, entry_date: '2025-01-15' },
]
const result = calculateFoodCost(lines, period.start, period.end)
expect(result.foodPurchases).toBe(40000)
expect(result.foodRevenue).toBe(100000)
expect(result.foodCostPercent).toBe(40)
expect(result.period).toEqual(period)
})
it('returns 0% when revenue is zero', () => {
const lines: JournalLineData[] = [
{ account_number: '4010', debit_amount: 5000, credit_amount: 0, entry_date: '2025-01-10' },
]
const result = calculateFoodCost(lines, period.start, period.end)
expect(result.foodPurchases).toBe(5000)
expect(result.foodRevenue).toBe(0)
expect(result.foodCostPercent).toBe(0)
})
it('returns 0 purchases and 0 revenue when no matching accounts', () => {
const lines: JournalLineData[] = [
{ account_number: '1930', debit_amount: 10000, credit_amount: 0, entry_date: '2025-01-05' },
{ account_number: '2440', debit_amount: 0, credit_amount: 10000, entry_date: '2025-01-05' },
]
const result = calculateFoodCost(lines, period.start, period.end)
expect(result.foodPurchases).toBe(0)
expect(result.foodRevenue).toBe(0)
expect(result.foodCostPercent).toBe(0)
})
it('filters lines by date range correctly', () => {
const lines: JournalLineData[] = [
// Inside period
{ account_number: '4010', debit_amount: 20000, credit_amount: 0, entry_date: '2025-01-15' },
{ account_number: '3001', debit_amount: 0, credit_amount: 50000, entry_date: '2025-01-15' },
// Outside period (February)
{ account_number: '4010', debit_amount: 99999, credit_amount: 0, entry_date: '2025-02-15' },
{ account_number: '3001', debit_amount: 0, credit_amount: 99999, entry_date: '2025-02-15' },
]
const result = calculateFoodCost(lines, period.start, period.end)
expect(result.foodPurchases).toBe(20000)
expect(result.foodRevenue).toBe(50000)
expect(result.foodCostPercent).toBe(40)
})
it('handles monetary rounding correctly', () => {
const lines: JournalLineData[] = [
{ account_number: '4010', debit_amount: 33333.33, credit_amount: 0, entry_date: '2025-01-10' },
{ account_number: '3001', debit_amount: 0, credit_amount: 99999.99, entry_date: '2025-01-10' },
]
const result = calculateFoodCost(lines, period.start, period.end)
expect(result.foodPurchases).toBe(33333.33)
expect(result.foodRevenue).toBe(99999.99)
// 33333.33 / 99999.99 * 100 = 33.333334... -> rounded to 33.33
expect(result.foodCostPercent).toBe(33.33)
})
it('handles multiple purchase and revenue lines', () => {
const lines: JournalLineData[] = [
{ account_number: '4010', debit_amount: 15000, credit_amount: 0, entry_date: '2025-01-05' },
{ account_number: '4020', debit_amount: 10000, credit_amount: 0, entry_date: '2025-01-10' },
{ account_number: '4010', debit_amount: 0, credit_amount: 2000, entry_date: '2025-01-12' }, // Return/credit
{ account_number: '3001', debit_amount: 0, credit_amount: 60000, entry_date: '2025-01-15' },
{ account_number: '3002', debit_amount: 0, credit_amount: 20000, entry_date: '2025-01-20' },
]
const result = calculateFoodCost(lines, period.start, period.end)
// Purchases: 15000 + 10000 - 2000 = 23000
expect(result.foodPurchases).toBe(23000)
// Revenue: 60000 + 20000 = 80000
expect(result.foodRevenue).toBe(80000)
// 23000 / 80000 * 100 = 28.75
expect(result.foodCostPercent).toBe(28.75)
})
it('includes boundary dates in the period', () => {
const lines: JournalLineData[] = [
{ account_number: '4010', debit_amount: 5000, credit_amount: 0, entry_date: '2025-01-01' },
{ account_number: '4010', debit_amount: 5000, credit_amount: 0, entry_date: '2025-01-31' },
{ account_number: '3001', debit_amount: 0, credit_amount: 50000, entry_date: '2025-01-15' },
]
const result = calculateFoodCost(lines, period.start, period.end)
expect(result.foodPurchases).toBe(10000)
expect(result.foodRevenue).toBe(50000)
expect(result.foodCostPercent).toBe(20)
})
})
@@ -1,51 +0,0 @@
/**
* Calculate food cost percentage from journal entry line data.
* Food Cost % = (Food Purchases / Food Revenue) * 100
*
* Food purchases: accounts 4000-4999 (varuinkop)
* Food revenue: accounts 3000-3999 (intakter)
*/
export interface FoodCostResult {
foodPurchases: number
foodRevenue: number
foodCostPercent: number
period: { start: string; end: string }
}
export interface JournalLineData {
account_number: string
debit_amount: number
credit_amount: number
entry_date: string
}
export function calculateFoodCost(
lines: JournalLineData[],
periodStart: string,
periodEnd: string
): FoodCostResult {
const periodLines = lines.filter(
l => l.entry_date >= periodStart && l.entry_date <= periodEnd
)
// Food purchases: debit side of 4000-4999 accounts
const foodPurchases = periodLines
.filter(l => l.account_number >= '4000' && l.account_number <= '4999')
.reduce((sum, l) => sum + l.debit_amount - l.credit_amount, 0)
// Food revenue: credit side of 3000-3999 accounts
const foodRevenue = periodLines
.filter(l => l.account_number >= '3000' && l.account_number <= '3999')
.reduce((sum, l) => sum + l.credit_amount - l.debit_amount, 0)
const foodCostPercent = foodRevenue > 0
? Math.round((foodPurchases / foodRevenue) * 10000) / 100
: 0
return {
foodPurchases: Math.round(foodPurchases * 100) / 100,
foodRevenue: Math.round(foodRevenue * 100) / 100,
foodCostPercent,
period: { start: periodStart, end: periodEnd },
}
}
@@ -1,2 +0,0 @@
// POS Import extension — Z-rapport import for restaurant POS systems
export const POS_IMPORT_EXTENSION_ID = 'restaurant/pos-import'
@@ -1,225 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculatePosSummary,
validatePaymentBreakdown,
calculatePaymentTrend,
isVatRateInRange,
type DailySale,
} from '../pos-calculator'
describe('calculatePosSummary', () => {
it('calculates summary statistics from multiple days', () => {
const sales: DailySale[] = [
{ date: '2025-01-01', total: 10000, cash: 2000, card: 5000, swish: 3000, vat: 2400 },
{ date: '2025-01-02', total: 12000, cash: 3000, card: 6000, swish: 3000, vat: 2880 },
{ date: '2025-01-03', total: 8000, cash: 1000, card: 4000, swish: 3000, vat: 1920 },
]
const result = calculatePosSummary(sales)
expect(result.totalSales).toBe(30000)
expect(result.avgPerDay).toBe(10000)
expect(result.totalCash).toBe(6000)
expect(result.totalCard).toBe(15000)
expect(result.totalSwish).toBe(9000)
expect(result.totalVat).toBe(7200)
expect(result.dayCount).toBe(3)
// 6000 / 30000 = 20%
expect(result.cashPercent).toBe(20)
// 15000 / 30000 = 50%
expect(result.cardPercent).toBe(50)
// 9000 / 30000 = 30%
expect(result.swishPercent).toBe(30)
// 7200 / 30000 = 24%
expect(result.avgVatRate).toBe(24)
})
it('returns zeroes for an empty sales array', () => {
const result = calculatePosSummary([])
expect(result.totalSales).toBe(0)
expect(result.avgPerDay).toBe(0)
expect(result.totalCash).toBe(0)
expect(result.totalCard).toBe(0)
expect(result.totalSwish).toBe(0)
expect(result.totalVat).toBe(0)
expect(result.cashPercent).toBe(0)
expect(result.cardPercent).toBe(0)
expect(result.swishPercent).toBe(0)
expect(result.avgVatRate).toBe(0)
expect(result.dayCount).toBe(0)
})
it('handles monetary rounding edge cases', () => {
const sales: DailySale[] = [
{ date: '2025-01-01', total: 33333.33, cash: 11111.11, card: 11111.11, swish: 11111.11, vat: 7999.99 },
]
const result = calculatePosSummary(sales)
expect(result.totalSales).toBe(33333.33)
expect(result.avgPerDay).toBe(33333.33)
expect(result.totalCash).toBe(11111.11)
expect(result.totalCard).toBe(11111.11)
expect(result.totalSwish).toBe(11111.11)
expect(result.totalVat).toBe(7999.99)
// 11111.11 / 33333.33 * 100 = 33.333333... -> 33.33
expect(result.cashPercent).toBe(33.33)
expect(result.cardPercent).toBe(33.33)
expect(result.swishPercent).toBe(33.33)
// 7999.99 / 33333.33 * 100 = 24.00... -> 24
expect(result.avgVatRate).toBe(24)
})
it('handles a single day with zero total', () => {
const sales: DailySale[] = [
{ date: '2025-01-01', total: 0, cash: 0, card: 0, swish: 0, vat: 0 },
]
const result = calculatePosSummary(sales)
expect(result.totalSales).toBe(0)
expect(result.avgPerDay).toBe(0)
expect(result.cashPercent).toBe(0)
expect(result.cardPercent).toBe(0)
expect(result.swishPercent).toBe(0)
expect(result.avgVatRate).toBe(0)
expect(result.dayCount).toBe(1)
})
})
describe('validatePaymentBreakdown', () => {
it('returns null when breakdown is within 5% of total', () => {
// Cash + Card + Swish = 10000, Total = 10000 -> 0% difference
const result = validatePaymentBreakdown(10000, 3000, 5000, 2000)
expect(result).toBeNull()
})
it('returns null when breakdown differs by exactly 5%', () => {
// Total = 10000, payment sum = 9500 -> 5% difference (boundary)
const result = validatePaymentBreakdown(10000, 3000, 4500, 2000)
expect(result).toBeNull()
})
it('returns a warning when breakdown differs by more than 5%', () => {
// Total = 10000, payment sum = 9000 -> 10% difference
const result = validatePaymentBreakdown(10000, 2000, 4000, 3000)
expect(result).not.toBeNull()
expect(result).toContain('10%')
})
it('returns null when total is zero', () => {
const result = validatePaymentBreakdown(0, 100, 200, 300)
expect(result).toBeNull()
})
it('returns null when all payment methods are zero', () => {
const result = validatePaymentBreakdown(10000, 0, 0, 0)
expect(result).toBeNull()
})
it('returns a warning with correct amounts in message', () => {
// Total = 1000, payment sum = 500 -> 50% difference
const result = validatePaymentBreakdown(1000, 200, 200, 100)
expect(result).not.toBeNull()
expect(result).toContain('500')
expect(result).toContain('1000')
expect(result).toContain('50%')
})
})
describe('calculatePaymentTrend', () => {
it('groups sales by month and calculates percentages', () => {
const sales: DailySale[] = [
{ date: '2025-01-05', total: 10000, cash: 2000, card: 5000, swish: 3000, vat: 2400 },
{ date: '2025-01-15', total: 10000, cash: 3000, card: 4000, swish: 3000, vat: 2400 },
{ date: '2025-02-10', total: 20000, cash: 4000, card: 10000, swish: 6000, vat: 4800 },
]
const result = calculatePaymentTrend(sales)
// Sorted descending: February first
expect(result).toHaveLength(2)
expect(result[0].month).toBe('2025-02')
expect(result[1].month).toBe('2025-01')
// February: single day
expect(result[0].cash).toBe(4000)
expect(result[0].card).toBe(10000)
expect(result[0].swish).toBe(6000)
// 4000 / 20000 = 20%
expect(result[0].cashPct).toBe(20)
// 10000 / 20000 = 50%
expect(result[0].cardPct).toBe(50)
// 6000 / 20000 = 30%
expect(result[0].swishPct).toBe(30)
// January: two days aggregated
expect(result[1].cash).toBe(5000)
expect(result[1].card).toBe(9000)
expect(result[1].swish).toBe(6000)
// 5000 / 20000 = 25%
expect(result[1].cashPct).toBe(25)
// 9000 / 20000 = 45%
expect(result[1].cardPct).toBe(45)
// 6000 / 20000 = 30%
expect(result[1].swishPct).toBe(30)
})
it('returns empty array for empty sales', () => {
const result = calculatePaymentTrend([])
expect(result).toEqual([])
})
it('handles monetary rounding in trend aggregation', () => {
const sales: DailySale[] = [
{ date: '2025-03-01', total: 3333.33, cash: 1111.11, card: 1111.11, swish: 1111.11, vat: 800 },
{ date: '2025-03-02', total: 3333.34, cash: 1111.12, card: 1111.11, swish: 1111.11, vat: 800 },
]
const result = calculatePaymentTrend(sales)
expect(result).toHaveLength(1)
expect(result[0].month).toBe('2025-03')
// 1111.11 + 1111.12 = 2222.23
expect(result[0].cash).toBe(2222.23)
// 1111.11 + 1111.11 = 2222.22
expect(result[0].card).toBe(2222.22)
expect(result[0].swish).toBe(2222.22)
})
})
describe('isVatRateInRange', () => {
it('returns true when VAT rate is within 20-30%', () => {
// 2400 / 10000 = 24%
expect(isVatRateInRange(2400, 10000)).toBe(true)
})
it('returns true at the lower boundary (20%)', () => {
// 2000 / 10000 = 20%
expect(isVatRateInRange(2000, 10000)).toBe(true)
})
it('returns true at the upper boundary (30%)', () => {
// 3000 / 10000 = 30%
expect(isVatRateInRange(3000, 10000)).toBe(true)
})
it('returns false when VAT rate is below 20%', () => {
// 1500 / 10000 = 15%
expect(isVatRateInRange(1500, 10000)).toBe(false)
})
it('returns false when VAT rate is above 30%', () => {
// 3500 / 10000 = 35%
expect(isVatRateInRange(3500, 10000)).toBe(false)
})
it('returns false when subtotal is zero', () => {
expect(isVatRateInRange(100, 0)).toBe(false)
})
it('returns false when subtotal is negative', () => {
expect(isVatRateInRange(100, -500)).toBe(false)
})
})
@@ -1,183 +0,0 @@
/**
* Pure calculation functions for the POS Import extension.
*
* Computes summary statistics, payment method trends, and validation
* for daily POS (point-of-sale) sales data imported from restaurant
* cash register systems.
*
* All monetary calculations use Math.round(x * 100) / 100 per project convention.
*/
export interface DailySale {
date: string
total: number
cash: number
card: number
swish: number
vat: number
}
export interface PosSummary {
totalSales: number
avgPerDay: number
totalCash: number
totalCard: number
totalSwish: number
totalVat: number
cashPercent: number
cardPercent: number
swishPercent: number
avgVatRate: number
dayCount: number
}
export interface PaymentMethodTrend {
month: string
cash: number
card: number
swish: number
cashPct: number
cardPct: number
swishPct: number
}
/**
* Calculate summary statistics from daily sales data.
*
* Returns totals, averages, and payment method percentage breakdowns.
* All monetary values are rounded to 2 decimal places.
* Percentages are rounded to 2 decimal places.
*/
export function calculatePosSummary(sales: DailySale[]): PosSummary {
if (sales.length === 0) {
return {
totalSales: 0,
avgPerDay: 0,
totalCash: 0,
totalCard: 0,
totalSwish: 0,
totalVat: 0,
cashPercent: 0,
cardPercent: 0,
swishPercent: 0,
avgVatRate: 0,
dayCount: 0,
}
}
const totalSales = sales.reduce((sum, d) => sum + d.total, 0)
const totalCash = sales.reduce((sum, d) => sum + d.cash, 0)
const totalCard = sales.reduce((sum, d) => sum + d.card, 0)
const totalSwish = sales.reduce((sum, d) => sum + d.swish, 0)
const totalVat = sales.reduce((sum, d) => sum + d.vat, 0)
const avgPerDay = Math.round(totalSales / sales.length * 100) / 100
const cashPercent = totalSales > 0
? Math.round(totalCash / totalSales * 10000) / 100
: 0
const cardPercent = totalSales > 0
? Math.round(totalCard / totalSales * 10000) / 100
: 0
const swishPercent = totalSales > 0
? Math.round(totalSwish / totalSales * 10000) / 100
: 0
const avgVatRate = totalSales > 0
? Math.round(totalVat / totalSales * 10000) / 100
: 0
return {
totalSales: Math.round(totalSales * 100) / 100,
avgPerDay,
totalCash: Math.round(totalCash * 100) / 100,
totalCard: Math.round(totalCard * 100) / 100,
totalSwish: Math.round(totalSwish * 100) / 100,
totalVat: Math.round(totalVat * 100) / 100,
cashPercent,
cardPercent,
swishPercent,
avgVatRate,
dayCount: sales.length,
}
}
/**
* Validate that the payment method breakdown matches the total.
*
* Returns a warning message if the sum of cash + card + swish differs
* from the total by more than 5%. Returns null if within tolerance
* or if the total is zero.
*/
export function validatePaymentBreakdown(
total: number,
cash: number,
card: number,
swish: number
): string | null {
if (total <= 0) return null
const paymentSum = Math.round((cash + card + swish) * 100) / 100
const roundedTotal = Math.round(total * 100) / 100
if (paymentSum === 0) return null
const diffPct = Math.round(
Math.abs(paymentSum - roundedTotal) / roundedTotal * 10000
) / 100
if (diffPct > 5) {
return `Payment breakdown (${paymentSum}) differs from total (${roundedTotal}) by ${diffPct}%`
}
return null
}
/**
* Calculate monthly payment method trends from daily sales data.
*
* Groups sales by month (YYYY-MM) and computes the absolute amounts
* and percentage share for each payment method. Results are sorted
* in descending chronological order (newest month first).
*/
export function calculatePaymentTrend(sales: DailySale[]): PaymentMethodTrend[] {
if (sales.length === 0) return []
const map = new Map<string, { cash: number; card: number; swish: number; total: number }>()
for (const d of sales) {
const month = d.date.slice(0, 7)
const existing = map.get(month) ?? { cash: 0, card: 0, swish: 0, total: 0 }
existing.cash += d.cash
existing.card += d.card
existing.swish += d.swish
existing.total += d.total
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, vals]) => ({
month,
cash: Math.round(vals.cash * 100) / 100,
card: Math.round(vals.card * 100) / 100,
swish: Math.round(vals.swish * 100) / 100,
cashPct: vals.total > 0 ? Math.round(vals.cash / vals.total * 10000) / 100 : 0,
cardPct: vals.total > 0 ? Math.round(vals.card / vals.total * 10000) / 100 : 0,
swishPct: vals.total > 0 ? Math.round(vals.swish / vals.total * 10000) / 100 : 0,
}))
}
/**
* Check if a VAT rate falls within the expected range for Swedish restaurants.
*
* Swedish restaurant VAT is typically 12% on food and 25% on alcohol.
* The blended effective rate on total sales (including VAT) usually
* falls between 20% and 30%. Returns true if within that range.
*
* A subtotal of zero returns false (cannot determine rate).
*/
export function isVatRateInRange(vatAmount: number, subtotal: number): boolean {
if (subtotal <= 0) return false
const rate = Math.round(vatAmount / subtotal * 10000) / 100
return rate >= 20 && rate <= 30
}
@@ -1,2 +0,0 @@
// Tip Tracking extension — Track tips per shift for restaurant staff
export const TIP_TRACKING_EXTENSION_ID = 'restaurant/tip-tracking'
@@ -1,196 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateTipSummary,
calculateEmployeeTotals,
calculateEqualSplit,
calculateHoursSplit,
calculateCustomSplit,
calculateMonthlyTipTrend,
type TipEntry,
} from '../tip-calculator'
describe('calculateTipSummary', () => {
it('calculates total tips, average per shift, and entry count', () => {
const entries: TipEntry[] = [
{ date: '2025-01-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 350 },
{ date: '2025-01-10', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 520 },
{ date: '2025-01-11', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 430 },
]
const result = calculateTipSummary(entries)
expect(result.totalTips).toBe(1300)
expect(result.avgPerShift).toBe(433.33)
expect(result.entryCount).toBe(3)
})
it('returns zeros for an empty entries array', () => {
const result = calculateTipSummary([])
expect(result.totalTips).toBe(0)
expect(result.avgPerShift).toBe(0)
expect(result.entryCount).toBe(0)
})
})
describe('calculateEmployeeTotals', () => {
it('calculates per-employee totals with multiple employees', () => {
const entries: TipEntry[] = [
{ date: '2025-01-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 300 },
{ date: '2025-01-10', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 500 },
{ date: '2025-01-11', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 400 },
{ date: '2025-01-11', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 600 },
{ date: '2025-01-12', shift: 'lunch', employeeId: 'e3', employeeName: 'Clara', amount: 250 },
]
const result = calculateEmployeeTotals(entries)
// Sorted by total descending: Björn (1100), Anna (700), Clara (250)
expect(result).toHaveLength(3)
expect(result[0]).toEqual({ employeeId: 'e2', name: 'Björn', total: 1100, count: 2, average: 550 })
expect(result[1]).toEqual({ employeeId: 'e1', name: 'Anna', total: 700, count: 2, average: 350 })
expect(result[2]).toEqual({ employeeId: 'e3', name: 'Clara', total: 250, count: 1, average: 250 })
})
it('calculates correct average for a single employee', () => {
const entries: TipEntry[] = [
{ date: '2025-01-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 333.33 },
{ date: '2025-01-11', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 666.67 },
]
const result = calculateEmployeeTotals(entries)
expect(result).toHaveLength(1)
expect(result[0].total).toBe(1000)
expect(result[0].count).toBe(2)
expect(result[0].average).toBe(500)
})
})
describe('calculateEqualSplit', () => {
it('splits pool equally between 2 employees', () => {
const result = calculateEqualSplit(1000, [
{ id: 'e1', name: 'Anna' },
{ id: 'e2', name: 'Björn' },
])
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ employeeId: 'e1', name: 'Anna', share: 500 })
expect(result[1]).toEqual({ employeeId: 'e2', name: 'Björn', share: 500 })
})
it('splits pool equally between 3 employees with rounding', () => {
const result = calculateEqualSplit(1000, [
{ id: 'e1', name: 'Anna' },
{ id: 'e2', name: 'Björn' },
{ id: 'e3', name: 'Clara' },
])
expect(result).toHaveLength(3)
// 1000 / 3 = 333.333... -> rounded to 333.33
expect(result[0].share).toBe(333.33)
expect(result[1].share).toBe(333.33)
expect(result[2].share).toBe(333.33)
})
it('returns empty array for no employees', () => {
const result = calculateEqualSplit(1000, [])
expect(result).toEqual([])
})
})
describe('calculateHoursSplit', () => {
it('distributes pool proportionally by hours worked', () => {
const result = calculateHoursSplit(1200, [
{ id: 'e1', name: 'Anna', hours: 8 },
{ id: 'e2', name: 'Björn', hours: 4 },
])
expect(result).toHaveLength(2)
// Anna: 1200 * (8/12) = 800
expect(result[0]).toEqual({ employeeId: 'e1', name: 'Anna', share: 800 })
// Björn: 1200 * (4/12) = 400
expect(result[1]).toEqual({ employeeId: 'e2', name: 'Björn', share: 400 })
})
it('returns zero shares when all employees have zero hours', () => {
const result = calculateHoursSplit(1000, [
{ id: 'e1', name: 'Anna', hours: 0 },
{ id: 'e2', name: 'Björn', hours: 0 },
])
expect(result).toHaveLength(2)
expect(result[0].share).toBe(0)
expect(result[1].share).toBe(0)
})
it('handles rounding correctly with uneven hours', () => {
const result = calculateHoursSplit(1000, [
{ id: 'e1', name: 'Anna', hours: 7 },
{ id: 'e2', name: 'Björn', hours: 3 },
{ id: 'e3', name: 'Clara', hours: 5 },
])
// Total hours: 15
// Anna: 1000 * 7/15 = 466.666... -> 466.67
expect(result[0].share).toBe(466.67)
// Björn: 1000 * 3/15 = 200
expect(result[1].share).toBe(200)
// Clara: 1000 * 5/15 = 333.333... -> 333.33
expect(result[2].share).toBe(333.33)
})
})
describe('calculateCustomSplit', () => {
it('distributes pool by custom percentages', () => {
const result = calculateCustomSplit(2000, [
{ id: 'e1', name: 'Anna', pct: 50 },
{ id: 'e2', name: 'Björn', pct: 30 },
{ id: 'e3', name: 'Clara', pct: 20 },
])
expect(result).toHaveLength(3)
expect(result[0]).toEqual({ employeeId: 'e1', name: 'Anna', share: 1000 })
expect(result[1]).toEqual({ employeeId: 'e2', name: 'Björn', share: 600 })
expect(result[2]).toEqual({ employeeId: 'e3', name: 'Clara', share: 400 })
})
it('handles rounding with fractional percentages', () => {
const result = calculateCustomSplit(1000, [
{ id: 'e1', name: 'Anna', pct: 33.33 },
{ id: 'e2', name: 'Björn', pct: 33.33 },
{ id: 'e3', name: 'Clara', pct: 33.34 },
])
// 1000 * 33.33 / 100 = 333.3 -> 333.3
expect(result[0].share).toBe(333.3)
expect(result[1].share).toBe(333.3)
// 1000 * 33.34 / 100 = 333.4 -> 333.4
expect(result[2].share).toBe(333.4)
})
})
describe('calculateMonthlyTipTrend', () => {
it('aggregates tips by month in chronological order', () => {
const entries: TipEntry[] = [
{ date: '2025-03-05', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 200 },
{ date: '2025-01-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 300 },
{ date: '2025-01-15', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 500 },
{ date: '2025-02-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 400 },
{ date: '2025-03-10', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 350 },
]
const result = calculateMonthlyTipTrend(entries)
expect(result).toEqual([
{ month: '2025-01', total: 800 },
{ month: '2025-02', total: 400 },
{ month: '2025-03', total: 550 },
])
})
it('returns empty array for no entries', () => {
const result = calculateMonthlyTipTrend([])
expect(result).toEqual([])
})
})
@@ -1,180 +0,0 @@
/**
* Pure calculation functions for the Tip Tracking extension.
* Handles tip summaries, per-employee totals, pool distribution
* (equal, hours-based, custom percentage), and monthly trends.
*
* All monetary values use Math.round(x * 100) / 100 for precision.
*/
export interface TipEntry {
date: string
shift: string
employeeId: string
employeeName: string
amount: number
}
export interface TipSummary {
totalTips: number
avgPerShift: number
entryCount: number
}
export interface EmployeeTipSummary {
employeeId: string
name: string
total: number
count: number
average: number
}
export interface PoolDistribution {
employeeId: string
name: string
share: number
}
/**
* Calculate tip summary for a set of entries.
* Returns total tips, average per shift, and entry count.
*/
export function calculateTipSummary(entries: TipEntry[]): TipSummary {
if (entries.length === 0) {
return { totalTips: 0, avgPerShift: 0, entryCount: 0 }
}
const totalTips = entries.reduce((sum, e) => sum + e.amount, 0)
const roundedTotal = Math.round(totalTips * 100) / 100
const avgPerShift = Math.round((roundedTotal / entries.length) * 100) / 100
return {
totalTips: roundedTotal,
avgPerShift,
entryCount: entries.length,
}
}
/**
* Calculate per-employee totals from tip entries.
* Groups entries by employeeId and returns sorted by total descending.
*/
export function calculateEmployeeTotals(entries: TipEntry[]): EmployeeTipSummary[] {
const map = new Map<string, { name: string; total: number; count: number }>()
for (const entry of entries) {
const existing = map.get(entry.employeeId)
if (existing) {
existing.total += entry.amount
existing.count += 1
} else {
map.set(entry.employeeId, {
name: entry.employeeName,
total: entry.amount,
count: 1,
})
}
}
const results: EmployeeTipSummary[] = []
for (const [employeeId, data] of map) {
const total = Math.round(data.total * 100) / 100
const average = Math.round((total / data.count) * 100) / 100
results.push({
employeeId,
name: data.name,
total,
count: data.count,
average,
})
}
return results.sort((a, b) => b.total - a.total)
}
/**
* Calculate pool distribution using equal split.
* Each employee receives an equal share of the pool amount.
*/
export function calculateEqualSplit(
poolAmount: number,
employees: { id: string; name: string }[]
): PoolDistribution[] {
if (employees.length === 0) {
return []
}
const share = Math.round((poolAmount / employees.length) * 100) / 100
return employees.map(e => ({
employeeId: e.id,
name: e.name,
share,
}))
}
/**
* Calculate pool distribution by hours worked.
* Each employee's share is proportional to their hours.
* Employees with zero hours receive zero.
*/
export function calculateHoursSplit(
poolAmount: number,
employeeHours: { id: string; name: string; hours: number }[]
): PoolDistribution[] {
if (employeeHours.length === 0) {
return []
}
const totalHours = employeeHours.reduce((sum, e) => sum + e.hours, 0)
if (totalHours === 0) {
return employeeHours.map(e => ({
employeeId: e.id,
name: e.name,
share: 0,
}))
}
return employeeHours.map(e => ({
employeeId: e.id,
name: e.name,
share: Math.round((poolAmount * (e.hours / totalHours)) * 100) / 100,
}))
}
/**
* Calculate pool distribution by custom percentages.
* Each employee's share is determined by their assigned percentage.
* Percentages do not need to sum to 100 — each is applied independently.
*/
export function calculateCustomSplit(
poolAmount: number,
employeePcts: { id: string; name: string; pct: number }[]
): PoolDistribution[] {
return employeePcts.map(e => ({
employeeId: e.id,
name: e.name,
share: Math.round((poolAmount * (e.pct / 100)) * 100) / 100,
}))
}
/**
* Calculate monthly trend — total tips per month.
* Returns an array sorted by month (YYYY-MM) in ascending order.
*/
export function calculateMonthlyTipTrend(entries: TipEntry[]): { month: string; total: number }[] {
const map = new Map<string, number>()
for (const entry of entries) {
// Extract YYYY-MM from date string
const month = entry.date.substring(0, 7)
map.set(month, (map.get(month) ?? 0) + entry.amount)
}
const results: { month: string; total: number }[] = []
for (const [month, total] of map) {
results.push({ month, total: Math.round(total * 100) / 100 })
}
return results.sort((a, b) => a.month.localeCompare(b.month))
}
-244
View File
@@ -1,244 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { SRUExportResult, SRUCoverageStats } from '@/extensions/sru-export/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function SRUExportView({ periodId }: { periodId: string }) {
const [data, setData] = useState<SRUExportResult | null>(null)
const [coverage, setCoverage] = useState<SRUCoverageStats | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchCoverage()
}, [])
async function fetchCoverage() {
try {
const res = await fetch('/api/extensions/sru-export/coverage')
const result = await res.json()
if (result.data) {
setCoverage(result.data)
}
} catch {
// Coverage is optional, ignore errors
}
}
const fetchExport = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/extensions/sru-export?period_id=${periodId}&format=json`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta SRU-export')
} finally {
setLoading(false)
}
}
const downloadSRU = () => {
window.open(`/api/extensions/sru-export?period_id=${periodId}&format=sru`, '_blank')
}
const formLabel = data?.formType === 'INK2' ? 'INK2 (Aktiebolag)' : 'NE (Enskild firma)'
return (
<div className="space-y-4">
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">SRU-export</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Generera SRU-fil (Standardiserat Räkenskapsutdrag) för elektronisk inlämning
till Skatteverket. Blanketttyp bestäms automatiskt utifrån företagsform.
</p>
<div className="flex gap-2">
<Button onClick={fetchExport} disabled={loading}>
{loading ? 'Laddar...' : 'Förhandsgranska'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
</Button>
)}
</div>
</CardContent>
</Card>
{/* Coverage warning */}
{coverage && coverage.accountsWithoutSRU > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
<p className="text-sm text-orange-800">
{coverage.accountsWithoutSRU} av {coverage.totalAccounts} konton saknar SRU-kod
({coverage.coveragePercent}% täckning).
Konton utan SRU-kod inkluderas inte i exporten.
</p>
{coverage.missingAccounts.length <= 5 && (
<ul className="text-xs text-orange-700 mt-1 space-y-0.5">
{coverage.missingAccounts.map((a) => (
<li key={a.accountNumber}>
{a.accountNumber} — {a.accountName}
</li>
))}
</ul>
)}
</div>
</div>
</CardContent>
</Card>
)}
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-orange-800">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company + form info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{data.companyName || 'Okänt företag'}</CardTitle>
<div className="flex gap-2">
<Badge className="bg-blue-100 text-blue-800">{formLabel}</Badge>
<Badge className="bg-blue-100 text-blue-800">{data.fiscalYear.name}</Badge>
</div>
</div>
{data.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* SRU balances table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">SRU-poster</CardTitle>
</CardHeader>
<CardContent>
{data.balances.length === 0 ? (
<p className="text-sm text-muted-foreground">
Inga poster med belopp att exportera.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-20">SRU-kod</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-32 text-right">Belopp</th>
</tr>
</thead>
<tbody>
{data.balances.map((b) => (
<SRUBalanceRow key={b.sruCode} balance={b} />
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Förhandsgranska&quot; för att se SRU-uppgifter för valt räkenskapsår.
</CardContent>
</Card>
)}
</div>
)
}
function SRUBalanceRow({
balance,
}: {
balance: SRUExportResult['balances'][number]
}) {
const [expanded, setExpanded] = useState(false)
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => balance.accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2 font-mono">{balance.sruCode}</td>
<td className="py-2">
{balance.description}
{balance.accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({balance.accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right">{formatAmount(balance.amount)} kr</td>
</tr>
{expanded && balance.accounts.length > 0 && (
<tr>
<td colSpan={3} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{balance.accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1 w-16"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">{formatAmount(acc.amount)} kr</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { Extension } from '@/lib/extensions/types'
/**
* SRU Export Extension
*
* Generates SRU (Standardiserat Räkenskapsutdrag) files from chart_of_accounts
* sru_code mappings. Supports both NE (EF) and INK2 (AB) form types.
*
* No event handlers or settings panel needed — SRU codes are managed
* directly on chart_of_accounts.
*/
export const sruExportExtension: Extension = {
id: 'sru-export',
name: 'SRU-export',
version: '1.0.0',
reportTypes: [
{
id: 'sru-generic',
name: 'SRU-export',
description: 'Generera SRU-fil för Skatteverket',
},
],
}
@@ -1,9 +0,0 @@
/**
* Re-export from core. SRU generator now lives in lib/reports/sru-export/sru-generator.ts.
*/
export {
generateSRUFile,
sruFileToString,
validateSRUFile,
getSRUFilename,
} from '@/lib/reports/sru-export/sru-generator'
-9
View File
@@ -1,9 +0,0 @@
/**
* Re-export from core. SRU engine now lives in lib/reports/sru-export/sru-engine.ts.
*/
export {
type SRUBalance,
type SRUCoverageStats,
aggregateBalancesBySRU,
getSRUCoverage,
} from '@/lib/reports/sru-export/sru-engine'
-12
View File
@@ -1,12 +0,0 @@
/**
* Re-export from core. SRU generic generator now lives in lib/reports/sru-export/sru-generic-generator.ts.
*/
export {
type SRUFormType,
type GenericSRUParams,
SRU_CODE_DESCRIPTIONS,
generateGenericSRU,
getGenericSRUFilename,
sruFileToString,
validateSRUFile,
} from '@/lib/reports/sru-export/sru-generic-generator'
-7
View File
@@ -1,7 +0,0 @@
/**
* Re-export from core. SRU export types now live in lib/reports/sru-export/types.ts.
*/
export {
type SRUExportResult,
type SRUCoverageStats,
} from '@/lib/reports/sru-export/types'
-2
View File
@@ -1,2 +0,0 @@
// Billable Hours extension — Track and report billable consultant hours
export const BILLABLE_HOURS_EXTENSION_ID = 'tech/billable-hours'
@@ -1,193 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateUtilization,
calculateProjectTimeStats,
buildWeeklyGrid,
calculateMonthlySummary,
getEffectiveRate,
type TimeEntry,
} from '../billable-hours-calculator'
describe('calculateUtilization', () => {
it('calculates basic utilization KPIs', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 6, billable: true },
{ date: '2025-03-03', projectId: 'p1', hours: 2, billable: false },
]
const result = calculateUtilization(entries, 1000)
expect(result.totalHours).toBe(8)
expect(result.billableHours).toBe(6)
expect(result.nonBillableHours).toBe(2)
expect(result.utilization).toBe(75)
expect(result.revenue).toBe(6000)
expect(result.effectiveRate).toBe(750)
})
it('returns all zeros for empty entries', () => {
const result = calculateUtilization([], 1500)
expect(result.totalHours).toBe(0)
expect(result.billableHours).toBe(0)
expect(result.nonBillableHours).toBe(0)
expect(result.utilization).toBe(0)
expect(result.effectiveRate).toBe(0)
expect(result.revenue).toBe(0)
})
it('returns 100% utilization when all hours are billable', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 4, billable: true },
{ date: '2025-03-04', projectId: 'p2', hours: 4, billable: true },
]
const result = calculateUtilization(entries, 800)
expect(result.utilization).toBe(100)
expect(result.billableHours).toBe(8)
expect(result.nonBillableHours).toBe(0)
expect(result.revenue).toBe(6400)
})
it('returns 0% utilization and zero revenue when all hours are non-billable', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 3, billable: false },
{ date: '2025-03-04', projectId: 'p1', hours: 5, billable: false },
]
const result = calculateUtilization(entries, 1200)
expect(result.utilization).toBe(0)
expect(result.billableHours).toBe(0)
expect(result.nonBillableHours).toBe(8)
expect(result.revenue).toBe(0)
expect(result.effectiveRate).toBe(0)
})
it('calculates revenue as billable hours times rate', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 10, billable: true },
{ date: '2025-03-04', projectId: 'p1', hours: 5, billable: false },
]
const result = calculateUtilization(entries, 950)
expect(result.revenue).toBe(9500)
// effectiveRate = 9500 / 15 = 633.33
expect(result.effectiveRate).toBe(633.33)
})
})
describe('calculateProjectTimeStats', () => {
it('calculates per-project stats for multiple projects', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'alpha', hours: 4, billable: true },
{ date: '2025-03-03', projectId: 'alpha', hours: 1, billable: false },
{ date: '2025-03-03', projectId: 'beta', hours: 3, billable: true },
{ date: '2025-03-04', projectId: 'beta', hours: 2, billable: true },
]
const result = calculateProjectTimeStats(entries)
expect(result).toHaveLength(2)
const alpha = result.find(p => p.projectId === 'alpha')!
expect(alpha.totalHours).toBe(5)
expect(alpha.billableHours).toBe(4)
expect(alpha.utilization).toBe(80)
const beta = result.find(p => p.projectId === 'beta')!
expect(beta.totalHours).toBe(5)
expect(beta.billableHours).toBe(5)
expect(beta.utilization).toBe(100)
})
it('returns empty array for empty entries', () => {
const result = calculateProjectTimeStats([])
expect(result).toEqual([])
})
})
describe('buildWeeklyGrid', () => {
const weekDates = [
'2025-03-03', // Mon
'2025-03-04', // Tue
'2025-03-05', // Wed
'2025-03-06', // Thu
'2025-03-07', // Fri
'2025-03-08', // Sat
'2025-03-09', // Sun
]
it('builds grid with entries on different days', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 4, billable: true },
{ date: '2025-03-04', projectId: 'p1', hours: 6, billable: true },
{ date: '2025-03-03', projectId: 'p2', hours: 2, billable: true },
{ date: '2025-03-05', projectId: 'p2', hours: 3, billable: false },
]
const result = buildWeeklyGrid(entries, weekDates, ['p1', 'p2'])
expect(result.projects).toHaveLength(2)
const p1 = result.projects.find(p => p.projectId === 'p1')!
expect(p1.days).toEqual([4, 6, 0, 0, 0, 0, 0])
const p2 = result.projects.find(p => p.projectId === 'p2')!
expect(p2.days).toEqual([2, 0, 3, 0, 0, 0, 0])
expect(result.dayTotals).toEqual([6, 6, 3, 0, 0, 0, 0])
})
it('returns zeros for an empty week', () => {
const result = buildWeeklyGrid([], weekDates, ['p1'])
expect(result.projects).toHaveLength(1)
expect(result.projects[0].days).toEqual([0, 0, 0, 0, 0, 0, 0])
expect(result.dayTotals).toEqual([0, 0, 0, 0, 0, 0, 0])
})
})
describe('calculateMonthlySummary', () => {
it('groups entries into monthly summaries across two months', () => {
const entries: TimeEntry[] = [
{ date: '2025-01-10', projectId: 'p1', hours: 40, billable: true },
{ date: '2025-01-15', projectId: 'p1', hours: 8, billable: false },
{ date: '2025-02-05', projectId: 'p1', hours: 32, billable: true },
{ date: '2025-02-10', projectId: 'p1', hours: 4, billable: false },
]
const result = calculateMonthlySummary(entries, 1000)
expect(result).toHaveLength(2)
expect(result[0].month).toBe('2025-01')
expect(result[0].billableHours).toBe(40)
expect(result[0].nonBillableHours).toBe(8)
expect(result[0].totalHours).toBe(48)
expect(result[0].revenue).toBe(40000)
expect(result[1].month).toBe('2025-02')
expect(result[1].billableHours).toBe(32)
expect(result[1].nonBillableHours).toBe(4)
expect(result[1].totalHours).toBe(36)
expect(result[1].revenue).toBe(32000)
})
it('returns empty array for no entries', () => {
const result = calculateMonthlySummary([], 1000)
expect(result).toEqual([])
})
})
describe('getEffectiveRate', () => {
it('returns project rate when defined', () => {
expect(getEffectiveRate(1500, 1000)).toBe(1500)
})
it('falls back to global rate when project rate is undefined', () => {
expect(getEffectiveRate(undefined, 1000)).toBe(1000)
})
})
@@ -1,190 +0,0 @@
/**
* Pure calculation functions for billable hours tracking.
* No side effects, no database calls — just math on time entries.
*/
export interface TimeEntry {
date: string
projectId: string
hours: number
billable: boolean
}
export interface UtilizationKPIs {
totalHours: number
billableHours: number
nonBillableHours: number
utilization: number // billable / total * 100, or 0
effectiveRate: number // (billableHours * hourlyRate) / totalHours, or 0
revenue: number // billableHours * hourlyRate
}
export interface ProjectTimeStats {
projectId: string
totalHours: number
billableHours: number
utilization: number
}
export interface WeeklyGrid {
projects: { projectId: string; days: number[] }[] // days[0..6] = Mon..Sun
dayTotals: number[] // 7 day totals
}
export interface MonthlyPeriodSummary {
month: string
billableHours: number
nonBillableHours: number
totalHours: number
revenue: number
}
/**
* Calculate utilization KPIs for a set of time entries.
*/
export function calculateUtilization(
entries: TimeEntry[],
hourlyRate: number
): UtilizationKPIs {
const totalHours = Math.round(
entries.reduce((sum, e) => sum + e.hours, 0) * 100
) / 100
const billableHours = Math.round(
entries.filter(e => e.billable).reduce((sum, e) => sum + e.hours, 0) * 100
) / 100
const nonBillableHours = Math.round((totalHours - billableHours) * 100) / 100
const utilization = totalHours > 0
? Math.round((billableHours / totalHours) * 10000) / 100
: 0
const revenue = Math.round(billableHours * hourlyRate * 100) / 100
const effectiveRate = totalHours > 0
? Math.round((revenue / totalHours) * 100) / 100
: 0
return {
totalHours,
billableHours,
nonBillableHours,
utilization,
effectiveRate,
revenue,
}
}
/**
* Calculate per-project time statistics.
*/
export function calculateProjectTimeStats(
entries: TimeEntry[]
): ProjectTimeStats[] {
const projectMap = new Map<string, { total: number; billable: number }>()
for (const entry of entries) {
const existing = projectMap.get(entry.projectId) ?? { total: 0, billable: 0 }
existing.total += entry.hours
if (entry.billable) {
existing.billable += entry.hours
}
projectMap.set(entry.projectId, existing)
}
return Array.from(projectMap.entries()).map(([projectId, stats]) => {
const totalHours = Math.round(stats.total * 100) / 100
const billableHours = Math.round(stats.billable * 100) / 100
const utilization = totalHours > 0
? Math.round((billableHours / totalHours) * 10000) / 100
: 0
return { projectId, totalHours, billableHours, utilization }
})
}
/**
* Build a weekly timesheet grid for the given week dates (7 date strings,
* Mon-Sun) and project IDs. Each project row has 7 day values. dayTotals
* sums all projects per day.
*/
export function buildWeeklyGrid(
entries: TimeEntry[],
weekDates: string[],
projectIds: string[]
): WeeklyGrid {
const dateIndex = new Map<string, number>()
for (let i = 0; i < weekDates.length; i++) {
dateIndex.set(weekDates[i], i)
}
const projects = projectIds.map(projectId => {
const days = [0, 0, 0, 0, 0, 0, 0]
for (const entry of entries) {
if (entry.projectId !== projectId) continue
const idx = dateIndex.get(entry.date)
if (idx !== undefined) {
days[idx] = Math.round((days[idx] + entry.hours) * 100) / 100
}
}
return { projectId, days }
})
const dayTotals = [0, 0, 0, 0, 0, 0, 0]
for (const project of projects) {
for (let i = 0; i < 7; i++) {
dayTotals[i] = Math.round((dayTotals[i] + project.days[i]) * 100) / 100
}
}
return { projects, dayTotals }
}
/**
* Calculate monthly period summaries, grouped by YYYY-MM.
*/
export function calculateMonthlySummary(
entries: TimeEntry[],
hourlyRate: number
): MonthlyPeriodSummary[] {
const monthMap = new Map<string, { billable: number; nonBillable: number }>()
for (const entry of entries) {
// Extract YYYY-MM from the date string
const month = entry.date.substring(0, 7)
const existing = monthMap.get(month) ?? { billable: 0, nonBillable: 0 }
if (entry.billable) {
existing.billable += entry.hours
} else {
existing.nonBillable += entry.hours
}
monthMap.set(month, existing)
}
return Array.from(monthMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, stats]) => {
const billableHours = Math.round(stats.billable * 100) / 100
const nonBillableHours = Math.round(stats.nonBillable * 100) / 100
const totalHours = Math.round((billableHours + nonBillableHours) * 100) / 100
const revenue = Math.round(billableHours * hourlyRate * 100) / 100
return { month, billableHours, nonBillableHours, totalHours, revenue }
})
}
/**
* Get the effective rate for a project. A project-specific rate overrides
* the global rate. If the project rate is undefined, fall back to the global rate.
*/
export function getEffectiveRate(
projectRate: number | undefined,
globalRate: number
): number {
return projectRate !== undefined ? projectRate : globalRate
}
-2
View File
@@ -1,2 +0,0 @@
// Project Billing extension — Track billing per client project
export const PROJECT_BILLING_EXTENSION_ID = 'tech/project-billing'
@@ -1,206 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
calculateProjectBillingStats,
calculateAggregateStats,
type BillingEntry,
type CostEntry,
} from '../billing-calculator'
describe('calculateProjectBillingStats', () => {
const makeBilling = (overrides: Partial<BillingEntry> = {}): BillingEntry => ({
projectId: 'proj-1',
amount: 10000,
date: '2025-03-15',
invoiced: true,
...overrides,
})
const makeCost = (overrides: Partial<CostEntry> = {}): CostEntry => ({
projectId: 'proj-1',
amount: 5000,
date: '2025-03-10',
category: 'labor',
...overrides,
})
it('calculates CORRECT margin as (revenue - costs) / revenue * 100', () => {
const billings = [makeBilling({ amount: 100000 })]
const costs = [makeCost({ amount: 60000 })]
const result = calculateProjectBillingStats(billings, costs, 200000)
// Margin = (100000 - 60000) / 100000 * 100 = 40%
expect(result.margin).toBe(40)
// NOT the buggy formula: (budget - billed) / budget = (200000 - 100000) / 200000 = 50%
expect(result.margin).not.toBe(50)
})
it('returns 100% margin when there are no costs', () => {
const billings = [makeBilling({ amount: 80000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
// (80000 - 0) / 80000 * 100 = 100%
expect(result.margin).toBe(100)
expect(result.totalCosts).toBe(0)
})
it('returns negative margin when costs exceed revenue', () => {
const billings = [makeBilling({ amount: 50000 })]
const costs = [makeCost({ amount: 75000 })]
const result = calculateProjectBillingStats(billings, costs, 100000)
// (50000 - 75000) / 50000 * 100 = -50%
expect(result.margin).toBe(-50)
})
it('returns 0% margin when revenue is zero', () => {
const billings: BillingEntry[] = []
const costs = [makeCost({ amount: 10000 })]
const result = calculateProjectBillingStats(billings, costs, 50000)
expect(result.margin).toBe(0)
expect(result.totalBilled).toBe(0)
expect(result.totalCosts).toBe(10000)
})
it('calculates budget used as billed / budget * 100', () => {
const billings = [makeBilling({ amount: 60000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 120000)
// 60000 / 120000 * 100 = 50%
expect(result.budgetUsed).toBe(50)
})
it('caps budget used at 100% when billed exceeds budget', () => {
const billings = [makeBilling({ amount: 150000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.budgetUsed).toBe(100)
})
it('calculates budget remaining as max(budget - billed, 0)', () => {
const billings = [makeBilling({ amount: 70000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.budgetRemaining).toBe(30000)
})
it('never returns negative budget remaining', () => {
const billings = [makeBilling({ amount: 120000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.budgetRemaining).toBe(0)
})
it('sums uninvoiced entries correctly', () => {
const billings = [
makeBilling({ amount: 30000, invoiced: true }),
makeBilling({ amount: 20000, invoiced: false }),
makeBilling({ amount: 15000, invoiced: false }),
makeBilling({ amount: 10000, invoiced: true }),
]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.uninvoicedAmount).toBe(35000)
expect(result.totalBilled).toBe(75000)
})
it('returns 0 uninvoiced when all entries are invoiced', () => {
const billings = [
makeBilling({ amount: 25000, invoiced: true }),
makeBilling({ amount: 15000, invoiced: true }),
]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 50000)
expect(result.uninvoicedAmount).toBe(0)
})
it('returns all zeros for empty billings and costs', () => {
const result = calculateProjectBillingStats([], [], 100000)
expect(result.totalBilled).toBe(0)
expect(result.totalCosts).toBe(0)
expect(result.margin).toBe(0)
expect(result.budgetUsed).toBe(0)
expect(result.budgetRemaining).toBe(100000)
expect(result.uninvoicedAmount).toBe(0)
})
it('rounds monetary values correctly with floating point edge cases', () => {
const billings = [
makeBilling({ amount: 33333.33 }),
makeBilling({ amount: 33333.33 }),
makeBilling({ amount: 33333.34 }),
]
const costs = [
makeCost({ amount: 11111.11 }),
makeCost({ amount: 11111.11 }),
makeCost({ amount: 11111.11 }),
]
const result = calculateProjectBillingStats(billings, costs, 150000)
// totalBilled = 100000.00, totalCosts = 33333.33
expect(result.totalBilled).toBe(100000)
expect(result.totalCosts).toBe(33333.33)
// margin = (100000 - 33333.33) / 100000 * 100 = 66.6667 -> 66.67
expect(result.margin).toBe(66.67)
})
})
describe('calculateAggregateStats', () => {
it('aggregates stats across multiple projects', () => {
const stats = [
{
totalBilled: 100000,
totalCosts: 60000,
margin: 40,
budgetUsed: 80,
budgetRemaining: 25000,
uninvoicedAmount: 10000,
},
{
totalBilled: 50000,
totalCosts: 20000,
margin: 60,
budgetUsed: 50,
budgetRemaining: 50000,
uninvoicedAmount: 5000,
},
]
const result = calculateAggregateStats(stats)
expect(result.totalBilled).toBe(150000)
expect(result.totalCosts).toBe(80000)
expect(result.avgMargin).toBe(50) // (40 + 60) / 2
expect(result.totalUninvoiced).toBe(15000)
expect(result.totalBudgetRemaining).toBe(75000)
})
it('returns all zeros for empty project stats', () => {
const result = calculateAggregateStats([])
expect(result.totalBilled).toBe(0)
expect(result.totalCosts).toBe(0)
expect(result.avgMargin).toBe(0)
expect(result.totalUninvoiced).toBe(0)
expect(result.totalBudgetRemaining).toBe(0)
})
})
@@ -1,107 +0,0 @@
/**
* Calculate project billing statistics.
*
* IMPORTANT: Margin is calculated as (revenue - costs) / revenue * 100.
* This is the correct gross margin formula. A previous implementation
* incorrectly used (budget - billed) / budget which conflates budget
* utilization with profitability.
*/
export interface BillingEntry {
projectId: string
amount: number
date: string
invoiced: boolean
}
export interface CostEntry {
projectId: string
amount: number
date: string
category: string
}
export interface ProjectBillingStats {
totalBilled: number
totalCosts: number
margin: number
budgetUsed: number
budgetRemaining: number
uninvoicedAmount: number
}
export interface AggregateStats {
totalBilled: number
totalCosts: number
avgMargin: number
totalUninvoiced: number
totalBudgetRemaining: number
}
export function calculateProjectBillingStats(
billings: BillingEntry[],
costs: CostEntry[],
budget: number
): ProjectBillingStats {
const totalBilled = billings.reduce((sum, b) => sum + b.amount, 0)
const roundedBilled = Math.round(totalBilled * 100) / 100
const totalCosts = costs.reduce((sum, c) => sum + c.amount, 0)
const roundedCosts = Math.round(totalCosts * 100) / 100
// CORRECT margin formula: (revenue - costs) / revenue * 100
// Revenue = totalBilled. Returns 0 when there is no revenue.
const margin = roundedBilled > 0
? Math.round(((roundedBilled - roundedCosts) / roundedBilled) * 10000) / 100
: 0
const budgetUsedRaw = budget > 0
? Math.round((roundedBilled / budget) * 10000) / 100
: 0
const budgetUsed = Math.min(budgetUsedRaw, 100)
const budgetRemaining = Math.round(Math.max(budget - roundedBilled, 0) * 100) / 100
const uninvoicedAmount = billings
.filter(b => !b.invoiced)
.reduce((sum, b) => sum + b.amount, 0)
const roundedUninvoiced = Math.round(uninvoicedAmount * 100) / 100
return {
totalBilled: roundedBilled,
totalCosts: roundedCosts,
margin,
budgetUsed,
budgetRemaining,
uninvoicedAmount: roundedUninvoiced,
}
}
export function calculateAggregateStats(
projectStats: ProjectBillingStats[]
): AggregateStats {
if (projectStats.length === 0) {
return {
totalBilled: 0,
totalCosts: 0,
avgMargin: 0,
totalUninvoiced: 0,
totalBudgetRemaining: 0,
}
}
const totalBilled = projectStats.reduce((sum, s) => sum + s.totalBilled, 0)
const totalCosts = projectStats.reduce((sum, s) => sum + s.totalCosts, 0)
const totalUninvoiced = projectStats.reduce((sum, s) => sum + s.uninvoicedAmount, 0)
const totalBudgetRemaining = projectStats.reduce((sum, s) => sum + s.budgetRemaining, 0)
const avgMargin = projectStats.reduce((sum, s) => sum + s.margin, 0) / projectStats.length
return {
totalBilled: Math.round(totalBilled * 100) / 100,
totalCosts: Math.round(totalCosts * 100) / 100,
avgMargin: Math.round(avgMargin * 100) / 100,
totalUninvoiced: Math.round(totalUninvoiced * 100) / 100,
totalBudgetRemaining: Math.round(totalBudgetRemaining * 100) / 100,
}
}