generated from westfarn/web_django_template
191 lines
7.1 KiB
Python
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")
|