feat: overhaul all 12 sector extensions with full CRUD, validation, and tests
Add shared components (ConfirmDeleteDialog, EditEntryDialog, validation utils), enhance all 12 extension workspaces with edit/delete dialogs, input validation, period comparisons, and new analytics features. Fix critical bugs in ProjectBilling margin calculation and EarningsPerLiter revenue allocation. Add pure calculation modules with 183 new tests across all extensions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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}`
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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}`
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user