From d1160673a71d6bac98b554575cdad2589fae9b68 Mon Sep 17 00:00:00 2001 From: oimwiodev Date: Mon, 15 Jun 2026 23:05:59 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20v1.0.0=20=E2=80=94=20Vela=20Plat?= =?UTF-8?q?form=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted P&L tracking app with component-level pricing. Offers: Atlas/Atlas+/Rif/Rif+ with granular cost breakdown. API + MCP + multi-user auth. --- .gitignore | 13 + CHANGELOG.md | 32 ++ CLAUDE.md | 121 ++++++ backend/auth.py | 111 +++++ backend/database.py | 22 + backend/main.py | 640 ++++++++++++++++++++++++++++ backend/models.py | 96 +++++ backend/requirements.txt | 7 + backend/templates/base.html | 91 ++++ backend/templates/dashboard.html | 159 +++++++ backend/templates/login.html | 60 +++ backend/templates/services.html | 345 +++++++++++++++ backend/templates/transactions.html | 196 +++++++++ mcp/server.py | 494 +++++++++++++++++++++ 14 files changed, 2387 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 backend/auth.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 backend/templates/base.html create mode 100644 backend/templates/dashboard.html create mode 100644 backend/templates/login.html create mode 100644 backend/templates/services.html create mode 100644 backend/templates/transactions.html create mode 100644 mcp/server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..789e3f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*.db +.env +.venv/ +venv/ +*.egg-info/ +dist/ +build/ +.DS_Store +*.swp +*.swo +*~ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6e02bb9 --- /dev/null +++ b/CHANGELOG.md @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0dc1aba --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..cdec3e8 --- /dev/null +++ b/backend/auth.py @@ -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 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..710abd9 --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..c5d3192 --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..8786339 --- /dev/null +++ b/backend/models.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..04faf14 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/templates/base.html b/backend/templates/base.html new file mode 100644 index 0000000..121924a --- /dev/null +++ b/backend/templates/base.html @@ -0,0 +1,91 @@ + + + + + + {% block title %}Vela Platform{% endblock %} + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + {% block scripts %}{% endblock %} + + diff --git a/backend/templates/dashboard.html b/backend/templates/dashboard.html new file mode 100644 index 0000000..bf4461f --- /dev/null +++ b/backend/templates/dashboard.html @@ -0,0 +1,159 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Vela Platform{% endblock %} +{% block content %} + +
+

Dashboard

+ {{ pnl.month }} +
+ + +
+
+
+
+ Revenue +

{{ "%.0f"|format(pnl.total_revenue) }} MAD

+
+
+
+
+
+
+ Cost +

{{ "%.0f"|format(pnl.total_cost) }} MAD

+
+
+
+
+
+
+ Gross Profit +

{{ "%.0f"|format(pnl.gross_profit) }} MAD

+
+
+
+
+
+
+ Net Profit +

{{ "%.0f"|format(pnl.net_profit) }} MAD

+
+
+
+
+ +
+
+
+
Margin Breakdown
+
+ + + + +
Gross Margin{{ "%.1f"|format(pnl.gross_margin_pct) }}%
OpEx (fixed){{ "%.0f"|format(pnl.opex) }} MAD
Net Margin{{ "%.1f"|format(pnl.net_margin_pct) }}%
+
+
+
+
+
+
Active Offers — Cost Breakdown
+
+
+ + + + + + + + + + + + {% for s in services %} + + + + + + + + {% endfor %} + +
OfferSellCostMarginComponents
{{ s.name }}{{ "%.0f"|format(s.sell_price) }} MAD{{ "%.0f"|format(s.cost_price) }} MAD + {{ "%.0f"|format(s.sell_price - s.cost_price) }} MAD + + {{ s.components|length }} items +
+
+
+
+
+
+ + +
+
12-Month Trend
+
+ +
+
+ +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/backend/templates/login.html b/backend/templates/login.html new file mode 100644 index 0000000..5bdfa3e --- /dev/null +++ b/backend/templates/login.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Login — Vela Platform{% endblock %} +{% block content %} +
+
+
+
+

🔐 Sign In

+
+
+ + +
+
+ + +
+
+ +
+

+ Demo: admin / admin123  |  viewer / viewer123 +

+
+
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/backend/templates/services.html b/backend/templates/services.html new file mode 100644 index 0000000..81a46a3 --- /dev/null +++ b/backend/templates/services.html @@ -0,0 +1,345 @@ +{% extends "base.html" %} +{% block title %}Offers — Vela Platform{% endblock %} +{% block content %} + +
+

Offers & Pricing

+ {% if current_user.role == 'admin' %} + + {% endif %} +
+ +{% for s in services %} +
+ +
+
+ {{ s.name }} + {{ s.description }} +
+
+ Sell: {{ "%.0f"|format(s.sell_price) }} MAD + Cost: {{ "%.0f"|format(s.cost_price) }} MAD + + Margin: {{ "%.0f"|format(s.sell_price - s.cost_price) }} MAD + + + {{ 'Active' if s.active else 'Inactive' }} + + +
+
+ + +
+
+
+ + + + + + + + + + + {% if current_user.role == 'admin' %} + + {% endif %} + + + + {% for c in s.components %} + + + + + + + + + {% if current_user.role == 'admin' %} + + {% endif %} + + {% else %} + + {% endfor %} + + + + + + + + + + + {% if current_user.role == 'admin' %} + + {% endif %} + + +
ComponentUnit CostUnit SellQtyRow CostRow SellMargin
{{ c.name }} + {% if c.notes %}
{{ c.notes }}{% endif %} +
{{ "%.0f"|format(c.unit_cost) }}{{ "%.0f"|format(c.unit_sell) }}{{ c.quantity }}{{ "%.0f"|format(c.unit_cost * c.quantity) }}{{ "%.0f"|format(c.unit_sell * c.quantity) }} + {{ "%.0f"|format(c.unit_sell - c.unit_cost) }} + +
+ + +
+
No components yet.
TOTAL{{ "%.0f"|format(s.cost_price) }}{{ "%.0f"|format(s.sell_price) }} + {{ "%.0f"|format(s.sell_price - s.cost_price) }} + + +
+
+
+
+
+{% else %} +
+
+ +

No offers yet. Create your first one!

+
+
+{% endfor %} + +{% if current_user.role == 'admin' %} + + + + + +{% endif %} + +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/backend/templates/transactions.html b/backend/templates/transactions.html new file mode 100644 index 0000000..6f4b1e2 --- /dev/null +++ b/backend/templates/transactions.html @@ -0,0 +1,196 @@ +{% extends "base.html" %} +{% block title %}Transactions — Vela Platform{% endblock %} +{% block content %} + +
+

Transactions

+
+ +
+ +
+
+
Add Transaction
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ + +
+
+
+ Transaction History +
+ + +
+
+
+
+ + + + + + + + + + + + + + + +
ServiceQtyRevenueCostMonthNotes
Loading...
+
+
+ + +
+
+
+ +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/mcp/server.py b/mcp/server.py new file mode 100644 index 0000000..c73bf42 --- /dev/null +++ b/mcp/server.py @@ -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()