308 lines
10 KiB
Python
308 lines
10 KiB
Python
|
|
# -*- 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",
|
|||
|
|
}
|