app/stores/budget.ts

808 lines
26 KiB
TypeScript

import { defineStore } from "pinia";
import { allocatePayroll } from "~/types/members";
export const useBudgetStore = defineStore(
"budget",
() => {
// Schema version for persistence
const schemaVersion = "2.0";
// Canonical categories from WizardRevenueStep - matches the wizard exactly
const revenueCategories = ref([
"Games & Products",
"Services & Contracts",
"Grants & Funding",
"Community Support",
"Partnerships",
"Investment Income",
"In-Kind Contributions",
]);
// Revenue subcategories by main category (for reference and grouping)
const revenueSubcategories = ref({
"Games & Products": [
"Direct sales",
"Platform revenue share",
"DLC/expansions",
"Merchandise",
],
"Services & Contracts": [
"Contract development",
"Consulting",
"Workshops/teaching",
"Technical services",
],
"Grants & Funding": [
"Government funding",
"Arts council grants",
"Foundation support",
"Research grants",
],
"Community Support": [
"Patreon/subscriptions",
"Crowdfunding",
"Donations",
"Mutual aid received",
],
Partnerships: [
"Corporate partnerships",
"Academic partnerships",
"Sponsorships",
],
"Investment Income": ["Impact investment", "Venture capital", "Loans"],
"In-Kind Contributions": [
"Office space",
"Equipment/hardware",
"Software licenses",
"Professional services",
"Marketing/PR services",
"Legal services",
],
});
const expenseCategories = ref([
"Salaries & Benefits",
"Development Costs",
"Equipment & Technology",
"Marketing & Outreach",
"Office & Operations",
"Legal & Professional",
"Other Expenses",
]);
// Define budget item type
interface BudgetItem {
id: string;
name: string;
mainCategory: string;
subcategory: string;
source: string;
monthlyValues: Record<string, number>;
values: {
year1: { best: number; worst: number; mostLikely: number };
year2: { best: number; worst: number; mostLikely: number };
year3: { best: number; worst: number; mostLikely: number };
};
}
// NEW: Budget worksheet structure (starts empty, populated from wizard data)
const budgetWorksheet = ref<{
revenue: BudgetItem[];
expenses: BudgetItem[];
}>({
revenue: [],
expenses: [],
});
// Track if worksheet has been initialized from wizard data
const isInitialized = ref(false);
// LEGACY: Keep for backward compatibility
const budgetLines = ref({});
const overheadCosts = ref([]);
const productionCosts = ref([]);
// Computed grouped data for category headers
const groupedRevenue = computed(() => {
const groups: Record<string, any[]> = {};
revenueCategories.value.forEach((category) => {
groups[category] = budgetWorksheet.value.revenue.filter(
(item) => item.mainCategory === category
);
});
return groups;
});
const groupedExpenses = computed(() => {
const groups: Record<string, any[]> = {};
expenseCategories.value.forEach((category) => {
groups[category] = budgetWorksheet.value.expenses.filter(
(item) => item.mainCategory === category
);
});
return groups;
});
// Computed totals for budget worksheet (legacy - keep for backward compatibility)
const budgetTotals = computed(() => {
const years = ["year1", "year2", "year3"];
const scenarios = ["best", "worst", "mostLikely"];
const totals = {};
years.forEach((year) => {
totals[year] = {};
scenarios.forEach((scenario) => {
// Calculate revenue total
const revenueTotal = budgetWorksheet.value.revenue.reduce(
(sum, item) => {
return sum + (item.values?.[year]?.[scenario] || 0);
},
0
);
// Calculate expenses total
const expensesTotal = budgetWorksheet.value.expenses.reduce(
(sum, item) => {
return sum + (item.values?.[year]?.[scenario] || 0);
},
0
);
// Calculate net income
const netIncome = revenueTotal - expensesTotal;
totals[year][scenario] = {
revenue: revenueTotal,
expenses: expensesTotal,
net: netIncome,
};
});
});
return totals;
});
// Monthly totals computation
const monthlyTotals = computed(() => {
const totals: Record<
string,
{ revenue: number; expenses: number; net: number }
> = {};
// Generate month keys for next 12 months
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
// Calculate revenue total for this month
const revenueTotal = budgetWorksheet.value.revenue.reduce(
(sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
},
0
);
// Calculate expenses total for this month
const expensesTotal = budgetWorksheet.value.expenses.reduce(
(sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
},
0
);
// Calculate net income
const netIncome = revenueTotal - expensesTotal;
totals[monthKey] = {
revenue: revenueTotal,
expenses: expensesTotal,
net: netIncome,
};
}
return totals;
});
// LEGACY: Keep for backward compatibility
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = String(currentDate.getMonth() + 1).padStart(2, "0");
const currentPeriod = ref(`${currentYear}-${currentMonth}`);
const currentBudget = computed(() => {
return (
budgetLines.value[currentPeriod.value] || {
period: currentPeriod.value,
revenueByStream: {},
payrollCosts: { memberHours: [], oncostApplied: 0 },
overheadCosts: [],
productionCosts: [],
savingsChange: 0,
net: 0,
}
);
});
// Actions
function setBudgetLine(period, budgetData) {
budgetLines.value[period] = {
period,
...budgetData,
};
}
function updateRevenue(period, streamId, type, amount) {
if (!budgetLines.value[period]) {
budgetLines.value[period] = { period, revenueByStream: {} };
}
if (!budgetLines.value[period].revenueByStream[streamId]) {
budgetLines.value[period].revenueByStream[streamId] = {};
}
budgetLines.value[period].revenueByStream[streamId][type] = amount;
}
// Wizard-required actions
function addOverheadLine(cost) {
// Allow creating a blank line so the user can fill it out in the UI
const safeName = cost?.name ?? "";
const safeAmountMonthly =
typeof cost?.amountMonthly === "number" &&
!Number.isNaN(cost.amountMonthly)
? cost.amountMonthly
: 0;
overheadCosts.value.push({
id: Date.now().toString(),
name: safeName,
amount: safeAmountMonthly,
category: cost?.category || "Operations",
recurring: cost?.recurring ?? true,
...cost,
});
}
function removeOverheadLine(id) {
const index = overheadCosts.value.findIndex((c) => c.id === id);
if (index > -1) {
overheadCosts.value.splice(index, 1);
}
}
function addOverheadCost(cost) {
addOverheadLine({ name: cost.name, amountMonthly: cost.amount, ...cost });
}
function addProductionCost(cost) {
productionCosts.value.push({
id: Date.now().toString(),
name: cost.name,
amount: cost.amount,
category: cost.category || "Production",
period: cost.period,
...cost,
});
}
function setCurrentPeriod(period) {
currentPeriod.value = period;
}
// Helper function to map stream category to budget category
function mapStreamToBudgetCategory(streamCategory) {
const categoryLower = (streamCategory || "").toLowerCase();
// More comprehensive category mapping
if (categoryLower.includes("game") || categoryLower.includes("product")) {
return "Games & Products";
} else if (categoryLower.includes("service") || categoryLower.includes("consulting")) {
return "Services & Contracts";
} else if (categoryLower.includes("grant") || categoryLower.includes("funding")) {
return "Grants & Funding";
} else if (categoryLower.includes("community") || categoryLower.includes("donation")) {
return "Community Support";
} else if (categoryLower.includes("partnership")) {
return "Partnerships";
} else if (categoryLower.includes("investment")) {
return "Investment Income";
} else if (categoryLower.includes("other")) {
return "Services & Contracts"; // Map "Other" to services as fallback
} else {
return "Games & Products"; // Default fallback
}
}
// Initialize worksheet from wizard data
async function initializeFromWizardData() {
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
console.log("Already initialized with data, skipping...");
return;
}
console.log("Initializing budget from wizard data...");
try {
// Use the new coopBuilder store instead of the old stores
const coopStore = useCoopBuilderStore();
console.log("Streams:", coopStore.streams.length, "streams");
console.log("Members:", coopStore.members.length, "members");
console.log("Equal wage:", coopStore.equalHourlyWage || "No wage set");
console.log("Overhead costs:", coopStore.overheadCosts.length, "costs");
// Clear existing data
budgetWorksheet.value.revenue = [];
budgetWorksheet.value.expenses = [];
// Add revenue streams from wizard (but don't auto-load fixtures)
// Note: We don't auto-load fixtures anymore, but wizard data should still work
coopStore.streams.forEach((stream) => {
const monthlyAmount = stream.monthly || 0;
console.log(
"Adding stream:",
stream.label,
"category:",
stream.category,
"amount:",
monthlyAmount
);
// Use the helper function for category mapping
const mappedCategory = mapStreamToBudgetCategory(stream.category);
console.log(
"Mapped category from",
stream.category,
"to",
mappedCategory
);
// Create monthly values - split the annual target evenly across 12 months
const monthlyValues: Record<string, number> = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
monthlyValues[monthKey] = monthlyAmount;
}
console.log(
"Created monthly values for",
stream.label,
":",
monthlyValues
);
budgetWorksheet.value.revenue.push({
id: `revenue-${stream.id}`,
name: stream.label,
mainCategory: mappedCategory,
subcategory: "Direct sales", // Default subcategory for coopStore streams
source: "wizard",
monthlyValues,
values: {
year1: {
best: monthlyAmount * 12,
worst: monthlyAmount * 6,
mostLikely: monthlyAmount * 10,
},
year2: {
best: monthlyAmount * 15,
worst: monthlyAmount * 8,
mostLikely: monthlyAmount * 12,
},
year3: {
best: monthlyAmount * 18,
worst: monthlyAmount * 10,
mostLikely: monthlyAmount * 15,
},
},
});
});
// Add payroll from wizard data using the allocatePayroll function
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
const hourlyWage = coopStore.equalHourlyWage || 0;
const oncostPct = coopStore.payrollOncostPct || 0;
// Calculate total payroll budget (before oncosts)
const basePayrollBudget = totalHours * hourlyWage;
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Calculate total with oncosts
const monthlyPayroll = basePayrollBudget * (1 + oncostPct / 100);
// Create monthly values for payroll
const monthlyValues: Record<string, number> = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
monthlyValues[monthKey] = monthlyPayroll;
}
budgetWorksheet.value.expenses.push({
id: "expense-payroll",
name: "Payroll",
mainCategory: "Salaries & Benefits",
subcategory: "Base wages and benefits",
source: "wizard",
monthlyValues,
values: {
year1: {
best: monthlyPayroll * 12,
worst: monthlyPayroll * 8,
mostLikely: monthlyPayroll * 12,
},
year2: {
best: monthlyPayroll * 14,
worst: monthlyPayroll * 10,
mostLikely: monthlyPayroll * 13,
},
year3: {
best: monthlyPayroll * 16,
worst: monthlyPayroll * 12,
mostLikely: monthlyPayroll * 15,
},
},
});
}
// Add overhead costs from wizard
coopStore.overheadCosts.forEach((cost) => {
if (cost.amount && cost.amount > 0) {
const annualAmount = cost.amount * 12;
// Map overhead cost categories to expense categories
let expenseCategory = "Other Expenses"; // Default
if (cost.category === "Operations")
expenseCategory = "Office & Operations";
else if (cost.category === "Tools")
expenseCategory = "Equipment & Technology";
else if (cost.category === "Professional")
expenseCategory = "Legal & Professional";
else if (cost.category === "Marketing")
expenseCategory = "Marketing & Outreach";
// Create monthly values for overhead costs
const monthlyValues: Record<string, number> = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
monthlyValues[monthKey] = cost.amount;
}
budgetWorksheet.value.expenses.push({
id: `expense-${cost.id}`,
name: cost.name,
mainCategory: expenseCategory,
subcategory: cost.name, // Use the cost name as subcategory
source: "wizard",
monthlyValues,
values: {
year1: {
best: annualAmount,
worst: annualAmount * 0.8,
mostLikely: annualAmount,
},
year2: {
best: annualAmount * 1.1,
worst: annualAmount * 0.9,
mostLikely: annualAmount * 1.05,
},
year3: {
best: annualAmount * 1.2,
worst: annualAmount,
mostLikely: annualAmount * 1.1,
},
},
});
}
});
// Production costs are handled within overhead costs in the new architecture
// DISABLED: No sample data - budget should start empty
// if (budgetWorksheet.value.revenue.length === 0) {
// console.log("Adding sample revenue line");
// // ... sample revenue creation code removed
// }
// DISABLED: No sample data - expenses should start empty
// if (budgetWorksheet.value.expenses.length === 0) {
// console.log("Adding sample expense line");
// // ... sample expense creation code removed
// }
// Debug: Log all revenue items and their categories
console.log("Final revenue items:");
budgetWorksheet.value.revenue.forEach((item) => {
console.log(
`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`
);
});
console.log("Final expense items:");
budgetWorksheet.value.expenses.forEach((item) => {
console.log(
`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`
);
});
// Ensure all items have monthlyValues and new structure - migrate existing items
[
...budgetWorksheet.value.revenue,
...budgetWorksheet.value.expenses,
].forEach((item) => {
// Migrate to new structure if needed
if (item.category && !item.mainCategory) {
console.log("Migrating item structure for:", item.name);
item.mainCategory = item.category; // Old category becomes mainCategory
// Set appropriate subcategory based on the main category and item name
if (item.category === "Games & Products") {
const gameSubcategories = [
"Direct sales",
"Platform revenue share",
"DLC/expansions",
"Merchandise",
];
item.subcategory = gameSubcategories.includes(item.name)
? item.name
: "Direct sales";
} else if (item.category === "Services & Contracts") {
const serviceSubcategories = [
"Contract development",
"Consulting",
"Workshops/teaching",
"Technical services",
];
item.subcategory = serviceSubcategories.includes(item.name)
? item.name
: "Contract development";
} else if (item.category === "Investment Income") {
const investmentSubcategories = [
"Impact investment",
"Venture capital",
"Loans",
];
item.subcategory = investmentSubcategories.includes(item.name)
? item.name
: "Impact investment";
} else if (item.category === "Salaries & Benefits") {
item.subcategory = "Base wages and benefits";
} else if (item.category === "Office & Operations") {
// Map specific office tools to appropriate subcategories
if (
item.name.toLowerCase().includes("rent") ||
item.name.toLowerCase().includes("office")
) {
item.subcategory = "Rent";
} else if (item.name.toLowerCase().includes("util")) {
item.subcategory = "Utilities";
} else if (item.name.toLowerCase().includes("insurance")) {
item.subcategory = "Insurance";
} else {
item.subcategory = "Office supplies";
}
} else {
// For other categories, use appropriate default
item.subcategory = "Miscellaneous";
}
delete item.category; // Remove old property
}
if (!item.monthlyValues) {
console.log("Migrating item to monthly values:", item.name);
item.monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
// Try to use most likely value divided by 12, or default to 0
const yearlyValue = item.values?.year1?.mostLikely || 0;
item.monthlyValues[monthKey] = Math.round(yearlyValue / 12);
}
console.log(
"Added monthly values to",
item.name,
":",
item.monthlyValues
);
}
});
console.log(
"Initialization complete. Revenue items:",
budgetWorksheet.value.revenue.length,
"Expense items:",
budgetWorksheet.value.expenses.length
);
isInitialized.value = true;
} catch (error) {
console.error("Error initializing budget from wizard data:", error);
// DISABLED: No fallback sample data - budget should remain empty on errors
// Budget initialization complete (without automatic fallback data)
isInitialized.value = true;
}
}
// NEW: Budget worksheet functions
function updateBudgetValue(category, itemId, year, scenario, value) {
const items = budgetWorksheet.value[category];
const item = items.find((i) => i.id === itemId);
if (item) {
item.values[year][scenario] = Number(value) || 0;
}
}
function updateMonthlyValue(category, itemId, monthKey, value) {
const items = budgetWorksheet.value[category];
const item = items.find((i) => i.id === itemId);
if (item) {
if (!item.monthlyValues) {
item.monthlyValues = {};
}
item.monthlyValues[monthKey] = Number(value) || 0;
}
}
function addBudgetItem(category, name, selectedCategory = "") {
const id = `${category}-${Date.now()}`;
// Create empty monthly values for next 12 months
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
monthlyValues[monthKey] = 0;
}
const newItem = {
id,
name,
mainCategory:
selectedCategory ||
(category === "revenue" ? "Games & Products" : "Other Expenses"),
subcategory: "", // Will be set by user via dropdown
source: "user",
monthlyValues,
values: {
year1: { best: 0, worst: 0, mostLikely: 0 },
year2: { best: 0, worst: 0, mostLikely: 0 },
year3: { best: 0, worst: 0, mostLikely: 0 },
},
};
budgetWorksheet.value[category].push(newItem);
return id;
}
function removeBudgetItem(category, itemId) {
const items = budgetWorksheet.value[category];
const index = items.findIndex((i) => i.id === itemId);
if (index > -1) {
items.splice(index, 1);
}
}
function renameBudgetItem(category, itemId, newName) {
const items = budgetWorksheet.value[category];
const item = items.find((i) => i.id === itemId);
if (item) {
item.name = newName;
}
}
function updateBudgetCategory(category, itemId, newCategory) {
const items = budgetWorksheet.value[category];
const item = items.find((i) => i.id === itemId);
if (item) {
item.category = newCategory;
}
}
function addCustomCategory(type, categoryName) {
if (
type === "revenue" &&
!revenueCategories.value.includes(categoryName)
) {
revenueCategories.value.push(categoryName);
} else if (
type === "expenses" &&
!expenseCategories.value.includes(categoryName)
) {
expenseCategories.value.push(categoryName);
}
}
// Reset function
function resetBudgetOverhead() {
overheadCosts.value = [];
productionCosts.value = [];
}
function resetBudgetWorksheet() {
// Reset all values to 0 but keep the structure
[
...budgetWorksheet.value.revenue,
...budgetWorksheet.value.expenses,
].forEach((item) => {
Object.keys(item.values).forEach((year) => {
Object.keys(item.values[year]).forEach((scenario) => {
item.values[year][scenario] = 0;
});
});
});
}
return {
// NEW: Budget worksheet
budgetWorksheet,
budgetTotals,
monthlyTotals,
revenueCategories,
expenseCategories,
revenueSubcategories,
groupedRevenue,
groupedExpenses,
isInitialized,
initializeFromWizardData,
updateBudgetValue,
updateMonthlyValue,
addBudgetItem,
removeBudgetItem,
renameBudgetItem,
updateBudgetCategory,
addCustomCategory,
resetBudgetWorksheet,
// LEGACY: Keep for backward compatibility
budgetLines,
overheadCosts,
productionCosts,
currentPeriod: readonly(currentPeriod),
currentBudget,
schemaVersion,
setBudgetLine,
updateRevenue,
// Wizard actions
addOverheadLine,
removeOverheadLine,
resetBudgetOverhead,
// Legacy actions
addOverheadCost,
addProductionCost,
setCurrentPeriod,
};
},
{
persist: {
key: "urgent-tools-budget",
paths: [
"budgetWorksheet",
"revenueCategories",
"expenseCategories",
"isInitialized",
"overheadCosts",
"productionCosts",
"currentPeriod",
],
},
}
);