Files
accounted/extensions/tech/project-billing/lib/billing-calculator.ts
T
EmilandClaude Opus 4.6 6a5b2b7792 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>
2026-02-21 15:21:49 +01:00

108 lines
3.0 KiB
TypeScript

/**
* 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,
}
}