Files

609 lines
20 KiB
Python

"""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)