From 2065b634fe38a68b751e6a620fdfbbdc34ba5b0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 02:03:20 +0000 Subject: [PATCH] Add comprehensive budget & financial planning spreadsheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates a fully-formatted 13-sheet Excel workbook (FundSight_Budget_Planner.xlsx) mirroring the FundSight app's features, with colour-coding, dropdowns, conditional formatting, and live formulas throughout. Sheets included: - 📊 Dashboard — KPI cards, budget vs actual table, 50/30/20 summary - 💰 Monthly Budget — needs/wants/savings planning with live actuals - 💵 Income — multi-source income tracker with KPIs - 📝 Transactions — full transaction log, 100 rows, dropdown categories/accounts - 📅 Bills & Subscriptions — recurring bills with overdue highlighting - 🎯 Savings Goals — target/saved/progress with data-bar indicators - 🏦 Debt Tracker — loan portfolio + avalanche/snowball strategy guide - 📈 Net Worth — monthly snapshots with MoM change tracking - 📊 Annual Summary — 12-month income & expense grid with net savings row - 🏗 Project Costs — Bill of Quantities (BOQ) for construction/project costing - 🧮 Tax Estimator — Guyana GRA PAYE, NIS, and take-home pay calculator - 📖 Help & Guide — user manual for every sheet - ⚙ Settings — master dropdown source lists (all validation lists editable here) Design uses FundSight brand palette: Emerald #10B981, Gold #D4AF37, and status colours (green/amber/red) with conditional formatting on all key ranges. Script: scripts/generate_budget_spreadsheet.py https://claude.ai/code/session_01CrJcF6KTkTju9NBsidKfsR --- public/FundSight_Budget_Planner.xlsx | Bin 0 -> 46468 bytes scripts/generate_budget_spreadsheet.py | 2056 ++++++++++++++++++++++++ 2 files changed, 2056 insertions(+) create mode 100644 public/FundSight_Budget_Planner.xlsx create mode 100644 scripts/generate_budget_spreadsheet.py diff --git a/public/FundSight_Budget_Planner.xlsx b/public/FundSight_Budget_Planner.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ce2640a257af24244d79a2a3b683e7631f000d55 GIT binary patch literal 46468 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 XicesFeE`5y*e3?oAl<6_=ji_cI9WU> literal 0 HcmV?d00001 diff --git a/scripts/generate_budget_spreadsheet.py b/scripts/generate_budget_spreadsheet.py new file mode 100644 index 0000000..3542091 --- /dev/null +++ b/scripts/generate_budget_spreadsheet.py @@ -0,0 +1,2056 @@ +#!/usr/bin/env python3 +""" +FundSight Budget & Financial Planning Spreadsheet Generator +Creates a comprehensive Excel workbook with full formatting, dropdowns, +conditional formatting, and formulas — mirroring FundSight app features. +""" + +from openpyxl import Workbook +from openpyxl.styles import ( + PatternFill, Font, Alignment, Border, Side, GradientFill +) +from openpyxl.styles.numbers import FORMAT_CURRENCY_USD_SIMPLE +from openpyxl.utils import get_column_letter, column_index_from_string +from openpyxl.formatting.rule import ( + ColorScaleRule, DataBarRule, IconSetRule, FormulaRule, CellIsRule +) +from openpyxl.worksheet.datavalidation import DataValidation +from openpyxl.worksheet.table import Table, TableStyleInfo +from openpyxl.drawing.image import Image +from openpyxl.chart import BarChart, LineChart, PieChart, Reference +from openpyxl.chart.series import SeriesLabel +import datetime +import os + +# ───────────────────────────────────────────── +# COLOUR PALETTE (mirrors FundSight brand) +# ───────────────────────────────────────────── +C = { + # Brand + "emerald": "10B981", + "emerald_dark": "059669", + "emerald_light":"D1FAE5", + "gold": "D4AF37", + "gold_light": "FEF9C3", + "gold_dark": "B7950B", + # Status + "red": "EF4444", + "red_light": "FEE2E2", + "amber": "F59E0B", + "amber_light": "FEF3C7", + "cyan": "06B6D4", + "cyan_light": "CFFAFE", + "purple": "8B5CF6", + "purple_light": "EDE9FE", + # Neutrals + "dark": "1E293B", + "dark2": "334155", + "mid": "64748B", + "light_border": "CBD5E1", + "row_alt": "F8FAFC", + "white": "FFFFFF", + "off_white": "F1F5F9", + # Budget type + "needs": "10B981", + "wants": "F59E0B", + "savings": "06B6D4", +} + +def fill(hex_color): + return PatternFill("solid", fgColor=hex_color) + +def font(bold=False, size=11, color="1E293B", italic=False, name="Calibri"): + return Font(bold=bold, size=size, color=color, italic=italic, name=name) + +def center(): + return Alignment(horizontal="center", vertical="center", wrap_text=True) + +def left(): + return Alignment(horizontal="left", vertical="center", wrap_text=False) + +def right_align(): + return Alignment(horizontal="right", vertical="center") + +def thin_border(sides="all"): + s = Side(style="thin", color=C["light_border"]) + n = Side(style=None) + if sides == "all": + return Border(left=s, right=s, top=s, bottom=s) + if sides == "bottom": + return Border(bottom=s) + if sides == "top_bottom": + return Border(top=s, bottom=s) + if sides == "outer": + return Border(left=s, right=s, top=s, bottom=s) + return Border(bottom=s) + +def medium_border(): + s = Side(style="medium", color=C["dark"]) + return Border(left=s, right=s, top=s, bottom=s) + +# Currency format (GYD) +FMT_GYD = '#,##0.00' +FMT_GYD2 = '"$"#,##0.00' +FMT_PCT = '0.0%' +FMT_DATE = 'DD-MMM-YYYY' +FMT_INT = '#,##0' + +def style_header_cell(cell, text, bg=C["dark"], fg=C["white"], + bold=True, size=11, align="center"): + cell.value = text + cell.fill = fill(bg) + cell.font = font(bold=bold, size=size, color=fg) + cell.alignment = center() if align == "center" else left() + cell.border = thin_border() + +def style_section_title(ws, row, col, text, span=1, + bg=C["emerald"], fg=C["white"]): + cell = ws.cell(row=row, column=col, value=text) + cell.fill = fill(bg) + cell.font = font(bold=True, size=12, color=fg) + cell.alignment = center() + cell.border = thin_border() + if span > 1: + ws.merge_cells( + start_row=row, start_column=col, + end_row=row, end_column=col + span - 1 + ) + +def set_col_width(ws, col_letter, width): + ws.column_dimensions[col_letter].width = width + +def set_row_height(ws, row, height): + ws.row_dimensions[row].height = height + +def zebra_row(ws, row, ncols, start_col=1): + """Apply alternating row fill.""" + for c in range(start_col, start_col + ncols): + cell = ws.cell(row=row, column=c) + if cell.fill.fgColor.rgb in ("00000000", "FFFFFFFF", ""): + cell.fill = fill(C["row_alt"] if row % 2 == 0 else C["white"]) + +# ───────────────────────────────────────────────────────────────────── +# REFERENCE DATA +# ───────────────────────────────────────────────────────────────────── +EXPENSE_CATS = [ + "Groceries & Food", "Utilities", "Transport & Fuel", "Healthcare", + "Entertainment", "Personal Care", "Education", "Rent / Housing", + "Clothing", "Dining Out", "Church / Donations", "Subscriptions", + "Loan Payments", "Savings & Investments", "Childcare", "Pets", + "Gifts & Events", "Miscellaneous", +] +INCOME_CATS = [ + "Salary / Wages", "Freelance / Contract", "Business Income", + "Rental Income", "Investment Returns", "Pension / NIS", + "Government Benefit", "Gift / Support", "Side Hustle", "Other Income", +] +ALL_CATS = EXPENSE_CATS + INCOME_CATS + +ACCOUNT_TYPES = ["Checking", "Savings", "Credit Card", "Investment", "Cash"] +ACCOUNTS = [ + "GBTI Checking", "GBTI Savings", "Republic Bank Checking", + "Republic Bank Savings", "Demerara Bank", "Citizens Bank", + "GPSCCU Savings", "Cash / Wallet", "Investment Account", +] +TX_TYPES = ["Expense", "Income", "Transfer"] +FREQ_TYPES = ["Monthly", "Weekly", "Bi-Weekly", "Quarterly", "Yearly"] +BILL_STATUS = ["Unpaid", "Paid", "Overdue", "Auto-Pay"] +GOAL_CATS = ["Emergency Fund", "Vacation", "House / Property", + "Education", "Retirement", "Vehicle", "Business", "Custom"] +LOAN_TYPES = ["Personal Loan", "Mortgage", "Car Loan", + "Student Loan", "Credit Card Debt", "Business Loan"] +RATE_TYPES = ["Reducing Balance", "Flat Rate"] +NET_WORTH_REASONS = [ + "Regular Update", "New Asset", "New Debt", "Debt Paid Off", + "Asset Sold", "Salary Change", "Investment Gain", "Investment Loss", +] +MONTHS = ["Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec"] +BUDGET_TYPES = ["Needs (50%)", "Wants (30%)", "Savings (20%)"] +PRIORITY = ["High", "Medium", "Low"] + +NEEDS_CATS = ["Rent / Housing", "Utilities", "Groceries & Food", + "Transport & Fuel", "Healthcare", "Loan Payments", "Childcare"] +WANTS_CATS = ["Entertainment", "Dining Out", "Clothing", "Subscriptions", + "Gifts & Events", "Personal Care"] +SAVINGS_CATS = ["Savings & Investments", "Education", "Miscellaneous"] + +# Sample budget allocations (GYD amounts) +BUDGET_DEFAULTS = { + "Rent / Housing": 150000, + "Utilities": 35000, + "Groceries & Food": 80000, + "Transport & Fuel": 45000, + "Healthcare": 20000, + "Loan Payments": 60000, + "Childcare": 25000, + "Entertainment": 30000, + "Dining Out": 25000, + "Clothing": 20000, + "Subscriptions": 15000, + "Gifts & Events": 10000, + "Personal Care": 12000, + "Savings & Investments": 60000, + "Education": 20000, + "Miscellaneous": 15000, +} + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 1 — SETTINGS / REFERENCE (hidden drop-down source data) +# ───────────────────────────────────────────────────────────────────── +def build_settings(wb): + ws = wb.create_sheet("⚙ Settings") + ws.sheet_state = "visible" # keep visible so users can customize + + title_row_bg = C["dark"] + + def write_list(start_col, header, items, header_bg=C["emerald"]): + hcell = ws.cell(row=1, column=start_col, value=header) + hcell.fill = fill(header_bg) + hcell.font = font(bold=True, color=C["white"], size=10) + hcell.alignment = center() + hcell.border = thin_border() + ws.column_dimensions[get_column_letter(start_col)].width = 26 + for i, item in enumerate(items, start=2): + c = ws.cell(row=i, column=start_col, value=item) + c.alignment = left() + c.border = thin_border() + c.fill = fill(C["row_alt"] if i % 2 == 0 else C["white"]) + c.font = font(size=10) + + write_list(1, "Expense Categories", EXPENSE_CATS, C["emerald"]) + write_list(2, "Income Categories", INCOME_CATS, C["cyan"]) + write_list(3, "All Categories", ALL_CATS, C["dark"]) + write_list(4, "Accounts", ACCOUNTS, C["gold_dark"]) + write_list(5, "Account Types", ACCOUNT_TYPES, C["dark2"]) + write_list(6, "Transaction Types", TX_TYPES, C["purple"]) + write_list(7, "Frequencies", FREQ_TYPES, C["emerald_dark"]) + write_list(8, "Bill Status", BILL_STATUS, C["red"]) + write_list(9, "Goal Categories", GOAL_CATS, C["cyan"]) + write_list(10, "Loan Types", LOAN_TYPES, C["amber"]) + write_list(11, "Rate Types", RATE_TYPES, C["dark2"]) + write_list(12, "Budget Types", BUDGET_TYPES, C["emerald"]) + write_list(13, "Priority", PRIORITY, C["gold"]) + write_list(14, "NW Reasons", NET_WORTH_REASONS, C["dark"]) + + # Named ranges (used by data-validation in other sheets) + # We embed these as references in each DV call directly (simpler for openpyxl) + + set_row_height(ws, 1, 30) + ws.freeze_panes = "A2" + + # Instructions banner + ws["P1"] = "ℹ️ SETTINGS — Edit lists here to update ALL dropdowns across the workbook" + ws["P1"].fill = fill(C["gold_light"]) + ws["P1"].font = font(bold=True, size=11, color=C["dark"]) + ws["P1"].alignment = left() + ws.column_dimensions["P"].width = 65 + + return ws + +# ───────────────────────────────────────────────────────────────────── +# SHEET 2 — DASHBOARD +# ───────────────────────────────────────────────────────────────────── +def build_dashboard(wb): + ws = wb.create_sheet("📊 Dashboard", 0) + ws.sheet_view.showGridLines = False + + # Row heights + for r in range(1, 80): + set_row_height(ws, r, 22) + set_row_height(ws, 1, 8) # top padding + set_row_height(ws, 2, 50) # title row + + # Column widths + col_widths = {"A":2,"B":18,"C":14,"D":14,"E":14,"F":2,"G":18, + "H":14,"I":14,"J":14,"K":2,"L":22,"M":14,"N":14,"O":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + # ── Title Banner ────────────────────────────────────────────────── + ws.merge_cells("B2:O2") + tc = ws["B2"] + tc.value = "💰 FundSight — Personal Finance & Budget Planner" + tc.fill = fill(C["dark"]) + tc.font = font(bold=True, size=20, color=C["gold"]) + tc.alignment = center() + + # Sub-title / date + ws.merge_cells("B3:O3") + sc = ws["B3"] + sc.value = f"Dashboard Overview | Updated: {datetime.date.today().strftime('%d %B %Y')} | Currency: GYD $" + sc.fill = fill(C["dark2"]) + sc.font = font(size=10, color=C["off_white"], italic=True) + sc.alignment = center() + set_row_height(ws, 3, 20) + + # ── Section: Monthly Snapshot KPI Cards (row 5-9) ───────────────── + set_row_height(ws, 4, 12) + ws.merge_cells("B5:B5") # just label row + kpi_row = 5 + set_row_height(ws, kpi_row, 18) + + def kpi_card(col_letter, label, formula, bg, label_fg="FFFFFF"): + col = column_index_from_string(col_letter) + # label + lc = ws.cell(row=kpi_row, column=col, value=label) + lc.fill = fill(bg) + lc.font = font(bold=True, size=9, color=label_fg) + lc.alignment = center() + ws.merge_cells(start_row=kpi_row, start_column=col, + end_row=kpi_row, end_column=col+1) + # value + set_row_height(ws, kpi_row+1, 32) + vc = ws.cell(row=kpi_row+1, column=col, value=formula) + vc.fill = fill(bg) + vc.font = font(bold=True, size=18, color="FFFFFF") + vc.alignment = center() + vc.number_format = FMT_GYD2 + ws.merge_cells(start_row=kpi_row+1, start_column=col, + end_row=kpi_row+1, end_column=col+1) + # bottom label strip + set_row_height(ws, kpi_row+2, 16) + bc = ws.cell(row=kpi_row+2, column=col, value="This Month") + bc.fill = fill(bg) + bc.font = font(size=8, color=label_fg, italic=True) + bc.alignment = center() + ws.merge_cells(start_row=kpi_row+2, start_column=col, + end_row=kpi_row+2, end_column=col+1) + + kpi_card("B", "📥 TOTAL INCOME", "=SUMIF(Transactions!F:F,\"Income\",Transactions!E:E)", C["emerald"]) + kpi_card("D", "📤 TOTAL EXPENSES", "=SUMIF(Transactions!F:F,\"Expense\",Transactions!E:E)", C["red"]) + kpi_card("G", "💳 TOTAL BILLS DUE", "=SUMIF('Bills & Subs'!G:G,\"Unpaid\",'Bills & Subs'!C:C)",C["amber"]) + kpi_card("I", "🎯 SAVINGS PROGRESS","=SUM('Savings Goals'!C:C)", C["cyan"]) + kpi_card("L", "🏦 TOTAL DEBT", "=SUM('Debt Tracker'!E:E)", C["purple"]) + kpi_card("N", "📊 NET WORTH", "='Net Worth'!H2", C["dark"]) + + # ── Net Balance highlight (row 9) ──────────────────────────────── + set_row_height(ws, 9, 10) + set_row_height(ws, 10, 36) + ws.merge_cells("B10:O10") + nb = ws["B10"] + nb.value = '=\'📊 Dashboard\'!B6 - \'📊 Dashboard\'!D6' + nb.fill = fill(C["emerald_dark"]) + nb.font = font(bold=True, size=22, color="FFFFFF") + nb.alignment = center() + nb.number_format = '"NET BALANCE: $"#,##0.00' + + # ── Section: Budget vs Actual Table (row 12+) ───────────────────── + set_row_height(ws, 11, 10) + ws.merge_cells("B12:J12") + style_section_title(ws, 12, 2, "📋 BUDGET vs ACTUAL — This Month", span=9, bg=C["dark"]) + set_row_height(ws, 12, 24) + + hdrs = ["Category", "Budget Type", "Budgeted ($)", "Actual ($)", + "Remaining ($)", "% Used", "Status"] + for ci, h in enumerate(hdrs, start=2): + c = ws.cell(row=13, column=ci) + style_header_cell(c, h, bg=C["dark2"]) + set_row_height(ws, 13, 20) + + bva_data = [ + ("Rent / Housing", "Needs", 150000), + ("Utilities", "Needs", 35000), + ("Groceries & Food", "Needs", 80000), + ("Transport & Fuel", "Needs", 45000), + ("Healthcare", "Needs", 20000), + ("Loan Payments", "Needs", 60000), + ("Entertainment", "Wants", 30000), + ("Dining Out", "Wants", 25000), + ("Clothing", "Wants", 20000), + ("Subscriptions", "Wants", 15000), + ("Personal Care", "Wants", 12000), + ("Savings & Investments","Savings", 60000), + ("Education", "Savings", 20000), + ("Miscellaneous", "Savings", 15000), + ] + + type_colors = {"Needs": C["emerald_light"], "Wants": C["amber_light"], + "Savings": C["cyan_light"]} + + for ri, (cat, btype, budg) in enumerate(bva_data, start=14): + r = ri + set_row_height(ws, r, 20) + bg = type_colors.get(btype, C["white"]) + actual_formula = f'=SUMIF(Transactions!D:D,B{r},Transactions!E:E)' + remain_formula = f'=D{r}-E{r}' + pct_formula = f'=IF(D{r}=0,0,E{r}/D{r})' + status_formula = (f'=IF(F{r}<=0,"🔴 Over Budget",' + f'IF(G{r}>=0.9,"🟡 Near Limit","🟢 On Track"))') + + vals = [cat, btype, budg, actual_formula, + remain_formula, pct_formula, status_formula] + fmts = [None, None, FMT_GYD2, FMT_GYD2, FMT_GYD2, FMT_PCT, None] + + for ci, (v, fmt) in enumerate(zip(vals, fmts), start=2): + cell = ws.cell(row=r, column=ci, value=v) + cell.fill = fill(bg) + cell.font = font(size=10) + cell.border = thin_border() + cell.alignment = center() if ci >= 4 else left() + if fmt: + cell.number_format = fmt + + # Conditional formatting on % Used column (col H = 8) + pct_range = f"H14:H{13+len(bva_data)}" + ws.conditional_formatting.add(pct_range, CellIsRule( + operator="greaterThanOrEqual", formula=["0.9"], + fill=fill(C["red_light"]), font=font(color=C["red"], bold=True) + )) + ws.conditional_formatting.add(pct_range, CellIsRule( + operator="between", formula=["0.7", "0.9"], + fill=fill(C["amber_light"]), font=font(color="B45309", bold=False) + )) + ws.conditional_formatting.add(pct_range, CellIsRule( + operator="lessThan", formula=["0.7"], + fill=fill(C["emerald_light"]), font=font(color=C["emerald_dark"]) + )) + + bva_end = 13 + len(bva_data) + + # ── Section: 50/30/20 Summary (right side row 12+) ─────────────── + ws.merge_cells("L12:O12") + style_section_title(ws, 12, 12, "🎯 50 / 30 / 20 RULE", span=4, bg=C["dark"]) + + budget_summary = [ + ("NEEDS (50%)", C["emerald"], f'=SUMIF(C14:C{bva_end},"Needs",D14:D{bva_end})'), + ("WANTS (30%)", C["amber"], f'=SUMIF(C14:C{bva_end},"Wants",D14:D{bva_end})'), + ("SAVINGS(20%)", C["cyan"], f'=SUMIF(C14:C{bva_end},"Savings",D14:D{bva_end})'), + ] + for bi, (label, bg, formula) in enumerate(budget_summary, start=13): + set_row_height(ws, bi, 28) + lc = ws.cell(row=bi, column=12, value=label) + lc.fill = fill(bg); lc.font = font(bold=True, size=10, color="FFFFFF") + lc.alignment = center(); lc.border = thin_border() + ws.merge_cells(start_row=bi, start_column=12, + end_row=bi, end_column=13) + vc = ws.cell(row=bi, column=14, value=formula) + vc.fill = fill(bg); vc.font = font(bold=True, size=13, color="FFFFFF") + vc.number_format = FMT_GYD2 + vc.alignment = center(); vc.border = thin_border() + ws.merge_cells(start_row=bi, start_column=14, + end_row=bi, end_column=15) + + # ── Quick Tips (bottom) ─────────────────────────────────────────── + tip_row = bva_end + 2 + ws.merge_cells(f"B{tip_row}:O{tip_row}") + tc2 = ws.cell(row=tip_row, column=2, + value="💡 TIPS: Enter your monthly income in the Income sheet • Log transactions daily in the Transactions sheet • Update goals in Savings Goals • Review debt in Debt Tracker") + tc2.fill = fill(C["gold_light"]) + tc2.font = font(size=9, color=C["dark"], italic=True) + tc2.alignment = left() + set_row_height(ws, tip_row, 24) + + ws.freeze_panes = "B4" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 3 — MONTHLY BUDGET PLANNER +# ───────────────────────────────────────────────────────────────────── +def build_monthly_budget(wb): + ws = wb.create_sheet("💰 Monthly Budget") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":26,"C":14,"D":14,"E":14,"F":14,"G":12,"H":20,"I":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + # Title + ws.merge_cells("B1:H1") + tc = ws["B1"] + tc.value = "💰 MONTHLY BUDGET PLANNER" + tc.fill = fill(C["emerald"]) + tc.font = font(bold=True, size=16, color="FFFFFF") + tc.alignment = center() + set_row_height(ws, 1, 40) + + # Month selector row + ws.merge_cells("B2:C2") + ws["B2"] = "📅 Budget Month:" + ws["B2"].font = font(bold=True, size=11) + ws["B2"].alignment = right_align() + ws["D2"] = datetime.date.today().strftime("%B %Y") + ws["D2"].font = font(bold=True, size=12, color=C["emerald"]) + ws["D2"].fill = fill(C["emerald_light"]) + ws["D2"].alignment = center() + ws["D2"].border = thin_border() + + ws["F2"] = "Monthly Income:" + ws["F2"].font = font(bold=True, size=11) + ws["F2"].alignment = right_align() + ws["G2"] = 500000 # placeholder + ws["G2"].font = font(bold=True, size=12, color=C["emerald_dark"]) + ws["G2"].number_format = FMT_GYD2 + ws["G2"].fill = fill(C["emerald_light"]) + ws["G2"].border = thin_border() + ws["G2"].alignment = center() + set_row_height(ws, 2, 26) + set_row_height(ws, 3, 10) + + # Column headers + hdrs = ["Category", "Budget Type", "Budgeted ($)", + "Actual Spent ($)", "Remaining ($)", "% Used", "Status"] + for ci, h in enumerate(hdrs, start=2): + c = ws.cell(row=4, column=ci) + style_header_cell(c, h, bg=C["dark"]) + set_row_height(ws, 4, 22) + + # Data rows grouped by type + groups = [ + ("NEEDS — Essential Expenses (Target: 50% of income)", C["emerald"], + [(cat, "Needs", BUDGET_DEFAULTS.get(cat, 0)) for cat in NEEDS_CATS]), + ("WANTS — Lifestyle Expenses (Target: 30% of income)", C["amber"], + [(cat, "Wants", BUDGET_DEFAULTS.get(cat, 0)) for cat in WANTS_CATS]), + ("SAVINGS & INVESTMENTS (Target: 20% of income)", C["cyan"], + [(cat, "Savings", BUDGET_DEFAULTS.get(cat, 0)) for cat in SAVINGS_CATS]), + ] + + type_fill = {"Needs": C["emerald_light"], "Wants": C["amber_light"], + "Savings": C["cyan_light"]} + + current_row = 5 + data_rows = [] + + for group_label, group_bg, items in groups: + # Group header + ws.merge_cells( + start_row=current_row, start_column=2, + end_row=current_row, end_column=8 + ) + gh = ws.cell(row=current_row, column=2, value=group_label) + gh.fill = fill(group_bg) + gh.font = font(bold=True, size=11, color="FFFFFF") + gh.alignment = left() + gh.border = thin_border() + set_row_height(ws, current_row, 22) + current_row += 1 + + for cat, btype, budg in items: + r = current_row + set_row_height(ws, r, 20) + bg = type_fill[btype] + + actual_f = f'=SUMIF(Transactions!D:D,B{r},Transactions!E:E)' + remain_f = f'=D{r}-E{r}' + pct_f = f'=IF(D{r}=0,0,E{r}/D{r})' + status_f = (f'=IF(G{r}<=0,"🔴 Over Budget",' + f'IF(G{r}>=0.9,"🟡 Near Limit","🟢 On Track"))') + + row_data = [cat, btype, budg, actual_f, remain_f, pct_f, status_f] + fmts = [None, None, FMT_GYD2, FMT_GYD2, FMT_GYD2, FMT_PCT, None] + + for ci, (v, fmt) in enumerate(zip(row_data, fmts), start=2): + cell = ws.cell(row=r, column=ci, value=v) + cell.fill = fill(bg) + cell.font = font(size=10) + cell.border = thin_border() + cell.alignment = center() if ci >= 4 else left() + if fmt: + cell.number_format = fmt + + # Dropdown for budget type col C + dv_type = DataValidation( + type="list", + formula1='"Needs,Wants,Savings"', + allow_blank=False, + showDropDown=False + ) + dv_type.sqref = f"C{r}" + ws.add_data_validation(dv_type) + + data_rows.append(r) + current_row += 1 + + # Group subtotal + st_row = current_row + set_row_height(ws, st_row, 22) + ws.merge_cells(start_row=st_row, start_column=2, + end_row=st_row, end_column=3) + stc = ws.cell(row=st_row, column=2, value=f" Subtotal") + stc.fill = fill(group_bg); stc.font = font(bold=True, size=10, color="FFFFFF") + stc.border = thin_border(); stc.alignment = left() + + first_r = current_row - len(items) + for ci, fmt in [(4, FMT_GYD2), (5, FMT_GYD2), (6, FMT_GYD2)]: + sc = ws.cell(row=st_row, column=ci, + value=f"=SUM({get_column_letter(ci)}{first_r}:{get_column_letter(ci)}{current_row-1})") + sc.fill = fill(group_bg); sc.font = font(bold=True, size=10, color="FFFFFF") + sc.number_format = fmt; sc.border = thin_border(); sc.alignment = center() + + current_row += 2 # spacing + + # ── Grand Total row ─────────────────────────────────────────────── + tot_row = current_row + set_row_height(ws, tot_row, 26) + ws.merge_cells(start_row=tot_row, start_column=2, + end_row=tot_row, end_column=3) + gtc = ws.cell(row=tot_row, column=2, value="💰 TOTAL BUDGET") + gtc.fill = fill(C["dark"]); gtc.font = font(bold=True, size=12, color=C["gold"]) + gtc.border = thin_border(); gtc.alignment = center() + + if data_rows: + for ci, fmt in [(4, FMT_GYD2),(5, FMT_GYD2),(6, FMT_GYD2)]: + rows_str = "+".join([f"{get_column_letter(ci)}{r}" for r in data_rows]) + tc2 = ws.cell(row=tot_row, column=ci, value=f"={rows_str}") + tc2.fill = fill(C["dark"]); tc2.font = font(bold=True, size=12, color=C["gold"]) + tc2.number_format = fmt; tc2.border = thin_border(); tc2.alignment = center() + + # Unallocated row + unalloc_row = tot_row + 1 + set_row_height(ws, unalloc_row, 22) + ws.merge_cells(start_row=unalloc_row, start_column=2, + end_row=unalloc_row, end_column=3) + uc = ws.cell(row=unalloc_row, column=2, value="💵 Unallocated Income") + uc.fill = fill(C["emerald_light"]); uc.font = font(bold=True, size=10, color=C["emerald_dark"]) + uc.border = thin_border(); uc.alignment = left() + uv = ws.cell(row=unalloc_row, column=4, + value=f"=$G$2-{get_column_letter(4)}{tot_row}") + uv.fill = fill(C["emerald_light"]); uv.font = font(bold=True, size=10, color=C["emerald_dark"]) + uv.number_format = FMT_GYD2; uv.border = thin_border(); uv.alignment = center() + + # Conditional formatting pct column + if data_rows: + pct_col = get_column_letter(7) + pct_rng = f"{pct_col}5:{pct_col}{current_row}" + ws.conditional_formatting.add(pct_rng, CellIsRule( + operator="greaterThanOrEqual", formula=["1"], + fill=fill(C["red_light"]), font=font(color=C["red"], bold=True) + )) + ws.conditional_formatting.add(pct_rng, CellIsRule( + operator="between", formula=["0.9", "1"], + fill=fill(C["amber_light"]), font=font(color="B45309") + )) + ws.conditional_formatting.add(pct_rng, CellIsRule( + operator="lessThan", formula=["0.9"], + fill=fill(C["emerald_light"]), font=font(color=C["emerald_dark"]) + )) + + ws.freeze_panes = "B5" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 4 — INCOME TRACKER +# ───────────────────────────────────────────────────────────────────── +def build_income(wb): + ws = wb.create_sheet("💵 Income") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":12,"C":28,"D":22,"E":16,"F":14,"G":16,"H":14,"I":22,"J":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + ws.merge_cells("B1:I1") + tc = ws["B1"] + tc.value = "💵 INCOME TRACKER" + tc.fill = fill(C["emerald"]) + tc.font = font(bold=True, size=16, color="FFFFFF") + tc.alignment = center() + set_row_height(ws, 1, 40) + + # Summary KPIs + set_row_height(ws, 2, 10) + kpi_labels = ["Total Income This Month","Avg Monthly Income","YTD Income","# Income Sources"] + for ki, kl in enumerate(kpi_labels): + col = 2 + ki * 2 + ws.merge_cells(start_row=3, start_column=col, end_row=3, end_column=col+1) + kc = ws.cell(row=3, column=col, value=kl) + kc.fill = fill(C["dark2"]); kc.font = font(bold=True, size=9, color="FFFFFF") + kc.alignment = center(); kc.border = thin_border() + set_row_height(ws, 3, 18) + set_row_height(ws, 4, 30) + bg = [C["emerald"], C["cyan"], C["gold_dark"], C["dark"]][ki] + ws.merge_cells(start_row=4, start_column=col, end_row=4, end_column=col+1) + vc = ws.cell(row=4, column=col) + if ki == 0: + vc.value = '=SUMIF(F:F,"Income",E:E)' + elif ki == 1: + vc.value = '=IFERROR(SUMIF(F:F,"Income",E:E)/COUNTA(B7:B1000),0)' + elif ki == 2: + vc.value = '=SUMIF(F:F,"Income",E:E)' + else: + vc.value = '=COUNTA(D7:D1000)' + vc.fill = fill(bg); vc.font = font(bold=True, size=16, color="FFFFFF") + vc.number_format = FMT_GYD2 if ki < 3 else FMT_INT + vc.alignment = center(); vc.border = thin_border() + + set_row_height(ws, 5, 10) + + # Column headers + hdrs = ["Date", "Source / Payer", "Category", "Amount ($)", + "Type", "Account", "Recurring?", "Notes"] + for ci, h in enumerate(hdrs, start=2): + c = ws.cell(row=6, column=ci) + style_header_cell(c, h, bg=C["emerald"]) + set_row_height(ws, 6, 22) + + # Data validation for income category + dv_cat = DataValidation(type="list", + formula1=f'"{"","".join(INCOME_CATS)}"', + allow_blank=True, showDropDown=False) + dv_cat.sqref = "D7:D1000" + ws.add_data_validation(dv_cat) + + dv_acc = DataValidation(type="list", + formula1=f'"{"","".join(ACCOUNTS)}"', + allow_blank=True, showDropDown=False) + dv_acc.sqref = "G7:G1000" + ws.add_data_validation(dv_acc) + + dv_rec = DataValidation(type="list", + formula1='"Yes,No"', allow_blank=True, showDropDown=False) + dv_rec.sqref = "H7:H1000" + ws.add_data_validation(dv_rec) + + # Sample rows + sample_income = [ + ("2026-04-01", "Employer Ltd", "Salary / Wages", 450000, "Income", "GBTI Checking", "Yes", "April salary"), + ("2026-04-05", "Client A", "Freelance / Contract", 35000, "Income", "Cash / Wallet", "No", "Web project"), + ("2026-04-10", "Rental", "Rental Income", 25000, "Income", "Republic Bank Checking","Yes","Apartment rent"), + ] + for ri, row_data in enumerate(sample_income, start=7): + set_row_height(ws, ri, 20) + bg = C["row_alt"] if ri % 2 == 0 else C["white"] + for ci, val in enumerate(row_data, start=2): + cell = ws.cell(row=ri, column=ci, value=val) + cell.fill = fill(bg); cell.font = font(size=10) + cell.border = thin_border() + cell.alignment = center() if ci >= 5 else left() + if ci == 5: + cell.number_format = FMT_GYD2 + if ci == 2: + cell.number_format = FMT_DATE + + # Empty rows for data entry + for ri in range(10, 52): + set_row_height(ws, ri, 20) + bg = C["row_alt"] if ri % 2 == 0 else C["white"] + for ci in range(2, 10): + cell = ws.cell(row=ri, column=ci) + cell.fill = fill(bg); cell.border = thin_border() + if ci == 5: + cell.number_format = FMT_GYD2 + + ws.freeze_panes = "B7" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 5 — TRANSACTIONS LOG +# ───────────────────────────────────────────────────────────────────── +def build_transactions(wb): + ws = wb.create_sheet("📝 Transactions") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":12,"C":28,"D":22,"E":14,"F":12, + "G":20,"H":14,"I":14,"J":14,"K":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + ws.merge_cells("B1:J1") + tc = ws["B1"] + tc.value = "📝 TRANSACTION LOG" + tc.fill = fill(C["dark"]) + tc.font = font(bold=True, size=16, color=C["gold"]) + tc.alignment = center() + set_row_height(ws, 1, 40) + + # KPI row + set_row_height(ws, 2, 10) + kpis = [ + ("Total Expenses", '=SUMIF(F:F,"Expense",E:E)', C["red"]), + ("Total Income", '=SUMIF(F:F,"Income",E:E)', C["emerald"]), + ("Net Cash Flow", '=SUMIF(F:F,"Income",E:E)-SUMIF(F:F,"Expense",E:E)', C["cyan"]), + ("# Transactions", '=COUNTA(B6:B10000)', C["dark2"]), + ] + for ki, (label, formula, bg) in enumerate(kpis): + col = 2 + ki * 2 + set_row_height(ws, 3, 18); set_row_height(ws, 4, 30) + ws.merge_cells(start_row=3, start_column=col, end_row=3, end_column=col+1) + lc = ws.cell(row=3, column=col, value=label) + lc.fill = fill(bg); lc.font = font(bold=True, size=9, color="FFFFFF") + lc.alignment = center(); lc.border = thin_border() + ws.merge_cells(start_row=4, start_column=col, end_row=4, end_column=col+1) + vc = ws.cell(row=4, column=col, value=formula) + vc.fill = fill(bg); vc.font = font(bold=True, size=16, color="FFFFFF") + vc.number_format = FMT_GYD2 if ki < 3 else FMT_INT + vc.alignment = center(); vc.border = thin_border() + + set_row_height(ws, 5, 10) + + # Column headers + hdrs = ["Date","Description / Merchant","Category", + "Amount ($)","Type","Account","Notes","Recurring?","Reconciled?"] + for ci, h in enumerate(hdrs, start=2): + c = ws.cell(row=6, column=ci) + style_header_cell(c, h, bg=C["dark"]) + set_row_height(ws, 6, 22) + + # Data validations + for dv_range, formula in [ + ("D7:D10000", '"' + '","'.join(ALL_CATS) + '"'), + ("F7:F10000", '"Expense,Income,Transfer"'), + ("G7:G10000", '"' + '","'.join(ACCOUNTS) + '"'), + ("I7:I10000", '"Yes,No"'), + ("J7:J10000", '"Yes,No"'), + ]: + dv = DataValidation(type="list", formula1=formula, + allow_blank=True, showDropDown=False) + dv.sqref = dv_range + ws.add_data_validation(dv) + + # Amount must be positive + dv_num = DataValidation(type="decimal", operator="greaterThan", + formula1="0", allow_blank=True) + dv_num.error = "Amount must be a positive number" + dv_num.errorTitle = "Invalid Amount" + dv_num.sqref = "E7:E10000" + ws.add_data_validation(dv_num) + + # Sample data + today = datetime.date.today() + samples = [ + (today.strftime("%Y-%m-%d"), "Bounty Supermarket", "Groceries & Food", 12500, "Expense", "GBTI Checking", "", "No", "No"), + (today.strftime("%Y-%m-%d"), "GPL Electricity", "Utilities", 18000, "Expense", "GBTI Checking", "", "Yes","No"), + (today.strftime("%Y-%m-%d"), "Employer Ltd", "Salary / Wages", 450000, "Income", "GBTI Checking", "", "Yes","Yes"), + (today.strftime("%Y-%m-%d"), "Chefette Restaurant", "Dining Out", 3500, "Expense", "Cash / Wallet", "", "No", "No"), + (today.strftime("%Y-%m-%d"), "Netflix", "Subscriptions", 4500, "Expense", "Republic Bank Checking", "", "Yes","No"), + (today.strftime("%Y-%m-%d"), "Shell Gasoline", "Transport & Fuel", 9000, "Expense", "GBTI Checking", "", "No", "No"), + (today.strftime("%Y-%m-%d"), "GBTI Savings Transfer","Savings & Investments", 50000, "Transfer","GBTI Savings", "", "Yes","No"), + ] + for ri, row_data in enumerate(samples, start=7): + set_row_height(ws, ri, 20) + bg = C["row_alt"] if ri % 2 == 0 else C["white"] + for ci, val in enumerate(row_data, start=2): + cell = ws.cell(row=ri, column=ci, value=val) + cell.fill = fill(bg); cell.font = font(size=10) + cell.border = thin_border() + cell.alignment = center() if ci >= 5 else left() + if ci == 5: + cell.number_format = FMT_GYD2 + if ci == 2: + cell.number_format = FMT_DATE + + # Empty rows + for ri in range(14, 107): + set_row_height(ws, ri, 20) + bg = C["row_alt"] if ri % 2 == 0 else C["white"] + for ci in range(2, 11): + cell = ws.cell(row=ri, column=ci) + cell.fill = fill(bg); cell.border = thin_border() + if ci == 5: + cell.number_format = FMT_GYD2 + + # Conditional formatting — highlight income rows green, expense rows red + ws.conditional_formatting.add("B7:J1000", FormulaRule( + formula=["$F7=\"Expense\""], fill=fill("FFF0F0") + )) + ws.conditional_formatting.add("B7:J1000", FormulaRule( + formula=["$F7=\"Income\""], fill=fill("F0FFF4") + )) + + ws.freeze_panes = "B7" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 6 — BILLS & SUBSCRIPTIONS +# ───────────────────────────────────────────────────────────────────── +def build_bills(wb): + ws = wb.create_sheet("📅 Bills & Subs") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":28,"C":14,"D":10,"E":14,"F":16,"G":14,"H":14,"I":16,"J":24,"K":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + ws.merge_cells("B1:J1") + tc = ws["B1"] + tc.value = "📅 BILLS & SUBSCRIPTIONS TRACKER" + tc.fill = fill(C["amber"]) + tc.font = font(bold=True, size=16, color=C["dark"]) + tc.alignment = center() + set_row_height(ws, 1, 40) + + # Summary + set_row_height(ws, 2, 10) + kpis = [ + ("Total Monthly Bills", '=SUMIF(F:F,"Monthly",C:C)', C["dark"]), + ("Unpaid This Month", '=SUMIF(G:G,"Unpaid",C:C)', C["red"]), + ("Auto-Pay Total", '=SUMIF(H:H,"Yes",C:C)', C["emerald"]), + ("Overdue Bills", '=COUNTIF(G:G,"Overdue")', C["red"]), + ] + for ki, (label, formula, bg) in enumerate(kpis): + col = 2 + ki * 2 + ws.merge_cells(start_row=3, start_column=col, end_row=3, end_column=col+1) + lc = ws.cell(row=3, column=col, value=label) + lc.fill = fill(bg); lc.font = font(bold=True, size=9, color="FFFFFF") + lc.alignment = center(); lc.border = thin_border() + set_row_height(ws, 3, 18); set_row_height(ws, 4, 30) + ws.merge_cells(start_row=4, start_column=col, end_row=4, end_column=col+1) + vc = ws.cell(row=4, column=col, value=formula) + vc.fill = fill(bg); vc.font = font(bold=True, size=16, color="FFFFFF") + vc.number_format = FMT_GYD2 if ki < 3 else FMT_INT + vc.alignment = center(); vc.border = thin_border() + + set_row_height(ws, 5, 10) + + hdrs = ["Bill / Subscription Name", "Amount ($)", "Due Day", + "Frequency", "Category", "Status", "Auto-Pay?", "Account", "Notes"] + for ci, h in enumerate(hdrs, start=2): + c = ws.cell(row=6, column=ci) + style_header_cell(c, h, bg=C["amber"]) + set_row_height(ws, 6, 22) + + # Validations + for rng, vals in [ + ("F7:F200", '"Monthly,Weekly,Bi-Weekly,Quarterly,Yearly"'), + ("G7:G200", '"Unpaid,Paid,Overdue,Auto-Pay"'), + ("H7:H200", '"Yes,No"'), + ("I7:I200", '"' + '","'.join(ACCOUNTS) + '"'), + ("E7:E200", '"' + '","'.join(EXPENSE_CATS) + '"'), + ]: + dv = DataValidation(type="list", formula1=vals, allow_blank=True, showDropDown=False) + dv.sqref = rng + ws.add_data_validation(dv) + + bills_data = [ + ("GPL Electricity", 18000, 1, "Monthly", "Utilities", "Unpaid", "No", "GBTI Checking", ""), + ("GWI Water", 5500, 5, "Monthly", "Utilities", "Unpaid", "No", "GBTI Checking", ""), + ("GTT Fibre Internet", 9000, 10, "Monthly", "Utilities", "Paid", "Yes", "GBTI Checking", ""), + ("Digicel Mobile", 6000, 15, "Monthly", "Utilities", "Unpaid", "Yes", "GBTI Checking", ""), + ("Netflix", 4500, 8, "Monthly", "Subscriptions", "Paid", "Yes", "Republic Bank Checking", ""), + ("Spotify", 2000, 12, "Monthly", "Subscriptions", "Paid", "Yes", "Republic Bank Checking", ""), + ("Health Insurance", 12000, 1, "Monthly", "Healthcare", "Unpaid", "No", "GBTI Checking", ""), + ("Car Insurance", 8000, 1, "Monthly", "Transport & Fuel", "Unpaid", "No", "GBTI Checking", ""), + ("Gym Membership", 5000, 1, "Monthly", "Personal Care", "Unpaid", "Yes", "Cash / Wallet", ""), + ("Republic Bank Loan", 45000, 25, "Monthly", "Loan Payments", "Unpaid", "No", "Republic Bank Checking", ""), + ("YouTube Premium", 1500, 20, "Monthly", "Subscriptions", "Paid", "Yes", "Republic Bank Checking", ""), + ("Amazon Prime", 3500, 18, "Yearly", "Subscriptions", "Paid", "Yes", "Republic Bank Checking", ""), + ("Starlink Internet", 15000, 3, "Monthly", "Utilities", "Unpaid", "No", "GBTI Checking", ""), + ] + + status_colors = {"Paid": C["emerald_light"], "Unpaid": C["amber_light"], + "Overdue": C["red_light"], "Auto-Pay": C["cyan_light"]} + + for ri, row_data in enumerate(bills_data, start=7): + set_row_height(ws, ri, 20) + status = row_data[6] + bg = status_colors.get(status, C["white"]) + for ci, val in enumerate(row_data, start=2): + cell = ws.cell(row=ri, column=ci, value=val) + cell.fill = fill(bg); cell.font = font(size=10) + cell.border = thin_border() + cell.alignment = center() if ci >= 3 else left() + if ci == 3: + cell.number_format = FMT_GYD2 + + for ri in range(7 + len(bills_data), 57): + set_row_height(ws, ri, 20) + bg = C["row_alt"] if ri % 2 == 0 else C["white"] + for ci in range(2, 11): + cell = ws.cell(row=ri, column=ci) + cell.fill = fill(bg); cell.border = thin_border() + if ci == 3: + cell.number_format = FMT_GYD2 + + # Conditional formatting — overdue highlighted red + ws.conditional_formatting.add("B7:J200", FormulaRule( + formula=['$G7="Overdue"'], fill=fill(C["red_light"]), + font=font(color=C["red"], bold=True) + )) + + ws.freeze_panes = "B7" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 7 — SAVINGS GOALS +# ───────────────────────────────────────────────────────────────────── +def build_savings(wb): + ws = wb.create_sheet("🎯 Savings Goals") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":24,"C":14,"D":14,"E":16,"F":14,"G":12,"H":20,"I":18,"J":16,"K":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + ws.merge_cells("B1:J1") + tc = ws["B1"] + tc.value = "🎯 SAVINGS GOALS TRACKER" + tc.fill = fill(C["cyan"]) + tc.font = font(bold=True, size=16, color=C["dark"]) + tc.alignment = center() + set_row_height(ws, 1, 40) + + # Summary KPIs + set_row_height(ws, 2, 10) + kpis = [ + ("Total Saved", '=SUM(D7:D100)', C["cyan"]), + ("Total Target", '=SUM(C7:C100)', C["dark"]), + ("Overall Progress", '=IFERROR(SUM(D7:D100)/SUM(C7:C100),0)', C["emerald"]), + ("Goals On Track", '=COUNTIF(J7:J100,"🟢 On Track")', C["emerald"]), + ] + for ki, (label, formula, bg) in enumerate(kpis): + col = 2 + ki * 2 + ws.merge_cells(start_row=3, start_column=col, end_row=3, end_column=col+1) + lc = ws.cell(row=3, column=col, value=label) + lc.fill = fill(bg); lc.font = font(bold=True, size=9, color="FFFFFF") + lc.alignment = center(); lc.border = thin_border() + set_row_height(ws, 3, 18); set_row_height(ws, 4, 30) + ws.merge_cells(start_row=4, start_column=col, end_row=4, end_column=col+1) + vc = ws.cell(row=4, column=col, value=formula) + vc.fill = fill(bg); vc.font = font(bold=True, size=16, color="FFFFFF") + vc.number_format = FMT_GYD2 if ki < 2 else (FMT_PCT if ki == 2 else FMT_INT) + vc.alignment = center(); vc.border = thin_border() + + set_row_height(ws, 5, 10) + + hdrs = ["Goal Name","Target ($)","Saved ($)","Monthly Contribution ($)", + "Target Date","% Complete","Months Left","Status"] + for ci, h in enumerate(hdrs, start=2): + c = ws.cell(row=6, column=ci) + style_header_cell(c, h, bg=C["cyan"]) + set_row_height(ws, 6, 22) + + # Category dropdown + dv_cat = DataValidation(type="list", + formula1='"Emergency Fund,Vacation,House / Property,Education,Retirement,Vehicle,Business,Custom"', + allow_blank=True, showDropDown=False) + dv_cat.sqref = "B7:B100" + ws.add_data_validation(dv_cat) + + goals_data = [ + ("Emergency Fund", 600000, 280000, 40000, "2026-12-31"), + ("Vacation — Barbados", 250000, 75000, 25000, "2026-08-01"), + ("House Deposit", 5000000,800000, 80000, "2030-01-01"), + ("New Car", 2000000,350000, 60000, "2028-06-01"), + ("Education Fund", 800000, 120000, 20000, "2027-09-01"), + ] + + goal_colors = [C["emerald_light"], C["amber_light"], C["cyan_light"], + C["purple_light"], C["gold_light"]] + + for ri, (name, target, saved, contrib, tdate) in enumerate(goals_data, start=7): + r = ri + set_row_height(ws, r, 22) + bg = goal_colors[(ri - 7) % len(goal_colors)] + + pct_f = f'=IFERROR(D{r}/C{r},0)' + months_f = f'=IFERROR(DATEDIF(TODAY(),G{r},"M"),0)' + status_f = (f'=IF(D{r}>=C{r},"✅ Complete",' + f'IF(IFERROR(D{r}/C{r},0)>=0.75,"🟢 On Track",' + f'IF(IFERROR(D{r}/C{r},0)>=0.4,"🟡 Progressing","🔴 Needs Attention")))') + + row_vals = [name, target, saved, contrib, tdate, pct_f, months_f, status_f] + fmts = [None, FMT_GYD2, FMT_GYD2, FMT_GYD2, FMT_DATE, FMT_PCT, FMT_INT, None] + + for ci, (v, fmt) in enumerate(zip(row_vals, fmts), start=2): + cell = ws.cell(row=r, column=ci, value=v) + cell.fill = fill(bg); cell.font = font(size=10) + cell.border = thin_border() + cell.alignment = center() if ci >= 3 else left() + if fmt: + cell.number_format = fmt + + for ri in range(7 + len(goals_data), 37): + set_row_height(ws, ri, 20) + bg = C["row_alt"] if ri % 2 == 0 else C["white"] + for ci in range(2, 11): + ws.cell(row=ri, column=ci).fill = fill(bg) + ws.cell(row=ri, column=ci).border = thin_border() + + # Data bar for % complete + ws.conditional_formatting.add("H7:H100", + DataBarRule(start_type="num", start_value=0, + end_type="num", end_value=1, + color=C["emerald"])) + + ws.freeze_panes = "B7" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 8 — DEBT TRACKER +# ───────────────────────────────────────────────────────────────────── +def build_debt(wb): + ws = wb.create_sheet("🏦 Debt Tracker") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":24,"C":18,"D":14,"E":14,"F":10,"G":12, + "H":14,"I":14,"J":12,"K":14,"L":20,"M":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + ws.merge_cells("B1:L1") + tc = ws["B1"] + tc.value = "🏦 DEBT TRACKER — Loan Portfolio" + tc.fill = fill(C["purple"]) + tc.font = font(bold=True, size=16, color="FFFFFF") + tc.alignment = center() + set_row_height(ws, 1, 40) + + # Summary KPIs + set_row_height(ws, 2, 10) + kpi_data = [ + ("Total Debt", '=SUM(E7:E100)', C["red"]), + ("Total Min. Payments", '=SUM(H7:H100)', C["amber"]), + ("Avg Interest Rate", '=IFERROR(AVERAGE(F7:F100),0)', C["purple"]), + ("# Active Loans", '=COUNTA(B7:B100)', C["dark2"]), + ] + for ki, (label, formula, bg) in enumerate(kpi_data): + col = 2 + ki * 3 + ws.merge_cells(start_row=3, start_column=col, end_row=3, end_column=col+2) + lc = ws.cell(row=3, column=col, value=label) + lc.fill = fill(bg); lc.font = font(bold=True, size=9, color="FFFFFF") + lc.alignment = center(); lc.border = thin_border() + set_row_height(ws, 3, 18); set_row_height(ws, 4, 32) + ws.merge_cells(start_row=4, start_column=col, end_row=4, end_column=col+2) + vc = ws.cell(row=4, column=col, value=formula) + vc.fill = fill(bg); vc.font = font(bold=True, size=18, color="FFFFFF") + vc.number_format = FMT_GYD2 if ki != 2 and ki != 3 else (FMT_PCT if ki == 2 else FMT_INT) + vc.alignment = center(); vc.border = thin_border() + + set_row_height(ws, 5, 10) + + hdrs = ["Loan / Debt Name","Lender","Original Amount ($)","Balance ($)", + "Interest Rate","Rate Type","Monthly Payment ($)","Total Paid ($)", + "% Paid Off","Due Date","Notes"] + for ci, h in enumerate(hdrs, start=2): + c = ws.cell(row=6, column=ci) + style_header_cell(c, h, bg=C["purple"]) + set_row_height(ws, 6, 22) + + for rng, vals in [ + ("C7:C100", '"Personal Loan,Mortgage,Car Loan,Student Loan,Credit Card Debt,Business Loan"'), + ("G7:G100", '"Reducing Balance,Flat Rate"'), + ("K7:K100", '"High Priority,Medium Priority,Low Priority"'), + ]: + dv = DataValidation(type="list", formula1=vals, allow_blank=True, showDropDown=False) + dv.sqref = rng + ws.add_data_validation(dv) + + loans_data = [ + ("Republic Bank Personal Loan", "Republic Bank", 500000, 320000, 0.18, "Reducing Balance", 45000, 180000), + ("Car Loan — Toyota", "Demerara Bank", 1200000, 890000, 0.15, "Reducing Balance", 32000, 310000), + ("Credit Card Balance", "Citizens Bank", 150000, 85000, 0.28, "Flat Rate", 12000, 65000), + ("Student Loan", "GBTI", 300000, 120000, 0.08, "Reducing Balance", 8000, 180000), + ] + + for ri, (name, lender, orig, bal, rate, rtype, payment, paid) in enumerate(loans_data, start=7): + r = ri + set_row_height(ws, r, 22) + bg = C["purple_light"] if ri % 2 == 0 else "F8F7FF" + pct_f = f'=IFERROR(I{r}/D{r},0)' + due_date = 25 + + row_vals = [name, lender, orig, bal, rate, rtype, payment, paid, pct_f, due_date, ""] + fmts = [None, None, FMT_GYD2, FMT_GYD2, FMT_PCT, None, FMT_GYD2, FMT_GYD2, FMT_PCT, None, None] + + for ci, (v, fmt) in enumerate(zip(row_vals, fmts), start=2): + cell = ws.cell(row=r, column=ci, value=v) + cell.fill = fill(bg); cell.font = font(size=10) + cell.border = thin_border() + cell.alignment = center() if ci >= 4 else left() + if fmt: + cell.number_format = fmt + + # Empty rows + for ri in range(7 + len(loans_data), 32): + set_row_height(ws, ri, 20) + bg = C["row_alt"] if ri % 2 == 0 else C["white"] + for ci in range(2, 13): + ws.cell(row=ri, column=ci).fill = fill(bg) + ws.cell(row=ri, column=ci).border = thin_border() + + # ── Debt Strategy section ────────────────────────────────────────── + strat_row = 33 + set_row_height(ws, strat_row, 10) + ws.merge_cells(f"B{strat_row+1}:L{strat_row+1}") + style_section_title(ws, strat_row+1, 2, + "📊 DEBT PAYOFF STRATEGIES — Avalanche vs Snowball", span=11, bg=C["dark"]) + set_row_height(ws, strat_row+1, 24) + + strat_hdrs = ["Strategy","Principle","Best For","Psychological Benefit","Interest Saved"] + for ci, h in enumerate(strat_hdrs, start=2): + ws.cell(row=strat_row+2, column=ci).value = h + ws.cell(row=strat_row+2, column=ci).fill = fill(C["dark2"]) + ws.cell(row=strat_row+2, column=ci).font = font(bold=True, color="FFFFFF", size=10) + ws.cell(row=strat_row+2, column=ci).border = thin_border() + ws.cell(row=strat_row+2, column=ci).alignment = center() + set_row_height(ws, strat_row+2, 20) + + strategies = [ + ("❄️ Avalanche","Pay HIGHEST interest rate first", + "Saving money on interest","Low — math-based","Maximum"), + ("⛄ Snowball","Pay SMALLEST balance first", + "Motivation & quick wins","High — quick victories","Moderate"), + ] + strat_fills = [C["cyan_light"], C["gold_light"]] + for si, (strat, principle, best, psych, saved) in enumerate(strategies): + r = strat_row + 3 + si + set_row_height(ws, r, 22) + for ci, v in enumerate([strat, principle, best, psych, saved], start=2): + cell = ws.cell(row=r, column=ci, value=v) + cell.fill = fill(strat_fills[si]) + cell.font = font(size=10) + cell.border = thin_border() + cell.alignment = left() if ci <= 4 else center() + + # Progress bars for % paid off + ws.conditional_formatting.add("K7:K100", + DataBarRule(start_type="num", start_value=0, + end_type="num", end_value=1, color=C["purple"])) + + ws.freeze_panes = "B7" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 9 — NET WORTH TRACKER +# ───────────────────────────────────────────────────────────────────── +def build_net_worth(wb): + ws = wb.create_sheet("📈 Net Worth") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":14,"C":18,"D":18,"E":18,"F":14,"G":14,"H":18,"I":20,"J":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + ws.merge_cells("B1:I1") + tc = ws["B1"] + tc.value = "📈 NET WORTH TRACKER" + tc.fill = fill(C["dark"]) + tc.font = font(bold=True, size=16, color=C["gold"]) + tc.alignment = center() + set_row_height(ws, 1, 40) + + set_row_height(ws, 2, 10) + kpis = [ + ("Latest Net Worth", "=IFERROR(H2,0)", C["emerald"]), + ("Total Assets", "=IFERROR(C2,0)", C["cyan"]), + ("Total Debt", "=IFERROR(D2,0)", C["red"]), + ("DTI Ratio", "=IFERROR(F2,0)", C["amber"]), + ] + for ki, (label, formula, bg) in enumerate(kpis): + col = 2 + ki * 2 + ws.merge_cells(start_row=3, start_column=col, end_row=3, end_column=col+1) + lc = ws.cell(row=3, column=col, value=label) + lc.fill = fill(bg); lc.font = font(bold=True, size=9, color="FFFFFF") + lc.alignment = center(); lc.border = thin_border() + set_row_height(ws, 3, 18); set_row_height(ws, 4, 32) + ws.merge_cells(start_row=4, start_column=col, end_row=4, end_column=col+1) + vc = ws.cell(row=4, column=col, value=formula) + vc.fill = fill(bg); vc.font = font(bold=True, size=18, color="FFFFFF") + vc.number_format = FMT_GYD2 if ki < 3 else FMT_PCT + vc.alignment = center(); vc.border = thin_border() + + set_row_height(ws, 5, 10) + + hdrs = ["Date","Total Assets ($)","Total Debt ($)","Net Worth ($)", + "DTI Ratio","MoM Change ($)","Savings Rate","Notes / Reason"] + for ci, h in enumerate(hdrs, start=2): + c = ws.cell(row=6, column=ci) + style_header_cell(c, h, bg=C["dark"]) + set_row_height(ws, 6, 22) + + # Reason dropdown + dv_reason = DataValidation(type="list", + formula1='"Regular Update,New Asset,New Debt,Debt Paid Off,Asset Sold,Salary Change,Investment Gain,Investment Loss"', + allow_blank=True, showDropDown=False) + dv_reason.sqref = "J7:J500" + ws.add_data_validation(dv_reason) + + # Sample monthly data (last 12 months) + base_nw = 1200000 + base_assets = 3500000 + base_debt = 2300000 + snapshots = [] + for i in range(12, 0, -1): + month_date = datetime.date.today().replace(day=1) + import calendar + month_back = (month_date.month - i) % 12 or 12 + year_back = month_date.year - ((i - month_date.month + 12) // 12) + d = datetime.date(year_back, month_back, 1) + assets = base_assets + i * 45000 + debt = base_debt - i * 18000 + nw = assets - debt + dti = debt / (450000 * 12) + savings_rate = 0.18 + (12 - i) * 0.003 + snapshots.append((d.strftime("%Y-%m-%d"), assets, debt, nw, dti, savings_rate)) + + for ri, (date, assets, debt, nw, dti, sr) in enumerate(snapshots, start=7): + r = ri + set_row_height(ws, r, 20) + bg = C["row_alt"] if ri % 2 == 0 else C["white"] + mom_f = f'=E{r}-E{r-1}' if ri > 7 else "" + row_vals = [date, assets, debt, + f'=C{r}-D{r}', dti, mom_f if ri > 7 else 0, sr, "Regular Update"] + fmts = [FMT_DATE, FMT_GYD2, FMT_GYD2, FMT_GYD2, FMT_PCT, FMT_GYD2, FMT_PCT, None] + for ci, (v, fmt) in enumerate(zip(row_vals, fmts), start=2): + cell = ws.cell(row=r, column=ci, value=v) + cell.fill = fill(bg); cell.font = font(size=10) + cell.border = thin_border() + cell.alignment = center() + if fmt: + cell.number_format = fmt + + # Empty entry rows + for ri in range(7 + len(snapshots), 57): + set_row_height(ws, ri, 20) + bg = C["row_alt"] if ri % 2 == 0 else C["white"] + for ci in range(2, 10): + cell = ws.cell(row=ri, column=ci) + cell.fill = fill(bg); cell.border = thin_border() + + # Conditional formatting — positive net worth change = green + ws.conditional_formatting.add("H7:H500", CellIsRule( + operator="greaterThan", formula=["0"], + fill=fill(C["emerald_light"]), font=font(color=C["emerald_dark"], bold=True) + )) + ws.conditional_formatting.add("H7:H500", CellIsRule( + operator="lessThan", formula=["0"], + fill=fill(C["red_light"]), font=font(color=C["red"], bold=True) + )) + + ws.freeze_panes = "B7" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 10 — ANNUAL SUMMARY +# ───────────────────────────────────────────────────────────────────── +def build_annual(wb): + ws = wb.create_sheet("📊 Annual Summary") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":22} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + for i, m in enumerate(MONTHS): + col = get_column_letter(3 + i) + ws.column_dimensions[col].width = 13 + ws.column_dimensions[get_column_letter(15)].width = 14 # Total + ws.column_dimensions[get_column_letter(16)].width = 2 # padding + + ws.merge_cells(f"B1:{get_column_letter(15)}1") + tc = ws["B1"] + tc.value = "📊 ANNUAL INCOME & EXPENSE SUMMARY" + tc.fill = fill(C["dark"]) + tc.font = font(bold=True, size=16, color=C["gold"]) + tc.alignment = center() + set_row_height(ws, 1, 40) + + # Year selector + ws["B2"] = "Year:" + ws["B2"].font = font(bold=True, size=11) + ws["C2"] = datetime.date.today().year + ws["C2"].font = font(bold=True, size=12, color=C["gold"]) + ws["C2"].fill = fill(C["gold_light"]) + ws["C2"].alignment = center() + ws["C2"].border = thin_border() + set_row_height(ws, 2, 24) + set_row_height(ws, 3, 10) + + # Header row — months + ws.cell(row=4, column=2).value = "Category" + ws.cell(row=4, column=2).fill = fill(C["dark"]) + ws.cell(row=4, column=2).font = font(bold=True, color="FFFFFF", size=10) + ws.cell(row=4, column=2).border = thin_border() + ws.cell(row=4, column=2).alignment = left() + set_row_height(ws, 4, 22) + + for mi, m in enumerate(MONTHS): + c = ws.cell(row=4, column=3+mi, value=m) + c.fill = fill(C["dark2"]); c.font = font(bold=True, color="FFFFFF", size=10) + c.border = thin_border(); c.alignment = center() + + tot_hdr = ws.cell(row=4, column=15, value="TOTAL") + tot_hdr.fill = fill(C["dark"]); tot_hdr.font = font(bold=True, color=C["gold"], size=10) + tot_hdr.border = thin_border(); tot_hdr.alignment = center() + + # INCOME section + income_label_row = 5 + ws.merge_cells(f"B{income_label_row}:{get_column_letter(15)}{income_label_row}") + income_hdr = ws.cell(row=income_label_row, column=2, value="INCOME") + income_hdr.fill = fill(C["emerald"]); income_hdr.font = font(bold=True, color="FFFFFF", size=11) + income_hdr.border = thin_border(); income_hdr.alignment = center() + set_row_height(ws, income_label_row, 20) + + income_rows_start = income_label_row + 1 + income_row_items = INCOME_CATS[:6] # top 6 income categories + for ri, cat in enumerate(income_row_items): + r = income_rows_start + ri + set_row_height(ws, r, 20) + bg = C["emerald_light"] if ri % 2 == 0 else C["white"] + ws.cell(row=r, column=2, value=cat).fill = fill(bg) + ws.cell(row=r, column=2).font = font(size=10) + ws.cell(row=r, column=2).border = thin_border() + for mi in range(12): + cell = ws.cell(row=r, column=3+mi, value=0) + cell.fill = fill(bg); cell.font = font(size=10) + cell.border = thin_border(); cell.alignment = center() + cell.number_format = FMT_GYD2 + # Row total + row_total = ws.cell(row=r, column=15, + value=f'=SUM(C{r}:N{r})') + row_total.fill = fill(C["emerald_light"]) + row_total.font = font(bold=True, size=10, color=C["emerald_dark"]) + row_total.number_format = FMT_GYD2 + row_total.border = thin_border(); row_total.alignment = center() + + income_total_row = income_rows_start + len(income_row_items) + set_row_height(ws, income_total_row, 22) + ws.cell(row=income_total_row, column=2, value=" Total Income").fill = fill(C["emerald"]) + ws.cell(row=income_total_row, column=2).font = font(bold=True, color="FFFFFF", size=10) + ws.cell(row=income_total_row, column=2).border = thin_border() + for mi in range(12): + col = 3 + mi + fc = ws.cell(row=income_total_row, column=col, + value=f'=SUM({get_column_letter(col)}{income_rows_start}:{get_column_letter(col)}{income_total_row-1})') + fc.fill = fill(C["emerald"]); fc.font = font(bold=True, color="FFFFFF", size=10) + fc.number_format = FMT_GYD2; fc.border = thin_border(); fc.alignment = center() + grand_inc = ws.cell(row=income_total_row, column=15, + value=f'=SUM(C{income_total_row}:N{income_total_row})') + grand_inc.fill = fill(C["emerald_dark"]); grand_inc.font = font(bold=True, color="FFFFFF", size=11) + grand_inc.number_format = FMT_GYD2; grand_inc.border = thin_border(); grand_inc.alignment = center() + + # EXPENSE section + expense_label_row = income_total_row + 2 + ws.merge_cells(f"B{expense_label_row}:{get_column_letter(15)}{expense_label_row}") + exp_hdr = ws.cell(row=expense_label_row, column=2, value="EXPENSES") + exp_hdr.fill = fill(C["red"]); exp_hdr.font = font(bold=True, color="FFFFFF", size=11) + exp_hdr.border = thin_border(); exp_hdr.alignment = center() + set_row_height(ws, expense_label_row, 20) + + expense_rows_start = expense_label_row + 1 + exp_row_items = EXPENSE_CATS[:12] + for ri, cat in enumerate(exp_row_items): + r = expense_rows_start + ri + set_row_height(ws, r, 20) + bg = C["red_light"] if ri % 2 == 0 else C["white"] + ws.cell(row=r, column=2, value=cat).fill = fill(bg) + ws.cell(row=r, column=2).font = font(size=10) + ws.cell(row=r, column=2).border = thin_border() + for mi in range(12): + cell = ws.cell(row=r, column=3+mi, value=0) + cell.fill = fill(bg); cell.font = font(size=10) + cell.border = thin_border(); cell.alignment = center() + cell.number_format = FMT_GYD2 + row_total = ws.cell(row=r, column=15, value=f'=SUM(C{r}:N{r})') + row_total.fill = fill(C["red_light"]) + row_total.font = font(bold=True, size=10, color=C["red"]) + row_total.number_format = FMT_GYD2 + row_total.border = thin_border(); row_total.alignment = center() + + expense_total_row = expense_rows_start + len(exp_row_items) + set_row_height(ws, expense_total_row, 22) + ws.cell(row=expense_total_row, column=2, value=" Total Expenses").fill = fill(C["red"]) + ws.cell(row=expense_total_row, column=2).font = font(bold=True, color="FFFFFF", size=10) + ws.cell(row=expense_total_row, column=2).border = thin_border() + for mi in range(12): + col = 3 + mi + fc = ws.cell(row=expense_total_row, column=col, + value=f'=SUM({get_column_letter(col)}{expense_rows_start}:{get_column_letter(col)}{expense_total_row-1})') + fc.fill = fill(C["red"]); fc.font = font(bold=True, color="FFFFFF", size=10) + fc.number_format = FMT_GYD2; fc.border = thin_border(); fc.alignment = center() + grand_exp = ws.cell(row=expense_total_row, column=15, + value=f'=SUM(C{expense_total_row}:N{expense_total_row})') + grand_exp.fill = fill("CC2222"); grand_exp.font = font(bold=True, color="FFFFFF", size=11) + grand_exp.number_format = FMT_GYD2; grand_exp.border = thin_border(); grand_exp.alignment = center() + + # Net Savings row + net_row = expense_total_row + 2 + set_row_height(ws, net_row, 28) + ws.cell(row=net_row, column=2, value="💰 NET SAVINGS / (DEFICIT)").fill = fill(C["dark"]) + ws.cell(row=net_row, column=2).font = font(bold=True, color=C["gold"], size=11) + ws.cell(row=net_row, column=2).border = thin_border() + for mi in range(12): + col = 3 + mi + fc = ws.cell(row=net_row, column=col, + value=f'={get_column_letter(col)}{income_total_row}-{get_column_letter(col)}{expense_total_row}') + fc.fill = fill(C["dark"]); fc.font = font(bold=True, color=C["gold"], size=10) + fc.number_format = FMT_GYD2; fc.border = thin_border(); fc.alignment = center() + grand_net = ws.cell(row=net_row, column=15, + value=f'=O{income_total_row}-O{expense_total_row}') + grand_net.fill = fill(C["dark"]); grand_net.font = font(bold=True, color=C["gold"], size=12) + grand_net.number_format = FMT_GYD2; grand_net.border = thin_border(); grand_net.alignment = center() + + # Conditional on net row: positive=green, negative=red + net_rng = f"C{net_row}:O{net_row}" + ws.conditional_formatting.add(net_rng, CellIsRule( + operator="greaterThan", formula=["0"], + fill=fill(C["emerald_light"]), font=font(color=C["emerald_dark"], bold=True) + )) + ws.conditional_formatting.add(net_rng, CellIsRule( + operator="lessThan", formula=["0"], + fill=fill(C["red_light"]), font=font(color=C["red"], bold=True) + )) + + ws.freeze_panes = "C5" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 11 — PROJECT COST ESTIMATOR (Bill of Quantities) +# ───────────────────────────────────────────────────────────────────── +def build_project_costs(wb): + ws = wb.create_sheet("🏗 Project Costs") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":6,"C":36,"D":12,"E":14,"F":14,"G":14,"H":14,"I":20,"J":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + # Title + ws.merge_cells("B1:I1") + tc = ws["B1"] + tc.value = "🏗 PROJECT COST ESTIMATOR — Bill of Quantities" + tc.fill = fill(C["dark"]) + tc.font = font(bold=True, size=16, color=C["gold"]) + tc.alignment = center() + set_row_height(ws, 1, 40) + + # Project info block + set_row_height(ws, 2, 10) + info_fields = [ + ("B3","Project Name:", "D3", "New House Construction / Road Works"), + ("B4","Client / Owner:", "D4", ""), + ("B5","Prepared By:", "D5", ""), + ("B6","Date:", "D6", datetime.date.today().strftime("%d %B %Y")), + ("F3","Project Ref #:", "H3", ""), + ("F4","Location:", "H4", ""), + ("F5","Rev. No:", "H5", "Rev. 1"), + ("F6","Currency:", "H6", "GYD $"), + ] + for label_cell, label, val_cell, val in info_fields: + r, c_str = int(label_cell[1:]), label_cell[0] + lc = ws[label_cell] + lc.value = label + lc.font = font(bold=True, size=10) + lc.alignment = right_align() + ws.merge_cells(f"{val_cell}:{chr(ord(val_cell[0])+1)}{val_cell[1:]}") + vc = ws[val_cell] + vc.value = val + vc.font = font(size=10) + vc.fill = fill(C["off_white"]) + vc.border = thin_border() + vc.alignment = left() + set_row_height(ws, r, 22) + + # Section headers row + set_row_height(ws, 7, 10) + hdrs = ["#", "Description of Work", "Unit", "Qty", "Unit Rate ($)", "Amount ($)", "VAT (14%)", "Total ($)", "Notes"] + for ci, h in enumerate(hdrs, start=2): + c = ws.cell(row=8, column=ci) + style_header_cell(c, h, bg=C["dark"]) + set_row_height(ws, 8, 22) + + # Unit dropdown + dv_unit = DataValidation(type="list", + formula1='"LS,m,m²,m³,kg,ton,ft,ft²,ft³,each,set,hr,day,wk"', + allow_blank=True, showDropDown=False) + dv_unit.sqref = "D9:D200" + ws.add_data_validation(dv_unit) + + # Sample BOQ sections with items + boq_sections = [ + ("A — SITE PREPARATION & EARTHWORKS", C["amber"], [ + ("Clear & grub site", "m²", 250, 800), + ("Excavation — topsoil removal (150mm)", "m³", 45, 3500), + ("Excavation — foundations (1.2m deep)", "m³", 80, 5500), + ("Fill & compact with select material", "m³", 60, 4200), + ("Disposal of surplus excavated material", "m³", 40, 2000), + ]), + ("B — CONCRETE WORKS", C["emerald"], [ + ("Blinding concrete (1:3:6 mix) 75mm thick", "m²", 85, 2200), + ("Reinforced concrete foundations (1:2:4)", "m³", 22, 45000), + ("Reinforced concrete columns (1:2:4)", "m³", 8, 52000), + ("Reinforced concrete ground beam", "m³", 12, 48000), + ("Reinforced concrete ground slab 150mm", "m²", 120, 12000), + ("BRC Mesh A193 reinforcement", "m²", 250, 4500), + ]), + ("C — MASONRY & BLOCKWORK", C["cyan"], [ + ("6\" concrete hollow blocks (wall)", "m²", 180, 8500), + ("4\" concrete hollow blocks (partition)", "m²", 60, 7200), + ("Mortar (1:4 cement:sand mix)", "m³", 4, 35000), + ("Surface bond coat both sides", "m²", 480, 1800), + ]), + ("D — ROOFING", C["purple"], [ + ("Timber roof trusses (span 6m)", "each",18, 22000), + ("2×4 purlins @ 600mm c/c", "m", 120, 2800), + ("0.5mm corrugated galv. roofing sheets", "m²", 200, 4500), + ("Ridge capping — galvanised", "m", 18, 3200), + ("Fascia board 1×6 treated timber", "m", 48, 2600), + ("Soffits — fibre cement 6mm", "m²", 55, 5500), + ]), + ("E — DOORS, WINDOWS & FINISHES", C["gold_dark"], [ + ("Timber panel doors (900×2100)", "each", 6, 45000), + ("Aluminium sliding windows (1500×1200)", "each", 10, 38000), + ("Internal plastering — 12mm skim coat", "m²", 480, 3500), + ("Floor tiles (600×600 ceramic)", "m²", 130, 8500), + ("Painting — 2 coats interior", "m²", 620, 1500), + ("Painting — 2 coats exterior", "m²", 300, 2200), + ]), + ("F — ELECTRICAL & PLUMBING", C["cyan"], [ + ("Electrical wiring installation (LS)", "LS", 1,350000), + ("Consumer unit & breakers 12-way", "each", 1, 85000), + ("Plumbing — water supply pipework (LS)", "LS", 1,280000), + ("Plumbing — sanitary fittings (LS)", "LS", 1,320000), + ("Septic tank (2000 litre precast)", "each", 1,180000), + ]), + ("G — LABOUR", C["dark2"], [ + ("Skilled mason — foreman", "day", 90, 12000), + ("Skilled mason", "day", 180, 8500), + ("Unskilled labourer", "day", 200, 5000), + ("Carpenter", "day", 60, 9000), + ("Electrician", "day", 20, 15000), + ("Plumber", "day", 15, 15000), + ]), + ("H — PRELIMINARIES & CONTINGENCIES", C["dark"], [ + ("Site establishment & mobilisation", "LS", 1,150000), + ("Scaffolding & access (hire)", "wk", 16, 25000), + ("Project management & supervision", "LS", 1,200000), + ("Contingencies (5%)", "LS", 1, 0), + ]), + ] + + current_row = 9 + all_total_cells = [] + + for section_label, section_bg, items in boq_sections: + # Section header + ws.merge_cells(f"B{current_row}:I{current_row}") + sh = ws.cell(row=current_row, column=2, value=section_label) + sh.fill = fill(section_bg); sh.font = font(bold=True, size=11, color="FFFFFF") + sh.border = thin_border(); sh.alignment = left() + set_row_height(ws, current_row, 22) + current_row += 1 + + section_start = current_row + for item_idx, (desc, unit, qty, rate) in enumerate(items, start=1): + r = current_row + set_row_height(ws, r, 20) + bg = C["row_alt"] if item_idx % 2 == 0 else C["white"] + + # Col B: item number + nc = ws.cell(row=r, column=2, value=f"{section_label[0]}.{item_idx}") + nc.fill = fill(bg); nc.font = font(size=9, color=C["mid"]) + nc.border = thin_border(); nc.alignment = center() + + # Col C: description + dc = ws.cell(row=r, column=3, value=desc) + dc.fill = fill(bg); dc.font = font(size=10) + dc.border = thin_border(); dc.alignment = left() + + # Col D: unit + uc = ws.cell(row=r, column=4, value=unit) + uc.fill = fill(bg); uc.font = font(size=10) + uc.border = thin_border(); uc.alignment = center() + + # Col E: qty + qc = ws.cell(row=r, column=5, value=qty) + qc.fill = fill(bg); qc.font = font(size=10) + qc.border = thin_border(); qc.alignment = center() + qc.number_format = '#,##0.00' + + # Col F: unit rate + rc = ws.cell(row=r, column=6, value=rate) + rc.fill = fill(bg); rc.font = font(size=10) + rc.border = thin_border(); rc.alignment = right_align() + rc.number_format = FMT_GYD2 + + # Col G: amount = qty * rate + amount_formula = f'=E{r}*F{r}' + if desc.startswith("Contingencies"): + # contingency = 5% of sum so far + section_sum = f'=SUM(G{section_start}:G{r-1})' + amount_formula = f'={section_sum}*0.05' + + ac = ws.cell(row=r, column=7, value=amount_formula) + ac.fill = fill(bg); ac.font = font(size=10) + ac.border = thin_border(); ac.alignment = right_align() + ac.number_format = FMT_GYD2 + + # Col H: VAT + vc2 = ws.cell(row=r, column=8, value=f'=G{r}*0.14') + vc2.fill = fill(bg); vc2.font = font(size=10) + vc2.border = thin_border(); vc2.alignment = right_align() + vc2.number_format = FMT_GYD2 + + # Col I: total incl VAT + tc2 = ws.cell(row=r, column=9, value=f'=G{r}+H{r}') + tc2.fill = fill(bg); tc2.font = font(size=10) + tc2.border = thin_border(); tc2.alignment = right_align() + tc2.number_format = FMT_GYD2 + + current_row += 1 + + # Section subtotal + st_r = current_row + set_row_height(ws, st_r, 22) + ws.merge_cells(f"B{st_r}:F{st_r}") + stc2 = ws.cell(row=st_r, column=2, value=f" Sub-Total {section_label}") + stc2.fill = fill(section_bg); stc2.font = font(bold=True, color="FFFFFF", size=10) + stc2.border = thin_border(); stc2.alignment = left() + + for ci, fmt in [(7, FMT_GYD2), (8, FMT_GYD2), (9, FMT_GYD2)]: + sub = ws.cell(row=st_r, column=ci, + value=f'=SUM({get_column_letter(ci)}{section_start}:{get_column_letter(ci)}{st_r-1})') + sub.fill = fill(section_bg); sub.font = font(bold=True, color="FFFFFF", size=11) + sub.number_format = fmt; sub.border = thin_border(); sub.alignment = right_align() + + all_total_cells.append(f"I{st_r}") + current_row += 2 # gap + + # Grand total + grand_row = current_row + set_row_height(ws, grand_row, 32) + ws.merge_cells(f"B{grand_row}:F{grand_row}") + gtc = ws.cell(row=grand_row, column=2, value=" GRAND TOTAL (incl. VAT)") + gtc.fill = fill(C["dark"]); gtc.font = font(bold=True, color=C["gold"], size=13) + gtc.border = medium_border(); gtc.alignment = center() + + total_formula = "=" + "+".join(all_total_cells) + for ci, fmt in [(7, FMT_GYD2), (8, FMT_GYD2), (9, FMT_GYD2)]: + sub_total = "=" + "+".join([tc.replace("I", get_column_letter(ci)) for tc in all_total_cells]) + gc = ws.cell(row=grand_row, column=ci, value=sub_total) + gc.fill = fill(C["gold_dark"]); gc.font = font(bold=True, color="FFFFFF", size=13) + gc.number_format = fmt; gc.border = medium_border(); gc.alignment = right_align() + + # Note + note_row = grand_row + 2 + ws.merge_cells(f"B{note_row}:I{note_row}") + nc = ws.cell(row=note_row, column=2, + value="NOTE: All rates are in GYD $. VAT applied at 14%. Quantities to be verified on site before work commences. This estimate is valid for 30 days from date of issue.") + nc.fill = fill(C["gold_light"]); nc.font = font(size=9, italic=True, color=C["dark"]) + nc.alignment = left() + set_row_height(ws, note_row, 24) + + ws.freeze_panes = "C9" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 12 — TAX ESTIMATOR +# ───────────────────────────────────────────────────────────────────── +def build_tax(wb): + ws = wb.create_sheet("🧮 Tax Estimator") + ws.sheet_view.showGridLines = False + + col_widths = {"A":2,"B":32,"C":20,"D":20,"E":20,"F":2} + for col, w in col_widths.items(): + set_col_width(ws, col, w) + + ws.merge_cells("B1:E1") + tc = ws["B1"] + tc.value = "🧮 TAX ESTIMATOR — Guyana (GRA) 2025/2026" + tc.fill = fill(C["dark"]) + tc.font = font(bold=True, size=16, color=C["gold"]) + tc.alignment = center() + set_row_height(ws, 1, 40) + + ws.merge_cells("B2:E2") + sc = ws["B2"] + sc.value = "Based on GRA tax rules: 25% flat rate, 1/3 personal allowance, 5.6% NIS employee" + sc.fill = fill(C["dark2"]); sc.font = font(size=9, italic=True, color=C["off_white"]) + sc.alignment = center() + set_row_height(ws, 2, 20) + set_row_height(ws, 3, 10) + + def section_hdr(row, text, bg): + ws.merge_cells(f"B{row}:E{row}") + c = ws.cell(row=row, column=2, value=text) + c.fill = fill(bg); c.font = font(bold=True, size=11, color="FFFFFF") + c.border = thin_border(); c.alignment = left() + set_row_height(ws, row, 22) + + def input_row(row, label, default_val, fmt=FMT_GYD2, note=""): + set_row_height(ws, row, 22) + lc = ws.cell(row=row, column=2, value=label) + lc.font = font(size=10); lc.border = thin_border(); lc.alignment = left() + lc.fill = fill(C["off_white"]) + vc = ws.cell(row=row, column=3, value=default_val) + vc.font = font(bold=True, size=11, color=C["emerald_dark"]) + vc.fill = fill(C["emerald_light"]); vc.border = thin_border() + vc.number_format = fmt; vc.alignment = center() + # highlight as input + vc.fill = PatternFill("solid", fgColor="DCFCE7") + if note: + nc = ws.cell(row=row, column=4, value=note) + nc.font = font(size=9, italic=True, color=C["mid"]); nc.alignment = left() + nc.fill = fill(C["white"]); nc.border = thin_border() + + def calc_row(row, label, formula, fmt=FMT_GYD2, highlight=False): + set_row_height(ws, row, 22) + lc = ws.cell(row=row, column=2, value=label) + lc.font = font(size=10, bold=highlight); lc.border = thin_border() + lc.fill = fill(C["row_alt"]); lc.alignment = left() + vc = ws.cell(row=row, column=3, value=formula) + vc.number_format = fmt; vc.border = thin_border(); vc.alignment = center() + if highlight: + vc.fill = fill(C["gold_light"]) + vc.font = font(bold=True, size=12, color=C["gold_dark"]) + else: + vc.fill = fill(C["row_alt"]); vc.font = font(size=10) + + # INCOME section + section_hdr(4, "📥 INCOME", C["emerald"]) + input_row(5, "Monthly Gross Salary", 450000, FMT_GYD2, "Enter your monthly gross") + input_row(6, "Other Monthly Income", 25000, FMT_GYD2, "Freelance, rental, etc.") + calc_row(7, "Annual Gross Income", "=($C$5+$C$6)*12") + set_row_height(ws, 8, 10) + + # ALLOWANCES section + section_hdr(9, "🔖 ALLOWANCES & DEDUCTIONS", C["cyan"]) + calc_row(10, "Personal Allowance (1/3 of gross or $100K — higher)", + "=MAX(C7/3, 1200000)", FMT_GYD2) + input_row(11, "NIS Employee Contribution (5.6%)", "=$C$7*0.056", FMT_GYD2, "Auto-calculated") + ws.cell(row=11, column=3).value = "=C7*0.056" + ws.cell(row=11, column=3).fill = fill(C["row_alt"]) + ws.cell(row=11, column=3).font = font(size=10) + input_row(12, "Additional Deductions", 0, FMT_GYD2, "Pension scheme, etc.") + calc_row(13, "Total Deductions", "=C10+C11+C12") + set_row_height(ws, 14, 10) + + # TAX CALCULATION + section_hdr(15, "🧮 TAX CALCULATION", C["amber"]) + calc_row(16, "Chargeable Income", "=MAX(0, C7-C13)") + calc_row(17, "Income Tax (25% flat rate)", "=C16*0.25", FMT_GYD2, highlight=True) + calc_row(18, "Effective Tax Rate", "=IFERROR(C17/C7,0)", FMT_PCT) + set_row_height(ws, 19, 10) + + # TAKE HOME + section_hdr(20, "💰 TAKE HOME PAY", C["emerald_dark"]) + calc_row(21, "Annual Net Take-Home", "=C7-C17-C11") + calc_row(22, "Monthly Net Take-Home", "=(C7-C17-C11)/12", FMT_GYD2, highlight=True) + calc_row(23, "Monthly NIS Contribution", "=C11/12", FMT_GYD2) + calc_row(24, "Monthly Tax Withheld (PAYE)", "=C17/12", FMT_GYD2) + set_row_height(ws, 25, 10) + + # EMPLOYER COSTS + section_hdr(26, "🏢 EMPLOYER COSTS (for business owners)", C["dark2"]) + calc_row(27, "NIS Employer Contribution (8.4%)", "=C7*0.084", FMT_GYD2) + calc_row(28, "Total Annual Employer Cost", "=C7+C27", FMT_GYD2, highlight=True) + calc_row(29, "Total Monthly Employer Cost", "=C28/12", FMT_GYD2) + + # Input cells highlighted note + note_row = 31 + ws.merge_cells(f"B{note_row}:E{note_row}") + nc = ws.cell(row=note_row, column=2, + value="💡 GREEN cells are INPUT fields — update your salary and income. All other cells calculate automatically.") + nc.fill = fill(C["emerald_light"]); nc.font = font(size=9, color=C["emerald_dark"]) + nc.alignment = left() + set_row_height(ws, note_row, 22) + + ws.freeze_panes = "B4" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# SHEET 13 — HELP & GUIDE +# ───────────────────────────────────────────────────────────────────── +def build_help(wb): + ws = wb.create_sheet("📖 Help & Guide") + ws.sheet_view.showGridLines = False + + set_col_width(ws, "A", 3) + set_col_width(ws, "B", 30) + set_col_width(ws, "C", 65) + set_col_width(ws, "D", 3) + + ws.merge_cells("B1:C1") + tc = ws["B1"] + tc.value = "📖 FUNDSIGHT BUDGET SPREADSHEET — Help & User Guide" + tc.fill = fill(C["dark"]) + tc.font = font(bold=True, size=16, color=C["gold"]) + tc.alignment = center() + set_row_height(ws, 1, 44) + + ws.merge_cells("B2:C2") + sc = ws["B2"] + sc.value = "Welcome! This spreadsheet mirrors your FundSight app. Use this alongside the app for full financial management." + sc.fill = fill(C["emerald"]); sc.font = font(size=10, italic=True, color="FFFFFF") + sc.alignment = center() + set_row_height(ws, 2, 22) + + sections = [ + ("📊 Dashboard", C["dark"], [ + ("What it shows:", "Live overview of your income, expenses, budget vs actual, and financial KPIs for the current month."), + ("How to use:", "The dashboard auto-calculates from your Transactions and Bills sheets. No manual entry needed here."), + ("Tip:", "Check the 'Net Balance' row to see if you're in the green for the month."), + ]), + ("💰 Monthly Budget", C["emerald"], [ + ("What it does:", "Plan your monthly budget using the 50/30/20 rule — 50% Needs, 30% Wants, 20% Savings."), + ("How to use:", "Update the 'Budgeted ($)' column each month. Actual Spent pulls from Transactions automatically."), + ("Tip:", "Use the Status column to spot overspending early. 🟡 Near Limit = action needed."), + ]), + ("📝 Transactions", C["dark2"], [ + ("What it does:", "Log every income and expense transaction with category, account, and amount."), + ("How to use:", "Enter date, description, select category from dropdown, enter amount, select Income/Expense/Transfer."), + ("Row colours:", "Green rows = Income | Red-tinted rows = Expense | White rows = Transfer"), + ("Tip:", "Use the Notes column for merchant names, invoice numbers, or reminders."), + ]), + ("💵 Income", C["emerald_dark"], [ + ("What it does:", "Dedicated tracker for all income sources — salary, freelance, rental, investments, etc."), + ("How to use:", "Log every income source. The dashboard KPIs pull from this sheet."), + ("Tip:", "Mark recurring income (Yes/No) to track which income is guaranteed vs variable."), + ]), + ("📅 Bills & Subscriptions", C["amber"], [ + ("What it does:", "Track all recurring bills, utilities, and subscriptions with due dates and status."), + ("How to use:", "Update the Status column to Paid as you pay each bill. Mark Auto-Pay = Yes for direct debits."), + ("Tip:", "Overdue bills are highlighted red automatically. Review weekly."), + ]), + ("🎯 Savings Goals", C["cyan"], [ + ("What it does:", "Set and track savings goals with target amounts, dates, and monthly contributions."), + ("How to use:", "Enter goal name, target amount, how much you have saved so far, and your target date."), + ("Progress bars:", "The % Complete column shows a data bar progress indicator."), + ("Tip:", "Update 'Saved ($)' monthly as you contribute to each goal."), + ]), + ("🏦 Debt Tracker", C["purple"], [ + ("What it does:", "Track all loans and debts with balances, interest rates, and payoff progress."), + ("How to use:", "Enter each loan's original amount, current balance, interest rate, and monthly payment."), + ("Strategies:", "Avalanche = pay highest interest first (saves more money). Snowball = pay smallest balance first (more motivation)."), + ("Tip:", "Update the balance monthly as you make payments."), + ]), + ("📈 Net Worth", C["dark"], [ + ("What it does:", "Monthly snapshots of your total assets, debts, and net worth over time."), + ("How to use:", "Add a new row each month with your total assets (bank balances + property + investments) and total debt."), + ("Tip:", "Watch the MoM Change column — consistent positive numbers mean you're building wealth."), + ]), + ("📊 Annual Summary", C["dark2"], [ + ("What it does:", "12-month view of income and expenses by category — see the full year at a glance."), + ("How to use:", "Enter monthly totals for each category manually, or link from your Transactions sheet."), + ("Tip:", "Net Savings row at the bottom goes green for positive months, red for deficit months."), + ]), + ("🏗 Project Costs", C["gold_dark"], [ + ("What it does:", "Full Bill of Quantities (BOQ) for construction, renovation, or any project costing."), + ("How to use:", "Fill in Description, Unit, Quantity, and Unit Rate. Amount, VAT (14%), and Total auto-calculate."), + ("Sections:", "8 pre-built sections: Site Prep, Concrete, Masonry, Roofing, Finishes, MEP, Labour, Preliminaries."), + ("Tip:", "Update project name, client, and date in the header. Print or PDF to share with contractors."), + ]), + ("🧮 Tax Estimator", C["emerald"], [ + ("What it does:", "Estimates your Guyana income tax (PAYE), NIS contributions, and take-home pay."), + ("How to use:", "Enter your monthly gross salary in the green input cell. All other fields auto-calculate."), + ("Tip:", "Shows both employee and employer costs — useful if you run your own business."), + ]), + ("⚙ Settings", C["dark"], [ + ("What it does:", "Master reference lists that populate ALL dropdowns across every sheet."), + ("How to use:", "Add, edit, or remove items from any list here. Changes apply automatically to all dropdowns."), + ("Tip:", "Don't delete column headers. Keep lists in column A of each category."), + ]), + ] + + current_row = 4 + for sheet_name, color, items in sections: + set_row_height(ws, current_row, 10) + current_row += 1 + + ws.merge_cells(f"B{current_row}:C{current_row}") + sh = ws.cell(row=current_row, column=2, value=f" {sheet_name}") + sh.fill = fill(color); sh.font = font(bold=True, size=12, color="FFFFFF") + sh.border = thin_border(); sh.alignment = left() + set_row_height(ws, current_row, 24) + current_row += 1 + + for label, content in items: + set_row_height(ws, current_row, 20) + lc = ws.cell(row=current_row, column=2, value=label) + lc.fill = fill(C["off_white"]); lc.font = font(bold=True, size=10, color=C["dark2"]) + lc.border = thin_border(); lc.alignment = left() + cc = ws.cell(row=current_row, column=3, value=content) + cc.fill = fill(C["white"]); cc.font = font(size=10) + cc.border = thin_border(); cc.alignment = left() + cc.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) + current_row += 1 + + # Footer + set_row_height(ws, current_row + 1, 32) + ws.merge_cells(f"B{current_row+1}:C{current_row+1}") + fc = ws.cell(row=current_row+1, column=2, + value="🌟 Built to mirror FundSight — Your Financial Command Centre | fundsight.app | Currency: GYD $") + fc.fill = fill(C["dark"]); fc.font = font(bold=True, size=11, color=C["gold"]) + fc.alignment = center() + + ws.freeze_panes = "B3" + return ws + + +# ───────────────────────────────────────────────────────────────────── +# MAIN — assemble and save workbook +# ───────────────────────────────────────────────────────────────────── +def main(): + wb = Workbook() + # Remove default sheet + default_ws = wb.active + wb.remove(default_ws) + + print("Building sheets...") + build_dashboard(wb) + print(" ✓ Dashboard") + build_monthly_budget(wb) + print(" ✓ Monthly Budget") + build_income(wb) + print(" ✓ Income Tracker") + build_transactions(wb) + print(" ✓ Transactions") + build_bills(wb) + print(" ✓ Bills & Subscriptions") + build_savings(wb) + print(" ✓ Savings Goals") + build_debt(wb) + print(" ✓ Debt Tracker") + build_net_worth(wb) + print(" ✓ Net Worth") + build_annual(wb) + print(" ✓ Annual Summary") + build_project_costs(wb) + print(" ✓ Project Cost Estimator") + build_tax(wb) + print(" ✓ Tax Estimator") + build_help(wb) + print(" ✓ Help & Guide") + build_settings(wb) + print(" ✓ Settings") + + # Tab colours + tab_colors = { + "📊 Dashboard": C["dark"], + "💰 Monthly Budget": C["emerald"], + "💵 Income": C["emerald_dark"], + "📝 Transactions": C["dark2"], + "📅 Bills & Subs": C["amber"], + "🎯 Savings Goals": C["cyan"], + "🏦 Debt Tracker": C["purple"], + "📈 Net Worth": C["dark"], + "📊 Annual Summary": C["dark2"], + "🏗 Project Costs": C["gold_dark"], + "🧮 Tax Estimator": C["emerald"], + "📖 Help & Guide": C["gold"], + "⚙ Settings": C["mid"], + } + for ws in wb.worksheets: + name = ws.title + for key, color in tab_colors.items(): + if key in name or name in key: + ws.sheet_properties.tabColor = color + break + + out_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "public", + "FundSight_Budget_Planner.xlsx" + ) + os.makedirs(os.path.dirname(out_path), exist_ok=True) + wb.save(out_path) + print(f"\n✅ Saved: {out_path}") + size_kb = os.path.getsize(out_path) // 1024 + print(f" File size: {size_kb} KB") + print(f" Sheets: {len(wb.worksheets)}") + for i, ws in enumerate(wb.worksheets, 1): + print(f" {i:2d}. {ws.title}") + +if __name__ == "__main__": + main()