This commit is contained in:
2026-05-20 00:09:28 +02:00
parent 968bf7d102
commit 51b5ddc6f2
695 changed files with 999722 additions and 270 deletions

View File

@@ -4,6 +4,9 @@
# 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).
@@ -12,10 +15,12 @@ from __future__ import annotations
import os
import time
import sqlite3
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, Field, field_validator
from wc_period_payload import resolve_wc_period_fields
router = APIRouter()
@@ -28,6 +33,7 @@ def _get_db_path() -> str:
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")
@@ -43,7 +49,21 @@ def _require_wc_secret(x_wc_secret: Optional[str] = Header(default=None, alias="
return x_wc_secret
class ProvisionRequest(BaseModel):
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
@@ -51,6 +71,29 @@ class ProvisionRequest(BaseModel):
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(
@@ -76,12 +119,13 @@ def wc_provision(
sub_id = f"wc_sub_{payload.wc_subscription_id}"
customer_id = f"wc_order_{payload.wc_order_id}"
email = payload.customer_email.strip().lower()
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(
@@ -92,18 +136,34 @@ def wc_provision(
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),
})
return {
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()
@@ -112,9 +172,9 @@ def wc_provision(
INSERT INTO licenses(
subscription_id, customer_id, status, lookup_key,
allowed_users, devices_per_user, customer_email, client_reference_id,
current_period_end, updated_at, license_key
current_period_start, current_period_end, updated_at, license_key
)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(subscription_id) DO UPDATE SET
customer_id=excluded.customer_id,
status=excluded.status,
@@ -122,7 +182,8 @@ def wc_provision(
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_end=COALESCE(excluded.current_period_end, current_period_end),
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)
""",
@@ -135,7 +196,8 @@ def wc_provision(
payload.devices_per_user,
email,
f"wc_order_{payload.wc_order_id}",
None,
period_start,
period_end,
now,
new_key,
),
@@ -148,6 +210,7 @@ def wc_provision(
"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
@@ -159,10 +222,60 @@ def wc_provision(
"error": str(exc),
})
return {
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}