"""Vela Platform — P&L manager. Single FastAPI app with admin panel.""" import logging from datetime import datetime, timezone from typing import Optional from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from pydantic import BaseModel import os from pathlib import Path from auth import ( JWT_EXPIRE_HOURS, create_access_token, get_current_user, hash_password, require_admin, verify_password, ) from database import SessionLocal, engine, get_db from models import Base, Expense, ExpenseTemplate, Service, ServiceComponent, Transaction, User TEMPLATE_DIR = Path(__file__).parent / "templates" # ── Pydantic request schemas ─────────────────────────────────────────────── class ServiceUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None sell_price: Optional[float] = None cost_price: Optional[float] = None active: Optional[bool] = None sort_order: Optional[int] = None class ComponentUpdate(BaseModel): name: Optional[str] = None unit_cost: Optional[float] = None quantity: Optional[int] = None class TransactionCreate(BaseModel): service_id: Optional[int] = None service_name: Optional[str] = None quantity: int = 1 unit_sell: Optional[float] = None unit_cost: Optional[float] = None month: Optional[str] = None source_type: str = "manual" source_id: Optional[int] = None payment_status: str = "unpaid" notes: str = "" class ExpenseCreate(BaseModel): name: str amount: float = 0 category: str = "general" month: Optional[str] = None notes: str = "" logging.basicConfig(level=logging.INFO) logger = logging.getLogger("vela") app = FastAPI(title="Vela Platform", version="2.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Static files for logos app.mount("/static", StaticFiles(directory=str(TEMPLATE_DIR)), name="static") # ── seed defaults ────────────────────────────────────────────────────────── DEFAULT_SERVICES = [ { "name": "Rif", "description": "Entry-level home server. Photos, files, peace of mind.", "sell_price": 3499.0, "sort_order": 1, "components": [ ("Mini PC", 800.0, 1, "Refurbished office mini PC"), ("SSD 512GB", 500.0, 1, "NVMe boot drive"), ("RAM 8GB", 300.0, 1, "DDR4"), ("Transport", 140.0, 1, ""), ("Expertise", 359.0, 1, "Setup & config"), ], }, { "name": "Rif+", "description": "Rif with backup drive. Extra safety for your files.", "sell_price": 3990.0, "sort_order": 2, "components": [ ("Mini PC", 800.0, 1, "Refurbished office mini PC"), ("SSD 512GB", 500.0, 1, "NVMe boot drive"), ("Backup HDD 1TB", 300.0, 1, "Internal backup drive"), ("RAM 8GB", 300.0, 1, "DDR4"), ("Transport", 140.0, 1, ""), ("Expertise", 359.0, 1, "Setup & config"), ], }, { "name": "Atlas", "description": "Pro home server. 1TB storage, 16GB RAM, fast and capable.", "sell_price": 4390.0, "sort_order": 3, "components": [ ("Mini PC", 800.0, 1, "Refurbished office mini PC"), ("SSD 1TB", 800.0, 1, "NVMe storage drive"), ("RAM 16GB", 600.0, 1, "DDR4"), ("Transport", 140.0, 1, ""), ("Expertise", 359.0, 1, "Setup & config"), ], }, { "name": "Atlas+", "description": "Maxed out. 1TB SSD + 1TB backup HDD, 16GB RAM, total peace of mind.", "sell_price": 4790.0, "sort_order": 4, "components": [ ("Mini PC", 800.0, 1, "Refurbished office mini PC"), ("SSD 1TB", 800.0, 1, "NVMe storage drive"), ("Backup HDD 1TB", 300.0, 1, "Internal backup drive"), ("RAM 16GB", 600.0, 1, "DDR4"), ("Transport", 140.0, 1, ""), ("Expertise", 359.0, 1, "Setup & config"), ], }, ] DEFAULT_EXPENSE_TEMPLATES = [ ("Internet", 500.0, "utilities"), ("Electricity", 400.0, "utilities"), ("Transport", 200.0, "operations"), ] # ── startup ──────────────────────────────────────────────────────────────── @app.on_event("startup") def startup(): Base.metadata.create_all(bind=engine) db = SessionLocal() try: # seed admin if not db.query(User).first(): db.add(User(email="oimwiodev", password_hash=hash_password("kxdr781020"))) db.commit() logger.info("Seeded admin user") # seed services if not db.query(Service).first(): for svc_data in DEFAULT_SERVICES: comps = svc_data.pop("components") svc = Service(**svc_data) db.add(svc) db.flush() for name, cost, qty, notes in comps: db.add(ServiceComponent( service_id=svc.id, name=name, unit_cost=cost, unit_sell=round(cost * 1.3), quantity=qty, notes=notes, )) # set cost_price and sell_price from components comp_list = [c for c in comps] svc.cost_price = sum(c[1] * c[2] for c in comp_list) svc.sell_price = svc_data["sell_price"] db.commit() logger.info("Seeded service tiers") # seed expense templates if not db.query(ExpenseTemplate).first(): for name, amount, category in DEFAULT_EXPENSE_TEMPLATES: db.add(ExpenseTemplate(name=name, amount=amount, category=category)) db.commit() logger.info("Seeded expense templates") finally: db.close() # ── public API ───────────────────────────────────────────────────────────── @app.get("/api/public/pricing") def public_pricing(db: Session = Depends(get_db)): services = db.query(Service).filter(Service.active == True).order_by(Service.sort_order).all() return [ { "id": s.id, "name": s.name, "description": s.description, "sell_price": s.sell_price, "active": s.active, } for s in services ] @app.get("/api/public/availability") def public_availability(db: Session = Depends(get_db)): """Returns which tiers are in stock — no auth, super simple.""" services = db.query(Service).order_by(Service.sort_order).all() return [ {"name": s.name, "available": s.active, "sell_price": s.sell_price} for s in services ] @app.get("/api/public/services/{service_id}") def public_service_detail(service_id: int, db: Session = Depends(get_db)): svc = db.query(Service).filter(Service.id == service_id, Service.active == True).first() if not svc: raise HTTPException(status_code=404, detail="Service not found") return { "id": svc.id, "name": svc.name, "description": svc.description, "sell_price": svc.sell_price, "components": [{"name": c.name, "notes": c.notes} for c in svc.components], } # ── auth routes ──────────────────────────────────────────────────────────── @app.get("/login", response_class=HTMLResponse) def login_page(): return FileResponse(TEMPLATE_DIR / "login.html") @app.post("/api/auth/login") def api_login( request: Request, username: str = Form(...), password: str = Form(...), db: Session = Depends(get_db), ): user = db.query(User).filter(User.email == username, User.active == True).first() if not user or not verify_password(password, user.password_hash): raise HTTPException(status_code=401, detail="Wrong username or password") token = create_access_token(user.id) resp = JSONResponse({"token": token, "email": user.email, "redirect": "/"}) resp.set_cookie( key="vela_token", value=token, httponly=True, max_age=JWT_EXPIRE_HOURS * 3600, samesite="lax", ) return resp @app.post("/api/auth/logout") def api_logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie("vela_token") return resp @app.get("/api/verify") def api_verify(user: User = Depends(get_current_user)): return {"email": user.email, "id": user.id} # ── pages ────────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) def homepage(): return FileResponse(TEMPLATE_DIR / "index.html") @app.get("/admin", response_class=HTMLResponse) def admin_panel(): return FileResponse(TEMPLATE_DIR / "admin.html") @app.get("/backup", response_class=HTMLResponse) def backup_page(): return FileResponse(TEMPLATE_DIR / "backup.html") # ── services API (admin) ─────────────────────────────────────────────────── @app.get("/api/services") def list_services(db: Session = Depends(get_db)): services = db.query(Service).order_by(Service.sort_order).all() return [ { "id": s.id, "name": s.name, "description": s.description, "sell_price": s.sell_price, "cost_price": s.cost_price, "active": s.active, "sort_order": s.sort_order, "components": [ { "id": c.id, "name": c.name, "unit_cost": c.unit_cost, "unit_sell": c.unit_sell, "quantity": c.quantity, "notes": c.notes, } for c in s.components ], } for s in services ] @app.put("/api/services/{service_id}") def update_service( service_id: int, data: ServiceUpdate, db: Session = Depends(get_db), ): svc = db.query(Service).filter(Service.id == service_id).first() if not svc: raise HTTPException(status_code=404) if data.name is not None: svc.name = data.name if data.description is not None: svc.description = data.description if data.sell_price is not None: svc.sell_price = data.sell_price if data.cost_price is not None: svc.cost_price = data.cost_price if data.active is not None: svc.active = data.active if data.sort_order is not None: svc.sort_order = data.sort_order db.commit() return {"ok": True} @app.put("/api/services/{service_id}/toggle") def toggle_service(service_id: int, db: Session = Depends(get_db)): svc = db.query(Service).filter(Service.id == service_id).first() if not svc: raise HTTPException(status_code=404) svc.active = not svc.active db.commit() return {"ok": True, "active": svc.active} @app.put("/api/services/{service_id}/components/{component_id}") def update_component( service_id: int, component_id: int, data: ComponentUpdate, db: Session = Depends(get_db), ): comp = ( db.query(ServiceComponent) .filter(ServiceComponent.id == component_id, ServiceComponent.service_id == service_id) .first() ) if not comp: raise HTTPException(status_code=404) if data.name is not None: comp.name = data.name if data.unit_cost is not None: comp.unit_cost = data.unit_cost if data.quantity is not None: comp.quantity = data.quantity # recalc service cost_price svc = db.query(Service).filter(Service.id == service_id).first() svc.cost_price = sum(c.unit_cost * c.quantity for c in svc.components) db.commit() return {"ok": True} # ── transactions API ────────────────────────────────────────────────────── @app.get("/api/transactions") def list_transactions( month: Optional[str] = None, limit: int = 200, db: Session = Depends(get_db), ): q = db.query(Transaction).order_by(Transaction.created_at.desc()) if month: q = q.filter(Transaction.month_start_date == month) return [ { "id": t.id, "source_type": t.source_type, "source_id": t.source_id, "service_name": t.service_name_snapshot, "quantity": t.quantity, "revenue": t.revenue_total, "cogs": t.cogs_total, "profit": t.gross_profit_total, "month": t.month_start_date, "payment_status": t.payment_status, "notes": t.notes, "created_at": t.created_at.isoformat() if t.created_at else None, } for t in q.limit(limit).all() ] @app.post("/api/transactions") def create_transaction( data: TransactionCreate, db: Session = Depends(get_db), ): service_id = data.service_id svc = db.query(Service).filter(Service.id == service_id).first() if service_id else None svc_name = svc.name if svc else data.service_name or "Manual" quantity = data.quantity # snapshot prices NOW or use provided values unit_sell = data.unit_sell if data.unit_sell is not None else (svc.sell_price if svc else 0) unit_cost = data.unit_cost if data.unit_cost is not None else (svc.cost_price if svc else 0) revenue = unit_sell * quantity cogs = unit_cost * quantity profit = revenue - cogs month = data.month or datetime.now(timezone.utc).strftime("%Y-%m") t = Transaction( source_type=data.source_type, source_id=data.source_id, service_id=service_id, service_name_snapshot=svc_name, quantity=quantity, unit_sell_price_snapshot=unit_sell, unit_cost_snapshot=unit_cost, revenue_total=revenue, cogs_total=cogs, gross_profit_total=profit, month_start_date=month, payment_status=data.payment_status, notes=data.notes, ) db.add(t) db.commit() return {"ok": True, "id": t.id} @app.delete("/api/transactions/{txn_id}") def delete_transaction(txn_id: int, db: Session = Depends(get_db)): t = db.query(Transaction).filter(Transaction.id == txn_id).first() if not t: raise HTTPException(status_code=404, detail="Transaction not found") db.delete(t) db.commit() return {"ok": True} # ── expenses API ─────────────────────────────────────────────────────────── @app.get("/api/expenses") def list_expenses( month: Optional[str] = None, db: Session = Depends(get_db), ): q = db.query(Expense).filter(Expense.deleted_at == None).order_by(Expense.created_at.desc()) if month: q = q.filter(Expense.month_start_date == month) return [ { "id": e.id, "name": e.name, "amount": e.amount, "category": e.category, "month": e.month_start_date, "notes": e.notes, } for e in q.all() ] @app.post("/api/expenses") def create_expense(data: ExpenseCreate, db: Session = Depends(get_db)): e = Expense( name=data.name, amount=data.amount, category=data.category, month_start_date=data.month or datetime.now(timezone.utc).strftime("%Y-%m"), notes=data.notes, ) db.add(e) db.commit() return {"ok": True, "id": e.id} @app.delete("/api/expenses/{expense_id}") def delete_expense(expense_id: int, db: Session = Depends(get_db)): e = db.query(Expense).filter(Expense.id == expense_id).first() if not e: raise HTTPException(status_code=404) e.deleted_at = datetime.now(timezone.utc) db.commit() return {"ok": True} @app.get("/api/expense-templates") def list_expense_templates(db: Session = Depends(get_db)): return [ {"id": et.id, "name": et.name, "amount": et.amount, "category": et.category, "active": et.active} for et in db.query(ExpenseTemplate).all() ] @app.post("/api/expenses/generate-month") def generate_monthly_expenses( month: Optional[str] = None, db: Session = Depends(get_db), ): """Generate actual expenses from active templates for a given month.""" if not month: month = datetime.now(timezone.utc).strftime("%Y-%m") templates = db.query(ExpenseTemplate).filter(ExpenseTemplate.active == True).all() created = 0 for tpl in templates: existing = ( db.query(Expense) .filter( Expense.name == tpl.name, Expense.month_start_date == month, Expense.deleted_at == None, ) .first() ) if not existing: db.add(Expense( name=tpl.name, amount=tpl.amount, category=tpl.category, month_start_date=month, )) created += 1 db.commit() return {"ok": True, "created": created, "month": month} # ── P&L dashboard API ───────────────────────────────────────────────────── @app.get("/api/pnl") def get_pnl(month: Optional[str] = None, db: Session = Depends(get_db)): """Return P&L for a single month or all months.""" from collections import defaultdict txn_q = db.query(Transaction) exp_q = db.query(Expense).filter(Expense.deleted_at == None) if month: txn_q = txn_q.filter(Transaction.month_start_date == month) exp_q = exp_q.filter(Expense.month_start_date == month) transactions = txn_q.all() expenses = exp_q.all() # aggregate by month monthly = defaultdict(lambda: {"revenue": 0, "cogs": 0, "gross_profit": 0, "expenses": 0, "net_profit": 0, "txn_count": 0}) for t in transactions: m = t.month_start_date monthly[m]["revenue"] += t.revenue_total monthly[m]["cogs"] += t.cogs_total monthly[m]["gross_profit"] += t.gross_profit_total monthly[m]["txn_count"] += 1 for e in expenses: monthly[e.month_start_date]["expenses"] += e.amount for m in monthly: monthly[m]["net_profit"] = monthly[m]["gross_profit"] - monthly[m]["expenses"] # summary total_revenue = sum(v["revenue"] for v in monthly.values()) total_cogs = sum(v["cogs"] for v in monthly.values()) total_gross = sum(v["gross_profit"] for v in monthly.values()) total_expenses = sum(v["expenses"] for v in monthly.values()) total_net = total_gross - total_expenses margin = (total_gross / total_revenue * 100) if total_revenue else 0 return { "summary": { "revenue": total_revenue, "cogs": total_cogs, "gross_profit": total_gross, "expenses": total_expenses, "net_profit": total_net, "margin_pct": round(margin, 1), }, "months": dict(sorted(monthly.items())), } @app.get("/health") def health(): return {"status": "ok"} # ── main ─────────────────────────────────────────────────────────────────── if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)