Files
accounted/extensions/ecommerce/shopify-import/lib/shopify-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

200 lines
5.3 KiB
TypeScript

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