2026-04-16 13:32:32 +02:00
|
|
|
|
# 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.
|
|
|
|
|
|
#
|
2026-05-20 00:09:28 +02:00
|
|
|
|
# Phase 1f: optionale Periodenfelder (ohne Woo /wc/v3/subscriptions REST) und
|
|
|
|
|
|
# POST /wc/subscription_period für reine Period-Updates.
|
|
|
|
|
|
#
|
2026-04-16 13:32:32 +02:00
|
|
|
|
# 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
|
2026-05-20 00:09:28 +02:00
|
|
|
|
from typing import Any, Dict, Optional, Union
|
2026-04-16 13:32:32 +02:00
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Header
|
2026-05-20 00:09:28 +02:00
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
|
|
|
|
|
|
|
|
from wc_period_payload import resolve_wc_period_fields
|
2026-04-16 13:32:32 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-20 00:09:28 +02:00
|
|
|
|
|
2026-04-16 13:32:32 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 00:09:28 +02:00
|
|
|
|
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):
|
2026-04-16 13:32:32 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-20 00:09:28 +02:00
|
|
|
|
@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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-16 13:32:32 +02:00
|
|
|
|
|
|
|
|
|
|
@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}"
|
2026-05-20 00:09:28 +02:00
|
|
|
|
email = str(payload.customer_email).strip().lower()
|
2026-04-16 13:32:32 +02:00
|
|
|
|
|
|
|
|
|
|
if not email:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="customer_email is required")
|
|
|
|
|
|
|
|
|
|
|
|
now = int(time.time())
|
2026-05-20 00:09:28 +02:00
|
|
|
|
period_start, period_end = _period_pair_from_bridge(payload)
|
2026-04-16 13:32:32 +02:00
|
|
|
|
|
|
|
|
|
|
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 ""
|
2026-05-20 00:09:28 +02:00
|
|
|
|
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()
|
2026-04-16 13:32:32 +02:00
|
|
|
|
_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,
|
2026-05-20 00:09:28 +02:00
|
|
|
|
"period_updated": bool(period_start is not None and period_end is not None),
|
2026-04-16 13:32:32 +02:00
|
|
|
|
})
|
2026-05-20 00:09:28 +02:00
|
|
|
|
out: Dict[str, Any] = {
|
2026-04-16 13:32:32 +02:00
|
|
|
|
"status": "already_provisioned",
|
|
|
|
|
|
"license_key": existing_key,
|
|
|
|
|
|
"license_status": existing_status,
|
|
|
|
|
|
"customer_email": email,
|
|
|
|
|
|
}
|
2026-05-20 00:09:28 +02:00
|
|
|
|
if period_start is not None and period_end is not None:
|
|
|
|
|
|
out["period_synced"] = True
|
|
|
|
|
|
return out
|
2026-04-16 13:32:32 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-20 00:09:28 +02:00
|
|
|
|
current_period_start, current_period_end, updated_at, license_key
|
2026-04-16 13:32:32 +02:00
|
|
|
|
)
|
2026-05-20 00:09:28 +02:00
|
|
|
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
2026-04-16 13:32:32 +02:00
|
|
|
|
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),
|
2026-05-20 00:09:28 +02:00
|
|
|
|
current_period_start=COALESCE(excluded.current_period_start, licenses.current_period_start),
|
|
|
|
|
|
current_period_end=COALESCE(excluded.current_period_end, licenses.current_period_end),
|
2026-04-16 13:32:32 +02:00
|
|
|
|
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}",
|
2026-05-20 00:09:28 +02:00
|
|
|
|
period_start,
|
|
|
|
|
|
period_end,
|
2026-04-16 13:32:32 +02:00
|
|
|
|
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,
|
2026-05-20 00:09:28 +02:00
|
|
|
|
"has_period": bool(period_start is not None and period_end is not None),
|
2026-04-16 13:32:32 +02:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-20 00:09:28 +02:00
|
|
|
|
res: Dict[str, Any] = {
|
2026-04-16 13:32:32 +02:00
|
|
|
|
"status": "provisioned",
|
|
|
|
|
|
"license_key": new_key,
|
|
|
|
|
|
"license_status": "active",
|
|
|
|
|
|
"customer_email": email,
|
|
|
|
|
|
"mail_sent": mail_sent,
|
|
|
|
|
|
}
|
2026-05-20 00:09:28 +02:00
|
|
|
|
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}
|