Add finance app with Stripe Checkout subscriptions (#21)
CI / test (pull_request) Successful in 9s
Unit Tests / test (pull_request) Successful in 10s

Introduce Invoice/Payment ledger models, $10/mo settings-backed pricing,
hosted Checkout + signed webhooks, Django admin, and authenticated list APIs.
This commit is contained in:
2026-07-26 19:16:34 -05:00
parent 9984d1c340
commit f92bd268b3
23 changed files with 1540 additions and 0 deletions
+332
View File
@@ -0,0 +1,332 @@
"""Idempotent Stripe webhook handlers that upsert Invoice / Payment rows."""
from __future__ import annotations
import logging
from datetime import datetime, timezone as dt_timezone
from typing import Any
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from finance.models import Invoice, Payment
logger = logging.getLogger(__name__)
User = get_user_model()
def _ts_to_dt(value: int | None):
if not value:
return None
return datetime.fromtimestamp(value, tz=dt_timezone.utc)
def _resolve_user(*, user_id: str | None = None, customer_email: str | None = None):
if user_id:
try:
return User.objects.get(pk=int(user_id))
except (User.DoesNotExist, TypeError, ValueError):
logger.warning("Webhook: user_id=%s not found", user_id)
if customer_email:
user = User.objects.filter(email__iexact=customer_email).first()
if user:
return user
logger.warning("Webhook: email=%s not found", customer_email)
return None
def _user_from_metadata(metadata: dict | None, *, email: str | None = None):
metadata = metadata or {}
return _resolve_user(
user_id=metadata.get("user_id") or metadata.get("client_reference_id"),
customer_email=email,
)
@transaction.atomic
def upsert_invoice_from_stripe(
*,
user,
stripe_invoice: dict[str, Any] | None = None,
stripe_checkout_session_id: str | None = None,
stripe_subscription_id: str | None = None,
stripe_customer_id: str | None = None,
status: str,
amount_due: int = 0,
amount_paid: int = 0,
currency: str = "usd",
period_start=None,
period_end=None,
hosted_invoice_url: str = "",
description: str = "",
) -> Invoice:
stripe_invoice_id = None
if stripe_invoice:
stripe_invoice_id = stripe_invoice.get("id")
stripe_subscription_id = (
stripe_subscription_id or stripe_invoice.get("subscription") or None
)
stripe_customer_id = (
stripe_customer_id or stripe_invoice.get("customer") or None
)
amount_due = int(stripe_invoice.get("amount_due") or amount_due or 0)
amount_paid = int(stripe_invoice.get("amount_paid") or amount_paid or 0)
currency = (stripe_invoice.get("currency") or currency or "usd").lower()
period_start = period_start or _ts_to_dt(
(stripe_invoice.get("period_start") or stripe_invoice.get("created"))
)
period_end = period_end or _ts_to_dt(stripe_invoice.get("period_end"))
hosted_invoice_url = (
hosted_invoice_url or stripe_invoice.get("hosted_invoice_url") or ""
)
description = description or stripe_invoice.get("description") or ""
lookup: dict[str, Any] = {}
if stripe_invoice_id:
lookup["stripe_invoice_id"] = stripe_invoice_id
elif stripe_checkout_session_id:
lookup["stripe_checkout_session_id"] = stripe_checkout_session_id
else:
raise ValueError("Need stripe_invoice_id or stripe_checkout_session_id")
defaults = {
"user": user,
"company": getattr(user, "company", None),
"provider": Invoice.Provider.STRIPE,
"status": status,
"currency": currency,
"amount_due": amount_due,
"amount_paid": amount_paid,
"period_start": period_start,
"period_end": period_end,
"stripe_subscription_id": stripe_subscription_id or None,
"stripe_customer_id": stripe_customer_id or "",
"hosted_invoice_url": hosted_invoice_url or "",
"description": description or "",
}
if stripe_invoice_id:
defaults["stripe_invoice_id"] = stripe_invoice_id
if stripe_checkout_session_id:
defaults["stripe_checkout_session_id"] = stripe_checkout_session_id
invoice, _created = Invoice.objects.update_or_create(
**lookup,
defaults=defaults,
)
if stripe_invoice_id and invoice.stripe_invoice_id != stripe_invoice_id:
invoice.stripe_invoice_id = stripe_invoice_id
invoice.save(update_fields=["stripe_invoice_id", "last_modified"])
return invoice
@transaction.atomic
def upsert_payment_from_stripe(
*,
user,
invoice: Invoice | None,
amount: int,
currency: str = "usd",
status: str,
stripe_payment_intent_id: str | None = None,
stripe_charge_id: str | None = None,
paid_at=None,
failure_message: str = "",
) -> Payment:
if not stripe_payment_intent_id and not stripe_charge_id:
raise ValueError("Need stripe_payment_intent_id or stripe_charge_id")
lookup: dict[str, Any] = {}
if stripe_payment_intent_id:
lookup["stripe_payment_intent_id"] = stripe_payment_intent_id
else:
lookup["stripe_charge_id"] = stripe_charge_id
defaults = {
"user": user,
"company": getattr(user, "company", None),
"invoice": invoice,
"provider": Payment.Provider.STRIPE,
"status": status,
"currency": (currency or "usd").lower(),
"amount": int(amount or 0),
"paid_at": paid_at,
"failure_message": failure_message or "",
}
if stripe_payment_intent_id:
defaults["stripe_payment_intent_id"] = stripe_payment_intent_id
if stripe_charge_id:
defaults["stripe_charge_id"] = stripe_charge_id
payment, _created = Payment.objects.update_or_create(
**lookup,
defaults=defaults,
)
return payment
def handle_checkout_session_completed(session: dict[str, Any]) -> Invoice | None:
metadata = session.get("metadata") or {}
customer_details = session.get("customer_details") or {}
user = _user_from_metadata(
metadata,
email=customer_details.get("email") or session.get("customer_email"),
)
if user is None and session.get("client_reference_id"):
user = _resolve_user(user_id=session.get("client_reference_id"))
if user is None:
logger.error(
"checkout.session.completed: cannot resolve user for session %s",
session.get("id"),
)
return None
amount_total = int(session.get("amount_total") or 0)
invoice = upsert_invoice_from_stripe(
user=user,
stripe_checkout_session_id=session.get("id"),
stripe_subscription_id=session.get("subscription") or None,
stripe_customer_id=session.get("customer") or None,
status=(
Invoice.Status.PAID
if session.get("payment_status") == "paid"
else Invoice.Status.OPEN
),
amount_due=amount_total,
amount_paid=amount_total if session.get("payment_status") == "paid" else 0,
currency=(session.get("currency") or "usd").lower(),
description="Subscription checkout",
)
payment_intent = session.get("payment_intent")
if payment_intent and session.get("payment_status") == "paid":
upsert_payment_from_stripe(
user=user,
invoice=invoice,
amount=amount_total,
currency=(session.get("currency") or "usd").lower(),
status=Payment.Status.SUCCEEDED,
stripe_payment_intent_id=(
payment_intent if isinstance(payment_intent, str) else None
),
paid_at=timezone.now(),
)
return invoice
def handle_invoice_paid(stripe_invoice: dict[str, Any]) -> Invoice | None:
metadata = stripe_invoice.get("metadata") or {}
user = _user_from_metadata(
metadata,
email=stripe_invoice.get("customer_email"),
)
if user is None:
existing = None
if stripe_invoice.get("id"):
existing = (
Invoice.objects.filter(stripe_invoice_id=stripe_invoice["id"])
.select_related("user")
.first()
)
if existing is None and stripe_invoice.get("subscription"):
existing = (
Invoice.objects.filter(
stripe_subscription_id=stripe_invoice["subscription"]
)
.select_related("user")
.order_by("-created")
.first()
)
if existing:
user = existing.user
if user is None:
logger.error(
"invoice.paid: cannot resolve user for invoice %s",
stripe_invoice.get("id"),
)
return None
invoice = upsert_invoice_from_stripe(
user=user,
stripe_invoice=stripe_invoice,
status=Invoice.Status.PAID,
)
payment_intent = stripe_invoice.get("payment_intent")
charge = stripe_invoice.get("charge")
if payment_intent or charge:
paid_at = _ts_to_dt(
(stripe_invoice.get("status_transitions") or {}).get("paid_at")
) or timezone.now()
upsert_payment_from_stripe(
user=user,
invoice=invoice,
amount=int(stripe_invoice.get("amount_paid") or 0),
currency=(stripe_invoice.get("currency") or "usd").lower(),
status=Payment.Status.SUCCEEDED,
stripe_payment_intent_id=(
payment_intent if isinstance(payment_intent, str) else None
),
stripe_charge_id=charge if isinstance(charge, str) else None,
paid_at=paid_at,
)
return invoice
def handle_invoice_payment_failed(stripe_invoice: dict[str, Any]) -> Invoice | None:
metadata = stripe_invoice.get("metadata") or {}
user = _user_from_metadata(
metadata,
email=stripe_invoice.get("customer_email"),
)
if user is None:
existing = (
Invoice.objects.filter(stripe_invoice_id=stripe_invoice.get("id"))
.select_related("user")
.first()
)
if existing:
user = existing.user
if user is None:
logger.error(
"invoice.payment_failed: cannot resolve user for invoice %s",
stripe_invoice.get("id"),
)
return None
invoice = upsert_invoice_from_stripe(
user=user,
stripe_invoice=stripe_invoice,
status=Invoice.Status.PAYMENT_FAILED,
)
payment_intent = stripe_invoice.get("payment_intent")
if payment_intent:
upsert_payment_from_stripe(
user=user,
invoice=invoice,
amount=int(stripe_invoice.get("amount_due") or 0),
currency=(stripe_invoice.get("currency") or "usd").lower(),
status=Payment.Status.FAILED,
stripe_payment_intent_id=(
payment_intent if isinstance(payment_intent, str) else None
),
failure_message="Stripe invoice payment failed",
)
return invoice
def dispatch_stripe_event(event: dict[str, Any]):
"""Route a verified Stripe event to the appropriate handler."""
event_type = event.get("type")
data_object = (event.get("data") or {}).get("object") or {}
if event_type == "checkout.session.completed":
return handle_checkout_session_completed(data_object)
if event_type == "invoice.paid":
return handle_invoice_paid(data_object)
if event_type == "invoice.payment_failed":
return handle_invoice_payment_failed(data_object)
logger.info("Ignoring unhandled Stripe event type: %s", event_type)
return None