# wc_routes.py – WooCommerce → Hetzner License Bridge # # Provides POST /wc/provision for WordPress to call after a successful # WooCommerce subscription purchase. Creates/updates a license entry and # sends the license key email to the customer. # # Phase 1f: optionale Periodenfelder (ohne Woo /wc/v3/subscriptions REST) und # POST /wc/subscription_period für reine Period-Updates. # # Mounted in backend_main.py with prefix="/wc". # Protected by a shared secret (WC_PROVISION_SECRET env var). from __future__ import annotations import os import time import sqlite3 from typing import Any, Dict, Optional, Union from fastapi import APIRouter, HTTPException, Header from pydantic import BaseModel, Field, field_validator from wc_period_payload import resolve_wc_period_fields router = APIRouter() DB_PATH: Optional[str] = None def _get_db_path() -> str: global DB_PATH if DB_PATH: return DB_PATH try: from stripe_routes import DB_PATH as _sr_db DB_PATH = _sr_db except ImportError: DB_PATH = os.path.join(os.path.dirname(__file__), "data", "stripe_webhook.sqlite") return DB_PATH def _require_wc_secret(x_wc_secret: Optional[str] = Header(default=None, alias="X-WC-Secret")) -> str: expected = os.environ.get("WC_PROVISION_SECRET", "").strip() if not expected: raise HTTPException(status_code=500, detail="WC_PROVISION_SECRET not configured on server") if not x_wc_secret or x_wc_secret.strip() != expected: raise HTTPException(status_code=401, detail="Unauthorized – invalid X-WC-Secret") return x_wc_secret class WcBridgeOptionalPeriodFields(BaseModel): """Optionale Woo-Subscription-Metadaten (Phase 1f); subscription_status wird ignoriert.""" current_period_start: Optional[int] = None current_period_end: Optional[int] = None next_payment_date: Optional[Union[int, str]] = None billing_period: Optional[str] = None billing_interval: Optional[int] = None subscription_status: Optional[str] = Field( default=None, description="Optional, nur Info; wird nicht in licenses.status geschrieben.", ) class ProvisionRequest(WcBridgeOptionalPeriodFields): customer_email: str wc_order_id: int wc_subscription_id: int lookup_key: str = "aza_basic_monthly" allowed_users: int = 1 devices_per_user: int = 2 @field_validator("customer_email") @classmethod def _v_email(cls, v: str) -> str: s = (v or "").strip().lower() if not s or "@" not in s: raise ValueError("customer_email must be a non-empty email address") return s class SubscriptionPeriodRequest(WcBridgeOptionalPeriodFields): wc_subscription_id: int = Field(..., gt=0) wc_order_id: Optional[int] = Field(default=None, description="Optional: zusätzliche Absicherung gegen falsche Zuordnung") def _period_pair_from_bridge(payload: WcBridgeOptionalPeriodFields) -> tuple[Optional[int], Optional[int]]: return resolve_wc_period_fields( current_period_start=payload.current_period_start, current_period_end=payload.current_period_end, next_payment_date=payload.next_payment_date, billing_period=payload.billing_period, billing_interval=payload.billing_interval, ) @router.post("/provision") def wc_provision( payload: ProvisionRequest, x_wc_secret: Optional[str] = Header(default=None, alias="X-WC-Secret"), ) -> Dict[str, Any]: """Called by WordPress after a successful WooCommerce subscription purchase. Idempotent: if a license for this wc_subscription_id already exists, returns the existing key without creating a duplicate. """ _require_wc_secret(x_wc_secret) from stripe_routes import ( _ensure_storage, _generate_license_key, send_license_email, _log_event, ) _ensure_storage() db = _get_db_path() sub_id = f"wc_sub_{payload.wc_subscription_id}" customer_id = f"wc_order_{payload.wc_order_id}" email = str(payload.customer_email).strip().lower() if not email: raise HTTPException(status_code=400, detail="customer_email is required") now = int(time.time()) period_start, period_end = _period_pair_from_bridge(payload) with sqlite3.connect(db) as con: row = con.execute( "SELECT license_key, status FROM licenses WHERE subscription_id = ?", (sub_id,), ).fetchone() if row and row[0]: existing_key = row[0] existing_status = row[1] or "" if period_start is not None and period_end is not None: con.execute( """ UPDATE licenses SET current_period_start=?, current_period_end=?, updated_at=? WHERE subscription_id=? """, (period_start, period_end, now, sub_id), ) con.commit() _log_event("wc_provision_idempotent", { "wc_subscription_id": payload.wc_subscription_id, "wc_order_id": payload.wc_order_id, "email": email, "license_key": existing_key, "period_updated": bool(period_start is not None and period_end is not None), }) out: Dict[str, Any] = { "status": "already_provisioned", "license_key": existing_key, "license_status": existing_status, "customer_email": email, } if period_start is not None and period_end is not None: out["period_synced"] = True return out new_key = _generate_license_key() con.execute( """ INSERT INTO licenses( subscription_id, customer_id, status, lookup_key, allowed_users, devices_per_user, customer_email, client_reference_id, current_period_start, current_period_end, updated_at, license_key ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(subscription_id) DO UPDATE SET customer_id=excluded.customer_id, status=excluded.status, lookup_key=COALESCE(NULLIF(excluded.lookup_key, ''), lookup_key), allowed_users=COALESCE(excluded.allowed_users, allowed_users), devices_per_user=COALESCE(excluded.devices_per_user, devices_per_user), customer_email=COALESCE(excluded.customer_email, customer_email), current_period_start=COALESCE(excluded.current_period_start, licenses.current_period_start), current_period_end=COALESCE(excluded.current_period_end, licenses.current_period_end), updated_at=excluded.updated_at, license_key=COALESCE(license_key, excluded.license_key) """, ( sub_id, customer_id, "active", payload.lookup_key, payload.allowed_users, payload.devices_per_user, email, f"wc_order_{payload.wc_order_id}", period_start, period_end, now, new_key, ), ) con.commit() _log_event("wc_provision_created", { "wc_subscription_id": payload.wc_subscription_id, "wc_order_id": payload.wc_order_id, "email": email, "license_key": new_key, "lookup_key": payload.lookup_key, "has_period": bool(period_start is not None and period_end is not None), }) mail_sent = False try: mail_sent = send_license_email(email, new_key) except Exception as exc: _log_event("wc_provision_mail_failed", { "email": email, "error": str(exc), }) res: Dict[str, Any] = { "status": "provisioned", "license_key": new_key, "license_status": "active", "customer_email": email, "mail_sent": mail_sent, } if period_start is not None and period_end is not None: res["period_synced"] = True return res @router.post("/subscription_period") def wc_subscription_period( payload: SubscriptionPeriodRequest, x_wc_secret: Optional[str] = Header(default=None, alias="X-WC-Secret"), ) -> Dict[str, Any]: """ Nur current_period_start / current_period_end / updated_at für wc_sub_*-Lizenzen. Gleiches Shared Secret wie /wc/provision. """ _require_wc_secret(x_wc_secret) from stripe_routes import _ensure_storage _ensure_storage() db = _get_db_path() period_start, period_end = _period_pair_from_bridge(payload) if period_start is None or period_end is None: raise HTTPException( status_code=400, detail="invalid_or_insufficient_period_fields", ) sub_id = f"wc_sub_{payload.wc_subscription_id}" now = int(time.time()) q = ( "UPDATE licenses SET current_period_start=?, current_period_end=?, updated_at=? " "WHERE subscription_id=? AND lower(trim(status))='active' " "AND subscription_id LIKE 'wc_sub_%'" ) params: list[Any] = [period_start, period_end, now, sub_id] if payload.wc_order_id is not None: q += " AND customer_id=?" params.append(f"wc_order_{int(payload.wc_order_id)}") with sqlite3.connect(db) as con: cur = con.execute(q, tuple(params)) con.commit() n = int(cur.rowcount or 0) if n == 0: raise HTTPException(status_code=404, detail="license_not_found_or_not_active_wc_sub") return {"ok": True, "rows_updated": n}