feat: initial KosmoConnect platform v0.1
Some checks failed
CI / lint-docs (push) Has been cancelled
CI / build-firmware (push) Has been cancelled
CI / test-backend (push) Has been cancelled
CI / test-web (push) Has been cancelled

Includes:
- Backend services: ingestion (:8001), weather API (:8002),
  gateway (:8003), billing (:8004) with BTCPay integration
- Shared asyncpg pool, TimescaleDB hypertable, Redis, Mosquitto MQTT
- React frontend: Dashboard (MapLibre) and Messaging (chat UI)
- Bridge daemon for Pi + Meshtastic (Serial/TCP T-Deck support)
- Production Docker Compose, Nginx reverse proxy, ops scripts
- DEPLOY.md with step-by-step deployment guide
This commit is contained in:
2026-04-12 17:30:15 +02:00
commit 0a4fb7b55e
95 changed files with 9903 additions and 0 deletions

116
backend/billing/README.md Normal file
View File

@@ -0,0 +1,116 @@
# KosmoConnect Billing Service
Integrates with **BTCPay Server** (`pay.cqre.net`) for subscription payments.
## What It Does
- **Invoice Creation**: Generates BTCPay invoices for plan purchases (Wanderer, Guardian, Sanctuary)
- **Webhook Handling**: Listens to BTCPay Server webhooks and updates subscription status on payment
- **Subscription Activation**: On `InvoiceSettled`, extends the user's active subscription in PostgreSQL
- **Invoice History**: Lets users view their past invoices and payment status
## Why BTCPay Server?
The Church of Kosmo operates its own payment infrastructure at `pay.cqre.net`. BTCPay Server is a self-hosted, open-source Bitcoin payment processor. It enables sovereign, censorship-resistant payments without relying on third-party card processors.
## Plan Pricing
| Plan | Monthly Price | Messages | Scope |
|------|---------------|----------|-------|
| **Wanderer** | $5.00 | 50/month | Any node on the mesh |
| **Guardian** | $12.00 | 500/month | Only whitelisted nodes |
| **Sanctuary** | $50.00 | Unlimited | Any node + API/webhooks |
Prices are denominated in `USD` and paid via BTCPay Server (settled in BTC or Lightning, depending on store configuration).
## Running Locally
```bash
cd backend
export BTCPAY_URL=https://pay.cqre.net
export BTCPAY_API_KEY=your_api_key_here
export BTCPAY_STORE_ID=your_store_id_here
export WEBHOOK_SECRET=your_webhook_secret_here
./run-dev.sh billing
```
## BTCPay Server Setup Checklist
1. **Create an API Key** in your BTCPay Server instance with the following permissions:
- `Create invoice`
- `View invoices`
- `Modify store webhooks`
2. **Create a Webhook** in your BTCPay store pointing to:
```
https://your-kosmoconnect-instance/api/v1/billing/webhooks/btcpay
```
Enable events:
- `Invoice created`
- `Invoice received payment`
- `Invoice processing`
- `Invoice expired`
- `Invoice settled`
- `Invoice invalid`
3. **Set the Webhook Secret** in the billing service (`WEBHOOK_SECRET`) to verify webhook signatures.
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/billing/invoices` | Create a new invoice for a plan |
| GET | `/api/v1/billing/invoices` | List user's invoices |
| GET | `/api/v1/billing/invoices/{invoice_id}` | Get invoice details + sync status |
| POST | `/api/v1/billing/webhooks/btcpay` | BTCPay webhook receiver |
## Example: Create an Invoice
```bash
curl -X POST http://localhost:8004/api/v1/billing/invoices \
-H "Content-Type: application/json" \
-H "X-User-ID: 11111111-1111-1111-1111-111111111111" \
-d '{"plan_type": "wanderer", "redirect_url": "https://kosmoconnect.local/thank-you"}'
```
Response:
```json
{
"invoice_id": "...",
"checkout_url": "https://pay.cqre.net/i/...",
"amount": 5.0,
"currency": "USD",
"plan_type": "wanderer"
}
```
## Webhook Payload
The billing service expects standard BTCPay Server webhook payloads. On `InvoiceSettled`, it:
1. Looks up the invoice in `btcpay_invoices`
2. Deactivates the user's previous subscription
3. Inserts a new active subscription with `valid_from = NOW()` and `valid_until = NOW() + 30 days`
4. Resets `messages_used` to `0`
## Testing Webhooks Locally
If you can't expose localhost to BTCPay, you can simulate a webhook:
```bash
curl -X POST http://localhost:8004/api/v1/billing/webhooks/btcpay \
-H "Content-Type: application/json" \
-d '{
"type": "InvoiceSettled",
"invoiceId": "your-test-invoice-id",
"status": "Settled"
}'
```
**Note:** Webhook signature verification is skipped if `WEBHOOK_SECRET` is not set.
## Troubleshooting
- **"BTCPay not configured"**: Set `BTCPAY_URL`, `BTCPAY_API_KEY`, and `BTCPAY_STORE_ID` environment variables.
- **403 on webhook**: Check that `WEBHOOK_SECRET` matches the secret configured in BTCPay Server.
- **Invoice not found on webhook**: Ensure the invoice was created through the billing service (so the `btcpay_invoice_id` exists in the database).

View File

View File

@@ -0,0 +1,79 @@
import base64
import hashlib
import hmac
import logging
from typing import Optional
import httpx
from . import config
logger = logging.getLogger("billing.btcpay")
class BTCPayClient:
def __init__(self):
self.base_url = config.BTCPAY_URL.rstrip("/")
self.api_key = config.BTCPAY_API_KEY
self.store_id = config.BTCPAY_STORE_ID
self.client = httpx.AsyncClient(
base_url=self.base_url,
headers={
"Authorization": f"token {self.api_key}",
"Content-Type": "application/json",
},
timeout=30.0,
)
async def create_invoice(
self,
amount: float,
currency: str,
order_id: str,
metadata: dict,
checkout_desc: Optional[str] = None,
) -> dict:
payload = {
"amount": amount,
"currency": currency,
"metadata": {
"orderId": order_id,
**metadata,
},
"checkout": {
"redirectURL": metadata.get("redirect_url", ""),
"redirectAutomatically": True,
},
}
if checkout_desc:
payload["metadata"]["itemDesc"] = checkout_desc
url = f"/api/v1/stores/{self.store_id}/invoices"
resp = await self.client.post(url, json=payload)
resp.raise_for_status()
return resp.json()
async def get_invoice(self, invoice_id: str) -> dict:
url = f"/api/v1/stores/{self.store_id}/invoices/{invoice_id}"
resp = await self.client.get(url)
resp.raise_for_status()
return resp.json()
def verify_webhook(self, body: bytes, signature_header: str) -> bool:
"""Verify BTCPay Server webhook signature using HMAC-SHA256."""
if not config.WEBHOOK_SECRET:
logger.warning("WEBHOOK_SECRET not set; skipping webhook verification")
return True
# BTCPay sends signature as "sha256=<hex>"
expected_prefix = "sha256="
if not signature_header.startswith(expected_prefix):
return False
received_sig = signature_header[len(expected_prefix):]
computed = hmac.new(
config.WEBHOOK_SECRET.encode(),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(received_sig, computed)

View File

@@ -0,0 +1,35 @@
import os
BTCPAY_URL = os.getenv("BTCPAY_URL", "https://pay.cqre.net")
BTCPAY_API_KEY = os.getenv("BTCPAY_API_KEY", "")
BTCPAY_STORE_ID = os.getenv("BTCPAY_STORE_ID", "")
# Plan configuration: monthly price in USD (or BTC if you prefer)
PLANS = {
"wanderer": {
"name": "Wanderer",
"price": 5.00,
"currency": "USD",
"message_quota": 50,
"duration_days": 30,
"scope": "network",
},
"guardian": {
"name": "Guardian",
"price": 12.00,
"currency": "USD",
"message_quota": 500,
"duration_days": 30,
"scope": "node",
},
"sanctuary": {
"name": "Sanctuary",
"price": 50.00,
"currency": "USD",
"message_quota": None,
"duration_days": 30,
"scope": "network",
},
}
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "")

274
backend/billing/src/main.py Normal file
View File

@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
KosmoConnect Billing Service
Integrates with BTCPay Server (pay.cqre.net) for subscription payments.
"""
import json
import logging
import os
import sys
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../shared"))
from db import get_pool
from billing.src.btcpay_client import BTCPayClient
from billing.src.models import CreateInvoiceRequest, CreateInvoiceResponse
import billing.src.config as config
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("billing")
pool = None
btcpay: Optional[BTCPayClient] = None
# ============================================================
# Subscription Management
# ============================================================
async def activate_subscription(user_id: str, plan_type: str, invoice_id: str):
plan = config.PLANS.get(plan_type)
if not plan:
logger.error("Unknown plan type: %s", plan_type)
return
duration = timedelta(days=plan["duration_days"])
valid_from = datetime.now(timezone.utc)
valid_until = valid_from + duration
async with pool.acquire() as conn:
# Deactivate previous subscriptions for this user
await conn.execute(
"UPDATE subscriptions SET is_active = false WHERE user_id = $1",
user_id,
)
await conn.execute(
"""
INSERT INTO subscriptions (
id, user_id, plan_type, btcpay_invoice_id, message_quota,
messages_used, valid_from, valid_until, is_active
) VALUES ($1, $2, $3, $4, $5, 0, $6, $7, true)
""",
uuid.uuid4(),
user_id,
plan_type,
invoice_id,
plan["message_quota"],
valid_from,
valid_until,
)
logger.info("Activated %s subscription for user %s until %s", plan_type, user_id, valid_until)
async def handle_invoice_webhook(invoice_id: str, status: str):
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM btcpay_invoices WHERE btcpay_invoice_id = $1",
invoice_id,
)
if not row:
logger.warning("Received webhook for unknown invoice %s", invoice_id)
return
db_status = status.capitalize() if status else "Pending"
settled_at = None
if status in ("Settled", "Complete"):
db_status = "Settled"
settled_at = datetime.now(timezone.utc)
await activate_subscription(row["user_id"], row["plan_type"], invoice_id)
elif status == "Expired":
db_status = "Expired"
elif status == "Invalid":
db_status = "Invalid"
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE btcpay_invoices
SET status = $1, settled_at = COALESCE($2, settled_at), updated_at = NOW()
WHERE btcpay_invoice_id = $3
""",
db_status,
settled_at,
invoice_id,
)
# ============================================================
# FastAPI App
# ============================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool, btcpay
pool = await get_pool()
btcpay = BTCPayClient()
yield
await pool.close()
await btcpay.client.aclose()
app = FastAPI(title="KosmoConnect Billing Service", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health():
return {"status": "ok", "service": "billing"}
@app.post("/api/v1/billing/invoices", status_code=201)
async def create_invoice(req: CreateInvoiceRequest, x_user_id: Optional[str] = Header(None)):
if not x_user_id:
raise HTTPException(status_code=401, detail="Missing X-User-ID header")
plan = config.PLANS.get(req.plan_type)
if not plan:
raise HTTPException(status_code=400, detail="Invalid plan type")
if not btcpay.store_id or not btcpay.api_key:
raise HTTPException(status_code=503, detail="BTCPay not configured")
order_id = f"kosmo-{x_user_id}-{req.plan_type}-{uuid.uuid4().hex[:8]}"
try:
invoice = await btcpay.create_invoice(
amount=plan["price"],
currency=plan["currency"],
order_id=order_id,
metadata={
"user_id": x_user_id,
"plan_type": req.plan_type,
"redirect_url": req.redirect_url or "",
},
checkout_desc=f"KosmoConnect {plan['name']} Plan",
)
except Exception as e:
logger.exception("BTCPay invoice creation failed: %s", e)
raise HTTPException(status_code=502, detail="Failed to create invoice with BTCPay")
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO btcpay_invoices (
user_id, btcpay_invoice_id, store_id, plan_type,
amount, currency, status, checkout_url, metadata
) VALUES ($1, $2, $3, $4, $5, $6, 'Pending', $7, $8)
""",
x_user_id,
invoice["id"],
btcpay.store_id,
req.plan_type,
plan["price"],
plan["currency"],
invoice.get("checkoutLink") or invoice.get("checkoutUrl", ""),
json.dumps({"order_id": order_id, "redirect_url": req.redirect_url or ""}),
)
return CreateInvoiceResponse(
invoice_id=invoice["id"],
checkout_url=invoice.get("checkoutLink") or invoice.get("checkoutUrl", ""),
amount=plan["price"],
currency=plan["currency"],
plan_type=req.plan_type,
)
@app.get("/api/v1/billing/invoices")
async def list_invoices(x_user_id: Optional[str] = Header(None)):
if not x_user_id:
raise HTTPException(status_code=401, detail="Missing X-User-ID header")
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
btcpay_invoice_id AS invoice_id,
plan_type,
amount,
currency,
status,
checkout_url,
created_at,
settled_at
FROM btcpay_invoices
WHERE user_id = $1
ORDER BY created_at DESC
""",
x_user_id,
)
return {"data": [dict(r) for r in rows]}
@app.get("/api/v1/billing/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, x_user_id: Optional[str] = Header(None)):
if not x_user_id:
raise HTTPException(status_code=401, detail="Missing X-User-ID header")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM btcpay_invoices WHERE btcpay_invoice_id = $1 AND user_id = $2",
invoice_id,
x_user_id,
)
if not row:
raise HTTPException(status_code=404, detail="Invoice not found")
# Optionally sync with BTCPay
try:
remote = await btcpay.get_invoice(invoice_id)
row_status = remote.get("status", row["status"])
if row_status != row["status"]:
await handle_invoice_webhook(invoice_id, row_status)
except Exception as e:
logger.warning("Could not sync invoice %s with BTCPay: %s", invoice_id, e)
return dict(row)
@app.post("/api/v1/billing/webhooks/btcpay")
async def btcpay_webhook(request: Request):
body = await request.body()
signature = request.headers.get("BTCPay-Sig", "")
if not btcpay.verify_webhook(body, signature):
logger.warning("BTCPay webhook signature verification failed")
raise HTTPException(status_code=401, detail="Invalid signature")
try:
payload = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON")
event_type = payload.get("type", "")
invoice_id = payload.get("invoiceId")
metadata = payload.get("metadata", {})
logger.info("BTCPay webhook: type=%s invoice=%s", event_type, invoice_id)
if event_type.startswith("Invoice") and invoice_id:
# For detailed events we may still need to query BTCPay for the exact status,
# but BTCPay v2 webhooks usually include enough info in payload["status"]
status = payload.get("status", "")
if not status and event_type == "InvoiceSettled":
status = "Settled"
elif not status and event_type == "InvoiceExpired":
status = "Expired"
elif not status and event_type == "InvoiceInvalid":
status = "Invalid"
await handle_invoice_webhook(invoice_id, status)
return {"status": "ok"}

View File

@@ -0,0 +1,28 @@
from typing import Optional
from pydantic import BaseModel
class CreateInvoiceRequest(BaseModel):
plan_type: str
redirect_url: Optional[str] = None
class CreateInvoiceResponse(BaseModel):
invoice_id: str
checkout_url: str
amount: float
currency: str
plan_type: str
class WebhookPayload(BaseModel):
# BTCPay webhook payload is flexible; we only validate the parts we need
deliveryId: Optional[str] = None
webhookId: Optional[str] = None
originalDeliveryId: Optional[str] = None
isRedelivery: bool = False
type: str
timestamp: int
storeId: Optional[str] = None
invoiceId: Optional[str] = None
metadata: Optional[dict] = None