Vela Platform v2: single monolith, real P&L, bilingual marketing site
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
"""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/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)
|
||||
Reference in New Issue
Block a user