From 918c48ffca8dacdb8fac5ad907ea693648263db8 Mon Sep 17 00:00:00 2001 From: oimwiodev Date: Fri, 26 Jun 2026 19:50:59 +0100 Subject: [PATCH] Vela Platform v2: single monolith, real P&L, bilingual marketing site --- .gitignore | 6 + README.md | 190 +++++++++++ auth.py | 67 ++++ database.py | 34 ++ main.py | 598 +++++++++++++++++++++++++++++++++++ models.py | 116 +++++++ templates/admin.html | 497 +++++++++++++++++++++++++++++ templates/backup.html | 148 +++++++++ templates/immich-logo.svg | 29 ++ templates/index.html | 353 +++++++++++++++++++++ templates/login.html | 52 +++ templates/nextcloud-logo.svg | 3 + 12 files changed, 2093 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 auth.py create mode 100644 database.py create mode 100644 main.py create mode 100644 models.py create mode 100644 templates/admin.html create mode 100644 templates/backup.html create mode 100644 templates/immich-logo.svg create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/nextcloud-logo.svg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c7eef0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +vela.db +vela.db-shm +vela.db-wal +.git/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6a14b4 --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# Vela Platform v2 + +Single FastAPI application that powers the Vela business — a home server installation service in Casablanca, Morocco. + +**Built:** June 26, 2026 +**Deployed:** Proxmox CT 124 @ 192.168.1.244 + +--- + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ nginx :80 │ +│ ├── / → Marketing site │ +│ ├── /admin → Admin P&L dashboard │ +│ ├── /backup → Backup explainer │ +│ ├── /static/* → Logos, assets │ +│ └── /api/* → JSON API │ +│ │ │ +│ uvicorn :8000 (systemd: vela.service) │ +│ ├── main.py — FastAPI app │ +│ ├── models.py — SQLAlchemy models │ +│ ├── database.py — SQLite WAL mode │ +│ └── auth.py — JWT auth │ +│ │ +│ vela.db (SQLite) │ +└─────────────────────────────────────────┘ +``` + +One box. One app. One database. No microservices. + +--- + +## What It Does + +### Public Marketing Site (`/`) +- Bilingual FR/EN with URL-based language toggle (`?lang=fr` / `?lang=en`) +- Dark/light mode toggle (persisted in localStorage) +- Hero section with value proposition +- Problem/solution section +- Live pricing cards pulled from API +- Immich & Nextcloud sections with real SVG logos +- "How it works" — 3-step visual +- WhatsApp CTA buttons (all pointing to +212 725-569519) +- Footer with barely-visible "Admin" link (30% opacity) + +### Backup Explainer (`/backup`) +- Explains backups to non-technical users +- Simple vs redundant backup comparison +- "The golden rule: if it only exists in one place, it doesn't really exist" +- Bilingual + +### Admin Panel (`/admin`) +- JWT login (username: `oimwiodev`, password: `kxdr781020`) +- Token stored in localStorage, verified via `/api/verify` +- 4 tabs: Dashboard, Tiers, Transactions, Expenses + +#### Dashboard +- Real-time P&L cards: Revenue, COGS, Gross Profit, Margin %, Expenses, Net Profit +- Monthly breakdown table with month filter +- Formula: **Revenue − COGS − Expenses = Net Profit** (not the fake v1 math) + +#### Tiers +- 4 tiers: Rif, Rif+, Atlas, Atlas+ +- Component breakdown with unit costs +- Inline edit: tier name, sell price, component costs +- Toggle active/inactive +- Cost price auto-calculated from components +- Margin % shown per tier + +#### Transactions +- Add manual sale (select tier → snapshots sell price + cost at time of sale) +- Filter by month +- Delete with confirm + undo timer: + - 1st click: "Sure?" (4s timeout) + - 2nd click: deletes, row fades, 8s undo window + +#### Expenses +- Add/delete expenses with month and category +- Monthly expense templates (auto-generate from templates) +- Soft delete (deleted_at timestamp) + +--- + +## API Endpoints + +### Public (no auth) +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/public/pricing` | Active tiers: name, description, sell_price | +| GET | `/api/public/services/{id}` | Single tier details + components | + +### Auth +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/auth/login` | Login (username + password) → JWT | +| POST | `/api/auth/logout` | Clear cookie | +| GET | `/api/verify` | Verify JWT, returns email | + +### Admin (JWT required) +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/pnl` | P&L summary + monthly breakdown | +| GET | `/api/services` | All tiers with components | +| PUT | `/api/services/{id}` | Update tier (name, price, active, etc.) | +| PUT | `/api/services/{id}/toggle` | Toggle active/inactive | +| PUT | `/api/services/{id}/components/{cid}` | Update component cost/qty | +| GET | `/api/transactions` | List transactions (optional `?month=`) | +| POST | `/api/transactions` | Create sale (snapshots prices) | +| DELETE | `/api/transactions/{id}` | Delete transaction | +| GET | `/api/expenses` | List expenses | +| POST | `/api/expenses` | Add expense | +| DELETE | `/api/expenses/{id}` | Soft-delete expense | +| GET | `/api/expense-templates` | List recurring templates | +| POST | `/api/expenses/generate-month` | Generate from templates | + +--- + +## Database Schema + +6 tables in SQLite (`vela.db`): + +| Table | Purpose | +|-------|---------| +| `users` | Single admin (email, bcrypt password) | +| `services` | 4 tiers (name, sell_price, cost_price, active, sort_order) | +| `service_components` | Per-tier parts (name, unit_cost, quantity) | +| `transactions` | Frozen sales with price/cost snapshots, unique (source_type, source_id) | +| `expenses` | Monthly costs with soft delete | +| `expense_templates` | Recurring expense patterns | + +Key design: **transactions freeze sell_price and cost_price at time of sale.** If you change an SSD cost later, old sales don't change. Real P&L math. + +SQLite pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000` + +--- + +## Deployment + +**Server:** Proxmox CT 124 (Debian 12) +**IP:** 192.168.1.244 +**Service:** `systemctl restart vela` +**Files:** `/opt/vela/` +**DB:** `/opt/vela/vela.db` + +### Update workflow +```bash +# 1. Edit files locally +# 2. Package and deploy +cd ~/src/vela-platform +tar czf /tmp/vela-update.tar.gz main.py templates/ +sshpass -p 'PW' scp /tmp/vela-update.tar.gz root@192.168.1.109:/tmp/ +sshpass -p 'PW' ssh root@192.168.1.109 " + pct push 124 /tmp/vela-update.tar.gz /tmp/vela-update.tar.gz && + pct exec 124 -- bash -c 'cd /opt/vela && tar xzf /tmp/vela-update.tar.gz && systemctl restart vela' +" +``` + +### Public access +Point Nginx Proxy Manager at `192.168.1.244:80` for your domain. + +--- + +## Design Decisions + +- **No Jinja2** — hit a cache bug (`unhashable type: 'dict'`). Switched to `FileResponse` serving raw HTML. All dynamic content via `fetch()` to JSON APIs. +- **No framework** — vanilla HTML/CSS/JS. No React, no Vue, no build step. +- **CSS variables** — dark/light mode is a single `data-theme` attribute. Zero hardcoded hex colors in JS. +- **Single admin** — no user management. One account. Simple. +- **Public API is read-only** — pricing only. No costs, no margins, no internals leak. + +--- + +## WhatsApp Sales Bot + +A separate Hermes agent with a custom SOUL.md handles WhatsApp conversations. The bot: +- Answers questions in French/Darija/English +- Fetches live pricing via `curl http://192.168.1.244/api/public/pricing` +- Follows a 4-step sales process: understand → recommend → handle objections → close +- Never hardcodes prices — always checks the API +- Personality: calm, direct, short messages, no corporate tone + +SOUL.md at `/tmp/vela-sales-bot-soul.md` + +--- + +## What Was Removed (v1) + +The old Vela had **two separate LXCs** (CT125 + CT126) with **three Python services** (two FastAPI apps + nginx, two auth systems, two databases). All nuked. Replaced with this single monolith. diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..e0ccfdb --- /dev/null +++ b/auth.py @@ -0,0 +1,67 @@ +"""Auth — JWT tokens, password hashing, admin guard.""" +import os +from datetime import datetime, timedelta, timezone + +import bcrypt +import jwt +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from database import get_db +from models import User + +JWT_SECRET = os.environ.get("VELA_JWT_SECRET", "change-me-in-production") +JWT_ALGORITHM = "HS256" +JWT_EXPIRE_HOURS = 24 + +bearer_scheme = HTTPBearer(auto_error=False) + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(password: str, hashed: str) -> bool: + return bcrypt.checkpw(password.encode(), hashed.encode()) + + +def create_access_token(user_id: int) -> str: + payload = { + "sub": str(user_id), + "exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS), + "iat": datetime.now(timezone.utc), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def get_current_user( + request: Request, + creds: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> User: + """Try JWT header first, then fall back to cookie.""" + token = None + if creds: + token = creds.credentials + elif "vela_token" in request.cookies: + token = request.cookies["vela_token"] + + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + user_id = int(payload["sub"]) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError, ValueError): + raise HTTPException(status_code=401, detail="Invalid or expired token") + + user = db.query(User).filter(User.id == user_id, User.active == True).first() + if not user: + raise HTTPException(status_code=401, detail="User not found") + return user + + +def require_admin(user: User = Depends(get_current_user)) -> User: + """Currently all users are admins — placeholder for future roles.""" + return user diff --git a/database.py b/database.py new file mode 100644 index 0000000..6531d89 --- /dev/null +++ b/database.py @@ -0,0 +1,34 @@ +"""Database setup — SQLite with WAL mode, foreign keys, busy timeout.""" +import os + +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +DB_DIR = os.environ.get("VELA_DB_DIR", os.path.dirname(os.path.abspath(__file__))) +DB_PATH = os.path.join(DB_DIR, "vela.db") +DATABASE_URL = f"sqlite:///{DB_PATH}" + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False}, + echo=False, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@event.listens_for(engine, "connect") +def _set_sqlite_pragmas(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.close() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..daed965 --- /dev/null +++ b/main.py @@ -0,0 +1,598 @@ +"""Vela Platform — P&L manager. Single FastAPI app with admin panel.""" +import logging +from datetime import datetime, timezone +from typing import Optional + +from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from sqlalchemy.orm import Session +from pydantic import BaseModel +import os +from pathlib import Path + +from auth import ( + JWT_EXPIRE_HOURS, + create_access_token, + get_current_user, + hash_password, + require_admin, + verify_password, +) +from database import SessionLocal, engine, get_db +from models import Base, Expense, ExpenseTemplate, Service, ServiceComponent, Transaction, User + +TEMPLATE_DIR = Path(__file__).parent / "templates" + + +# ── Pydantic request schemas ─────────────────────────────────────────────── +class ServiceUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + sell_price: Optional[float] = None + cost_price: Optional[float] = None + active: Optional[bool] = None + sort_order: Optional[int] = None + + +class ComponentUpdate(BaseModel): + name: Optional[str] = None + unit_cost: Optional[float] = None + quantity: Optional[int] = None + + +class TransactionCreate(BaseModel): + service_id: Optional[int] = None + service_name: Optional[str] = None + quantity: int = 1 + unit_sell: Optional[float] = None + unit_cost: Optional[float] = None + month: Optional[str] = None + source_type: str = "manual" + source_id: Optional[int] = None + payment_status: str = "unpaid" + notes: str = "" + + +class ExpenseCreate(BaseModel): + name: str + amount: float = 0 + category: str = "general" + month: Optional[str] = None + notes: str = "" + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("vela") + +app = FastAPI(title="Vela Platform", version="2.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Static files for logos +app.mount("/static", StaticFiles(directory=str(TEMPLATE_DIR)), name="static") + + +# ── seed defaults ────────────────────────────────────────────────────────── +DEFAULT_SERVICES = [ + { + "name": "Rif", + "description": "Entry-level home server. Photos, files, peace of mind.", + "sell_price": 3499.0, + "sort_order": 1, + "components": [ + ("Mini PC", 800.0, 1, "Refurbished office mini PC"), + ("SSD 512GB", 500.0, 1, "NVMe boot drive"), + ("RAM 8GB", 300.0, 1, "DDR4"), + ("Transport", 140.0, 1, ""), + ("Expertise", 359.0, 1, "Setup & config"), + ], + }, + { + "name": "Rif+", + "description": "Rif with backup drive. Extra safety for your files.", + "sell_price": 3990.0, + "sort_order": 2, + "components": [ + ("Mini PC", 800.0, 1, "Refurbished office mini PC"), + ("SSD 512GB", 500.0, 1, "NVMe boot drive"), + ("Backup HDD 1TB", 300.0, 1, "Internal backup drive"), + ("RAM 8GB", 300.0, 1, "DDR4"), + ("Transport", 140.0, 1, ""), + ("Expertise", 359.0, 1, "Setup & config"), + ], + }, + { + "name": "Atlas", + "description": "Pro home server. 1TB storage, 16GB RAM, fast and capable.", + "sell_price": 4390.0, + "sort_order": 3, + "components": [ + ("Mini PC", 800.0, 1, "Refurbished office mini PC"), + ("SSD 1TB", 800.0, 1, "NVMe storage drive"), + ("RAM 16GB", 600.0, 1, "DDR4"), + ("Transport", 140.0, 1, ""), + ("Expertise", 359.0, 1, "Setup & config"), + ], + }, + { + "name": "Atlas+", + "description": "Maxed out. 1TB SSD + 1TB backup HDD, 16GB RAM, total peace of mind.", + "sell_price": 4790.0, + "sort_order": 4, + "components": [ + ("Mini PC", 800.0, 1, "Refurbished office mini PC"), + ("SSD 1TB", 800.0, 1, "NVMe storage drive"), + ("Backup HDD 1TB", 300.0, 1, "Internal backup drive"), + ("RAM 16GB", 600.0, 1, "DDR4"), + ("Transport", 140.0, 1, ""), + ("Expertise", 359.0, 1, "Setup & config"), + ], + }, +] + +DEFAULT_EXPENSE_TEMPLATES = [ + ("Internet", 500.0, "utilities"), + ("Electricity", 400.0, "utilities"), + ("Transport", 200.0, "operations"), +] + + +# ── startup ──────────────────────────────────────────────────────────────── +@app.on_event("startup") +def startup(): + Base.metadata.create_all(bind=engine) + db = SessionLocal() + try: + # seed admin + if not db.query(User).first(): + db.add(User(email="oimwiodev", password_hash=hash_password("kxdr781020"))) + db.commit() + logger.info("Seeded admin user") + + # seed services + if not db.query(Service).first(): + for svc_data in DEFAULT_SERVICES: + comps = svc_data.pop("components") + svc = Service(**svc_data) + db.add(svc) + db.flush() + for name, cost, qty, notes in comps: + db.add(ServiceComponent( + service_id=svc.id, + name=name, + unit_cost=cost, + unit_sell=round(cost * 1.3), + quantity=qty, + notes=notes, + )) + # set cost_price and sell_price from components + comp_list = [c for c in comps] + svc.cost_price = sum(c[1] * c[2] for c in comp_list) + svc.sell_price = svc_data["sell_price"] + db.commit() + logger.info("Seeded service tiers") + + # seed expense templates + if not db.query(ExpenseTemplate).first(): + for name, amount, category in DEFAULT_EXPENSE_TEMPLATES: + db.add(ExpenseTemplate(name=name, amount=amount, category=category)) + db.commit() + logger.info("Seeded expense templates") + finally: + db.close() + + +# ── public API ───────────────────────────────────────────────────────────── +@app.get("/api/public/pricing") +def public_pricing(db: Session = Depends(get_db)): + services = db.query(Service).filter(Service.active == True).order_by(Service.sort_order).all() + return [ + { + "id": s.id, + "name": s.name, + "description": s.description, + "sell_price": s.sell_price, + "active": s.active, + } + for s in services + ] + + +@app.get("/api/public/services/{service_id}") +def public_service_detail(service_id: int, db: Session = Depends(get_db)): + svc = db.query(Service).filter(Service.id == service_id, Service.active == True).first() + if not svc: + raise HTTPException(status_code=404, detail="Service not found") + return { + "id": svc.id, + "name": svc.name, + "description": svc.description, + "sell_price": svc.sell_price, + "components": [{"name": c.name, "notes": c.notes} for c in svc.components], + } + + +# ── auth routes ──────────────────────────────────────────────────────────── +@app.get("/login", response_class=HTMLResponse) +def login_page(): + return FileResponse(TEMPLATE_DIR / "login.html") + + +@app.post("/api/auth/login") +def api_login( + request: Request, + username: str = Form(...), + password: str = Form(...), + db: Session = Depends(get_db), +): + user = db.query(User).filter(User.email == username, User.active == True).first() + if not user or not verify_password(password, user.password_hash): + raise HTTPException(status_code=401, detail="Wrong username or password") + + token = create_access_token(user.id) + resp = JSONResponse({"token": token, "email": user.email, "redirect": "/"}) + resp.set_cookie( + key="vela_token", + value=token, + httponly=True, + max_age=JWT_EXPIRE_HOURS * 3600, + samesite="lax", + ) + return resp + + +@app.post("/api/auth/logout") +def api_logout(): + resp = JSONResponse({"ok": True}) + resp.delete_cookie("vela_token") + return resp + + +@app.get("/api/verify") +def api_verify(user: User = Depends(get_current_user)): + return {"email": user.email, "id": user.id} + + +# ── pages ────────────────────────────────────────────────────────────────── +@app.get("/", response_class=HTMLResponse) +def homepage(): + return FileResponse(TEMPLATE_DIR / "index.html") + +@app.get("/admin", response_class=HTMLResponse) +def admin_panel(): + return FileResponse(TEMPLATE_DIR / "admin.html") + +@app.get("/backup", response_class=HTMLResponse) +def backup_page(): + return FileResponse(TEMPLATE_DIR / "backup.html") + + +# ── services API (admin) ─────────────────────────────────────────────────── +@app.get("/api/services") +def list_services(db: Session = Depends(get_db)): + services = db.query(Service).order_by(Service.sort_order).all() + return [ + { + "id": s.id, + "name": s.name, + "description": s.description, + "sell_price": s.sell_price, + "cost_price": s.cost_price, + "active": s.active, + "sort_order": s.sort_order, + "components": [ + { + "id": c.id, + "name": c.name, + "unit_cost": c.unit_cost, + "unit_sell": c.unit_sell, + "quantity": c.quantity, + "notes": c.notes, + } + for c in s.components + ], + } + for s in services + ] + + +@app.put("/api/services/{service_id}") +def update_service( + service_id: int, + data: ServiceUpdate, + db: Session = Depends(get_db), +): + svc = db.query(Service).filter(Service.id == service_id).first() + if not svc: + raise HTTPException(status_code=404) + + if data.name is not None: + svc.name = data.name + if data.description is not None: + svc.description = data.description + if data.sell_price is not None: + svc.sell_price = data.sell_price + if data.cost_price is not None: + svc.cost_price = data.cost_price + if data.active is not None: + svc.active = data.active + if data.sort_order is not None: + svc.sort_order = data.sort_order + + db.commit() + return {"ok": True} + + +@app.put("/api/services/{service_id}/toggle") +def toggle_service(service_id: int, db: Session = Depends(get_db)): + svc = db.query(Service).filter(Service.id == service_id).first() + if not svc: + raise HTTPException(status_code=404) + svc.active = not svc.active + db.commit() + return {"ok": True, "active": svc.active} + + +@app.put("/api/services/{service_id}/components/{component_id}") +def update_component( + service_id: int, + component_id: int, + data: ComponentUpdate, + db: Session = Depends(get_db), +): + comp = ( + db.query(ServiceComponent) + .filter(ServiceComponent.id == component_id, ServiceComponent.service_id == service_id) + .first() + ) + if not comp: + raise HTTPException(status_code=404) + + if data.name is not None: + comp.name = data.name + if data.unit_cost is not None: + comp.unit_cost = data.unit_cost + if data.quantity is not None: + comp.quantity = data.quantity + + # recalc service cost_price + svc = db.query(Service).filter(Service.id == service_id).first() + svc.cost_price = sum(c.unit_cost * c.quantity for c in svc.components) + + db.commit() + return {"ok": True} + + +# ── transactions API ────────────────────────────────────────────────────── +@app.get("/api/transactions") +def list_transactions( + month: Optional[str] = None, + limit: int = 200, + db: Session = Depends(get_db), +): + q = db.query(Transaction).order_by(Transaction.created_at.desc()) + if month: + q = q.filter(Transaction.month_start_date == month) + return [ + { + "id": t.id, + "source_type": t.source_type, + "source_id": t.source_id, + "service_name": t.service_name_snapshot, + "quantity": t.quantity, + "revenue": t.revenue_total, + "cogs": t.cogs_total, + "profit": t.gross_profit_total, + "month": t.month_start_date, + "payment_status": t.payment_status, + "notes": t.notes, + "created_at": t.created_at.isoformat() if t.created_at else None, + } + for t in q.limit(limit).all() + ] + + +@app.post("/api/transactions") +def create_transaction( + data: TransactionCreate, + db: Session = Depends(get_db), +): + service_id = data.service_id + svc = db.query(Service).filter(Service.id == service_id).first() if service_id else None + svc_name = svc.name if svc else data.service_name or "Manual" + quantity = data.quantity + + # snapshot prices NOW or use provided values + unit_sell = data.unit_sell if data.unit_sell is not None else (svc.sell_price if svc else 0) + unit_cost = data.unit_cost if data.unit_cost is not None else (svc.cost_price if svc else 0) + revenue = unit_sell * quantity + cogs = unit_cost * quantity + profit = revenue - cogs + month = data.month or datetime.now(timezone.utc).strftime("%Y-%m") + + t = Transaction( + source_type=data.source_type, + source_id=data.source_id, + service_id=service_id, + service_name_snapshot=svc_name, + quantity=quantity, + unit_sell_price_snapshot=unit_sell, + unit_cost_snapshot=unit_cost, + revenue_total=revenue, + cogs_total=cogs, + gross_profit_total=profit, + month_start_date=month, + payment_status=data.payment_status, + notes=data.notes, + ) + db.add(t) + db.commit() + return {"ok": True, "id": t.id} + + +@app.delete("/api/transactions/{txn_id}") +def delete_transaction(txn_id: int, db: Session = Depends(get_db)): + t = db.query(Transaction).filter(Transaction.id == txn_id).first() + if not t: + raise HTTPException(status_code=404, detail="Transaction not found") + db.delete(t) + db.commit() + return {"ok": True} + + +# ── expenses API ─────────────────────────────────────────────────────────── +@app.get("/api/expenses") +def list_expenses( + month: Optional[str] = None, + db: Session = Depends(get_db), +): + q = db.query(Expense).filter(Expense.deleted_at == None).order_by(Expense.created_at.desc()) + if month: + q = q.filter(Expense.month_start_date == month) + return [ + { + "id": e.id, + "name": e.name, + "amount": e.amount, + "category": e.category, + "month": e.month_start_date, + "notes": e.notes, + } + for e in q.all() + ] + + +@app.post("/api/expenses") +def create_expense(data: ExpenseCreate, db: Session = Depends(get_db)): + e = Expense( + name=data.name, + amount=data.amount, + category=data.category, + month_start_date=data.month or datetime.now(timezone.utc).strftime("%Y-%m"), + notes=data.notes, + ) + db.add(e) + db.commit() + return {"ok": True, "id": e.id} + + +@app.delete("/api/expenses/{expense_id}") +def delete_expense(expense_id: int, db: Session = Depends(get_db)): + e = db.query(Expense).filter(Expense.id == expense_id).first() + if not e: + raise HTTPException(status_code=404) + e.deleted_at = datetime.now(timezone.utc) + db.commit() + return {"ok": True} + + +@app.get("/api/expense-templates") +def list_expense_templates(db: Session = Depends(get_db)): + return [ + {"id": et.id, "name": et.name, "amount": et.amount, "category": et.category, "active": et.active} + for et in db.query(ExpenseTemplate).all() + ] + + +@app.post("/api/expenses/generate-month") +def generate_monthly_expenses( + month: Optional[str] = None, + db: Session = Depends(get_db), +): + """Generate actual expenses from active templates for a given month.""" + if not month: + month = datetime.now(timezone.utc).strftime("%Y-%m") + + templates = db.query(ExpenseTemplate).filter(ExpenseTemplate.active == True).all() + created = 0 + for tpl in templates: + existing = ( + db.query(Expense) + .filter( + Expense.name == tpl.name, + Expense.month_start_date == month, + Expense.deleted_at == None, + ) + .first() + ) + if not existing: + db.add(Expense( + name=tpl.name, + amount=tpl.amount, + category=tpl.category, + month_start_date=month, + )) + created += 1 + db.commit() + return {"ok": True, "created": created, "month": month} + + +# ── P&L dashboard API ───────────────────────────────────────────────────── +@app.get("/api/pnl") +def get_pnl(month: Optional[str] = None, db: Session = Depends(get_db)): + """Return P&L for a single month or all months.""" + from collections import defaultdict + + txn_q = db.query(Transaction) + exp_q = db.query(Expense).filter(Expense.deleted_at == None) + + if month: + txn_q = txn_q.filter(Transaction.month_start_date == month) + exp_q = exp_q.filter(Expense.month_start_date == month) + + transactions = txn_q.all() + expenses = exp_q.all() + + # aggregate by month + monthly = defaultdict(lambda: {"revenue": 0, "cogs": 0, "gross_profit": 0, "expenses": 0, "net_profit": 0, "txn_count": 0}) + + for t in transactions: + m = t.month_start_date + monthly[m]["revenue"] += t.revenue_total + monthly[m]["cogs"] += t.cogs_total + monthly[m]["gross_profit"] += t.gross_profit_total + monthly[m]["txn_count"] += 1 + + for e in expenses: + monthly[e.month_start_date]["expenses"] += e.amount + + for m in monthly: + monthly[m]["net_profit"] = monthly[m]["gross_profit"] - monthly[m]["expenses"] + + # summary + total_revenue = sum(v["revenue"] for v in monthly.values()) + total_cogs = sum(v["cogs"] for v in monthly.values()) + total_gross = sum(v["gross_profit"] for v in monthly.values()) + total_expenses = sum(v["expenses"] for v in monthly.values()) + total_net = total_gross - total_expenses + margin = (total_gross / total_revenue * 100) if total_revenue else 0 + + return { + "summary": { + "revenue": total_revenue, + "cogs": total_cogs, + "gross_profit": total_gross, + "expenses": total_expenses, + "net_profit": total_net, + "margin_pct": round(margin, 1), + }, + "months": dict(sorted(monthly.items())), + } + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +# ── main ─────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/models.py b/models.py new file mode 100644 index 0000000..9710eee --- /dev/null +++ b/models.py @@ -0,0 +1,116 @@ +"""Vela Platform — SQLAlchemy models.""" +from datetime import datetime, timezone + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + create_engine, +) +from sqlalchemy.orm import DeclarativeBase, relationship + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + email = Column(String(255), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + +class Service(Base): + __tablename__ = "services" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), unique=True, nullable=False) + description = Column(Text, default="") + sell_price = Column(Float, default=0.0) + cost_price = Column(Float, default=0.0) # sum of component unit_cost * quantity + active = Column(Boolean, default=True, nullable=False) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc)) + + components = relationship("ServiceComponent", back_populates="service", + cascade="all, delete-orphan") + + +class ServiceComponent(Base): + __tablename__ = "service_components" + + id = Column(Integer, primary_key=True, autoincrement=True) + service_id = Column(Integer, ForeignKey("services.id", ondelete="CASCADE"), nullable=False) + name = Column(String(100), nullable=False) + unit_cost = Column(Float, default=0.0) + unit_sell = Column(Float, default=0.0) + quantity = Column(Integer, default=1) + notes = Column(Text, default="") + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + service = relationship("Service", back_populates="components") + + +class Transaction(Base): + """Frozen transaction — snapshots prices at time of sale.""" + __tablename__ = "transactions" + __table_args__ = ( + UniqueConstraint("source_type", "source_id", name="uq_transaction_source"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + source_type = Column(String(20), default="manual") # "manual" or "order" + source_id = Column(Integer, nullable=True) + + service_id = Column(Integer, ForeignKey("services.id"), nullable=True) + service_name_snapshot = Column(String(100), nullable=False) + quantity = Column(Integer, default=1) + + unit_sell_price_snapshot = Column(Float, nullable=False) + unit_cost_snapshot = Column(Float, nullable=False) + revenue_total = Column(Float, nullable=False) + cogs_total = Column(Float, nullable=False) + gross_profit_total = Column(Float, nullable=False) + + month_start_date = Column(String(10), nullable=False) # "2026-06" + + payment_status = Column(String(20), default="unpaid") # unpaid, partial, paid, refunded + notes = Column(Text, default="") + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + +class Expense(Base): + __tablename__ = "expenses" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + amount = Column(Float, nullable=False) + category = Column(String(100), default="general") + month_start_date = Column(String(10), nullable=False) # "2026-06" + notes = Column(Text, default="") + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + deleted_at = Column(DateTime, nullable=True) + + +class ExpenseTemplate(Base): + """Recurring expense templates — generate actual expenses each month.""" + __tablename__ = "expense_templates" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + amount = Column(Float, nullable=False) + category = Column(String(100), default="general") + active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..8dc91ba --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,497 @@ + + + + + +Vela Platform + + + + + +
+ +
+ + +
+
Loading...
+
+ + +
+
+
+

Vela Platform

+ +
+
+ + +
+
+
+
Dashboard
+
Tiers
+
Transactions
+
Expenses
+
+
+ + +
+
+
+ Month: + + +
+
+
+ + +
+ + +
+
New Sale
+
+
+
+
+
+
+
+
+
+ Filter: + + +
+
+
+ + +
+
New Expense
+
+
+
+
+
+
+
+
+ Filter: + + + +
+
+
+ +
+
+ + + + diff --git a/templates/backup.html b/templates/backup.html new file mode 100644 index 0000000..176b7a5 --- /dev/null +++ b/templates/backup.html @@ -0,0 +1,148 @@ + + + + + +Vela — Sauvegarde + + + + + + +
+
+

Vos souvenirs sont trop précieux pour n'exister qu'à un seul endroit.Your memories are too precious to live in just one place.

+

La sauvegarde expliquée simplement. Sans jargon.Backups explained simply. No jargon.

+ +
+

📱 Imaginez ça

📱 Imagine this

+

Vous avez 4 000 photos de vos enfants sur votre téléphone. Un jour, vous le faites tomber. Écran noir. Plus rien.You have 4,000 photos of your kids on your phone. One day, you drop it. Black screen. Nothing.

+

Sans sauvegarde : ces 4 000 photos ont disparu. Pour toujours.Without backup: those 4,000 photos are gone. Forever.

+

Avec Vela : chaque photo que vous prenez est automatiquement copiée sur votre serveur Vela dès que vous rentrez chez vous. Votre téléphone peut mourir, vos photos survivent.

+

With Vela: every photo you take is automatically copied to your Vela server as soon as you get home. Your phone can die, your photos survive.

+
+ +

Comment ça marcheHow it works

+

C'est simple : au lieu d'avoir vos photos et fichiers uniquement sur votre téléphone, Vela en garde une copie chez vous. Automatiquement. Sans que vous ayez à faire quoi que ce soit.

+

It's simple: instead of having your photos and files only on your phone, Vela keeps a copy at home. Automatically. Without you doing anything.

+

C'est comme avoir un deuxième coffre-fort pour vos souvenirs. Si le premier brûle, le deuxième est toujours là.

+

It's like having a second safe for your memories. If the first one burns, the second one is still there.

+ +

Sauvegarde simple vs redondanteSimple vs redundant backup

+
+
+ +
+
+
+
+

✅ Sauvegarde simple

✅ Simple backup

+

Vos données sont copiées sur un disque dur.

+

Your data is copied to one hard drive.

+

✅ Protège contre :✅ Protects against:

+
    +
  • Téléphone cassé ou perdu
  • Broken or lost phone
  • +
  • Suppression accidentelle
  • Accidental deletion
  • +
+

❌ Ne protège pas contre :❌ Does not protect against:

+
    +
  • Panne du disque dur
  • Hard drive failure
  • +
+

Disponible sur :Available on: Rif, Atlas

+
+ +
+
+
+ +
+
+
+

💡 La règle d'or

💡 The golden rule

+

Si quelque chose n'existe qu'à un seul endroit, ça n'existe pas vraiment.

+

If something only exists in one place, it doesn't really exist.

+

Un téléphone, ce n'est pas un endroit sûr. Un disque dur non plus. Deux disques durs séparés, chez vous : là, vous êtes tranquille.

+

A phone is not a safe place. Neither is one hard drive. Two separate hard drives, at home: now you can relax.

+
+ +
+ Voir les offres VelaSee Vela plans +
+
+
+ + + + + + diff --git a/templates/immich-logo.svg b/templates/immich-logo.svg new file mode 100644 index 0000000..376fa6f --- /dev/null +++ b/templates/immich-logo.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..eaea2b8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,353 @@ + + + + + +Vela — Votre serveur privé, prêt à l'emploi + + + + + + + +
+
+

Vos données.
Chez vous.
Your data.
At home.

+

Vous prenez une photo. Vous rentrez. Elle est déjà là. Reconnaissance de visages, recherche intelligente, sauvegardes — tout tourne chez vous, pas dans le cloud de Google.You take a photo. You get home. It's already there. Face recognition, smart search, backups — all runs at home, not on Google's cloud.

+ Voir les offresSee plans + WhatsAppWhatsApp +
+
+ + +
+
+

Vous connaissez ça ?Sound familiar?

+
+
+
📱
+

Stockage plein

Storage full

+

Google Photos ou iCloud vous demandent de payer chaque mois.

+

Google Photos or iCloud asks you to pay every month.

+
+
+
💸
+

Abonnements qui s'accumulent

Subscriptions pile up

+

Google One, iCloud, Dropbox... chaque mois, ça monte.

+

Google One, iCloud, Dropbox... it adds up every month.

+
+
+
🔒
+

Vos données chez les autres

Your data on their servers

+

Vos photos de famille sont sur les serveurs de Google.

+

Your family photos live on Google's servers.

+
+
+
😰
+

Pas de vraie sauvegarde

No real backup

+

Si votre téléphone tombe, où sont vos fichiers ?

+

If your phone breaks, where are your files?

+
+
+
+
+ + +
+
+

La solution VelaThe Vela solution

+

Un petit boîtier chez vous. Tout votre monde numérique, en sécurité, sans abonnement.A small box in your home. Your entire digital world, safe, no subscription.

+
+
+
Immich
+

Immich

+

Vous prenez une photo. Vous rentrez chez vous. Elle est déjà sur votre Vela. Reconnaissance de visages, recherche par texte ("chien à la plage") — tout est privé, rien ne part chez Google.

+

You take a photo. You get home. It's already on your Vela. Face recognition, text search ("dog at the beach") — all private, nothing goes to Google.

+
+
+
Nextcloud
+

Nextcloud

+

Vos fichiers, vos documents, votre calendrier. Tout se synchronise tout seul entre votre téléphone et votre Vela.

+

Your files, documents, calendar. Everything syncs by itself between your phone and your Vela.

+
+
+
💾
+

Sauvegarde automatique

Auto backup

+

Vos données sont sauvegardées automatiquement. Plus jamais de panique.

+

Your data is backed up automatically. No more panic.

+
+
+
🇲🇦
+

Installé à Casablanca

Installed in Casablanca

+

On vient chez vous, on installe, on configure. Vous n'avez rien à faire.

+

We come to your home, install, configure. You do nothing.

+
+
+
+
+ + +
+
+

Choisissez votre VelaChoose your Vela

+

Prix unique. Pas d'abonnement. Installation incluse.One-time price. No subscription. Installation included.

+
+

Chargement...

+
+
+
+ + +
+
+

Comment ça marcheHow it works

+

Simple, rapide, sans stress.Simple, fast, stress-free.

+
+
+
1
+

Vous choisissez

You choose

+

Un modèle Vela adapté à vos besoins.

+

A Vela model that fits your needs.

+
+
+
2
+

On installe

We install

+

On vient chez vous, on branche, on configure tout.

+

We come to your home, plug in, configure everything.

+
+
+
3
+

Vous profitez

You enjoy

+

Vos photos, fichiers, sauvegardes — tout est là, chez vous.

+

Your photos, files, backups — all there, at home.

+
+
+
+
+ + +
+
+
+

Prêt à récupérer vos données ?Ready to take back your data?

+

Un Vela chez vous, c'est la tranquillité. Contactez-nous.A Vela at home means peace of mind. Contact us.

+ Discutons sur WhatsAppChat on WhatsApp +
+
+
+ + + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..8089cee --- /dev/null +++ b/templates/login.html @@ -0,0 +1,52 @@ + + + + + +Vela Platform — Login + + + +
+

Vela Platform

+

P&L Manager

+
+ + + + + +
+
+
+ + + diff --git a/templates/nextcloud-logo.svg b/templates/nextcloud-logo.svg new file mode 100644 index 0000000..cc0cdb6 --- /dev/null +++ b/templates/nextcloud-logo.svg @@ -0,0 +1,3 @@ + + +image/svg+xml \ No newline at end of file