Added extension functionality

This commit is contained in:
Emil
2026-02-21 11:39:45 +01:00
parent 2f83f232c6
commit 026497ed75
132 changed files with 3533 additions and 60 deletions
@@ -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'
+2
View File
@@ -0,0 +1,2 @@
// Occupancy extension — Track and report hotel room occupancy rates
export const OCCUPANCY_EXTENSION_ID = 'hotel/occupancy'
+2
View File
@@ -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 },
}
}
+4
View File
@@ -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'
+2
View File
@@ -0,0 +1,2 @@
// Billable Hours extension — Track and report billable consultant hours
export const BILLABLE_HOURS_EXTENSION_ID = 'tech/billable-hours'
+2
View File
@@ -0,0 +1,2 @@
// Project Billing extension — Track billing per client project
export const PROJECT_BILLING_EXTENSION_ID = 'tech/project-billing'