Added extension functionality
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// Project Cost extension — Track costs per construction project
|
||||
export const PROJECT_COST_EXTENSION_ID = 'construction/project-cost'
|
||||
@@ -0,0 +1,2 @@
|
||||
// ROT Calculator extension — Calculate ROT tax deductions for construction work
|
||||
export const ROT_CALCULATOR_EXTENSION_ID = 'construction/rot-calculator'
|
||||
@@ -0,0 +1,2 @@
|
||||
// Multichannel Revenue extension — Track revenue across sales channels
|
||||
export const MULTICHANNEL_REVENUE_EXTENSION_ID = 'ecommerce/multichannel-revenue'
|
||||
@@ -0,0 +1,2 @@
|
||||
// Shopify Import extension — Import orders and transactions from Shopify
|
||||
export const SHOPIFY_IMPORT_EXTENSION_ID = 'ecommerce/shopify-import'
|
||||
@@ -0,0 +1,2 @@
|
||||
// Occupancy extension — Track and report hotel room occupancy rates
|
||||
export const OCCUPANCY_EXTENSION_ID = 'hotel/occupancy'
|
||||
@@ -0,0 +1,2 @@
|
||||
// RevPAR extension — Revenue Per Available Room calculation for hotels
|
||||
export const REVPAR_EXTENSION_ID = 'hotel/revpar'
|
||||
@@ -0,0 +1,3 @@
|
||||
// Earnings Per Liter extension — Pattern B (manual data entry)
|
||||
// Tracks alcohol volume sold and calculates revenue per liter.
|
||||
export const EARNINGS_PER_LITER_EXTENSION_ID = 'restaurant/earnings-per-liter'
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateEarningsPerLiter,
|
||||
type LiterEntry,
|
||||
} from '../earnings-calculator'
|
||||
|
||||
describe('calculateEarningsPerLiter', () => {
|
||||
const period = { start: '2025-01-01', end: '2025-01-31' }
|
||||
|
||||
it('calculates earnings per liter correctly', () => {
|
||||
const entries: LiterEntry[] = [
|
||||
{ date: '2025-01-05', liters: 50, type: 'wine' },
|
||||
{ date: '2025-01-15', liters: 30, type: 'spirits' },
|
||||
{ date: '2025-01-20', liters: 120, type: 'beer' },
|
||||
]
|
||||
|
||||
const result = calculateEarningsPerLiter(100000, entries, period.start, period.end)
|
||||
|
||||
expect(result.totalRevenue).toBe(100000)
|
||||
expect(result.totalLiters).toBe(200)
|
||||
expect(result.earningsPerLiter).toBe(500)
|
||||
expect(result.period).toEqual(period)
|
||||
})
|
||||
|
||||
it('returns 0 earnings per liter when no liters sold', () => {
|
||||
const entries: LiterEntry[] = []
|
||||
|
||||
const result = calculateEarningsPerLiter(50000, entries, period.start, period.end)
|
||||
|
||||
expect(result.totalRevenue).toBe(50000)
|
||||
expect(result.totalLiters).toBe(0)
|
||||
expect(result.earningsPerLiter).toBe(0)
|
||||
})
|
||||
|
||||
it('filters entries by date range correctly', () => {
|
||||
const entries: LiterEntry[] = [
|
||||
{ date: '2025-01-10', liters: 40, type: 'wine' },
|
||||
{ date: '2025-02-15', liters: 999, type: 'beer' }, // Outside period
|
||||
{ date: '2024-12-31', liters: 888, type: 'spirits' }, // Outside period
|
||||
]
|
||||
|
||||
const result = calculateEarningsPerLiter(20000, entries, period.start, period.end)
|
||||
|
||||
expect(result.totalLiters).toBe(40)
|
||||
expect(result.earningsPerLiter).toBe(500)
|
||||
})
|
||||
|
||||
it('handles monetary rounding correctly', () => {
|
||||
const entries: LiterEntry[] = [
|
||||
{ date: '2025-01-10', liters: 3, type: 'spirits' },
|
||||
]
|
||||
|
||||
const result = calculateEarningsPerLiter(10000, entries, period.start, period.end)
|
||||
|
||||
// 10000 / 3 = 3333.333... -> 3333.33
|
||||
expect(result.earningsPerLiter).toBe(3333.33)
|
||||
})
|
||||
|
||||
it('handles liter rounding correctly', () => {
|
||||
const entries: LiterEntry[] = [
|
||||
{ date: '2025-01-05', liters: 1.555, type: 'wine' },
|
||||
{ date: '2025-01-10', liters: 2.777, type: 'beer' },
|
||||
]
|
||||
|
||||
const result = calculateEarningsPerLiter(5000, entries, period.start, period.end)
|
||||
|
||||
// 1.555 + 2.777 = 4.332 -> rounded to 4.33
|
||||
expect(result.totalLiters).toBe(4.33)
|
||||
})
|
||||
|
||||
it('includes boundary dates in the period', () => {
|
||||
const entries: LiterEntry[] = [
|
||||
{ date: '2025-01-01', liters: 10, type: 'wine' },
|
||||
{ date: '2025-01-31', liters: 20, type: 'beer' },
|
||||
]
|
||||
|
||||
const result = calculateEarningsPerLiter(9000, entries, period.start, period.end)
|
||||
|
||||
expect(result.totalLiters).toBe(30)
|
||||
expect(result.earningsPerLiter).toBe(300)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Calculate earnings per liter of alcohol sold.
|
||||
* Earnings Per Liter = Alcohol Revenue / Total Liters Sold
|
||||
*/
|
||||
export interface LiterEntry {
|
||||
date: string
|
||||
liters: number
|
||||
type: string // 'spirits' | 'wine' | 'beer'
|
||||
}
|
||||
|
||||
export interface EarningsPerLiterResult {
|
||||
totalRevenue: number
|
||||
totalLiters: number
|
||||
earningsPerLiter: number
|
||||
period: { start: string; end: string }
|
||||
}
|
||||
|
||||
export function calculateEarningsPerLiter(
|
||||
revenue: number,
|
||||
entries: LiterEntry[],
|
||||
periodStart: string,
|
||||
periodEnd: string
|
||||
): EarningsPerLiterResult {
|
||||
const periodEntries = entries.filter(
|
||||
e => e.date >= periodStart && e.date <= periodEnd
|
||||
)
|
||||
|
||||
const totalLiters = periodEntries.reduce((sum, e) => sum + e.liters, 0)
|
||||
|
||||
const earningsPerLiter = totalLiters > 0
|
||||
? Math.round((revenue / totalLiters) * 100) / 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
totalRevenue: Math.round(revenue * 100) / 100,
|
||||
totalLiters: Math.round(totalLiters * 100) / 100,
|
||||
earningsPerLiter,
|
||||
period: { start: periodStart, end: periodEnd },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Food Cost % extension — Pattern A (core data only)
|
||||
// Reads food purchase accounts (4000-series) and food revenue (3000-series)
|
||||
// from journal_entry_lines to calculate food cost percentage.
|
||||
export const FOOD_COST_EXTENSION_ID = 'restaurant/food-cost'
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateFoodCost,
|
||||
type JournalLineData,
|
||||
} from '../food-cost-calculator'
|
||||
|
||||
describe('calculateFoodCost', () => {
|
||||
const period = { start: '2025-01-01', end: '2025-01-31' }
|
||||
|
||||
it('calculates food cost percentage correctly', () => {
|
||||
const lines: JournalLineData[] = [
|
||||
// Food purchases (4010 - varuinkop livsmedel)
|
||||
{ account_number: '4010', debit_amount: 40000, credit_amount: 0, entry_date: '2025-01-15' },
|
||||
// Food revenue (3001 - intakter 25%)
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 100000, entry_date: '2025-01-15' },
|
||||
]
|
||||
|
||||
const result = calculateFoodCost(lines, period.start, period.end)
|
||||
|
||||
expect(result.foodPurchases).toBe(40000)
|
||||
expect(result.foodRevenue).toBe(100000)
|
||||
expect(result.foodCostPercent).toBe(40)
|
||||
expect(result.period).toEqual(period)
|
||||
})
|
||||
|
||||
it('returns 0% when revenue is zero', () => {
|
||||
const lines: JournalLineData[] = [
|
||||
{ account_number: '4010', debit_amount: 5000, credit_amount: 0, entry_date: '2025-01-10' },
|
||||
]
|
||||
|
||||
const result = calculateFoodCost(lines, period.start, period.end)
|
||||
|
||||
expect(result.foodPurchases).toBe(5000)
|
||||
expect(result.foodRevenue).toBe(0)
|
||||
expect(result.foodCostPercent).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 purchases and 0 revenue when no matching accounts', () => {
|
||||
const lines: JournalLineData[] = [
|
||||
{ account_number: '1930', debit_amount: 10000, credit_amount: 0, entry_date: '2025-01-05' },
|
||||
{ account_number: '2440', debit_amount: 0, credit_amount: 10000, entry_date: '2025-01-05' },
|
||||
]
|
||||
|
||||
const result = calculateFoodCost(lines, period.start, period.end)
|
||||
|
||||
expect(result.foodPurchases).toBe(0)
|
||||
expect(result.foodRevenue).toBe(0)
|
||||
expect(result.foodCostPercent).toBe(0)
|
||||
})
|
||||
|
||||
it('filters lines by date range correctly', () => {
|
||||
const lines: JournalLineData[] = [
|
||||
// Inside period
|
||||
{ account_number: '4010', debit_amount: 20000, credit_amount: 0, entry_date: '2025-01-15' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 50000, entry_date: '2025-01-15' },
|
||||
// Outside period (February)
|
||||
{ account_number: '4010', debit_amount: 99999, credit_amount: 0, entry_date: '2025-02-15' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 99999, entry_date: '2025-02-15' },
|
||||
]
|
||||
|
||||
const result = calculateFoodCost(lines, period.start, period.end)
|
||||
|
||||
expect(result.foodPurchases).toBe(20000)
|
||||
expect(result.foodRevenue).toBe(50000)
|
||||
expect(result.foodCostPercent).toBe(40)
|
||||
})
|
||||
|
||||
it('handles monetary rounding correctly', () => {
|
||||
const lines: JournalLineData[] = [
|
||||
{ account_number: '4010', debit_amount: 33333.33, credit_amount: 0, entry_date: '2025-01-10' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 99999.99, entry_date: '2025-01-10' },
|
||||
]
|
||||
|
||||
const result = calculateFoodCost(lines, period.start, period.end)
|
||||
|
||||
expect(result.foodPurchases).toBe(33333.33)
|
||||
expect(result.foodRevenue).toBe(99999.99)
|
||||
// 33333.33 / 99999.99 * 100 = 33.333334... -> rounded to 33.33
|
||||
expect(result.foodCostPercent).toBe(33.33)
|
||||
})
|
||||
|
||||
it('handles multiple purchase and revenue lines', () => {
|
||||
const lines: JournalLineData[] = [
|
||||
{ account_number: '4010', debit_amount: 15000, credit_amount: 0, entry_date: '2025-01-05' },
|
||||
{ account_number: '4020', debit_amount: 10000, credit_amount: 0, entry_date: '2025-01-10' },
|
||||
{ account_number: '4010', debit_amount: 0, credit_amount: 2000, entry_date: '2025-01-12' }, // Return/credit
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 60000, entry_date: '2025-01-15' },
|
||||
{ account_number: '3002', debit_amount: 0, credit_amount: 20000, entry_date: '2025-01-20' },
|
||||
]
|
||||
|
||||
const result = calculateFoodCost(lines, period.start, period.end)
|
||||
|
||||
// Purchases: 15000 + 10000 - 2000 = 23000
|
||||
expect(result.foodPurchases).toBe(23000)
|
||||
// Revenue: 60000 + 20000 = 80000
|
||||
expect(result.foodRevenue).toBe(80000)
|
||||
// 23000 / 80000 * 100 = 28.75
|
||||
expect(result.foodCostPercent).toBe(28.75)
|
||||
})
|
||||
|
||||
it('includes boundary dates in the period', () => {
|
||||
const lines: JournalLineData[] = [
|
||||
{ account_number: '4010', debit_amount: 5000, credit_amount: 0, entry_date: '2025-01-01' },
|
||||
{ account_number: '4010', debit_amount: 5000, credit_amount: 0, entry_date: '2025-01-31' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 50000, entry_date: '2025-01-15' },
|
||||
]
|
||||
|
||||
const result = calculateFoodCost(lines, period.start, period.end)
|
||||
|
||||
expect(result.foodPurchases).toBe(10000)
|
||||
expect(result.foodRevenue).toBe(50000)
|
||||
expect(result.foodCostPercent).toBe(20)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Calculate food cost percentage from journal entry line data.
|
||||
* Food Cost % = (Food Purchases / Food Revenue) * 100
|
||||
*
|
||||
* Food purchases: accounts 4000-4999 (varuinkop)
|
||||
* Food revenue: accounts 3000-3999 (intakter)
|
||||
*/
|
||||
export interface FoodCostResult {
|
||||
foodPurchases: number
|
||||
foodRevenue: number
|
||||
foodCostPercent: number
|
||||
period: { start: string; end: string }
|
||||
}
|
||||
|
||||
export interface JournalLineData {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
export function calculateFoodCost(
|
||||
lines: JournalLineData[],
|
||||
periodStart: string,
|
||||
periodEnd: string
|
||||
): FoodCostResult {
|
||||
const periodLines = lines.filter(
|
||||
l => l.entry_date >= periodStart && l.entry_date <= periodEnd
|
||||
)
|
||||
|
||||
// Food purchases: debit side of 4000-4999 accounts
|
||||
const foodPurchases = periodLines
|
||||
.filter(l => l.account_number >= '4000' && l.account_number <= '4999')
|
||||
.reduce((sum, l) => sum + l.debit_amount - l.credit_amount, 0)
|
||||
|
||||
// Food revenue: credit side of 3000-3999 accounts
|
||||
const foodRevenue = periodLines
|
||||
.filter(l => l.account_number >= '3000' && l.account_number <= '3999')
|
||||
.reduce((sum, l) => sum + l.credit_amount - l.debit_amount, 0)
|
||||
|
||||
const foodCostPercent = foodRevenue > 0
|
||||
? Math.round((foodPurchases / foodRevenue) * 10000) / 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
foodPurchases: Math.round(foodPurchases * 100) / 100,
|
||||
foodRevenue: Math.round(foodRevenue * 100) / 100,
|
||||
foodCostPercent,
|
||||
period: { start: periodStart, end: periodEnd },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// POS Import extension — Z-rapport import for restaurant POS systems
|
||||
export const POS_IMPORT_EXTENSION_ID = 'restaurant/pos-import'
|
||||
@@ -0,0 +1,2 @@
|
||||
// Tip Tracking extension — Track tips per shift for restaurant staff
|
||||
export const TIP_TRACKING_EXTENSION_ID = 'restaurant/tip-tracking'
|
||||
@@ -0,0 +1,2 @@
|
||||
// Billable Hours extension — Track and report billable consultant hours
|
||||
export const BILLABLE_HOURS_EXTENSION_ID = 'tech/billable-hours'
|
||||
@@ -0,0 +1,2 @@
|
||||
// Project Billing extension — Track billing per client project
|
||||
export const PROJECT_BILLING_EXTENSION_ID = 'tech/project-billing'
|
||||
Reference in New Issue
Block a user