🎉 v1.0.0 — Vela Platform launch

Self-hosted P&L tracking app with component-level pricing.
Offers: Atlas/Atlas+/Rif/Rif+ with granular cost breakdown.
API + MCP + multi-user auth.
This commit is contained in:
2026-06-15 23:05:59 +01:00
commit d1160673a7
14 changed files with 2387 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*.db
.env
.venv/
venv/
*.egg-info/
dist/
build/
.DS_Store
*.swp
*.swo
*~

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# Changelog
## v1.0.0 (2026-06-15) — Vela Platform Launch 🏔️
### 🚀 Initial Release
- Self-hosted P&L tracking web app for Best OF (C2C server/storage business)
- Built with FastAPI + SQLite + Jinja2 templates + Bootstrap 5
### 🏔️ Offers (Services)
- **Atlas** — Core 1TB storage (7 components, 4,000 MAD/month)
- **Atlas+** — Premium 1TB + backup (9 components, 5,000 MAD/month)
- **Rif** — Core 500GB server (7 components, 3,500 MAD/month)
- **Rif+** — Premium 500GB + backup (9 components, 4,500 MAD/month)
### 🔧 Features
- **Component-Level Pricing** — Each offer has granular breakdown (HDD, SSD, RAM, CPU, Casing, Bandwidth, Setup, Support, Sauvegarde)
- **Auto-Computed Prices** — Totals recalculate automatically when you edit any component
- **Inline Component Editor** — Add, edit, or delete components from the Offers page with live price updates
- **Monthly P&L Dashboard** — Revenue, cost, margin cards with 12-month trend chart
- **Transaction Logging** — Track monthly revenue per client per service
- **Multi-User Auth** — Admin (edit) + Viewer (read-only) roles
- **JWT Cookie Auth** — Login sets httpOnly cookie, API uses Bearer token
### 🧩 API & Integrations
- Full REST API for all CRUD operations
- MCP server for Hermes agent integration (components, transactions, P&L)
- All prices in MAD (Moroccan Dirham)
### 🐳 Deployment
- Standalone Python app (no Docker needed)
- systemd user service for auto-start
- Single SQLite database (zero config)

121
CLAUDE.md Normal file
View File

@@ -0,0 +1,121 @@
# Vela Platform 🏔️
A lightweight self-hosted P&L tracking app for a C2C server/storage business.
## Stack
- Python 3.11+ with FastAPI + uvicorn
- SQLite via SQLAlchemy (single file, zero setup)
- Jinja2 templates + Bootstrap 5 dark frontend
- Chart.js for trend charts
- JWT auth (cookie + Bearer header)
- stdio MCP server for Hermes integration
## Data Model
### User
- id, username, password_hash, display_name, role (admin|viewer)
### Service (Offer)
- id, name (Atlas/Atlas+/Rif/Rif+), description
- sell_price (computed from components), cost_price (computed from components)
- active
### ServiceComponent
- id, service_id (FK), name (RAM/HDD/SSD/etc.), unit_cost, unit_sell, quantity, notes
- `recompute_prices()` method sums cost/sell from components
### Transaction
- id, service_id (FK), quantity, revenue (calculated: qty * service.sell_price), cost (calculated: qty * service.cost_price), month (YYYY-MM), notes, created_by (FK user), created_at
### Monthly P&L (computed)
- month, total_revenue, total_cost, gross_profit, gross_margin%, opex (800 MAD), net_profit, net_margin%
## Preseed Data
- Users: admin (password: admin123), viewer (password: viewer123)
- Services with components (see COMPONENT_TEMPLATES in main.py)
- Opex: 800 MAD/month hardcoded
## Key Files
| Path | What |
|------|------|
| `backend/main.py` | FastAPI app (API + frontend routes) |
| `backend/models.py` | SQLAlchemy models |
| `backend/auth.py` | JWT auth, cookie support |
| `backend/database.py` | SQLite setup |
| `backend/templates/` | Jinja2 templates |
| `mcp/server.py` | MCP stdio server |
| `CLAUDE.md` | This file |
## API Endpoints
### Auth
- POST /api/auth/login -> {token, user} + set cookie
- GET /api/auth/me -> current user
### Services
- GET /api/services -> list with components
- GET /api/services/{id} -> single with components
- POST /api/services -> create (admin)
- PUT /api/services/{id} -> update (admin)
- DELETE /api/services/{id} -> deactivate (admin)
### Components
- GET /api/services/{id}/components -> list
- POST /api/services/{id}/components -> create (admin, JSON body)
- PUT /api/components/{id} -> update (admin, JSON body) — auto-recomputes prices
- DELETE /api/components/{id} -> delete (admin) — auto-recomputes prices
### Transactions
- GET /api/transactions?month=YYYY-MM -> list
- POST /api/transactions -> create {service_id, quantity, month, notes}
- DELETE /api/transactions/{id} -> (admin)
### P&L
- GET /api/pnl?month=YYYY-MM -> computed P&L
- GET /api/dashboard -> current + 12-month trend
## MCP Server (stdio)
Located at `mcp/server.py`. Run as a stdio MCP server via `python3 mcp/server.py`.
Tools: get_pnl, get_services, get_service, add_component, update_component, delete_component, add_transaction, get_dashboard
Resources: vela://pnl/current, vela://services, vela://services/{id}
## URLs
- Web app: http://192.168.1.30:8788
- MCP: registered as `vela-platform` in Hermes config
## Component Pricing Templates
Each offer has its own set of components. The total sell/cost is auto-computed:
### Atlas (1TB Storage)
HDD(1TB) 600/1000, RAM(16GB) 300/500, CPU alloc 200/400, Casing 200/300, Bandwidth 200/500, Setup 300/600, Support 200/700
→ Total: cost 2000, sell 4000
### Atlas+ (1TB + Backup)
HDD(1TB) 600/1000, SSD(256GB) 400/700, RAM(32GB) 600/1000, CPU alloc 300/600, Casing 200/300, Bandwidth 200/500, Sauvegarde 300/500, Setup 200/400, Support 200/700
→ Total: cost 2800, sell 5000
### Rif (500GB Server)
SSD(500GB) 300/600, RAM(16GB) 300/500, CPU alloc 200/400, Casing 200/300, Bandwidth 200/500, Setup 300/600, Support 200/700
→ Total: cost 1700, sell 3500
### Rif+ (500GB + Backup)
SSD(500GB) 300/600, HDD(1TB) 600/1000, RAM(32GB) 600/1000, CPU alloc 300/600, Casing 200/300, Bandwidth 200/500, Sauvegarde 300/500, Setup 200/400, Support 200/700
→ Total: cost 2200, sell 4500
## Deployment
```bash
cd ~/bestof-manager/backend && uvicorn main:app --host 0.0.0.0 --port 8788
# Or via systemd:
systemctl --user restart bestof-manager.service
```
## Auth Flow
- Page routes use cookie auth (httpOnly 'token' cookie set on login)
- API routes use Bearer token from Authorization header
- Login sets both cookie AND returns JSON token
- Token expires after 24h
- Pages without valid cookie redirect to /login

111
backend/auth.py Normal file
View File

@@ -0,0 +1,111 @@
"""Authentication: password hashing, JWT tokens, and FastAPI dependencies."""
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from database import get_db
from models import User
# Secret key for JWT signing (generate a random one if not set)
SECRET_KEY = os.environ.get("JWT_SECRET", "bestof-manager-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
security = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def create_access_token(user_id: int, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
payload = {
"sub": str(user_id),
"role": role,
"exp": expire,
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def _decode_token(token: str) -> Optional[dict]:
"""Try to decode a JWT token, return payload or None."""
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except (JWTError, ValueError, TypeError):
return None
def _token_to_user(token: str, db: Session) -> Optional[User]:
"""Given a token string, return User or None."""
payload = _decode_token(token)
if payload is None:
return None
try:
user_id = int(payload.get("sub"))
except (ValueError, TypeError):
return None
return db.query(User).filter(User.id == user_id).first()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
"""FastAPI dependency: validates JWT Bearer token and returns the current User."""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user = _token_to_user(credentials.credentials, db)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
return user
def get_page_user(
request: Request,
db: Session = Depends(get_db),
) -> Optional[User]:
"""FastAPI dependency for page routes: checks cookie first, then Bearer header.
Returns None (not 401) if not authenticated — caller should redirect to /login.
"""
token = request.cookies.get("token")
if token:
user = _token_to_user(token, db)
if user:
return user
# Fallback: check Authorization header (for AJAX page loads)
auth = request.headers.get("Authorization")
if auth and auth.startswith("Bearer "):
user = _token_to_user(auth[7:], db)
if user:
return user
return None
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""FastAPI dependency: requires admin role."""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user

22
backend/database.py Normal file
View File

@@ -0,0 +1,22 @@
"""Database setup: engine, session factory, and base model."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = "sqlite:///bestof.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
"""FastAPI dependency that yields a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()

640
backend/main.py Normal file
View File

@@ -0,0 +1,640 @@
"""Vela Platform — FastAPI application with API endpoints and Jinja2 frontend."""
from datetime import datetime, timezone
from typing import Optional
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from auth import (
create_access_token,
get_current_user,
get_page_user,
hash_password,
require_admin,
verify_password,
)
from database import Base, SessionLocal, engine, get_db
from models import Service, ServiceComponent, Transaction, User
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
app = FastAPI(title="Vela Platform", version="1.0.0")
# Mount templates via Jinja2
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
HARDCODED_OPEX = 800.0 # MAD/month
# ---------------------------------------------------------------------------
# Helper: serialize a service with its components
# ---------------------------------------------------------------------------
def service_to_dict(svc: Service) -> dict:
return {
"id": svc.id,
"name": svc.name,
"description": svc.description,
"sell_price": svc.sell_price,
"cost_price": svc.cost_price,
"active": svc.active,
"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 svc.components
],
}
# ---------------------------------------------------------------------------
# Startup: create tables + seed data + create component tables
# ---------------------------------------------------------------------------
COMPONENT_TEMPLATES = {
"Atlas": [
("HDD (1TB)", 600.0, 1000.0, 1, "Enterprise HDD"),
("RAM (16GB)", 300.0, 500.0, 1, "ECC RAM"),
("CPU alloc", 200.0, 400.0, 1, "CPU core allocation"),
("Casing & PSU", 200.0, 300.0, 1, "Chassis + power"),
("Bandwidth 1Gbps", 200.0, 500.0, 1, "Network port"),
("Setup & config", 300.0, 600.0, 1, "One-time setup amortised"),
("Support", 200.0, 700.0, 1, "Monthly support"),
],
"Atlas+": [
("HDD (1TB)", 600.0, 1000.0, 1, "Enterprise HDD"),
("SSD (256GB)", 400.0, 700.0, 1, "Cache SSD"),
("RAM (32GB)", 600.0, 1000.0, 1, "ECC RAM"),
("CPU alloc", 300.0, 600.0, 1, "CPU core allocation"),
("Casing & PSU", 200.0, 300.0, 1, "Chassis + power"),
("Bandwidth 1Gbps", 200.0, 500.0, 1, "Network port"),
("Sauvegarde", 300.0, 500.0, 1, "Backup service"),
("Setup & config", 200.0, 400.0, 1, "One-time setup amortised"),
("Support", 200.0, 700.0, 1, "Monthly support"),
],
"Rif": [
("SSD (500GB)", 300.0, 600.0, 1, "NVMe SSD"),
("RAM (16GB)", 300.0, 500.0, 1, "ECC RAM"),
("CPU alloc", 200.0, 400.0, 1, "CPU core allocation"),
("Casing & PSU", 200.0, 300.0, 1, "Chassis + power"),
("Bandwidth 1Gbps", 200.0, 500.0, 1, "Network port"),
("Setup & config", 300.0, 600.0, 1, "One-time setup amortised"),
("Support", 200.0, 700.0, 1, "Monthly support"),
],
"Rif+": [
("SSD (500GB)", 300.0, 600.0, 1, "NVMe SSD"),
("HDD (1TB)", 600.0, 1000.0, 1, "Enterprise HDD"),
("RAM (32GB)", 600.0, 1000.0, 1, "ECC RAM"),
("CPU alloc", 300.0, 600.0, 1, "CPU core allocation"),
("Casing & PSU", 200.0, 300.0, 1, "Chassis + power"),
("Bandwidth 1Gbps", 200.0, 500.0, 1, "Network port"),
("Sauvegarde", 300.0, 500.0, 1, "Backup service"),
("Setup & config", 200.0, 400.0, 1, "One-time setup amortised"),
("Support", 200.0, 700.0, 1, "Monthly support"),
],
}
def seed_database():
"""Create tables and insert preseed data if missing."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
# Seed users
if not db.query(User).filter(User.username == "admin").first():
db.add(
User(
username="admin",
password_hash=hash_password("admin123"),
display_name="Administrator",
role="admin",
)
)
if not db.query(User).filter(User.username == "viewer").first():
db.add(
User(
username="viewer",
password_hash=hash_password("viewer123"),
display_name="Viewer",
role="viewer",
)
)
# Seed services + components
for name, desc, sell, cost in [
("Atlas", "Core storage service", 4000.0, 2000.0),
("Atlas+", "Premium storage + backup service", 5000.0, 2800.0),
("Rif", "Core server service", 3500.0, 1700.0),
("Rif+", "Premium server + backup service", 4500.0, 2200.0),
]:
existing = db.query(Service).filter(Service.name == name).first()
if not existing:
svc = Service(
name=name,
description=desc,
sell_price=sell,
cost_price=cost,
active=True,
)
db.add(svc)
db.flush() # get the id
# Add component templates
for cname, ucost, usell, qty, notes in COMPONENT_TEMPLATES.get(name, []):
db.add(
ServiceComponent(
service_id=svc.id,
name=cname,
unit_cost=ucost,
unit_sell=usell,
quantity=qty,
notes=notes,
)
)
db.commit()
finally:
db.close()
seed_database()
# ---------------------------------------------------------------------------
# Helper: compute monthly P&L
# ---------------------------------------------------------------------------
def compute_pnl(month: str, db: Session) -> dict:
"""Compute P&L for a given YYYY-MM month."""
transactions = db.query(Transaction).filter(Transaction.month == month).all()
total_revenue = 0.0
total_cost = 0.0
for txn in transactions:
total_revenue += txn.revenue
total_cost += txn.cost
gross_profit = total_revenue - total_cost
gross_margin = (gross_profit / total_revenue * 100) if total_revenue > 0 else 0.0
opex = HARDCODED_OPEX
net_profit = gross_profit - opex
net_margin = (net_profit / total_revenue * 100) if total_revenue > 0 else 0.0
return {
"month": month,
"total_revenue": round(total_revenue, 2),
"total_cost": round(total_cost, 2),
"gross_profit": round(gross_profit, 2),
"gross_margin_pct": round(gross_margin, 2),
"opex": opex,
"net_profit": round(net_profit, 2),
"net_margin_pct": round(net_margin, 2),
}
def get_trend(db: Session, months: int = 12) -> list:
"""Return P&L for the last N months (those with transactions)."""
month_rows = (
db.query(Transaction.month)
.distinct()
.order_by(Transaction.month.desc())
.limit(months)
.all()
)
month_list = [row[0] for row in reversed(month_rows)]
return [compute_pnl(m, db) for m in month_list]
# ---------------------------------------------------------------------------
# API: Auth
# ---------------------------------------------------------------------------
@app.post("/api/auth/login")
def api_login(
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
token = create_access_token(user.id, user.role)
response = JSONResponse({
"token": token,
"user": {
"id": user.id,
"username": user.username,
"display_name": user.display_name,
"role": user.role,
},
})
response.set_cookie(
key="token",
value=token,
httponly=True,
max_age=86400,
samesite="lax",
secure=False,
path="/",
)
return response
@app.get("/api/auth/me")
def api_me(current_user: User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"display_name": current_user.display_name,
"role": current_user.role,
}
# ---------------------------------------------------------------------------
# API: Services
# ---------------------------------------------------------------------------
@app.get("/api/services")
def api_list_services(db: Session = Depends(get_db)):
return [service_to_dict(s) for s in db.query(Service).order_by(Service.name).all()]
@app.get("/api/services/{service_id}")
def api_get_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, detail="Service not found")
return service_to_dict(svc)
@app.post("/api/services")
def api_create_service(
name: str = Form(...),
description: str = Form(""),
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
svc = Service(
name=name,
description=description,
sell_price=0.0,
cost_price=0.0,
active=True,
)
db.add(svc)
db.commit()
db.refresh(svc)
return service_to_dict(svc)
@app.put("/api/services/{service_id}")
def api_update_service(
service_id: int,
name: str = Form(...),
description: str = Form(""),
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404, detail="Service not found")
svc.name = name
svc.description = description
svc.recompute_prices()
db.commit()
return service_to_dict(svc)
@app.delete("/api/services/{service_id}")
def api_deactivate_service(
service_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404, detail="Service not found")
svc.active = False
db.commit()
return {"ok": True}
# ---------------------------------------------------------------------------
# API: Service Components
# ---------------------------------------------------------------------------
@app.get("/api/services/{service_id}/components")
def api_list_components(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, detail="Service not found")
return [
{
"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 svc.components
]
class ComponentInput(BaseModel):
name: str
unit_cost: float = 0.0
unit_sell: float = 0.0
quantity: int = 1
notes: str = ""
@app.post("/api/services/{service_id}/components")
def api_create_component(
service_id: int,
input: ComponentInput,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
raise HTTPException(status_code=404, detail="Service not found")
comp = ServiceComponent(
service_id=service_id,
name=input.name,
unit_cost=input.unit_cost,
unit_sell=input.unit_sell,
quantity=input.quantity,
notes=input.notes,
)
db.add(comp)
db.flush()
svc.recompute_prices()
db.commit()
db.refresh(comp)
return {
"id": comp.id,
"name": comp.name,
"unit_cost": comp.unit_cost,
"unit_sell": comp.unit_sell,
"quantity": comp.quantity,
"notes": comp.notes,
}
@app.put("/api/components/{component_id}")
def api_update_component(
component_id: int,
input: ComponentInput,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
comp = db.query(ServiceComponent).filter(ServiceComponent.id == component_id).first()
if not comp:
raise HTTPException(status_code=404, detail="Component not found")
comp.name = input.name
comp.unit_cost = input.unit_cost
comp.unit_sell = input.unit_sell
comp.quantity = input.quantity
comp.notes = input.notes
db.flush()
svc = db.query(Service).filter(Service.id == comp.service_id).first()
if svc:
svc.recompute_prices()
db.commit()
return {
"id": comp.id,
"name": comp.name,
"unit_cost": comp.unit_cost,
"unit_sell": comp.unit_sell,
"quantity": comp.quantity,
"notes": comp.notes,
}
@app.delete("/api/components/{component_id}")
def api_delete_component(
component_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
comp = db.query(ServiceComponent).filter(ServiceComponent.id == component_id).first()
if not comp:
raise HTTPException(status_code=404, detail="Component not found")
svc = db.query(Service).filter(Service.id == comp.service_id).first()
db.delete(comp)
db.flush() # <-- ensures the deleted component is removed before recompute
if svc:
svc.recompute_prices()
db.commit()
return {"ok": True}
# ---------------------------------------------------------------------------
# API: Transactions
# ---------------------------------------------------------------------------
@app.get("/api/transactions")
def api_list_transactions(
month: Optional[str] = None,
db: Session = Depends(get_db),
):
query = db.query(Transaction)
if month:
query = query.filter(Transaction.month == month)
transactions = query.order_by(Transaction.created_at.desc()).all()
return [
{
"id": t.id,
"service_id": t.service_id,
"service_name": t.service.name,
"quantity": t.quantity,
"revenue": t.revenue,
"cost": t.cost,
"month": t.month,
"notes": t.notes,
"created_by": t.created_by,
"creator_name": t.creator.display_name,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in transactions
]
class CreateTransactionInput(BaseModel):
service_id: int
quantity: int
month: str
notes: str = ""
@app.post("/api/transactions")
def api_create_transaction(
input: CreateTransactionInput,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = db.query(Service).filter(Service.id == input.service_id).first()
if not service or not service.active:
raise HTTPException(status_code=400, detail="Invalid or inactive service")
txn = Transaction(
service_id=input.service_id,
quantity=input.quantity,
month=input.month,
notes=input.notes,
created_by=current_user.id,
)
db.add(txn)
db.commit()
db.refresh(txn)
return {
"id": txn.id,
"service_id": txn.service_id,
"service_name": txn.service.name,
"quantity": txn.quantity,
"revenue": txn.revenue,
"cost": txn.cost,
"month": txn.month,
"notes": txn.notes,
"created_by": txn.created_by,
"created_at": txn.created_at.isoformat() if txn.created_at else None,
}
@app.delete("/api/transactions/{transaction_id}")
def api_delete_transaction(
transaction_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(require_admin),
):
txn = db.query(Transaction).filter(Transaction.id == transaction_id).first()
if not txn:
raise HTTPException(status_code=404, detail="Transaction not found")
db.delete(txn)
db.commit()
return {"ok": True}
# ---------------------------------------------------------------------------
# API: P&L
# ---------------------------------------------------------------------------
@app.get("/api/pnl")
def api_pnl(month: str, db: Session = Depends(get_db)):
return compute_pnl(month, db)
# ---------------------------------------------------------------------------
# API: Dashboard
# ---------------------------------------------------------------------------
@app.get("/api/dashboard")
def api_dashboard(db: Session = Depends(get_db)):
current_month = datetime.now(timezone.utc).strftime("%Y-%m")
pnl = compute_pnl(current_month, db)
trend = get_trend(db, months=12)
return {
"current_month": pnl,
"trend": trend,
}
# ---------------------------------------------------------------------------
# Frontend Routes
# ---------------------------------------------------------------------------
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
"""Serve the login page."""
return templates.TemplateResponse(request, "login.html")
@app.get("/", response_class=HTMLResponse)
def dashboard_page(
request: Request,
current_user: Optional[User] = Depends(get_page_user),
db: Session = Depends(get_db),
):
"""Serve the dashboard page."""
if not current_user:
return RedirectResponse(url="/login")
current_month = datetime.now(timezone.utc).strftime("%Y-%m")
pnl = compute_pnl(current_month, db)
trend = get_trend(db, months=12)
services = db.query(Service).filter(Service.active == True).all()
return templates.TemplateResponse(
request, "dashboard.html",
{
"current_user": current_user,
"pnl": pnl,
"trend": trend,
"services": services,
},
)
@app.get("/transactions", response_class=HTMLResponse)
def transactions_page(
request: Request,
current_user: Optional[User] = Depends(get_page_user),
db: Session = Depends(get_db),
):
"""Serve the transactions page."""
if not current_user:
return RedirectResponse(url="/login")
current_month = datetime.now(timezone.utc).strftime("%Y-%m")
services = db.query(Service).filter(Service.active == True).all()
return templates.TemplateResponse(
request, "transactions.html",
{
"current_user": current_user,
"services": services,
"current_month": current_month,
},
)
@app.get("/services", response_class=HTMLResponse)
def services_page(
request: Request,
current_user: Optional[User] = Depends(get_page_user),
db: Session = Depends(get_db),
):
"""Serve the services management page."""
if not current_user:
return RedirectResponse(url="/login")
services = db.query(Service).order_by(Service.name).all()
services_json = [service_to_dict(s) for s in services]
return templates.TemplateResponse(
request, "services.html",
{
"services": services,
"services_json": services_json,
"current_user": current_user,
},
)
# ---------------------------------------------------------------------------
# Logout
# ---------------------------------------------------------------------------
@app.get("/logout")
def logout():
return RedirectResponse(url="/login")
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8788, reload=True)

96
backend/models.py Normal file
View File

@@ -0,0 +1,96 @@
"""SQLAlchemy models: User, Service, ServiceComponent, Transaction."""
from datetime import datetime, timezone
from sqlalchemy import (
Column,
Integer,
String,
Float,
Boolean,
DateTime,
ForeignKey,
)
from sqlalchemy.orm import relationship
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
display_name = Column(String, nullable=False)
role = Column(String, nullable=False, default="viewer") # admin | viewer
transactions = relationship("Transaction", back_populates="creator")
class Service(Base):
__tablename__ = "services"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False)
description = Column(String, default="")
sell_price = Column(Float, nullable=False) # MAD/month — auto-computed from components
cost_price = Column(Float, nullable=False) # MAD/month — auto-computed from components
active = Column(Boolean, default=True)
transactions = relationship("Transaction", back_populates="service")
components = relationship(
"ServiceComponent",
back_populates="service",
cascade="all, delete-orphan",
order_by="ServiceComponent.id",
)
def recompute_prices(self):
"""Update sell_price and cost_price from component totals."""
self.sell_price = sum(
(c.unit_sell * c.quantity) for c in self.components
) if self.components else 0.0
self.cost_price = sum(
(c.unit_cost * c.quantity) for c in self.components
) if self.components else 0.0
class ServiceComponent(Base):
"""A cost/sell component of a service offer (e.g. RAM, HDD, Transport)."""
__tablename__ = "service_components"
id = Column(Integer, primary_key=True, index=True)
service_id = Column(Integer, ForeignKey("services.id"), nullable=False)
name = Column(String, nullable=False)
unit_cost = Column(Float, nullable=False, default=0.0) # what we pay / unit
unit_sell = Column(Float, nullable=False, default=0.0) # what we charge / unit
quantity = Column(Integer, nullable=False, default=1)
notes = Column(String, default="")
service = relationship("Service", back_populates="components")
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
service_id = Column(Integer, ForeignKey("services.id"), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
month = Column(String, nullable=False) # YYYY-MM format
notes = Column(String, default="")
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
service = relationship("Service", back_populates="transactions")
creator = relationship("User", back_populates="transactions")
@property
def revenue(self) -> float:
"""Calculated: quantity * service.sell_price"""
return self.quantity * self.service.sell_price
@property
def cost(self) -> float:
"""Calculated: quantity * service.cost_price"""
return self.quantity * self.service.cost_price

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi>=0.104.0
uvicorn>=0.24.0
sqlalchemy>=2.0.0
bcrypt>=4.0.0
python-jose[cryptography]>=3.3.0
python-multipart>=0.0.6
jinja2>=3.1.0

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Vela Platform{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
body { padding-top: 70px; }
.navbar-brand { font-weight: 700; letter-spacing: -0.5px; }
.card-stat { border-left: 4px solid var(--bs-primary); }
.card-stat.green { border-left-color: var(--bs-success); }
.card-stat.red { border-left-color: var(--bs-danger); }
.card-stat.yellow { border-left-color: var(--bs-warning); }
.component-row td { vertical-align: middle; }
.component-row input { min-width: 60px; }
.total-label { font-weight: 700; font-size: 1.05rem; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary fixed-top border-bottom">
<div class="container">
<a class="navbar-brand" href="/">
🏔️ Vela Platform
</a>
{% if current_user %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/"><i class="bi bi-speedometer2"></i> Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/transactions"><i class="bi bi-list-ul"></i> Transactions</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/services"><i class="bi bi-gear"></i> Offers</a>
</li>
</ul>
<span class="navbar-text me-3">
<i class="bi bi-person-circle"></i> {{ current_user.display_name }}
<span class="badge bg-secondary ms-1">{{ current_user.role }}</span>
</span>
<a href="/logout" class="btn btn-outline-secondary btn-sm">Logout</a>
</div>
{% endif %}
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const TOKEN_KEY = 'vela_token';
function setToken(token) { localStorage.setItem(TOKEN_KEY, token); }
function getToken() { return localStorage.getItem(TOKEN_KEY); }
function clearToken() { localStorage.removeItem(TOKEN_KEY); }
async function apiFetch(url, options = {}) {
const token = getToken();
const headers = { ...options.headers };
if (token && !(options.body instanceof FormData)) {
headers['Authorization'] = `Bearer ${token}`;
headers['Content-Type'] = 'application/json';
} else if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const resp = await fetch(url, { ...options, headers });
if (resp.status === 401) {
clearToken();
window.location.href = '/login';
throw new Error('Unauthorized');
}
return resp;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}Dashboard — Vela Platform{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-speedometer2"></i> Dashboard</h2>
<span class="badge bg-primary fs-6">{{ pnl.month }}</span>
</div>
<!-- P&L Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3 col-6">
<div class="card card-stat h-100">
<div class="card-body">
<small class="text-muted">Revenue</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.total_revenue) }} MAD</h4>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card card-stat h-100">
<div class="card-body">
<small class="text-muted">Cost</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.total_cost) }} MAD</h4>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card card-stat green h-100">
<div class="card-body">
<small class="text-muted">Gross Profit</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.gross_profit) }} MAD</h4>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card card-stat {{ 'green' if pnl.net_profit >= 0 else 'red' }} h-100">
<div class="card-body">
<small class="text-muted">Net Profit</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.net_profit) }} MAD</h4>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">Margin Breakdown</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td>Gross Margin</td><td class="text-end">{{ "%.1f"|format(pnl.gross_margin_pct) }}%</td></tr>
<tr><td>OpEx (fixed)</td><td class="text-end">{{ "%.0f"|format(pnl.opex) }} MAD</td></tr>
<tr class="fw-bold"><td>Net Margin</td><td class="text-end">{{ "%.1f"|format(pnl.net_margin_pct) }}%</td></tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">Active Offers — Cost Breakdown</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Offer</th>
<th class="text-end">Sell</th>
<th class="text-end">Cost</th>
<th class="text-end">Margin</th>
<th class="text-end">Components</th>
</tr>
</thead>
<tbody>
{% for s in services %}
<tr>
<td><strong>{{ s.name }}</strong></td>
<td class="text-end">{{ "%.0f"|format(s.sell_price) }} MAD</td>
<td class="text-end text-muted">{{ "%.0f"|format(s.cost_price) }} MAD</td>
<td class="text-end {{ 'text-success' if (s.sell_price - s.cost_price) > 0 else 'text-danger' }}">
{{ "%.0f"|format(s.sell_price - s.cost_price) }} MAD
</td>
<td class="text-end text-muted">
<small>{{ s.components|length }} items</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 12-Month Trend Chart -->
<div class="card mb-4">
<div class="card-header">12-Month Trend</div>
<div class="card-body">
<canvas id="trendChart" height="80"></canvas>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const trendData = {{ trend | tojson }};
const labels = trendData.map(d => d.month);
const revenue = trendData.map(d => d.total_revenue);
const cost = trendData.map(d => d.total_cost);
const netProfit = trendData.map(d => d.net_profit);
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Revenue',
data: revenue,
borderColor: '#0d6efd',
backgroundColor: 'rgba(13,110,253,0.1)',
fill: true,
tension: 0.3,
},
{
label: 'Cost',
data: cost,
borderColor: '#dc3545',
backgroundColor: 'rgba(220,53,69,0.1)',
fill: true,
tension: 0.3,
},
{
label: 'Net Profit',
data: netProfit,
borderColor: '#198754',
backgroundColor: 'rgba(25,135,84,0.1)',
fill: true,
tension: 0.3,
borderWidth: 2,
},
],
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
},
scales: {
y: {
ticks: { callback: v => v + ' MAD' },
},
},
},
});
</script>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Login — Vela Platform{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-5 col-lg-4">
<div class="card shadow">
<div class="card-body p-4">
<h3 class="card-title text-center mb-4">🔐 Sign In</h3>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username"
placeholder="Enter username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="Enter password" required>
</div>
<div id="loginError" class="alert alert-danger d-none py-2"></div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
<p class="text-muted text-center mt-3 mb-0" style="font-size: 0.85rem;">
Demo: admin / admin123 &nbsp;|&nbsp; viewer / viewer123
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const errorEl = document.getElementById('loginError');
errorEl.classList.add('d-none');
const formData = new FormData(form);
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
body: formData,
});
if (!resp.ok) {
const data = await resp.json();
errorEl.textContent = data.detail || 'Login failed';
errorEl.classList.remove('d-none');
return;
}
const data = await resp.json();
setToken(data.token);
window.location.href = '/';
} catch (err) {
errorEl.textContent = 'Network error. Please try again.';
errorEl.classList.remove('d-none');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,345 @@
{% extends "base.html" %}
{% block title %}Offers — Vela Platform{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-gear"></i> Offers &amp; Pricing</h2>
{% if current_user.role == 'admin' %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#serviceModal"
onclick="resetServiceForm()">
<i class="bi bi-plus-lg"></i> Add Offer
</button>
{% endif %}
</div>
{% for s in services %}
<div class="card mb-4" id="svc-card-{{ s.id }}">
<!-- Service Header -->
<div class="card-header d-flex justify-content-between align-items-center"
style="cursor: pointer;" onclick="toggleService({{ s.id }})">
<div>
<strong class="fs-5">{{ s.name }}</strong>
<small class="text-muted ms-2">{{ s.description }}</small>
</div>
<div class="d-flex align-items-center gap-3">
<span class="badge bg-success fs-6">Sell: {{ "%.0f"|format(s.sell_price) }} MAD</span>
<span class="badge bg-danger fs-6">Cost: {{ "%.0f"|format(s.cost_price) }} MAD</span>
<span class="badge {{ 'bg-success' if (s.sell_price - s.cost_price) > 0 else 'bg-warning' }} fs-6">
Margin: {{ "%.0f"|format(s.sell_price - s.cost_price) }} MAD
</span>
<span class="badge {{ 'bg-success' if s.active else 'bg-secondary' }}">
{{ 'Active' if s.active else 'Inactive' }}
</span>
<i class="bi bi-chevron-down" id="chevron-{{ s.id }}"></i>
</div>
</div>
<!-- Service Body (collapse) -->
<div id="svc-body-{{ s.id }}">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width:22%">Component</th>
<th style="width:14%">Unit Cost</th>
<th style="width:14%">Unit Sell</th>
<th style="width:8%">Qty</th>
<th style="width:14%">Row Cost</th>
<th style="width:14%">Row Sell</th>
<th style="width:8%">Margin</th>
{% if current_user.role == 'admin' %}
<th style="width:6%"></th>
{% endif %}
</tr>
</thead>
<tbody id="comp-tbody-{{ s.id }}">
{% for c in s.components %}
<tr class="component-row" id="comp-row-{{ c.id }}">
<td><strong>{{ c.name }}</strong>
{% if c.notes %}<br><small class="text-muted">{{ c.notes }}</small>{% endif %}
</td>
<td>{{ "%.0f"|format(c.unit_cost) }}</td>
<td>{{ "%.0f"|format(c.unit_sell) }}</td>
<td>{{ c.quantity }}</td>
<td>{{ "%.0f"|format(c.unit_cost * c.quantity) }}</td>
<td>{{ "%.0f"|format(c.unit_sell * c.quantity) }}</td>
<td class="{{ 'text-success' if (c.unit_sell - c.unit_cost) > 0 else 'text-danger' }}">
{{ "%.0f"|format(c.unit_sell - c.unit_cost) }}
</td>
{% if current_user.role == 'admin' %}
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary"
onclick="editComponent({{ c.id }}, '{{ c.name|e }}', {{ c.unit_cost }}, {{ c.unit_sell }}, {{ c.quantity }}, '{{ c.notes|e }}')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger"
onclick="deleteComponent({{ c.id }})">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr class="text-muted"><td colspan="8" class="text-center py-3">No components yet.</td></tr>
{% endfor %}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td>TOTAL</td>
<td></td>
<td></td>
<td></td>
<td id="tot-cost-{{ s.id }}">{{ "%.0f"|format(s.cost_price) }}</td>
<td id="tot-sell-{{ s.id }}">{{ "%.0f"|format(s.sell_price) }}</td>
<td id="tot-margin-{{ s.id }}"
class="{{ 'text-success' if (s.sell_price - s.cost_price) > 0 else 'text-danger' }}">
{{ "%.0f"|format(s.sell_price - s.cost_price) }}
</td>
{% if current_user.role == 'admin' %}
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="addComponent({{ s.id }})">
<i class="bi bi-plus-lg"></i>
</button>
</td>
{% endif %}
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-5">
<i class="bi bi-box" style="font-size: 3rem;"></i>
<p class="mt-3">No offers yet. Create your first one!</p>
</div>
</div>
{% endfor %}
{% if current_user.role == 'admin' %}
<!-- Add/Edit Service Modal -->
<div class="modal fade" id="serviceModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="serviceForm">
<div class="modal-header">
<h5 class="modal-title" id="serviceModalTitle">Add Offer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="svcId" name="id" value="">
<div class="mb-3">
<label for="svcName" class="form-label">Offer Name</label>
<input type="text" class="form-control" id="svcName" name="name"
placeholder="e.g., Atlas" required>
</div>
<div class="mb-3">
<label for="svcDesc" class="form-label">Description</label>
<input type="text" class="form-control" id="svcDesc" name="description"
placeholder="Short description">
</div>
<div id="svcError" class="alert alert-danger d-none py-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add/Edit Component Modal -->
<div class="modal fade" id="compModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="compForm">
<div class="modal-header">
<h5 class="modal-title" id="compModalTitle">Add Component</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="compId" name="comp_id" value="">
<input type="hidden" id="compServiceId" name="service_id" value="">
<div class="mb-3">
<label for="compName" class="form-label">Component Name</label>
<input type="text" class="form-control" id="compName" name="name"
placeholder="e.g., RAM, HDD, Transport" required>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label for="compUnitCost" class="form-label">Unit Cost (MAD)</label>
<input type="number" class="form-control" id="compUnitCost" name="unit_cost"
step="0.01" min="0" value="0" required>
</div>
<div class="col-6">
<label for="compUnitSell" class="form-label">Unit Sell (MAD)</label>
<input type="number" class="form-control" id="compUnitSell" name="unit_sell"
step="0.01" min="0" value="0" required>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-4">
<label for="compQty" class="form-label">Quantity</label>
<input type="number" class="form-control" id="compQty" name="quantity"
min="1" value="1" required>
</div>
<div class="col-8">
<label for="compNotes" class="form-label">Notes</label>
<input type="text" class="form-control" id="compNotes" name="notes"
placeholder="Optional notes">
</div>
</div>
<div id="compError" class="alert alert-danger d-none py-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Component</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
{% if current_user.role == 'admin' %}
const compModal = new bootstrap.Modal(document.getElementById('compModal'));
const svcModal = new bootstrap.Modal(document.getElementById('serviceModal'));
// Toggle service body
function toggleService(id) {
const body = document.getElementById(`svc-body-${id}`);
const chevron = document.getElementById(`chevron-${id}`);
if (body.style.display === 'none') {
body.style.display = '';
chevron.className = 'bi bi-chevron-down';
} else {
body.style.display = 'none';
chevron.className = 'bi bi-chevron-up';
}
}
// Service CRUD
function resetServiceForm() {
document.getElementById('svcId').value = '';
document.getElementById('svcName').value = '';
document.getElementById('svcDesc').value = '';
document.getElementById('serviceModalTitle').textContent = 'Add Offer';
document.getElementById('svcError').classList.add('d-none');
}
document.getElementById('serviceForm').addEventListener('submit', async (e) => {
e.preventDefault();
const svcError = document.getElementById('svcError');
svcError.classList.add('d-none');
const formData = new FormData(e.target);
const id = formData.get('id');
const isEdit = !!id;
try {
const url = isEdit ? `/api/services/${id}` : '/api/services';
const method = isEdit ? 'PUT' : 'POST';
const resp = await apiFetch(url, { method, body: formData });
if (!resp.ok) {
const data = await resp.json();
svcError.textContent = data.detail || 'Failed to save offer';
svcError.classList.remove('d-none');
return;
}
svcModal.hide();
window.location.reload();
} catch (err) {
svcError.textContent = 'Error saving offer';
svcError.classList.remove('d-none');
}
});
// Component CRUD
function addComponent(serviceId) {
document.getElementById('compId').value = '';
document.getElementById('compServiceId').value = serviceId;
document.getElementById('compName').value = '';
document.getElementById('compUnitCost').value = '0';
document.getElementById('compUnitSell').value = '0';
document.getElementById('compQty').value = '1';
document.getElementById('compNotes').value = '';
document.getElementById('compModalTitle').textContent = 'Add Component';
document.getElementById('compError').classList.add('d-none');
compModal.show();
}
function editComponent(id, name, unitCost, unitSell, qty, notes) {
// Find the service ID from the card
const row = document.getElementById(`comp-row-${id}`);
const card = row.closest('.card');
const cardId = card.id; // svc-card-N
const svcId = parseInt(cardId.split('-')[2]);
document.getElementById('compId').value = id;
document.getElementById('compServiceId').value = svcId;
document.getElementById('compName').value = name;
document.getElementById('compUnitCost').value = unitCost;
document.getElementById('compUnitSell').value = unitSell;
document.getElementById('compQty').value = qty;
document.getElementById('compNotes').value = notes;
document.getElementById('compModalTitle').textContent = 'Edit Component';
document.getElementById('compError').classList.add('d-none');
compModal.show();
}
document.getElementById('compForm').addEventListener('submit', async (e) => {
e.preventDefault();
const compError = document.getElementById('compError');
compError.classList.add('d-none');
const compId = document.getElementById('compId').value;
const serviceId = document.getElementById('compServiceId').value;
const name = document.getElementById('compName').value;
const unitCost = parseFloat(document.getElementById('compUnitCost').value) || 0;
const unitSell = parseFloat(document.getElementById('compUnitSell').value) || 0;
const quantity = parseInt(document.getElementById('compQty').value) || 1;
const notes = document.getElementById('compNotes').value;
const body = JSON.stringify({ name, unit_cost: unitCost, unit_sell: unitSell, quantity, notes });
const isEdit = !!compId;
try {
const url = isEdit ? `/api/components/${compId}` : `/api/services/${serviceId}/components`;
const method = isEdit ? 'PUT' : 'POST';
const resp = await apiFetch(url, { method, body });
if (!resp.ok) {
const data = await resp.json();
compError.textContent = data.detail || 'Failed to save component';
compError.classList.remove('d-none');
return;
}
compModal.hide();
window.location.reload();
} catch (err) {
compError.textContent = 'Error saving component';
compError.classList.remove('d-none');
}
});
async function deleteComponent(id) {
if (!confirm('Delete this component?')) return;
try {
const resp = await apiFetch(`/api/components/${id}`, { method: 'DELETE' });
if (resp.ok) window.location.reload();
} catch (err) {
alert('Error deleting component');
}
}
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}Transactions — Vela Platform{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-list-ul"></i> Transactions</h2>
</div>
<div class="row g-3">
<!-- Add Transaction Form -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">Add Transaction</div>
<div class="card-body">
<form id="txnForm">
<div class="mb-3">
<label for="serviceId" class="form-label">Service</label>
<select class="form-select" id="serviceId" name="service_id" required>
<option value="">Select service...</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }} ({{ "%.0f"|format(s.sell_price) }} MAD)</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="quantity" class="form-label">Quantity</label>
<input type="number" class="form-control" id="quantity" name="quantity"
value="1" min="1" required>
</div>
<div class="mb-3">
<label for="month" class="form-label">Month</label>
<input type="month" class="form-control" id="month" name="month"
value="{{ current_month }}" required>
</div>
<div class="mb-3">
<label for="notes" class="form-label">Notes</label>
<input type="text" class="form-control" id="notes" name="notes"
placeholder="Optional notes">
</div>
<div id="txnError" class="alert alert-danger d-none py-2"></div>
<div id="txnSuccess" class="alert alert-success d-none py-2"></div>
<button type="submit" class="btn btn-primary w-100">Add Transaction</button>
</form>
</div>
</div>
</div>
<!-- Transactions List -->
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Transaction History</span>
<div class="d-flex gap-2">
<input type="month" id="filterMonth" class="form-control form-control-sm"
style="width: auto;" value="{{ current_month }}">
<button class="btn btn-sm btn-outline-secondary" onclick="loadTransactions()">
<i class="bi bi-funnel"></i> Filter
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Service</th>
<th>Qty</th>
<th>Revenue</th>
<th>Cost</th>
<th>Month</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody id="txnTableBody">
<tr><td colspan="7" class="text-center text-muted py-3">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Monthly Summary -->
<div class="card-footer" id="monthlySummary" style="display:none;">
<div class="d-flex gap-4">
<span><strong>Revenue:</strong> <span id="sumRevenue">0</span> MAD</span>
<span><strong>Cost:</strong> <span id="sumCost">0</span> MAD</span>
<span><strong>Profit:</strong> <span id="sumProfit">0</span> MAD</span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const txnForm = document.getElementById('txnForm');
const txnError = document.getElementById('txnError');
const txnSuccess = document.getElementById('txnSuccess');
txnForm.addEventListener('submit', async (e) => {
e.preventDefault();
txnError.classList.add('d-none');
txnSuccess.classList.add('d-none');
const formData = new FormData(txnForm);
try {
const resp = await apiFetch('/api/transactions', {
method: 'POST',
body: formData,
});
if (!resp.ok) {
const data = await resp.json();
txnError.textContent = data.detail || 'Failed to add transaction';
txnError.classList.remove('d-none');
return;
}
txnSuccess.textContent = 'Transaction added!';
txnSuccess.classList.remove('d-none');
txnForm.reset();
document.getElementById('month').value = '{{ current_month }}';
document.getElementById('quantity').value = '1';
loadTransactions();
setTimeout(() => txnSuccess.classList.add('d-none'), 3000);
} catch (err) {
txnError.textContent = 'Error adding transaction';
txnError.classList.remove('d-none');
}
});
async function loadTransactions() {
const month = document.getElementById('filterMonth').value;
const tbody = document.getElementById('txnTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Loading...</td></tr>';
try {
const url = month ? `/api/transactions?month=${month}` : '/api/transactions';
const resp = await apiFetch(url);
if (!resp.ok) throw new Error('Failed');
const data = await resp.json();
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">No transactions found</td></tr>';
document.getElementById('monthlySummary').style.display = 'none';
return;
}
let sumRevenue = 0, sumCost = 0;
tbody.innerHTML = data.map(t => {
sumRevenue += t.revenue;
sumCost += t.cost;
return `
<tr>
<td>${escapeHtml(t.service_name)}</td>
<td>${t.quantity}</td>
<td>${t.revenue.toFixed(0)} MAD</td>
<td>${t.cost.toFixed(0)} MAD</td>
<td>${t.month}</td>
<td><small>${escapeHtml(t.notes || '')}</small></td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTxn(${t.id})"
${getToken() ? '' : 'disabled'}>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`;
}).join('');
document.getElementById('sumRevenue').textContent = sumRevenue.toFixed(0);
document.getElementById('sumCost').textContent = sumCost.toFixed(0);
document.getElementById('sumProfit').textContent = (sumRevenue - sumCost).toFixed(0);
document.getElementById('monthlySummary').style.display = '';
} catch (err) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-3">Error loading transactions</td></tr>';
}
}
async function deleteTxn(id) {
if (!confirm('Delete this transaction?')) return;
try {
const resp = await apiFetch(`/api/transactions/${id}`, { method: 'DELETE' });
if (resp.ok) loadTransactions();
} catch (err) {
alert('Error deleting transaction');
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Initial load
loadTransactions();
</script>
{% endblock %}

494
mcp/server.py Normal file
View File

@@ -0,0 +1,494 @@
#!/usr/bin/env python3
"""
Vela Platform — MCP Server (stdio transport).
Provides tools and resources for interacting with the Vela Platform data.
Includes component-level pricing breakdown for each offer.
Usage:
python mcp/server.py
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from database import SessionLocal
from models import Service, ServiceComponent, Transaction
OPEX = 800.0
def compute_pnl(month: str) -> dict:
"""Compute P&L for a given month."""
db = SessionLocal()
try:
transactions = db.query(Transaction).filter(Transaction.month == month).all()
total_revenue = sum(t.revenue for t in transactions)
total_cost = sum(t.cost for t in transactions)
gross_profit = total_revenue - total_cost
gross_margin = (gross_profit / total_revenue * 100) if total_revenue > 0 else 0.0
net_profit = gross_profit - OPEX
net_margin = (net_profit / total_revenue * 100) if total_revenue > 0 else 0.0
return {
"month": month,
"total_revenue": round(total_revenue, 2),
"total_cost": round(total_cost, 2),
"gross_profit": round(gross_profit, 2),
"gross_margin_pct": round(gross_margin, 2),
"opex": OPEX,
"net_profit": round(net_profit, 2),
"net_margin_pct": round(net_margin, 2),
}
finally:
db.close()
def get_services() -> list:
"""Get all services with their component breakdowns."""
db = SessionLocal()
try:
services = db.query(Service).order_by(Service.name).all()
return [
{
"id": s.id,
"name": s.name,
"description": s.description,
"sell_price": s.sell_price,
"cost_price": s.cost_price,
"active": s.active,
"margin": round(s.sell_price - s.cost_price, 2),
"components": [
{
"id": c.id,
"name": c.name,
"unit_cost": c.unit_cost,
"unit_sell": c.unit_sell,
"quantity": c.quantity,
"row_cost": round(c.unit_cost * c.quantity, 2),
"row_sell": round(c.unit_sell * c.quantity, 2),
"notes": c.notes,
}
for c in s.components
],
}
for s in services
]
finally:
db.close()
def get_service(service_id: int) -> dict:
"""Get a single service with components."""
db = SessionLocal()
try:
s = db.query(Service).filter(Service.id == service_id).first()
if not s:
return {"error": "Service not found"}
return {
"id": s.id,
"name": s.name,
"description": s.description,
"sell_price": s.sell_price,
"cost_price": s.cost_price,
"active": s.active,
"components": [
{
"id": c.id,
"name": c.name,
"unit_cost": c.unit_cost,
"unit_sell": c.unit_sell,
"quantity": c.quantity,
"row_cost": round(c.unit_cost * c.quantity, 2),
"row_sell": round(c.unit_sell * c.quantity, 2),
}
for c in s.components
],
}
finally:
db.close()
def add_component(service_id: int, name: str, unit_cost: float, unit_sell: float,
quantity: int = 1, notes: str = "") -> dict:
"""Add a component to a service."""
db = SessionLocal()
try:
svc = db.query(Service).filter(Service.id == service_id).first()
if not svc:
return {"error": "Service not found"}
comp = ServiceComponent(
service_id=service_id, name=name,
unit_cost=unit_cost, unit_sell=unit_sell,
quantity=quantity, notes=notes,
)
db.add(comp)
db.flush()
svc.recompute_prices()
db.commit()
db.refresh(comp)
return {
"id": comp.id,
"name": comp.name,
"unit_cost": comp.unit_cost,
"unit_sell": comp.unit_sell,
"quantity": comp.quantity,
}
finally:
db.close()
def update_component(component_id: int, name: str = None, unit_cost: float = None,
unit_sell: float = None, quantity: int = None,
notes: str = None) -> dict:
"""Update a component."""
db = SessionLocal()
try:
comp = db.query(ServiceComponent).filter(ServiceComponent.id == component_id).first()
if not comp:
return {"error": "Component not found"}
if name is not None:
comp.name = name
if unit_cost is not None:
comp.unit_cost = unit_cost
if unit_sell is not None:
comp.unit_sell = unit_sell
if quantity is not None:
comp.quantity = quantity
if notes is not None:
comp.notes = notes
db.flush()
svc = db.query(Service).filter(Service.id == comp.service_id).first()
if svc:
svc.recompute_prices()
db.commit()
return {"ok": True, "id": comp.id}
finally:
db.close()
def delete_component(component_id: int) -> dict:
"""Delete a component."""
db = SessionLocal()
try:
comp = db.query(ServiceComponent).filter(ServiceComponent.id == component_id).first()
if not comp:
return {"error": "Component not found"}
svc = db.query(Service).filter(Service.id == comp.service_id).first()
db.delete(comp)
db.flush()
if svc:
svc.recompute_prices()
db.commit()
return {"ok": True}
finally:
db.close()
def add_transaction(service_id: int, quantity: int, month: str, notes: str = "") -> dict:
"""Add a new transaction."""
db = SessionLocal()
try:
service = db.query(Service).filter(Service.id == service_id).first()
if not service or not service.active:
return {"error": "Invalid or inactive service"}
txn = Transaction(
service_id=service_id, quantity=quantity,
month=month, notes=notes, created_by=1,
)
db.add(txn)
db.commit()
db.refresh(txn)
return {
"id": txn.id,
"service_name": txn.service.name,
"quantity": txn.quantity,
"revenue": txn.revenue,
"cost": txn.cost,
"month": txn.month,
"notes": txn.notes,
}
finally:
db.close()
def get_dashboard() -> dict:
"""Get current month dashboard overview."""
from datetime import datetime, timezone
current_month = datetime.now(timezone.utc).strftime("%Y-%m")
pnl = compute_pnl(current_month)
db = SessionLocal()
try:
month_rows = (
db.query(Transaction.month)
.distinct()
.order_by(Transaction.month.desc())
.limit(12)
.all()
)
month_list = [row[0] for row in reversed(month_rows)]
trend = [compute_pnl(m) for m in month_list]
return {"current_month": pnl, "trend": trend}
finally:
db.close()
# ---------------------------------------------------------------------------
# MCP Protocol Handler
# ---------------------------------------------------------------------------
def handle_request(request: dict) -> dict:
method = request.get("method", "")
req_id = request.get("id")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}, "resources": {}},
"serverInfo": {"name": "vela-platform", "version": "1.0.0"},
},
}
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"tools": [
{
"name": "get_pnl",
"description": "Get P&L data for a given month (YYYY-MM format).",
"inputSchema": {
"type": "object",
"properties": {
"month": {"type": "string", "description": "Month in YYYY-MM format"}
},
"required": ["month"],
},
},
{
"name": "get_services",
"description": "List all offers (services) with full component pricing breakdown.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_service",
"description": "Get a single offer (service) with its component breakdown.",
"inputSchema": {
"type": "object",
"properties": {
"service_id": {"type": "integer", "description": "Service ID"}
},
"required": ["service_id"],
},
},
{
"name": "add_component",
"description": "Add a pricing component (RAM, HDD, Transport, etc.) to an offer.",
"inputSchema": {
"type": "object",
"properties": {
"service_id": {"type": "integer"},
"name": {"type": "string", "description": "Component name"},
"unit_cost": {"type": "number", "description": "Unit cost price"},
"unit_sell": {"type": "number", "description": "Unit sell price"},
"quantity": {"type": "integer", "description": "Quantity, default 1"},
"notes": {"type": "string", "description": "Optional notes"},
},
"required": ["service_id", "name", "unit_cost", "unit_sell"],
},
},
{
"name": "update_component",
"description": "Update a pricing component. Only provided fields are changed.",
"inputSchema": {
"type": "object",
"properties": {
"component_id": {"type": "integer"},
"name": {"type": "string"},
"unit_cost": {"type": "number"},
"unit_sell": {"type": "number"},
"quantity": {"type": "integer"},
"notes": {"type": "string"},
},
"required": ["component_id"],
},
},
{
"name": "delete_component",
"description": "Delete a pricing component from an offer.",
"inputSchema": {
"type": "object",
"properties": {
"component_id": {"type": "integer"}
},
"required": ["component_id"],
},
},
{
"name": "add_transaction",
"description": "Add a new transaction (sale).",
"inputSchema": {
"type": "object",
"properties": {
"service_id": {"type": "integer"},
"quantity": {"type": "integer"},
"month": {"type": "string", "description": "YYYY-MM format"},
"notes": {"type": "string"},
},
"required": ["service_id", "quantity", "month"],
},
},
{
"name": "get_dashboard",
"description": "Get current month dashboard overview with 12-month trend.",
"inputSchema": {"type": "object", "properties": {}},
},
]
},
}
if method == "resources/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"resources": [
{
"uri": "vela://pnl/current",
"name": "Current Month P&L",
"description": "Profit & Loss for the current month",
"mimeType": "application/json",
},
{
"uri": "vela://services",
"name": "Service List with Components",
"description": "All offers with component pricing breakdown",
"mimeType": "application/json",
},
{
"uri": "vela://services/{id}",
"name": "Single Service Detail",
"description": "A specific offer with its components",
"mimeType": "application/json",
},
]
},
}
if method == "resources/read":
uri = request.get("params", {}).get("uri", "")
if uri == "vela://pnl/current":
from datetime import datetime, timezone
month = datetime.now(timezone.utc).strftime("%Y-%m")
content = json.dumps(compute_pnl(month), indent=2)
elif uri == "vela://services":
content = json.dumps(get_services(), indent=2)
elif uri.startswith("vela://services/"):
try:
svc_id = int(uri.split("/")[-1])
content = json.dumps(get_service(svc_id), indent=2)
except (ValueError, IndexError):
return {
"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32602, "message": f"Invalid resource URI: {uri}"},
}
else:
return {
"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32602, "message": f"Unknown resource: {uri}"},
}
return {
"jsonrpc": "2.0", "id": req_id,
"result": {
"contents": [{"uri": uri, "mimeType": "application/json", "text": content}]
},
}
if method == "tools/call":
tool_name = request.get("params", {}).get("name", "")
args = request.get("params", {}).get("arguments", {})
try:
if tool_name == "get_pnl":
result = compute_pnl(args["month"])
elif tool_name == "get_services":
result = get_services()
elif tool_name == "get_service":
result = get_service(args["service_id"])
elif tool_name == "add_component":
result = add_component(
args["service_id"], args["name"],
args["unit_cost"], args["unit_sell"],
args.get("quantity", 1), args.get("notes", ""),
)
elif tool_name == "update_component":
result = update_component(
args["component_id"],
args.get("name"), args.get("unit_cost"),
args.get("unit_sell"), args.get("quantity"),
args.get("notes"),
)
elif tool_name == "delete_component":
result = delete_component(args["component_id"])
elif tool_name == "add_transaction":
result = add_transaction(
args["service_id"], args["quantity"],
args["month"], args.get("notes", ""),
)
elif tool_name == "get_dashboard":
result = get_dashboard()
else:
return {
"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32601, "message": f"Unknown tool: {tool_name}"},
}
return {
"jsonrpc": "2.0", "id": req_id,
"result": {
"content": [{"type": "text", "text": json.dumps(result, indent=2)}]
},
}
except Exception as e:
return {
"jsonrpc": "2.0", "id": req_id,
"result": {
"content": [{"type": "text", "text": json.dumps({"error": str(e)})}],
"isError": True,
},
}
if "id" not in request:
return None
return {
"jsonrpc": "2.0", "id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
def main():
from main import seed_database
seed_database()
sys.stderr.write("Vela Platform MCP Server starting...\n")
sys.stderr.flush()
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
response = handle_request(request)
if response is not None:
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
except json.JSONDecodeError as e:
sys.stderr.write(f"JSON parse error: {e}\n")
sys.stderr.flush()
if __name__ == "__main__":
main()