Add finance app with Stripe Checkout subscriptions (#21) (#23)
Unit Tests / test (push) Successful in 10s

## Summary
- Closes #21 — new Django `finance` app with Stripe as payment provider
- Subscription price defaults to **$10 USD / month** via `SUBSCRIPTION_PRICE_AMOUNT_CENTS = 1000` in `settings.py` (env-overridable)
- Persists **Invoice** and **Payment** rows; both registered in Django admin (with payment inline on invoices)
- Checkout Session API redirects users to Stripe hosted payment; webhook verifies signatures and upserts ledger idempotently

## API
- `POST /api/finance/checkout/` — JWT auth → `{ checkout_url, session_id }`
- `GET /api/finance/invoices/` / `GET /api/finance/payments/` — own records
- `POST /api/finance/webhooks/stripe/` — Stripe signature-verified webhook

## Config
Documented in `.env.example` / `.env.prod.example`:
`STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`, optional `STRIPE_PRICE_ID`, `FRONTEND_BASE_URL`

## Test plan
- [x] `uv run python manage.py test finance` (17 tests)
- [ ] Set Stripe test keys locally; create checkout session; complete payment in Stripe test mode
- [ ] Confirm Invoice/Payment appear in `/admin/`
- [ ] Point Stripe webhook to `/api/finance/webhooks/stripe/` and verify `checkout.session.completed` / `invoice.paid`Reviewed-on: #23
This commit was merged in pull request #23.
This commit is contained in:
2026-07-26 17:35:06 -07:00
parent 9984d1c340
commit ad44359804
23 changed files with 1540 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
import logging
import stripe
from django.conf import settings
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from finance.models import Invoice, Payment
from finance.serializers import (
CheckoutSessionSerializer,
InvoiceSerializer,
PaymentSerializer,
)
from finance.services.stripe_service import (
StripeNotConfiguredError,
create_checkout_session,
)
from finance.services.webhooks import dispatch_stripe_event
logger = logging.getLogger(__name__)
class CreateCheckoutSessionView(APIView):
"""Create a Stripe Checkout Session and return the hosted redirect URL."""
def post(self, request):
serializer = CheckoutSessionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
session = create_checkout_session(
user=request.user,
success_url=serializer.validated_data.get("success_url"),
cancel_url=serializer.validated_data.get("cancel_url"),
)
except StripeNotConfiguredError as exc:
return Response(
{"detail": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
except stripe.StripeError as exc:
logger.exception("Stripe Checkout Session creation failed")
return Response(
{"detail": str(getattr(exc, "user_message", None) or exc)},
status=status.HTTP_502_BAD_GATEWAY,
)
# Persist a draft invoice keyed by checkout session for admin visibility
# before the webhook fires.
Invoice.objects.update_or_create(
stripe_checkout_session_id=session.id,
defaults={
"user": request.user,
"company": request.user.company,
"provider": Invoice.Provider.STRIPE,
"status": Invoice.Status.OPEN,
"currency": settings.SUBSCRIPTION_PRICE_CURRENCY,
"amount_due": settings.SUBSCRIPTION_PRICE_AMOUNT_CENTS,
"amount_paid": 0,
"stripe_customer_id": getattr(session, "customer", None) or "",
"description": settings.SUBSCRIPTION_PRODUCT_NAME,
},
)
return Response(
{
"checkout_url": session.url,
"session_id": session.id,
},
status=status.HTTP_201_CREATED,
)
class InvoiceListView(APIView):
def get(self, request):
invoices = Invoice.objects.filter(user=request.user)
return Response(InvoiceSerializer(invoices, many=True).data)
class PaymentListView(APIView):
def get(self, request):
payments = Payment.objects.filter(user=request.user)
return Response(PaymentSerializer(payments, many=True).data)
class StripeWebhookView(APIView):
"""Verify Stripe signatures and upsert local invoice/payment rows."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def post(self, request):
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
if not webhook_secret:
logger.error("STRIPE_WEBHOOK_SECRET is not configured")
return Response(
{"detail": "Webhook secret not configured"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
try:
event = stripe.Webhook.construct_event(
payload=payload,
sig_header=sig_header,
secret=webhook_secret,
)
except ValueError:
return Response(
{"detail": "Invalid payload"},
status=status.HTTP_400_BAD_REQUEST,
)
except stripe.SignatureVerificationError:
return Response(
{"detail": "Invalid signature"},
status=status.HTTP_400_BAD_REQUEST,
)
if hasattr(event, "to_dict"):
event = event.to_dict()
try:
dispatch_stripe_event(event)
except Exception:
logger.exception("Error handling Stripe event %s", event.get("id"))
return Response(
{"detail": "Webhook handler error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"received": True}, status=status.HTTP_200_OK)