Spaces:
Sleeping
Sleeping
File size: 8,898 Bytes
b7e98d4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | """
PNA Assistant β Stripe Webhook Handler
POST /webhook β Stripe sends checkout.session.completed events here
β We generate a PNA-PRO-XXXXX code and email it to the customer
Run standalone: uvicorn webhook:app --host 0.0.0.0 --port 8080
In Docker: separate service in docker-compose.yml
"""
import hashlib
import hmac
import json
import logging
import os
import random
import string
from datetime import datetime, timezone
from pathlib import Path
import boto3
from botocore.exceptions import ClientError
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
# βββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
AWS_REGION = os.getenv("AWS_REGION", "eu-west-2")
SES_FROM_EMAIL = os.getenv("SES_FROM_EMAIL", "lincoln@clinyqai.com")
# Where we persist issued codes (append-only JSONL)
CODES_LOG = Path("/app/codes.jsonl")
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("pna-webhook")
app = FastAPI(title="PNA Assistant Webhook", docs_url=None, redoc_url=None)
# βββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _generate_code(tier: str = "PRO") -> str:
"""Generate a unique activation code e.g. PNA-PRO-A7K2M9"""
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
return f"PNA-{tier}-{suffix}"
def _verify_stripe_signature(payload: bytes, sig_header: str, secret: str) -> bool:
"""
Validate Stripe-Signature header.
Stripe uses HMAC-SHA256 with the raw request body.
"""
if not secret:
log.warning("STRIPE_WEBHOOK_SECRET not set β skipping signature verification")
return True # dev mode only; set the secret in production
try:
parts = {k: v for part in sig_header.split(",") for k, v in [part.split("=", 1)]}
timestamp = parts.get("t", "")
v1_sig = parts.get("v1", "")
signed_payload = f"{timestamp}.".encode() + payload
expected = hmac.new(
secret.encode(),
signed_payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1_sig)
except Exception as exc:
log.error("Signature verification error: %s", exc)
return False
def _log_code(email: str, code: str, plan: str, session_id: str) -> None:
"""Append issued code to local JSONL log for admin audit trail."""
CODES_LOG.parent.mkdir(parents=True, exist_ok=True)
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"email": email,
"code": code,
"plan": plan,
"session_id": session_id,
}
with CODES_LOG.open("a") as f:
f.write(json.dumps(record) + "\n")
log.info("Code issued: %s β %s (%s)", email, code, plan)
def _send_activation_email(email: str, code: str, plan: str) -> bool:
"""
Send activation code to customer via Amazon SES.
Returns True if sent, False on error.
"""
label = "Pro" if plan == "PRO" else "Institution"
price = "Β£9.99/month" if plan == "PRO" else "Β£99/month"
body_html = f"""
<!DOCTYPE html>
<html>
<body style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;padding:2rem;color:#1e293b">
<div style="background:linear-gradient(135deg,#1a2460,#0d9488);padding:2rem;border-radius:12px;margin-bottom:2rem">
<h1 style="color:white;font-size:1.5rem;margin:0">π¨πΎββοΈ PNA Assistant</h1>
<p style="color:rgba(255,255,255,0.85);margin:0.5rem 0 0">
Professional Nurse Advocate AI β {label} Plan
</p>
</div>
<p>Thank you for subscribing to PNA Assistant <strong>{label}</strong> ({price}).</p>
<p>Your activation code is:</p>
<div style="background:#f0fdf4;border:2px solid #0d9488;border-radius:12px;padding:1.5rem;text-align:center;margin:1.5rem 0">
<span style="font-family:monospace;font-size:1.75rem;font-weight:700;color:#0d9488;letter-spacing:0.1em">
{code}
</span>
</div>
<p><strong>How to activate:</strong></p>
<ol>
<li>Go to <a href="https://pna.nursingcitizendevelopment.com">pna.nursingcitizendevelopment.com</a></li>
<li>Open the sidebar and find <em>Subscription</em></li>
<li>Paste your code in the <em>Activation code</em> box</li>
<li>Click Enter β your {label} features will unlock immediately</li>
</ol>
<p>Your {label} plan includes:</p>
<ul>
<li>π Download supervision notes (.docx)</li>
<li>π NMC revalidation CPD log export (.docx)</li>
<li>πΎ Save and resume sessions</li>
<li>π§ Monthly PNA newsletter</li>
{"<li>π« Multi-user access</li><li>π¨ Custom branding</li>" if plan == "INST" else ""}
</ul>
<p style="font-size:0.875rem;color:#64748b">
If you have any questions, reply to this email or contact
<a href="mailto:lincoln@clinyqai.com">lincoln@clinyqai.com</a>.<br><br>
This tool is for educational purposes only and does not replace clinical supervision
or your employer's formal governance processes.<br><br>
Contains public sector information licensed under the
<a href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/">
Open Government Licence v3.0
</a> β NHS England.
</p>
</body>
</html>
"""
body_text = (
f"Thank you for subscribing to PNA Assistant {label} ({price}).\n\n"
f"Your activation code is: {code}\n\n"
"To activate:\n"
"1. Go to pna.nursingcitizendevelopment.com\n"
"2. Open the sidebar β Subscription\n"
"3. Paste your code in the Activation code box\n\n"
"Questions? Email lincoln@clinyqai.com"
)
try:
ses = boto3.client("ses", region_name=AWS_REGION)
ses.send_email(
Source = f"PNA Assistant <{SES_FROM_EMAIL}>",
Destination = {"ToAddresses": [email]},
Message = {
"Subject": {"Data": f"Your PNA Assistant {label} activation code π"},
"Body": {
"Text": {"Data": body_text},
"Html": {"Data": body_html},
},
},
)
log.info("Activation email sent to %s", email)
return True
except ClientError as exc:
log.error("SES send failed: %s", exc.response["Error"]["Message"])
return False
except Exception as exc:
log.error("Email error: %s", exc)
return False
# βββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/webhook")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("Stripe-Signature", "")
# 1. Verify Stripe signature
if not _verify_stripe_signature(payload, sig_header, STRIPE_WEBHOOK_SECRET):
log.warning("Invalid Stripe signature β rejected")
raise HTTPException(status_code=400, detail="Invalid signature")
# 2. Parse event
try:
event = json.loads(payload)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON")
event_type = event.get("type", "")
log.info("Received Stripe event: %s", event_type)
# 3. Handle checkout completion
if event_type == "checkout.session.completed":
session = event["data"]["object"]
email = session.get("customer_details", {}).get("email") or session.get("customer_email")
amount = session.get("amount_total", 0) # pence/cents
session_id = session.get("id", "unknown")
if not email:
log.warning("No email in session %s β cannot send code", session_id)
return JSONResponse({"received": True, "warning": "no_email"})
# Determine tier from amount (999 = Β£9.99, 9900 = Β£99.00)
tier = "INST" if amount >= 9900 else "PRO"
code = _generate_code(tier)
# 4. Log the code (always)
_log_code(email, code, tier, session_id)
# 5. Email the customer
sent = _send_activation_email(email, code, tier)
if not sent:
log.warning(
"SES send failed for %s β code %s logged to %s",
email, code, CODES_LOG,
)
return JSONResponse({"received": True})
|