Files
accounted/extensions/restaurant/pos-import/lib/pos-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

184 lines
5.2 KiB
TypeScript

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