feat: initial KosmoConnect platform v0.1
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:
116
backend/billing/README.md
Normal file
116
backend/billing/README.md
Normal 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).
|
||||
0
backend/billing/src/__init__.py
Normal file
0
backend/billing/src/__init__.py
Normal file
79
backend/billing/src/btcpay_client.py
Normal file
79
backend/billing/src/btcpay_client.py
Normal 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)
|
||||
35
backend/billing/src/config.py
Normal file
35
backend/billing/src/config.py
Normal 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
274
backend/billing/src/main.py
Normal 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"}
|
||||
28
backend/billing/src/models.py
Normal file
28
backend/billing/src/models.py
Normal 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
|
||||
Reference in New Issue
Block a user