generated from westfarn/web_django_template
Initial commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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")
|
||||
Reference in New Issue
Block a user