"""Purchase order and corporate invoicing models.
Supports offline corporate purchases where organizations pay via wire transfer,
ACH, check, or other non-card methods. Each purchase order tracks line items,
partial payments, and credit notes independently of the cart/checkout flow.
"""
from decimal import Decimal
from django.conf import settings
from django.db import models
[docs]
class PurchaseOrder(models.Model):
"""A corporate purchase order for bulk or invoiced ticket purchases.
Purchase orders exist outside the normal cart/checkout flow and support
partial payments over time via wire, ACH, check, or Stripe. The
``balance_due`` property tracks the remaining amount after payments
and credit notes.
"""
[docs]
class Status(models.TextChoices):
"""Lifecycle states for a purchase order."""
DRAFT = "draft", "Draft"
SENT = "sent", "Sent"
PARTIALLY_PAID = "partially_paid", "Partially Paid"
PAID = "paid", "Paid"
OVERPAID = "overpaid", "Overpaid"
CANCELLED = "cancelled", "Cancelled"
conference = models.ForeignKey(
"program_conference.Conference",
on_delete=models.CASCADE,
related_name="purchase_orders",
)
organization_name = models.CharField(max_length=300)
contact_email = models.EmailField()
contact_name = models.CharField(max_length=200)
billing_address = models.TextField(blank=True, default="")
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.DRAFT,
)
stripe_invoice_id = models.CharField(
max_length=200,
blank=True,
default="",
help_text="Stripe Invoice ID (e.g. in_xxx) when sent via Stripe Invoicing.",
)
stripe_invoice_url = models.URLField(
max_length=500,
blank=True,
default="",
help_text="Stripe-hosted invoice URL for the customer to pay online.",
)
qbo_invoice_id = models.CharField(
max_length=200,
blank=True,
default="",
help_text="QuickBooks Online Invoice ID, set after invoice creation.",
)
qbo_invoice_url = models.URLField(
max_length=500,
blank=True,
default="",
help_text="Public URL to the QBO invoice for the customer.",
)
notes = models.TextField(blank=True, default="")
reference = models.CharField(
max_length=100,
unique=True,
help_text='Unique PO reference, e.g. "PO-A1B2C3".',
)
subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0)
total = models.DecimalField(max_digits=10, decimal_places=2, default=0)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="created_purchase_orders",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.reference} ({self.status})"
@property
def total_paid(self) -> Decimal:
"""Return the sum of all recorded payment amounts.
Uses the ``_annotated_total_paid`` annotation when available (set by
list views) to avoid per-row aggregate queries.
"""
annotated = getattr(self, "_annotated_total_paid", None)
if annotated is not None:
return Decimal(str(annotated))
return self.payments.aggregate(total=models.Sum("amount"))["total"] or Decimal("0.00")
@property
def total_credited(self) -> Decimal:
"""Return the sum of all credit note amounts.
Uses the ``_annotated_total_credited`` annotation when available (set by
list views) to avoid per-row aggregate queries.
"""
annotated = getattr(self, "_annotated_total_credited", None)
if annotated is not None:
return Decimal(str(annotated))
return self.credit_notes.aggregate(total=models.Sum("amount"))["total"] or Decimal("0.00")
@property
def balance_due(self) -> Decimal:
"""Return the outstanding balance after payments and credit notes."""
return self.total - self.total_paid - self.total_credited
[docs]
class PurchaseOrderLineItem(models.Model):
"""A single line item on a purchase order.
Each line references an optional ticket type or add-on for traceability,
but the description and pricing are snapshotted at creation time.
"""
purchase_order = models.ForeignKey(
PurchaseOrder,
on_delete=models.CASCADE,
related_name="line_items",
)
description = models.CharField(max_length=300)
quantity = models.PositiveIntegerField(default=1)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
line_total = models.DecimalField(max_digits=10, decimal_places=2)
ticket_type = models.ForeignKey(
"program_registration.TicketType",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="purchase_order_line_items",
)
addon = models.ForeignKey(
"program_registration.AddOn",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="purchase_order_line_items",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["id"]
def __str__(self) -> str:
return f"{self.quantity}x {self.description}"
[docs]
class PurchaseOrderPayment(models.Model):
"""A payment recorded against a purchase order.
Tracks individual payments received via wire transfer, ACH, check,
Stripe, or other methods. Multiple payments can be recorded as
partial payments arrive.
"""
[docs]
class Method(models.TextChoices):
"""Supported payment methods for purchase orders."""
WIRE = "wire", "Wire Transfer"
ACH = "ach", "ACH"
CHECK = "check", "Check"
STRIPE = "stripe", "Stripe"
OTHER = "other", "Other"
purchase_order = models.ForeignKey(
PurchaseOrder,
on_delete=models.CASCADE,
related_name="payments",
)
amount = models.DecimalField(max_digits=10, decimal_places=2)
reference = models.CharField(max_length=200, blank=True, default="")
method = models.CharField(
max_length=20,
choices=Method.choices,
default=Method.WIRE,
)
entered_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="entered_po_payments",
)
payment_date = models.DateField()
note = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-payment_date"]
def __str__(self) -> str:
return f"{self.get_method_display()} {self.amount} on {self.payment_date}"
[docs]
class PurchaseOrderCreditNote(models.Model):
"""A credit note issued against a purchase order.
Reduces the effective balance due on the purchase order. Used for
adjustments, corrections, or partial cancellations.
"""
purchase_order = models.ForeignKey(
PurchaseOrder,
on_delete=models.CASCADE,
related_name="credit_notes",
)
amount = models.DecimalField(max_digits=10, decimal_places=2)
reason = models.TextField()
issued_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="issued_po_credit_notes",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"Credit {self.amount} — {str(self.reason)[:50]}"