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; 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 = {}; revenueCategories.value.forEach((category) => { groups[category] = budgetWorksheet.value.revenue.filter( (item) => item.mainCategory === category ); }); return groups; }); const groupedExpenses = computed(() => { const groups: Record = {}; 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 = {}; 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 = {}; 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 = {}; 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", ], }, } );