Initial commit

This commit is contained in:
ai_ml_operations
2026-09-06 04:27:41 -07:00
commit 8a97e3fbe2
302 changed files with 34038 additions and 0 deletions
View File
+10
View File
@@ -0,0 +1,10 @@
from django.contrib import admin
from payments.models import Invoice
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
list_display = ("number", "contact", "amount", "currency", "status", "due_date")
list_filter = ("status", "currency")
search_fields = ("number", "description", "stripe_invoice_id")
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "payments"
verbose_name = "Payments"
def ready(self):
from payments import hooks
hooks.register()
+22
View File
@@ -0,0 +1,22 @@
from core.registry import register_dashboard_collector, register_feature, register_portal_nav
def register() -> None:
register_feature("payments")
register_portal_nav(
section="payments",
label="Invoices",
url_name="payments:invoice_list",
group="Billing",
order=10,
)
register_dashboard_collector(_dashboard)
def _dashboard(request) -> dict:
from payments.models import Invoice
open_count = Invoice.objects.exclude(
status__in=[Invoice.Status.PAID, Invoice.Status.VOID]
).count()
return {"open_invoices": open_count}
+43
View File
@@ -0,0 +1,43 @@
# Generated by Django 6.1 on 2026-08-26 11:38
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contacts', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Invoice',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('number', models.CharField(max_length=32, unique=True)),
('description', models.CharField(max_length=255)),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='usd', max_length=8)),
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('void', 'Void'), ('uncollectible', 'Uncollectible')], default='draft', max_length=16)),
('due_date', models.DateField(blank=True, null=True)),
('stripe_invoice_id', models.CharField(blank=True, max_length=255)),
('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)),
('hosted_invoice_url', models.URLField(blank=True)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
('contact', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='contacts.contact')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_invoices', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]
+52
View File
@@ -0,0 +1,52 @@
from decimal import Decimal
from django.conf import settings
from django.db import models
from contacts.models import Contact
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Invoice(UUIDPrimaryKeyModel, TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open"
PAID = "paid", "Paid"
VOID = "void", "Void"
UNCOLLECTIBLE = "uncollectible", "Uncollectible"
number = models.CharField(max_length=32, unique=True)
contact = models.ForeignKey(
Contact,
on_delete=models.PROTECT,
related_name="invoices",
)
description = models.CharField(max_length=255)
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=8, default="usd")
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.DRAFT
)
due_date = models.DateField(null=True, blank=True)
stripe_invoice_id = models.CharField(max_length=255, blank=True)
stripe_checkout_session_id = models.CharField(max_length=255, blank=True)
hosted_invoice_url = models.URLField(blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_invoices",
)
notes = models.TextField(blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.number} · {self.contact} · {self.amount} {self.currency}"
@property
def amount_cents(self) -> int:
return int((self.amount * Decimal("100")).quantize(Decimal("1")))
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from payments import views
app_name = "payments_public"
urlpatterns = [
path("<uuid:pk>/", views.pay_invoice, name="pay"),
path("<uuid:pk>/success/", views.pay_success, name="pay_success"),
path("<uuid:pk>/cancel/", views.pay_cancel, name="pay_cancel"),
]
+116
View File
@@ -0,0 +1,116 @@
"""Stripe invoice + Checkout pay-link helpers. Requires FEATURE_EMAIL_SMS to send."""
from __future__ import annotations
import logging
from datetime import date
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from payments.models import Invoice
logger = logging.getLogger(__name__)
class PaymentsError(RuntimeError):
pass
def _stripe():
secret = (settings.STRIPE_SECRET_KEY or "").strip()
if not secret:
raise PaymentsError("STRIPE_SECRET_KEY is not configured")
try:
import stripe
except ImportError as exc:
raise PaymentsError("stripe package is not installed") from exc
stripe.api_key = secret
return stripe
def next_invoice_number() -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"INV-{today}-"
existing = Invoice.objects.filter(number__startswith=prefix).count()
return f"{prefix}{existing + 1:03d}"
def create_checkout_session(invoice: Invoice, *, success_url: str, cancel_url: str) -> str:
stripe = _stripe()
session = stripe.checkout.Session.create(
mode="payment",
customer_email=invoice.contact.email or None,
line_items=[
{
"quantity": 1,
"price_data": {
"currency": (invoice.currency or "usd").lower(),
"unit_amount": invoice.amount_cents,
"product_data": {
"name": invoice.description or f"Invoice {invoice.number}",
},
},
}
],
metadata={"invoice_id": str(invoice.pk), "invoice_number": invoice.number},
success_url=success_url,
cancel_url=cancel_url,
)
invoice.stripe_checkout_session_id = session.id
invoice.hosted_invoice_url = session.url or ""
invoice.status = Invoice.Status.OPEN
invoice.save(
update_fields=[
"stripe_checkout_session_id",
"hosted_invoice_url",
"status",
"updated_at",
]
)
return session.url or ""
def mark_paid(invoice: Invoice, *, stripe_id: str = "") -> None:
invoice.status = Invoice.Status.PAID
invoice.paid_at = timezone.now()
if stripe_id and not invoice.stripe_invoice_id:
invoice.stripe_invoice_id = stripe_id
invoice.save(
update_fields=["status", "paid_at", "stripe_invoice_id", "updated_at"]
)
def send_invoice_email(invoice: Invoice, pay_url: str) -> bool:
"""Send pay-link email through Django mail (SMTP2GO)."""
to_email = (invoice.contact.email or "").strip()
if not to_email:
raise PaymentsError("Contact has no email address")
from django.core.mail import EmailMultiAlternatives
name = invoice.contact.full_name or "there"
amount = f"{invoice.amount} {invoice.currency.upper()}"
subject = f"Invoice {invoice.number} from {settings.SITE_NAME}"
text = (
f"Hi {name},\n\n"
f"Invoice {invoice.number} for {amount} is ready.\n"
f"{invoice.description}\n\n"
f"Pay securely: {pay_url}\n"
)
html = (
f"<p>Hi {name},</p>"
f"<p>Invoice <strong>{invoice.number}</strong> for "
f"<strong>{amount}</strong> is ready.</p>"
f"<p>{invoice.description}</p>"
f'<p><a href="{pay_url}">Pay this invoice</a></p>'
)
mail = EmailMultiAlternatives(
subject=subject,
body=text,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
mail.attach_alternative(html, "text/html")
mail.send(fail_silently=False)
return True
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Payment cancelled{% endblock %}
{% block content %}
<section class="section section-lg"><div class="container">
<h1>Payment cancelled</h1>
<p>No charge was made for {{ invoice.number }}. You can return to the pay link anytime.</p>
</div></section>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "portal_base.html" %}
{% block title %}New invoice · Portal{% endblock %}
{% block topbar_title %}New invoice{% endblock %}
{% block portal_content %}
<form method="post" class="form-grid">
{% csrf_token %}
<div class="field">
<label>Contact</label>
<select name="contact" required>
<option value="">Choose…</option>
{% for c in contacts %}
<option value="{{ c.pk }}">{{ c }} · {{ c.email }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Description</label><input name="description" required></div>
<div class="field"><label>Amount</label><input name="amount" type="number" step="0.01" min="0.01" required></div>
<div class="field"><label>Due date</label><input name="due_date" type="date"></div>
<div class="field"><label>Notes</label><textarea name="notes"></textarea></div>
<button class="btn btn-primary" type="submit">Create draft</button>
</form>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "portal_base.html" %}
{% block title %}{{ invoice.number }} · Portal{% endblock %}
{% block topbar_title %}{{ invoice.number }}{% endblock %}
{% block portal_content %}
<p><strong>{{ invoice.contact }}</strong> · {{ invoice.amount }} {{ invoice.currency|upper }} · {{ invoice.get_status_display }}</p>
<p>{{ invoice.description }}</p>
{% if invoice.hosted_invoice_url %}<p><a href="{{ invoice.hosted_invoice_url }}">Stripe pay link</a></p>{% endif %}
{% if invoice.status != 'void' and invoice.status != 'paid' %}
<form method="post" action="{% url 'payments:invoice_send' invoice.pk %}" style="display:inline">{% csrf_token %}
<button class="btn btn-primary" type="submit">Email pay link</button>
</form>
<form method="post" action="{% url 'payments:invoice_void' invoice.pk %}" style="display:inline" onsubmit="return confirm('Void this invoice?');">{% csrf_token %}
<button class="btn btn-ghost" type="submit">Void</button>
</form>
{% endif %}
<p><a href="{% url 'payments:invoice_list' %}">← All invoices</a></p>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "portal_base.html" %}
{% block title %}Invoices · Portal{% endblock %}
{% block topbar_title %}Invoices{% endblock %}
{% block portal_content %}
<p><a class="btn btn-primary" href="{% url 'payments:invoice_create' %}">New invoice</a></p>
<table class="table">
<thead><tr><th>Number</th><th>Contact</th><th>Amount</th><th>Status</th></tr></thead>
<tbody>
{% for inv in invoices %}
<tr>
<td><a href="{% url 'payments:invoice_detail' inv.pk %}">{{ inv.number }}</a></td>
<td>{{ inv.contact }}</td>
<td>{{ inv.amount }} {{ inv.currency|upper }}</td>
<td>{{ inv.get_status_display }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No invoices yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Pay {{ invoice.number }}{% endblock %}
{% block content %}
<section class="section section-lg"><div class="container">
<h1>Invoice {{ invoice.number }}</h1>
{% if closed %}<p>This invoice is no longer payable.</p>
{% elif error %}<p>{{ error }}</p>
{% else %}<p>Redirecting to checkout…</p>{% endif %}
</div></section>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Payment received{% endblock %}
{% block content %}
<section class="section section-lg"><div class="container">
<h1>Thank you</h1>
<p>Payment for {{ invoice.number }} was received{% if invoice.status == 'paid' %}{% endif %}.</p>
</div></section>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from contacts.models import Contact
from payments.models import Invoice
from payments.services import next_invoice_number
class PaymentsPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("biller", password="test-pass-123")
self.client = Client()
self.client.login(username="biller", password="test-pass-123")
self.contact = Contact.objects.create(
email="pay@example.com", first_name="Pat", last_name="Lee"
)
def test_list_requires_login(self):
anon = Client()
self.assertEqual(anon.get(reverse("payments:invoice_list")).status_code, 302)
def test_create_draft_invoice(self):
response = self.client.post(
reverse("payments:invoice_create"),
{
"contact": str(self.contact.pk),
"description": "Website package",
"amount": "150.00",
},
)
self.assertEqual(response.status_code, 302)
inv = Invoice.objects.get()
self.assertEqual(inv.amount, Decimal("150.00"))
self.assertEqual(inv.status, Invoice.Status.DRAFT)
self.assertTrue(inv.number.startswith("INV-"))
def test_next_number_increments(self):
n1 = next_invoice_number()
Invoice.objects.create(
number=n1,
contact=self.contact,
description="a",
amount=Decimal("1.00"),
)
n2 = next_invoice_number()
self.assertNotEqual(n1, n2)
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from payments import views
app_name = "payments"
urlpatterns = [
path("", views.invoice_list, name="invoice_list"),
path("new/", views.invoice_create, name="invoice_create"),
path("<uuid:pk>/", views.invoice_detail, name="invoice_detail"),
path("<uuid:pk>/send/", views.invoice_send, name="invoice_send"),
path("<uuid:pk>/void/", views.invoice_void, name="invoice_void"),
path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"),
]
+190
View File
@@ -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")