diff --git a/company_site/financial/stripe_billing.py b/company_site/financial/stripe_billing.py new file mode 100644 index 0000000..c41e439 --- /dev/null +++ b/company_site/financial/stripe_billing.py @@ -0,0 +1,383 @@ +"""Stripe REST helpers (stdlib HTTP) for invoices and subscriptions. + +Customers pay us — one-off hosted invoices and Checkout subscription sessions. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime +from decimal import Decimal + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import get_template +from django.utils import timezone + +from .models import BillingCustomer, Invoice, RecurringSubscription + +logger = logging.getLogger(__name__) + +STRIPE_API_BASE = "https://api.stripe.com/v1" + + +class StripeNotConfigured(Exception): + """Raised when Stripe secret key is missing.""" + + +class StripeAPIError(Exception): + """Raised when Stripe API returns an error.""" + + def __init__(self, message: str, status: int | None = None): + super().__init__(message) + self.status = status + self.user_message = message + + +def _secret_key() -> str: + key = getattr(settings, "STRIPE_SECRET_KEY", "") or "" + if not key: + raise StripeNotConfigured( + "STRIPE_SECRET_KEY is not set. Add it to the environment." + ) + return key + + +def _stripe_request(method: str, path: str, data: dict | None = None) -> dict: + """Call Stripe API with form-encoded body (Stripe default).""" + url = f"{STRIPE_API_BASE}{path}" + body = None + headers = { + "Authorization": f"Bearer {_secret_key()}", + } + if data is not None: + body = urllib.parse.urlencode(_flatten_params(data)).encode("utf-8") + headers["Content-Type"] = "application/x-www-form-urlencoded" + + request = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + payload = json.loads(raw) + message = payload.get("error", {}).get("message") or raw + except json.JSONDecodeError: + message = raw or str(exc) + raise StripeAPIError(message, status=exc.code) from exc + except urllib.error.URLError as exc: + raise StripeAPIError(f"Could not reach Stripe: {exc.reason}") from exc + + +def _flatten_params(data: dict, prefix: str = "") -> list[tuple[str, str]]: + """Flatten nested dicts/lists into Stripe form keys (a[b]=1, items[0][x]=y).""" + items: list[tuple[str, str]] = [] + for key, value in data.items(): + full_key = f"{prefix}[{key}]" if prefix else str(key) + if value is None: + continue + if isinstance(value, dict): + items.extend(_flatten_params(value, full_key)) + elif isinstance(value, (list, tuple)): + for index, entry in enumerate(value): + indexed = f"{full_key}[{index}]" + if isinstance(entry, dict): + items.extend(_flatten_params(entry, indexed)) + else: + items.append((indexed, str(entry))) + elif isinstance(value, bool): + items.append((full_key, "true" if value else "false")) + else: + items.append((full_key, str(value))) + return items + + +def construct_webhook_event(payload: bytes, sig_header: str, secret: str) -> dict: + """Verify Stripe-Signature and return event dict.""" + if not sig_header: + raise ValueError("Missing Stripe-Signature header") + + elements = {part.split("=", 1)[0]: part.split("=", 1)[1] for part in sig_header.split(",") if "=" in part} + timestamp = elements.get("t") + signatures = [part.split("=", 1)[1] for part in sig_header.split(",") if part.startswith("v1=")] + if not timestamp or not signatures: + raise ValueError("Invalid Stripe-Signature header") + + signed_payload = f"{timestamp}.{payload.decode('utf-8')}".encode("utf-8") + expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest() + if not any(hmac.compare_digest(expected, candidate) for candidate in signatures): + raise ValueError("Webhook signature verification failed") + + # Reject stale timestamps (>5 minutes) + if abs(time.time() - int(timestamp)) > 300: + raise ValueError("Webhook timestamp outside tolerance") + + return json.loads(payload.decode("utf-8")) + + +def ensure_stripe_customer(customer: BillingCustomer) -> str: + """Create Stripe Customer if needed; return stripe_customer_id.""" + if customer.stripe_customer_id: + return customer.stripe_customer_id + + stripe_customer = _stripe_request( + "POST", + "/customers", + { + "email": customer.email, + "name": customer.name, + "metadata": { + "local_customer_id": str(customer.pk), + "company": customer.company or "", + }, + }, + ) + customer.stripe_customer_id = stripe_customer["id"] + customer.save(update_fields=["stripe_customer_id", "last_modified"]) + return customer.stripe_customer_id + + +def create_one_off_invoice(invoice: Invoice) -> Invoice: + """Create + finalize a Stripe Invoice; store hosted pay URL.""" + stripe_customer_id = ensure_stripe_customer(invoice.customer) + + _stripe_request( + "POST", + "/invoiceitems", + { + "customer": stripe_customer_id, + "amount": invoice.amount_cents, + "currency": invoice.currency, + "description": invoice.description, + }, + ) + + create_data: dict = { + "customer": stripe_customer_id, + "collection_method": "send_invoice", + "auto_advance": True, + "metadata": {"local_invoice_id": str(invoice.pk)}, + } + if invoice.due_date: + due_dt = datetime.combine(invoice.due_date, datetime.min.time()) + if timezone.is_naive(due_dt): + due_dt = timezone.make_aware(due_dt, timezone.get_current_timezone()) + create_data["due_date"] = int(due_dt.timestamp()) + else: + create_data["days_until_due"] = 30 + + stripe_invoice = _stripe_request("POST", "/invoices", create_data) + stripe_invoice = _stripe_request( + "POST", f"/invoices/{stripe_invoice['id']}/finalize", {} + ) + + invoice.stripe_invoice_id = stripe_invoice["id"] + invoice.hosted_invoice_url = stripe_invoice.get("hosted_invoice_url") or "" + invoice.invoice_pdf_url = stripe_invoice.get("invoice_pdf") or "" + status = stripe_invoice.get("status") or Invoice.Status.OPEN + if status in dict(Invoice.Status.choices): + invoice.status = status + invoice.last_modified = timezone.now() + invoice.save( + update_fields=[ + "stripe_invoice_id", + "hosted_invoice_url", + "invoice_pdf_url", + "status", + "last_modified", + ] + ) + return invoice + + +def create_subscription_checkout( + subscription: RecurringSubscription, + *, + success_url: str, + cancel_url: str, +) -> RecurringSubscription: + """Create Stripe Product/Price + Checkout Session (subscription mode).""" + stripe_customer_id = ensure_stripe_customer(subscription.customer) + + product = _stripe_request( + "POST", + "/products", + { + "name": subscription.description, + "metadata": {"local_subscription_id": str(subscription.pk)}, + }, + ) + price = _stripe_request( + "POST", + "/prices", + { + "product": product["id"], + "unit_amount": subscription.amount_cents, + "currency": subscription.currency, + "recurring": {"interval": subscription.interval}, + }, + ) + session = _stripe_request( + "POST", + "/checkout/sessions", + { + "mode": "subscription", + "customer": stripe_customer_id, + "line_items": [{"price": price["id"], "quantity": 1}], + "success_url": success_url, + "cancel_url": cancel_url, + "metadata": {"local_subscription_id": str(subscription.pk)}, + "subscription_data": { + "metadata": {"local_subscription_id": str(subscription.pk)}, + }, + }, + ) + + subscription.stripe_product_id = product["id"] + subscription.stripe_price_id = price["id"] + subscription.checkout_session_id = session["id"] + subscription.checkout_url = session.get("url") or "" + subscription.status = RecurringSubscription.Status.INCOMPLETE + subscription.last_modified = timezone.now() + subscription.save( + update_fields=[ + "stripe_product_id", + "stripe_price_id", + "checkout_session_id", + "checkout_url", + "status", + "last_modified", + ] + ) + return subscription + + +def dollars_to_cents(amount: Decimal | float | str) -> int: + return int(round(Decimal(str(amount)) * 100)) + + +def send_pay_link_email( + *, + to_email: str, + customer_name: str, + description: str, + amount_dollars: float, + pay_url: str, + kind: str = "invoice", +) -> None: + """Email customer a Stripe payment / checkout link.""" + subject = ( + f"Payment request from AI ML Operations — {description}" + if kind == "invoice" + else f"Subscription signup — {description}" + ) + from_email = getattr( + settings, + "DEFAULT_FROM_EMAIL", + "AI ML Operations, LLC ", + ) + context = { + "customer_name": customer_name, + "description": description, + "amount_dollars": f"{amount_dollars:.2f}", + "pay_url": pay_url, + "kind": kind, + "subject": subject, + } + html_content = get_template("emails/invoice_pay_link.html").render(context) + text_content = get_template("emails/invoice_pay_link.txt").render(context) + msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email]) + msg.attach_alternative(html_content, "text/html") + msg.send(fail_silently=False) + + +def apply_stripe_invoice_event(stripe_invoice: dict) -> Invoice | None: + """Sync local Invoice from Stripe invoice object dict.""" + local_id = (stripe_invoice.get("metadata") or {}).get("local_invoice_id") + stripe_id = stripe_invoice.get("id") + invoice = None + if local_id: + invoice = Invoice.objects.filter(pk=local_id).first() + if invoice is None and stripe_id: + invoice = Invoice.objects.filter(stripe_invoice_id=stripe_id).first() + if invoice is None: + return None + + status = stripe_invoice.get("status") or invoice.status + if status in dict(Invoice.Status.choices): + invoice.status = status + invoice.hosted_invoice_url = ( + stripe_invoice.get("hosted_invoice_url") or invoice.hosted_invoice_url + ) + invoice.invoice_pdf_url = ( + stripe_invoice.get("invoice_pdf") or invoice.invoice_pdf_url + ) + if stripe_id: + invoice.stripe_invoice_id = stripe_id + invoice.last_modified = timezone.now() + invoice.save( + update_fields=[ + "status", + "hosted_invoice_url", + "invoice_pdf_url", + "stripe_invoice_id", + "last_modified", + ] + ) + return invoice + + +def apply_subscription_event(stripe_sub: dict) -> RecurringSubscription | None: + """Sync local RecurringSubscription from Stripe subscription object.""" + local_id = (stripe_sub.get("metadata") or {}).get("local_subscription_id") + stripe_id = stripe_sub.get("id") + sub = None + if local_id: + sub = RecurringSubscription.objects.filter(pk=local_id).first() + if sub is None and stripe_id: + sub = RecurringSubscription.objects.filter( + stripe_subscription_id=stripe_id + ).first() + if sub is None: + return None + + status = stripe_sub.get("status") or sub.status + if status in dict(RecurringSubscription.Status.choices): + sub.status = status + if stripe_id: + sub.stripe_subscription_id = stripe_id + sub.last_modified = timezone.now() + sub.save(update_fields=["status", "stripe_subscription_id", "last_modified"]) + return sub + + +def apply_checkout_session_completed(session: dict) -> RecurringSubscription | None: + """Attach subscription id after Checkout completes.""" + if session.get("mode") != "subscription": + return None + local_id = (session.get("metadata") or {}).get("local_subscription_id") + sub = None + if local_id: + sub = RecurringSubscription.objects.filter(pk=local_id).first() + if sub is None and session.get("id"): + sub = RecurringSubscription.objects.filter( + checkout_session_id=session["id"] + ).first() + if sub is None: + return None + + stripe_sub_id = session.get("subscription") + if stripe_sub_id: + sub.stripe_subscription_id = stripe_sub_id + sub.status = RecurringSubscription.Status.ACTIVE + sub.last_modified = timezone.now() + sub.save(update_fields=["stripe_subscription_id", "status", "last_modified"]) + return sub