609 lines
20 KiB
Python
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)
|