update
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
Phase 1d WooCommerce-Perioden-Sync – lokales Backup
|
||||
|
||||
Enthält Kopien von: aza_wc_period_sync.py (neu), admin_routes.py, wc_routes.py, stripe_routes.py
|
||||
|
||||
Rollback: Dateien aus diesem Ordner in die Projektwurzel kopieren.
|
||||
|
||||
Neu in Phase 1d: aza_wc_period_sync.py, tests/test_wc_period_sync_phase1d.py (nicht automatisch hier, liegt unter tests/)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WooCommerce Subscriptions → licenses.current_period_* (read-only REST, keine Zahlungen).
|
||||
|
||||
Konfiguration per Umgebungsvariable (mind. Site-URL + API-Keys erforderlich):
|
||||
AZA_WOOCOMMERCE_URL – Basis-URL Shop (https://…, ohne trailing slash)
|
||||
AZA_WOOCOMMERCE_CONSUMER_KEY
|
||||
AZA_WOOCOMMERCE_CONSUMER_SECRET
|
||||
|
||||
Alternative Namen (Fallback, gleiche Bedeutung):
|
||||
WOOCOMMERCE_URL, WOOCOMMERCE_CONSUMER_KEY, WOOCOMMERCE_CONSUMER_SECRET
|
||||
|
||||
Keine Secrets loggen. Nur Subscription GET /wp-json/wc/v3/subscriptions/{id}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
# Optional: nur wenn requests verfügbar (Backend-Dependencies)
|
||||
try:
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
except ImportError:
|
||||
requests = None # type: ignore
|
||||
HTTPBasicAuth = None # type: ignore
|
||||
|
||||
_WC_SUB_PREFIX_RE = re.compile(r"^wc_sub_(\d+)\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def extract_wc_subscription_numeric_id(subscription_id: str) -> Optional[int]:
|
||||
"""Nur echtes Präfix wc_sub_<Zahl>; sonst None (kein Raten)."""
|
||||
m = _WC_SUB_PREFIX_RE.match((subscription_id or "").strip())
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _woo_rest_credentials() -> Optional[Tuple[str, str, str]]:
|
||||
base = (
|
||||
os.environ.get("AZA_WOOCOMMERCE_URL", "").strip()
|
||||
or os.environ.get("WOOCOMMERCE_URL", "").strip()
|
||||
).rstrip("/")
|
||||
key = (
|
||||
os.environ.get("AZA_WOOCOMMERCE_CONSUMER_KEY", "").strip()
|
||||
or os.environ.get("WOOCOMMERCE_CONSUMER_KEY", "").strip()
|
||||
)
|
||||
secret = (
|
||||
os.environ.get("AZA_WOOCOMMERCE_CONSUMER_SECRET", "").strip()
|
||||
or os.environ.get("WOOCOMMERCE_CONSUMER_SECRET", "").strip()
|
||||
)
|
||||
if not base or not key or not secret:
|
||||
return None
|
||||
return base, key, secret
|
||||
|
||||
|
||||
def _stripe_db_path() -> Path:
|
||||
try:
|
||||
from stripe_routes import DB_PATH as _p # type: ignore
|
||||
|
||||
return Path(_p)
|
||||
except Exception:
|
||||
return Path(__file__).resolve().parent / "data" / "stripe_webhook.sqlite"
|
||||
|
||||
|
||||
def _parse_wc_datetime(value: Any) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return int(dt.timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _meta_find(meta: Any, keys: Tuple[str, ...]) -> Optional[str]:
|
||||
if not isinstance(meta, list):
|
||||
return None
|
||||
keyset = {k.lower() for k in keys}
|
||||
for item in meta:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
k = str(item.get("key") or "").strip()
|
||||
if k.lower() in keyset:
|
||||
v = item.get("value")
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _subtract_billing_period(end_ts: int, billing_period: str, interval: int) -> int:
|
||||
"""period_start aus period_end − Abrechnungsintervall (UTC)."""
|
||||
p = (billing_period or "month").strip().lower()
|
||||
iv = max(1, int(interval) if interval else 1)
|
||||
end = datetime.fromtimestamp(end_ts, tz=timezone.utc)
|
||||
if p == "month":
|
||||
y, m = end.year, end.month
|
||||
m -= iv
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
last_day = calendar.monthrange(y, m)[1]
|
||||
d = min(end.day, last_day)
|
||||
start = end.replace(year=y, month=m, day=d)
|
||||
elif p == "year":
|
||||
start = end.replace(year=end.year - iv)
|
||||
elif p == "week":
|
||||
start = end - timedelta(weeks=iv)
|
||||
elif p == "day":
|
||||
start = end - timedelta(days=iv)
|
||||
else:
|
||||
start = end - timedelta(days=30 * iv)
|
||||
return int(start.timestamp())
|
||||
|
||||
|
||||
def compute_period_unix_from_wc_subscription(obj: Dict[str, Any]) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""
|
||||
Liefert (period_start, period_end) in Unix-Sekunden UTC.
|
||||
Bevorzugt: next_payment_date → period_end; period_start = end − billing_period*interval.
|
||||
"""
|
||||
next_raw = obj.get("next_payment_date")
|
||||
if not next_raw:
|
||||
meta = obj.get("meta_data")
|
||||
nv = _meta_find(
|
||||
meta,
|
||||
(
|
||||
"_schedule_next_payment",
|
||||
"schedule_next_payment",
|
||||
),
|
||||
)
|
||||
if nv:
|
||||
next_raw = nv
|
||||
period_end = _parse_wc_datetime(next_raw)
|
||||
if period_end is None:
|
||||
return None, None
|
||||
bp = str(obj.get("billing_period") or "month")
|
||||
bi = obj.get("billing_interval")
|
||||
try:
|
||||
bi_int = int(bi) if bi is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
bi_int = 1
|
||||
period_start = _subtract_billing_period(period_end, bp, bi_int)
|
||||
sd = _parse_wc_datetime(obj.get("start_date_gmt") or obj.get("start_date") or obj.get("date_created"))
|
||||
if sd is not None and sd > period_start and sd < period_end:
|
||||
period_start = sd
|
||||
if period_start >= period_end:
|
||||
period_start = period_end - 86400
|
||||
return period_start, period_end
|
||||
|
||||
|
||||
def _fetch_wc_subscription(wc_sub_id: int) -> Dict[str, Any]:
|
||||
if requests is None or HTTPBasicAuth is None:
|
||||
raise RuntimeError("requests_not_available")
|
||||
cred = _woo_rest_credentials()
|
||||
if not cred:
|
||||
raise RuntimeError("woo_credentials_missing")
|
||||
base, key, secret = cred
|
||||
path = f"/wp-json/wc/v3/subscriptions/{int(wc_sub_id)}"
|
||||
url = f"{base}{path}"
|
||||
r = requests.get(url, auth=HTTPBasicAuth(key, secret), timeout=(5, 30))
|
||||
if r.status_code == 404:
|
||||
qurl = f"{base}/wp-json/wc/v3/subscriptions?search={quote(str(wc_sub_id))}&per_page=10"
|
||||
r2 = requests.get(qurl, auth=HTTPBasicAuth(key, secret), timeout=(5, 30))
|
||||
if r2.ok:
|
||||
arr = r2.json()
|
||||
if isinstance(arr, list) and len(arr) == 1 and isinstance(arr[0], dict):
|
||||
if int(arr[0].get("id") or 0) == wc_sub_id:
|
||||
return arr[0]
|
||||
raise RuntimeError("subscription_not_found_http_404")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("invalid_json_shape")
|
||||
return data
|
||||
|
||||
|
||||
def sync_active_license_periods_from_woocommerce_only() -> Dict[str, Any]:
|
||||
"""
|
||||
Nur Zeilen mit subscription_id LIKE wc_sub_%: WooCommerce Subscription lesen,
|
||||
current_period_start / current_period_end / updated_at setzen.
|
||||
Kein status, customer_id, lookup_key, keine Stripe-/WC-Writes außer SQLite-UPDATE.
|
||||
"""
|
||||
if not _woo_rest_credentials():
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "WOO_CREDENTIALS_MISSING",
|
||||
"message": "Set AZA_WOOCOMMERCE_URL + AZA_WOOCOMMERCE_CONSUMER_KEY + AZA_WOOCOMMERCE_CONSUMER_SECRET (or WOOCOMMERCE_* aliases).",
|
||||
}
|
||||
if requests is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "REQUESTS_MISSING",
|
||||
"message": "Python package requests required for Woo REST.",
|
||||
}
|
||||
|
||||
db_path = _stripe_db_path()
|
||||
if not db_path.exists():
|
||||
return {"ok": False, "error_code": "DB_MISSING", "message": "stripe_webhook.sqlite not found"}
|
||||
|
||||
now = int(time.time())
|
||||
total = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
errors: List[Dict[str, str]] = []
|
||||
|
||||
with sqlite3.connect(str(db_path)) as con:
|
||||
rows = con.execute(
|
||||
"""
|
||||
SELECT subscription_id FROM licenses
|
||||
WHERE lower(trim(status))='active'
|
||||
AND subscription_id IS NOT NULL
|
||||
AND subscription_id LIKE 'wc_sub_%'
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
for (sid_raw,) in rows:
|
||||
sid = str(sid_raw or "").strip()
|
||||
wc_num = extract_wc_subscription_numeric_id(sid)
|
||||
if wc_num is None:
|
||||
skipped += 1
|
||||
errors.append(
|
||||
{"subscription_id_prefix": sid[:16], "detail": "not_wc_sub_numeric"}
|
||||
)
|
||||
continue
|
||||
total += 1
|
||||
try:
|
||||
sub = _fetch_wc_subscription(wc_num)
|
||||
st = str(sub.get("status") or "").lower().replace("_", "-")
|
||||
if st in ("cancelled", "canceled", "expired", "trash"):
|
||||
skipped += 1
|
||||
errors.append(
|
||||
{
|
||||
"subscription_id_prefix": sid[:16],
|
||||
"detail": f"woo_status_{st}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
ps, pe = compute_period_unix_from_wc_subscription(sub)
|
||||
if ps is None or pe is None:
|
||||
skipped += 1
|
||||
errors.append(
|
||||
{
|
||||
"subscription_id_prefix": sid[:16],
|
||||
"detail": "no_next_payment_derivable",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if ps >= pe:
|
||||
skipped += 1
|
||||
errors.append(
|
||||
{
|
||||
"subscription_id_prefix": sid[:16],
|
||||
"detail": "invalid_period_order",
|
||||
}
|
||||
)
|
||||
continue
|
||||
cur = con.execute(
|
||||
"""
|
||||
UPDATE licenses
|
||||
SET current_period_start=?,
|
||||
current_period_end=?,
|
||||
updated_at=?
|
||||
WHERE subscription_id=?
|
||||
AND lower(trim(status))='active'
|
||||
""",
|
||||
(ps, pe, now, sid),
|
||||
)
|
||||
if cur.rowcount:
|
||||
updated += 1
|
||||
except Exception as exc:
|
||||
errors.append(
|
||||
{
|
||||
"subscription_id_prefix": sid[:16],
|
||||
"detail": type(exc).__name__,
|
||||
}
|
||||
)
|
||||
con.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"total_active_wc_sub_rows": total,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"failed_count": len(errors),
|
||||
"errors": errors[:25],
|
||||
"estimate_engine": "woocommerce_subscription_rest_v1",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
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 ProvisionRequest(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
@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 = payload.customer_email.strip().lower()
|
||||
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="customer_email is required")
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
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 ""
|
||||
_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,
|
||||
})
|
||||
return {
|
||||
"status": "already_provisioned",
|
||||
"license_key": existing_key,
|
||||
"license_status": existing_status,
|
||||
"customer_email": email,
|
||||
}
|
||||
|
||||
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_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_end=COALESCE(excluded.current_period_end, 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}",
|
||||
None,
|
||||
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,
|
||||
})
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "provisioned",
|
||||
"license_key": new_key,
|
||||
"license_status": "active",
|
||||
"customer_email": email,
|
||||
"mail_sent": mail_sent,
|
||||
}
|
||||
Reference in New Issue
Block a user