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:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user