Files
print_forge/site/payments/views.py
T
ai_ml_operations 8a97e3fbe2 Initial commit
2026-09-06 04:27:41 -07:00

191 lines
7.1 KiB
Python

import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST
from contacts.models import Contact
from payments.models import Invoice
from payments.services import (
PaymentsError,
create_checkout_session,
mark_paid,
next_invoice_number,
send_invoice_email,
)
logger = logging.getLogger(__name__)
def _site_base(request) -> str:
base = (settings.PUBLIC_SITE_URL or "").rstrip("/")
if base:
return base
return request.build_absolute_uri("/").rstrip("/")
@login_required
def invoice_list(request):
invoices = Invoice.objects.select_related("contact")[:200]
return render(request, "payments/list.html", {"invoices": invoices})
@login_required
@require_http_methods(["GET", "POST"])
def invoice_create(request):
contacts = Contact.objects.exclude(email="").order_by("first_name", "last_name")
if request.method == "POST":
contact_id = (request.POST.get("contact") or "").strip()
description = (request.POST.get("description") or "").strip()
amount_raw = (request.POST.get("amount") or "").strip()
due = (request.POST.get("due_date") or "").strip()
notes = (request.POST.get("notes") or "").strip()
errors = []
contact = Contact.objects.filter(pk=contact_id).first()
if not contact:
errors.append("Choose a contact with an email address.")
if not description:
errors.append("Description is required.")
try:
from decimal import Decimal, InvalidOperation
amount = Decimal(amount_raw)
if amount <= 0:
raise InvalidOperation
except Exception:
amount = None
errors.append("Enter a valid amount greater than zero.")
if errors:
for err in errors:
messages.error(request, err)
else:
invoice = Invoice.objects.create(
number=next_invoice_number(),
contact=contact,
description=description,
amount=amount,
currency=(settings.STRIPE_CURRENCY or "usd").lower(),
due_date=due or None,
notes=notes,
created_by=request.user,
)
messages.success(request, f"Draft {invoice.number} created.")
return redirect("payments:invoice_detail", pk=invoice.pk)
return render(request, "payments/create.html", {"contacts": contacts})
@login_required
def invoice_detail(request, pk):
invoice = get_object_or_404(Invoice.objects.select_related("contact"), pk=pk)
return render(request, "payments/detail.html", {"invoice": invoice})
@login_required
@require_POST
def invoice_send(request, pk):
invoice = get_object_or_404(Invoice, pk=pk)
if invoice.status == Invoice.Status.VOID:
messages.error(request, "Void invoices cannot be sent.")
return redirect("payments:invoice_detail", pk=invoice.pk)
base = _site_base(request)
success = base + reverse("payments_public:pay_success", kwargs={"pk": invoice.pk})
cancel = base + reverse("payments_public:pay_cancel", kwargs={"pk": invoice.pk})
try:
url = create_checkout_session(
invoice, success_url=success + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=cancel,
)
send_invoice_email(invoice, url or (base + reverse("payments_public:pay", kwargs={"pk": invoice.pk})))
except PaymentsError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("invoice send failed")
messages.error(request, f"Could not send invoice: {exc}")
else:
messages.success(request, f"Pay link emailed to {invoice.contact.email}.")
return redirect("payments:invoice_detail", pk=invoice.pk)
@login_required
@require_POST
def invoice_void(request, pk):
invoice = get_object_or_404(Invoice, pk=pk)
invoice.status = Invoice.Status.VOID
invoice.save(update_fields=["status", "updated_at"])
messages.success(request, f"{invoice.number} voided.")
return redirect("payments:invoice_detail", pk=invoice.pk)
def pay_invoice(request, pk):
invoice = get_object_or_404(Invoice, pk=pk)
if invoice.status == Invoice.Status.PAID:
return redirect("payments_public:pay_success", pk=invoice.pk)
if invoice.status == Invoice.Status.VOID:
return render(request, "payments/pay.html", {"invoice": invoice, "closed": True})
if invoice.hosted_invoice_url:
return redirect(invoice.hosted_invoice_url)
base = _site_base(request)
success = base + reverse("payments_public:pay_success", kwargs={"pk": invoice.pk})
cancel = base + reverse("payments_public:pay_cancel", kwargs={"pk": invoice.pk})
try:
url = create_checkout_session(
invoice,
success_url=success + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=cancel,
)
except PaymentsError as exc:
return render(
request,
"payments/pay.html",
{"invoice": invoice, "error": str(exc)},
)
return redirect(url)
def pay_success(request, pk):
invoice = get_object_or_404(Invoice, pk=pk)
return render(request, "payments/success.html", {"invoice": invoice})
def pay_cancel(request, pk):
invoice = get_object_or_404(Invoice, pk=pk)
return render(request, "payments/cancel.html", {"invoice": invoice})
@csrf_exempt
@require_http_methods(["POST"])
def stripe_webhook(request):
secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip()
if not secret:
logger.error("STRIPE_WEBHOOK_SECRET unset")
return HttpResponseBadRequest("webhook not configured")
try:
import stripe
except ImportError:
return HttpResponseBadRequest("stripe not installed")
sig = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(request.body, sig, secret)
except Exception:
logger.exception("stripe webhook signature failed")
return HttpResponseBadRequest("invalid signature")
obj = event.get("data", {}).get("object", {}) or {}
if event.get("type") in {"checkout.session.completed", "invoice.paid"}:
invoice_id = (obj.get("metadata") or {}).get("invoice_id") or ""
invoice = None
if invoice_id:
invoice = Invoice.objects.filter(pk=invoice_id).first()
if invoice is None:
session_id = obj.get("id") or ""
invoice = Invoice.objects.filter(stripe_checkout_session_id=session_id).first()
if invoice and invoice.status != Invoice.Status.PAID:
mark_paid(invoice, stripe_id=obj.get("id") or "")
logger.info("invoice %s marked paid via Stripe %s", invoice.number, event.get("type"))
return HttpResponse("ok")