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")