diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 39dcdf10a..b012d774f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -90,6 +90,9 @@ jobs:
- name: Install dependencies
run: pip install --group dev -e .
+ - name: Check agreements types
+ run: python -m mypy
+
- name: Run tests
run: python -Wd -m coverage run manage.py test -v2
env:
diff --git a/.gitignore b/.gitignore
index cf0bec2d5..b1ce33428 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@
docs/build
media/*
+private-media/*
static-root/
static/stylesheets/mq.css
static/stylesheets/no-mq.css
diff --git a/apps/agreements/__init__.py b/apps/agreements/__init__.py
new file mode 100644
index 000000000..ac1a63477
--- /dev/null
+++ b/apps/agreements/__init__.py
@@ -0,0 +1 @@
+"""PSF agreements: published terms, documents offered for signature, and their signatures."""
diff --git a/apps/agreements/admin.py b/apps/agreements/admin.py
new file mode 100644
index 000000000..7b93b0788
--- /dev/null
+++ b/apps/agreements/admin.py
@@ -0,0 +1,230 @@
+"""Manage program configuration and browse immutable signing records."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from django.contrib import admin
+from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
+from django.contrib.auth.models import Group
+
+from apps.agreements.auth import can_prepare, is_administrator
+from apps.agreements.models import (
+ Agreement,
+ AgreementRevision,
+ CustomContract,
+ Order,
+ OrderLine,
+ Program,
+ SignedCopy,
+ SigningLink,
+ Terms,
+ TermsVersion,
+)
+
+if TYPE_CHECKING:
+ from django.db.models import Model, QuerySet
+ from django.http import HttpRequest
+
+admin.site.unregister(Group)
+
+
+@admin.register(Group)
+class GroupAdmin(BaseGroupAdmin):
+ """Reserve role and permission administration for trusted identity admins."""
+
+ def has_add_permission(self, request: HttpRequest) -> bool:
+ """Prevent staff from creating groups that confer privileged roles."""
+ return request.user.is_superuser and super().has_add_permission(request)
+
+ def has_change_permission(self, request: HttpRequest, obj: Group | None = None) -> bool:
+ """Protect role names and permissions from delegated group editors."""
+ return request.user.is_superuser and super().has_change_permission(request, obj)
+
+ def has_delete_permission(self, request: HttpRequest, obj: Group | None = None) -> bool:
+ """Keep delegated staff from deleting roles and the memberships they confer."""
+ return request.user.is_superuser and super().has_delete_permission(request, obj)
+
+
+class _AgreementAdmin(admin.ModelAdmin):
+ """Use agreement groups, never Django's model or superuser permissions."""
+
+ def has_module_permission(self, request: HttpRequest) -> bool:
+ return can_prepare(request.user)
+
+ def has_view_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ return can_prepare(request.user)
+
+
+class _ConfigurationAdmin(_AgreementAdmin):
+ """Allow only administrators to create and change configuration."""
+
+ def has_add_permission(self, request: HttpRequest) -> bool:
+ return is_administrator(request.user)
+
+ def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ return is_administrator(request.user)
+
+ def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ return False
+
+
+class _ReadOnlyInline(admin.TabularInline):
+ extra = 0
+ can_delete = False
+
+ def has_view_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ return can_prepare(request.user)
+
+ def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ return False
+
+ def has_add_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ """Leave creating records to the workflow, which checks them."""
+ return False
+
+ def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ """Keep records unchanged."""
+ return False
+
+
+class TermsVersionInline(_ReadOnlyInline):
+ """Published versions; publish new ones from the terms editor."""
+
+ model = TermsVersion
+ fields = ("version", "sha256", "notes", "published_by", "published_at")
+ readonly_fields = fields
+
+
+@admin.register(Terms)
+class TermsAdmin(_ConfigurationAdmin):
+ """Create terms here; edit and publish their text on the site."""
+
+ list_display = ("title", "slug", "is_public", "under_review")
+ fields = ("slug", "title", "is_public", "under_review")
+ inlines = (TermsVersionInline,)
+
+ def get_readonly_fields(self, request: HttpRequest, obj: Terms | None = None) -> tuple[str, ...]:
+ """Keep published addresses and catalog references stable."""
+ if obj is not None and obj.versions.exists():
+ return ("slug",)
+ return ()
+
+
+class RevisionInline(_ReadOnlyInline):
+ """Every version of the text before signature."""
+
+ model = AgreementRevision
+ fields = ("revision", "note", "sha256", "created_by", "created_at")
+ readonly_fields = fields
+
+
+class SignedCopyInline(_ReadOnlyInline):
+ """Signed copies; download them from the agreement page."""
+
+ model = SignedCopy
+ fields = ("kind", "filename", "sha256", "uploaded_by", "uploaded_at")
+ readonly_fields = fields
+
+ def get_queryset(self, request: HttpRequest) -> QuerySet[SignedCopy]:
+ """Leave the file contents in the database."""
+ return super().get_queryset(request).defer("content")
+
+
+class SigningLinkInline(_ReadOnlyInline):
+ """Links sent; tokens are not stored."""
+
+ model = SigningLink
+ fields = ("name", "email", "created_by", "created_at", "expires_at", "used_at")
+ readonly_fields = fields
+
+
+@admin.register(Agreement)
+class AgreementAdmin(_AgreementAdmin):
+ """Browse agreements; every field is a record."""
+
+ list_display = ("title", "counterparty_name", "kind", "status", "offered_at")
+ list_filter = ("kind", "status", "signature_method")
+ search_fields = ("title", "counterparty_name", "signer_email", "counterparty_account__email")
+ inlines = (RevisionInline, SignedCopyInline, SigningLinkInline)
+
+ def get_readonly_fields(self, request: HttpRequest, obj: Agreement | None = None) -> list[str]:
+ """Show everything, change nothing."""
+ return [field.name for field in self.model._meta.get_fields() if field.concrete] # noqa: SLF001 - Django admin pattern requires _meta access
+
+ def has_add_permission(self, request: HttpRequest) -> bool:
+ """Agreements are offered from their draft."""
+ return False
+
+ def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ """Keep immutable records behind the signing workflow."""
+ return False
+
+ def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ """Agreements are records."""
+ return False
+
+
+@admin.register(CustomContract)
+class CustomContractAdmin(_AgreementAdmin):
+ """Browse custom contracts; write and offer them on the site."""
+
+ list_display = ("title", "counterparty_name", "created_by", "created")
+ search_fields = ("title", "counterparty_name")
+ readonly_fields = ("agreement", "created_by", "created", "modified")
+ raw_id_fields = ("counterparty_account",)
+
+ def has_add_permission(self, request: HttpRequest) -> bool:
+ """Create contracts on the site, where their author is recorded."""
+ return False
+
+ def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ """Keep edits behind the locked, draft-only site workflow."""
+ return False
+
+ def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ """Discard drafts through the locked site workflow."""
+ return False
+
+
+@admin.register(Program)
+class ProgramAdmin(_ConfigurationAdmin):
+ """Configure programs privately; publication is an explicit staff decision."""
+
+ list_display = ("title", "slug", "is_public")
+ list_filter = ("is_public",)
+ search_fields = ("title", "slug")
+ fields = ("title", "slug", "description", "is_public", "definition")
+
+
+class OrderLineInline(_ReadOnlyInline):
+ """Inspect selections without bypassing the builder's validation."""
+
+ model = OrderLine
+ fields = ("agreement", "tier", "addons", "special_terms", "pricing")
+ readonly_fields = fields
+
+
+@admin.register(Order)
+class OrderAdmin(_AgreementAdmin):
+ """Browse orders; use their customer-facing page for changes."""
+
+ list_display = ("legal_name", "program", "status", "created")
+ search_fields = ("legal_name", "customer_account__email")
+ inlines = (OrderLineInline,)
+
+ def get_readonly_fields(self, request: HttpRequest, obj: Order | None = None) -> list[str]:
+ """Keep selections and snapshots behind the workflow."""
+ return [field.name for field in self.model._meta.fields] # noqa: SLF001 - Django admin field metadata
+
+ def has_add_permission(self, request: HttpRequest) -> bool:
+ """Create orders through the validated builder."""
+ return False
+
+ def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ """Keep order mutations behind the validated site workflow."""
+ return False
+
+ def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
+ """Discard drafts through the authorized order page."""
+ return False
diff --git a/apps/agreements/apps.py b/apps/agreements/apps.py
new file mode 100644
index 000000000..d12ce8712
--- /dev/null
+++ b/apps/agreements/apps.py
@@ -0,0 +1,17 @@
+"""Django app configuration for PSF agreements."""
+
+from django.apps import AppConfig
+
+
+class AgreementsConfig(AppConfig):
+ """Drafts, signatures, and published terms for every agreement the PSF signs."""
+
+ name = "apps.agreements"
+ label = "agreements"
+ verbose_name = "Agreements"
+ default_auto_field = "django.db.models.BigAutoField"
+
+ def ready(self) -> None:
+ """Register the built-in document kinds."""
+ from apps.agreements import kinds # noqa: F401 - registers on import
+ from apps.agreements.orders import kinds as order_kinds # noqa: F401 - registers on import
diff --git a/apps/agreements/auth.py b/apps/agreements/auth.py
new file mode 100644
index 000000000..61e43918b
--- /dev/null
+++ b/apps/agreements/auth.py
@@ -0,0 +1,58 @@
+"""Agreement management is granted by named groups, never user permissions."""
+
+from __future__ import annotations
+
+from functools import wraps
+from typing import TYPE_CHECKING, Concatenate
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterable
+
+ from django.contrib.auth.models import AnonymousUser
+ from django.http import HttpRequest, HttpResponse
+
+ from apps.users.models import User
+
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+
+EDITORS = "Agreements Editors"
+ADMINISTRATORS = "Agreements Administrators"
+
+
+def _in_groups(user: User | AnonymousUser, names: Iterable[str]) -> bool:
+ return user.is_authenticated and user.is_active and user.groups.filter(name__in=names).exists()
+
+
+def can_prepare(user: User | AnonymousUser) -> bool:
+ """Allow either group to read records and prepare unoffered drafts."""
+ return _in_groups(user, (EDITORS, ADMINISTRATORS))
+
+
+def is_administrator(user: User | AnonymousUser) -> bool:
+ """Reserve configuration and agreement transitions for the administrator group."""
+ return _in_groups(user, (ADMINISTRATORS,))
+
+
+def _role_required[**P](
+ check: Callable[[User | AnonymousUser], bool],
+) -> Callable[
+ [Callable[Concatenate[HttpRequest, P], HttpResponse]], Callable[Concatenate[HttpRequest, P], HttpResponse]
+]:
+ def decorate(
+ view: Callable[Concatenate[HttpRequest, P], HttpResponse],
+ ) -> Callable[Concatenate[HttpRequest, P], HttpResponse]:
+ @login_required
+ @wraps(view)
+ def guarded(request: HttpRequest, *args: P.args, **kwargs: P.kwargs) -> HttpResponse:
+ if not check(request.user):
+ raise PermissionDenied
+ return view(request, *args, **kwargs)
+
+ return guarded
+
+ return decorate
+
+
+preparer_required = _role_required(can_prepare)
+administrator_required = _role_required(is_administrator)
diff --git a/apps/agreements/documents.py b/apps/agreements/documents.py
new file mode 100644
index 000000000..a929a6c9d
--- /dev/null
+++ b/apps/agreements/documents.py
@@ -0,0 +1,288 @@
+"""Render agreements and published terms as markdown, HTML, PDF, and DOCX.
+
+A document's markdown carries two placeholders, ``SIGNATURES`` and ``EFFECTIVE_DATE``, that are
+filled in when it is rendered; everything else is the text the counterparty signs, and its
+SHA-256 identifies it. User input must go through ``md()``. Documents may be written by PSF
+staff, so pandoc runs sandboxed with raw output disabled and a filter that rejects images.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import re
+import string
+import tempfile
+from datetime import UTC
+from functools import lru_cache
+from pathlib import Path
+from typing import TYPE_CHECKING, cast
+
+import pypandoc
+from django.utils import timezone
+from django.utils.dateformat import format as date_format
+from unidecode import unidecode
+
+from pydotorg.markup import sanitize
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterable, Sequence
+ from datetime import datetime
+
+ from apps.agreements.models import Agreement, TermsVersion
+
+FILTER = str(Path(__file__).parent / "pandoc_filters" / "agreement.lua")
+PANDOC_ARGS = ("--sandbox",)
+
+# Documents are escaped or staff-written; these extensions are off as a second layer so
+# nothing in a document can reach the LaTeX engine or the page as raw markup.
+MARKDOWN_FORMAT = "markdown-raw_tex-raw_html-raw_attribute-tex_math_dollars-tex_math_single_backslash"
+
+SIGNATURES = "AGREEMENTSIGNATURES"
+EFFECTIVE_DATE = "AGREEMENTEFFECTIVEDATE"
+PAGEBREAK = "::: pagebreak\n:::"
+
+PROVIDER_NAME = "Python Software Foundation"
+PROVIDER_PREAMBLE = (
+ "**PYTHON SOFTWARE FOUNDATION**, a Delaware nonprofit corporation and tax-exempt organization "
+ "under Section 501(c)(3) of the Internal Revenue Code (EIN 04-3594598), with its principal place "
+ "of business at 9450 SW Gemini Drive, ECM \\#90772, Beaverton, OR 97008 "
+ '(the "Provider" or the "PSF")'
+)
+
+_PUNCTUATION = re.compile(f"([{re.escape(string.punctuation)}])")
+_H2 = re.compile(r'
(.*?)
', re.DOTALL)
+_TAGS = re.compile(r"<[^>]+>")
+_LEGACY_TERMS_FINGERPRINT = re.compile(
+ r"(?Ppublished at \n]+>), SHA-256 "
+ r"(?:`[a-f0-9]{64}`|[a-f0-9]{16}(?: [a-f0-9]{16}){3})(?=\.)"
+)
+_LEGACY_FINGERPRINT_DESCRIPTION = re.compile(r"(?<=identified by its permanent address) and SHA\\?-256 hash(?=\.)")
+
+
+def md(value: object) -> str:
+ """Escape user-supplied text so pandoc renders it literally, on a single line."""
+ return _PUNCTUATION.sub(r"\\\1", " ".join(str(value).split()))
+
+
+def sha256(text: str) -> str:
+ """Hash ``text`` with SHA-256 and return the hex digest."""
+ return hashlib.sha256(text.encode()).hexdigest()
+
+
+def date(value: datetime | None) -> str:
+ """Format a datetime as a long local date."""
+ return date_format(timezone.localtime(value), "F j, Y")
+
+
+def _utc(value: datetime) -> str:
+ return f"{value.astimezone(UTC):%Y-%m-%d %H:%M:%S} UTC"
+
+
+def table(
+ header: tuple[str, str],
+ rows: Iterable[tuple[str, str]],
+ *,
+ widths: tuple[int, int] = (28, 52),
+ amounts: bool = False,
+) -> list[str]:
+ """Return a two-column pipe table; cells are already escaped markdown.
+
+ Pandoc sets a table full width, in the proportions of the separator row, only when a source
+ line exceeds 72 characters; the separator is always longer, so every table lines up.
+ """
+ right = ":" if amounts else ""
+ separator = f"|:{'-' * widths[0]}|{'-' * widths[1]}{right}|"
+ out = [f"| {header[0]} | {header[1]} |", separator]
+ return [*out, *(f"| {label} | {value} |" for label, value in rows), ""]
+
+
+def amounts_table(header: tuple[str, str], rows: Iterable[tuple[str, str]]) -> list[str]:
+ """Return a table whose second column holds right-aligned amounts."""
+ return table(header, rows, widths=(56, 24), amounts=True)
+
+
+# ---------- Signatures ----------
+
+
+def _signature_column(name: str, title: str, when: datetime | None, *, on_file: bool = False) -> list[str]:
+ blank = "\\_" * 18
+ if not when:
+ return [f"By: {blank}", "Name:", "Title:", "Date:"]
+ by = "Signed copy on file" if on_file else f"*/s/ {md(name)}*"
+ return [f"By: {by}", f"Name: {md(name)}", f"Title: {md(title)}", f"Date: {date(when)}"]
+
+
+def signature_table(
+ counterparty_name: str, psf: Sequence[str] | None = None, counterparty: Sequence[str] | None = None
+) -> str:
+ """Two signature columns; ``psf`` and ``counterparty`` are ``_signature_column`` rows, or blank."""
+ psf = psf or _signature_column("", "", None)
+ counterparty = counterparty or _signature_column("", "", None)
+ rows = [
+ f"| **{PROVIDER_NAME.upper()}** | **{md(counterparty_name.upper())}** |",
+ "|:------------------------------------|:------------------------------------|",
+ ]
+ rows += [f"| {p} | {c} |" for p, c in zip(psf, counterparty, strict=True)]
+ return "\n".join(rows)
+
+
+def preview_markdown(markdown: str, counterparty_name: str) -> str:
+ """Fill a draft's placeholders with blank signature lines."""
+ return markdown.replace(SIGNATURES, signature_table(counterparty_name)).replace(
+ EFFECTIVE_DATE, "The date of the last signature below."
+ )
+
+
+def _signature_record(agreement: Agreement) -> list[str]:
+ copies = set(agreement.signed_copies.values_list("kind", flat=True))
+ rows = [
+ ("Reference", agreement.reference),
+ ("Document", f"{agreement.title}, revision {agreement.revision}"),
+ ]
+ rows += [
+ (f"Terms: {version.terms.title}", f"Version {version.version}")
+ for version in agreement.terms_versions.select_related("terms")
+ ]
+ rows.append(("Signatory", f"{agreement.signer_name}, {agreement.signer_title} ({agreement.signer_email})"))
+ if agreement.signature_method == agreement.SignatureMethod.OFFLINE:
+ intro = (
+ f"{md(agreement.counterparty_name)} signed a copy of this document outside python.org. "
+ "The signed copy is kept with the agreement."
+ )
+ rows.append(("Signed", f"{date(agreement.signed_at)}, outside python.org"))
+ rows.append(("Signed copy", "On file"))
+ else:
+ how = (
+ "an emailed signing link" if agreement.signature_method == agreement.SignatureMethod.LINK else "an account"
+ )
+ intro = f"{md(agreement.counterparty_name)} signed this document on python.org with {how}."
+ rows.append(("Signed", _utc(cast("datetime", agreement.signed_at))))
+ if agreement.countersigned_at:
+ rows.append(("PSF signatory", f"{agreement.countersigner_name}, {agreement.countersigner_title}"))
+ rows.append(("PSF signed", _utc(agreement.countersigned_at)))
+ if "executed" in copies:
+ rows.append(("Countersigned copy", "On file"))
+ record = table(("Field", "Value"), [(md(label), md(value)) for label, value in rows], widths=(30, 50))
+ return [PAGEBREAK, "", "## Signature Record", "", intro, "", *record]
+
+
+def final_markdown(agreement: Agreement) -> str:
+ """Return an offered agreement's document with its signatures and signature record filled in."""
+ on_file = agreement.signature_method == agreement.SignatureMethod.OFFLINE
+ psf = counterparty = None
+ if agreement.countersigned_at:
+ psf = _signature_column(agreement.countersigner_name, agreement.countersigner_title, agreement.countersigned_at)
+ if agreement.signed_at:
+ counterparty = _signature_column(
+ agreement.signer_name, agreement.signer_title, agreement.signed_at, on_file=on_file
+ )
+ effective = (
+ date(agreement.countersigned_at) if agreement.countersigned_at else "The date of the last signature below."
+ )
+ text = agreement.document_markdown.replace(
+ SIGNATURES, signature_table(agreement.counterparty_name, psf, counterparty)
+ ).replace(EFFECTIVE_DATE, effective)
+ if agreement.signed_at:
+ text += "\n" + "\n".join(_signature_record(agreement))
+ return text
+
+
+def terms_download_markdown(version: TermsVersion) -> str:
+ """Return a published terms version with a title block, for PDF and DOCX downloads."""
+ terms = version.terms
+ header = [f"# {terms.title}", ""]
+ if terms.under_review:
+ header += ["**DRAFT — For Attorney Review**", ""]
+ header += [f"*Version {md(version.version)}, published at <{version.permanent_url}>*", ""]
+ return "\n".join([*header, version.markdown])
+
+
+# ---------- Rendering ----------
+
+
+def _convert(markdown: str | bytes, to: str, outputfile: str | None = None, extra_args: Iterable[str] = ()) -> str:
+ return pypandoc.convert_text(
+ markdown,
+ to,
+ format=MARKDOWN_FORMAT,
+ outputfile=outputfile,
+ filters=[FILTER],
+ extra_args=[*PANDOC_ARGS, *extra_args],
+ )
+
+
+@lru_cache(maxsize=32)
+def _html(markdown: str | bytes) -> str:
+ return sanitize(_convert(markdown, "html5"))
+
+
+def _toc_title(title: str) -> str:
+ """Lower-case exhibit headings, which the terms set in capitals, for the contents list."""
+ if not title.isupper():
+ return title
+ label, _, rest = title.partition(" — ")
+ return f"{label.title()} — {rest.capitalize()}" if rest else title.title()
+
+
+def render_html(markdown: str | bytes) -> tuple[str, list[tuple[str, str]]]:
+ """Render markdown to sanitized HTML plus a table of contents of its sections."""
+ html = _html(markdown)
+ toc = [(anchor, _toc_title(" ".join(_TAGS.sub("", title).split()))) for anchor, title in _H2.findall(html)]
+ return html, toc
+
+
+def render_agreement_preview(markdown: str) -> tuple[str, bool]:
+ """Hide generated legacy fingerprint metadata in HTML, never in stored text or downloads."""
+ preview = _LEGACY_TERMS_FINGERPRINT.sub(r"\g", markdown)
+ preview = _LEGACY_FINGERPRINT_DESCRIPTION.sub("", preview)
+ html, _ = render_html(preview)
+ return html, preview != markdown
+
+
+def _convert_file(markdown: str | bytes, to: str, suffix: str, extra_args: Iterable[str] = ()) -> bytes:
+ with tempfile.NamedTemporaryFile(suffix=suffix) as out:
+ _convert(markdown, to, outputfile=out.name, extra_args=extra_args)
+ return Path(out.name).read_bytes()
+
+
+# pdflatex (the only TeX engine in the image) sets Latin-1, Latin Extended-A, and common typography.
+_PDFLATEX_LAST_NATIVE = 0x17F
+_PDFLATEX_EXTRA = frozenset("‘’‚“”„–—…•€×·§¶†‡‰") # noqa: RUF001 - typographic characters pdflatex can set
+
+
+def _pdflatex_safe(markdown: str) -> str:
+ """Transliterate characters pdflatex can't set, so a counterparty's name never breaks the PDF.
+
+ Only the PDF rendering changes; HTML, DOCX, and the signed snapshot keep the exact text.
+ """
+
+ def replace(char: str) -> str:
+ if ord(char) <= _PDFLATEX_LAST_NATIVE or char in _PDFLATEX_EXTRA:
+ return char
+ return _PUNCTUATION.sub(r"\\\1", unidecode(char)) or "?"
+
+ return "".join(replace(char) for char in markdown)
+
+
+@lru_cache(maxsize=16)
+def render_pdf(markdown: str) -> bytes:
+ """Render markdown to PDF bytes."""
+ return _convert_file(
+ _pdflatex_safe(markdown),
+ "pdf",
+ ".pdf",
+ ["-V", "geometry:margin=1in", "-V", "fontsize=11pt", "-V", "linkcolor=black"],
+ )
+
+
+@lru_cache(maxsize=16)
+def render_docx(markdown: str | bytes) -> bytes:
+ """Render markdown to DOCX bytes."""
+ return _convert_file(markdown, "docx", ".docx")
+
+
+RENDERERS: dict[str, Callable[[str], bytes]] = {"pdf": render_pdf, "docx": render_docx}
+CONTENT_TYPES = {
+ "pdf": "application/pdf",
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+}
diff --git a/apps/agreements/forms/__init__.py b/apps/agreements/forms/__init__.py
new file mode 100644
index 000000000..cabdc2fab
--- /dev/null
+++ b/apps/agreements/forms/__init__.py
@@ -0,0 +1 @@
+"""Forms for signing agreements, publishing terms, and editing contracts."""
diff --git a/apps/agreements/forms/accounts.py b/apps/agreements/forms/accounts.py
new file mode 100644
index 000000000..dc405a16e
--- /dev/null
+++ b/apps/agreements/forms/accounts.py
@@ -0,0 +1,24 @@
+"""Account lookup shared by contract and order forms."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, cast
+
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+
+if TYPE_CHECKING:
+ from apps.users.models import User
+
+
+def account_for_email(email: str) -> User:
+ """Return the one active python.org account using ``email``, or raise ``ValidationError``."""
+ accounts = list(get_user_model().objects.filter(email__iexact=email, is_active=True)[:2])
+ if len(accounts) != 1:
+ msg = (
+ "No python.org account uses this email."
+ if not accounts
+ else "More than one account uses this email; ask which one to use."
+ )
+ raise ValidationError(msg)
+ return cast("User", accounts[0])
diff --git a/apps/agreements/forms/contracts.py b/apps/agreements/forms/contracts.py
new file mode 100644
index 000000000..b06a1ed5c
--- /dev/null
+++ b/apps/agreements/forms/contracts.py
@@ -0,0 +1,96 @@
+"""Forms for custom contracts and agreement document revisions."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, cast
+
+from django import forms
+
+from apps.agreements.documents import EFFECTIVE_DATE, SIGNATURES
+from apps.agreements.forms.accounts import account_for_email
+from apps.agreements.forms.validation import _check_markdown, _check_placeholders
+from apps.agreements.models import CustomContract, Terms
+
+if TYPE_CHECKING:
+ from apps.users.models import User
+
+MAX_NOTE_LENGTH = 255
+
+
+class EditDocumentForm(forms.Form):
+ """Edit one agreement's text before it is signed."""
+
+ markdown = forms.CharField(
+ label="Document text",
+ strip=False,
+ widget=forms.Textarea(attrs={"rows": 30, "spellcheck": "true", "class": "markdown-editor"}),
+ help_text=f"Markdown. {SIGNATURES} marks the signature block and {EFFECTIVE_DATE} the effective date.",
+ )
+ note = forms.CharField(
+ label="What changed and why",
+ max_length=MAX_NOTE_LENGTH,
+ help_text="Kept with the revision, for example: 'Net 45 payment terms, agreed with their counsel.'",
+ )
+ base_sha256 = forms.CharField(widget=forms.HiddenInput, max_length=64)
+
+ def clean_markdown(self) -> str:
+ """Normalize line endings and keep the signature placeholder."""
+ markdown: str = self.cleaned_data["markdown"].replace("\r\n", "\n")
+ _check_placeholders(markdown, required=True)
+ _check_markdown(markdown)
+ return markdown
+
+
+class CustomContractForm(forms.ModelForm):
+ """A one-off contract: who it is with, the text, and any terms it cites."""
+
+ instance: CustomContract
+
+ counterparty_account_email = forms.EmailField(
+ required=False,
+ label="Counterparty's python.org account",
+ help_text="Email of the account that may sign online. Leave empty to email a signing link or "
+ "collect a signed copy instead.",
+ )
+
+ class Meta:
+ """Staff-editable fields; the signature block is added automatically."""
+
+ model = CustomContract
+ fields = ("title", "counterparty_name", "terms", "body_markdown")
+ widgets = {"body_markdown": forms.Textarea(attrs={"rows": 24, "class": "markdown-editor"})}
+ help_texts = {
+ "body_markdown": "Markdown. The parties, any cited terms, and the signature block are added "
+ "above and below this text."
+ }
+
+ def __init__(self, *args: Any, can_link_accounts: bool = False, **kwargs: Any) -> None:
+ """Offer only terms with a published version, and show the current account."""
+ super().__init__(*args, **kwargs)
+ cast("forms.ModelChoiceField", self.fields["terms"]).queryset = Terms.objects.filter(
+ versions__isnull=False
+ ).distinct()
+ self.can_link_accounts = can_link_accounts
+ if can_link_accounts:
+ account = self.instance.counterparty_account
+ self.fields["counterparty_account_email"].initial = account.email if account else ""
+ else:
+ del self.fields["counterparty_account_email"]
+
+ def clean_counterparty_account_email(self) -> User | None:
+ """Resolve the email to exactly one account."""
+ email = self.cleaned_data["counterparty_account_email"]
+ return account_for_email(email) if email else None
+
+ def clean_body_markdown(self) -> str:
+ """Normalize line endings; placeholders are added automatically."""
+ markdown: str = self.cleaned_data["body_markdown"].replace("\r\n", "\n")
+ _check_placeholders(markdown, required=False)
+ _check_markdown(markdown)
+ return markdown
+
+ def save(self, commit: bool = True) -> CustomContract:
+ """Store the resolved account."""
+ if self.can_link_accounts:
+ self.instance.counterparty_account = self.cleaned_data["counterparty_account_email"]
+ return cast("CustomContract", super().save(commit=commit))
diff --git a/apps/agreements/forms/signing.py b/apps/agreements/forms/signing.py
new file mode 100644
index 000000000..5921920be
--- /dev/null
+++ b/apps/agreements/forms/signing.py
@@ -0,0 +1,111 @@
+"""Forms for signing and countersigning agreements."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, cast
+
+from django import forms
+from django.core.exceptions import ValidationError
+from django.utils import timezone
+
+from apps.agreements.models import SignedCopy
+from apps.agreements.workflow import Upload
+
+if TYPE_CHECKING:
+ from datetime import date
+
+ from django.core.files.uploadedfile import UploadedFile
+
+
+class _SeenDocument(forms.Form):
+ """Carries the SHA-256 of the text the signer was shown, so edits can't slip in unseen."""
+
+ document_sha256 = forms.CharField(widget=forms.HiddenInput, max_length=64)
+
+
+class SignForm(_SeenDocument):
+ """Acceptance on python.org."""
+
+ signer_name = forms.CharField(label="Full name", max_length=255)
+ signer_title = forms.CharField(label="Title", max_length=255)
+ accept = forms.BooleanField(
+ error_messages={"required": "Check the box to sign on behalf of the organization named above."}
+ )
+
+
+def _pdf_upload(file: UploadedFile | None) -> Upload | None:
+ """Return an ``Upload`` for a PDF of at most ``SignedCopy.MAX_BYTES``, or raise ``ValidationError``."""
+ if not file:
+ return None
+ if cast("int", file.size) > SignedCopy.MAX_BYTES:
+ msg = f"Upload at most {SignedCopy.MAX_BYTES // (1024 * 1024)} MB."
+ raise ValidationError(msg)
+ content = file.read()
+ if not content.startswith(b"%PDF-"):
+ msg = "Upload the signed copy as a PDF."
+ raise ValidationError(msg)
+ return Upload(filename=cast("str", file.name), content=content)
+
+
+class SignedCopyForm(_SeenDocument):
+ """Record a signature made outside python.org, such as through DocuSign or on paper."""
+
+ signed_copy = forms.FileField(label="Signed copy (PDF)")
+ signer_name = forms.CharField(label="Signatory's full name", max_length=255)
+ signer_title = forms.CharField(label="Signatory's title", max_length=255)
+ signer_email = forms.EmailField(label="Signatory's email")
+ signed_on = forms.DateField(label="Date signed", widget=forms.DateInput(attrs={"type": "date"}))
+ matches = forms.BooleanField(
+ error_messages={"required": "Confirm that the signed copy is this document, unchanged."}
+ )
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ """Keep upload labels distinct from the on-page account-signing form."""
+ kwargs.setdefault("auto_id", "id_copy_%s")
+ super().__init__(*args, **kwargs)
+
+ def clean_signed_copy(self) -> Upload | None:
+ """Accept a PDF only."""
+ return _pdf_upload(self.cleaned_data.get("signed_copy"))
+
+ def clean_signed_on(self) -> date:
+ """Reject signature dates in the future."""
+ signed_on: date = self.cleaned_data["signed_on"]
+ if signed_on > timezone.localdate():
+ msg = "Enter the date the copy was signed; it can't be in the future."
+ raise ValidationError(msg)
+ return signed_on
+
+
+class SigningLinkForm(forms.Form):
+ """Email a named signatory a one-time link to sign."""
+
+ name = forms.CharField(label="Signatory's full name", max_length=255)
+ email = forms.EmailField(label="Signatory's email")
+
+
+class CountersignForm(forms.Form):
+ """PSF countersignature by an authorized officer, optionally with a fully executed copy."""
+
+ name = forms.CharField(label="Full name", max_length=255)
+ title = forms.CharField(label="Title", max_length=255)
+ executed_copy = forms.FileField(
+ label="Fully executed copy (PDF, optional)",
+ required=False,
+ help_text="If both parties signed outside python.org, keep that copy with the agreement.",
+ )
+ accept = forms.BooleanField(
+ error_messages={"required": "Confirm that you are authorized to sign for the Python Software Foundation."}
+ )
+
+ def clean_executed_copy(self) -> Upload | None:
+ """Accept a PDF only."""
+ return _pdf_upload(self.cleaned_data.get("executed_copy"))
+
+
+class DeclineForm(forms.Form):
+ """The PSF declines a signed agreement."""
+
+ reason = forms.CharField(
+ widget=forms.Textarea(attrs={"rows": 3}), help_text="Shown to the counterparty on their agreement."
+ )
diff --git a/apps/agreements/forms/terms.py b/apps/agreements/forms/terms.py
new file mode 100644
index 000000000..571fc46d8
--- /dev/null
+++ b/apps/agreements/forms/terms.py
@@ -0,0 +1,81 @@
+"""Forms for editing and publishing terms."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, cast
+
+from django import forms
+
+from apps.agreements.forms.validation import _check_markdown, _check_placeholders
+from apps.agreements.models import TermsVersion
+
+if TYPE_CHECKING:
+ from apps.agreements.models import Terms
+
+
+class TermsForm(forms.Form):
+ """Edit terms: save the working copy, or publish it as a new version."""
+
+ SAVE = "save"
+ PUBLISH = "publish"
+
+ markdown = forms.CharField(
+ label="Terms text",
+ strip=False,
+ widget=forms.Textarea(attrs={"rows": 30, "spellcheck": "true", "class": "markdown-editor"}),
+ help_text="Markdown. Headings starting with '## ' become the contents list.",
+ )
+ under_review = forms.BooleanField(required=False, label="Draft for attorney review")
+ is_public = forms.BooleanField(
+ required=False,
+ label="Make published versions public",
+ help_text="Leave unchecked to restrict terms to staff and the parties to agreements that cite them.",
+ )
+ version = forms.SlugField(
+ required=False,
+ max_length=64,
+ help_text="A new label for this version, for example today's date. Part of its permanent address.",
+ )
+ notes = forms.CharField(
+ required=False, widget=forms.Textarea(attrs={"rows": 3}), help_text="What changed, for the version history."
+ )
+ action = forms.ChoiceField(choices=((SAVE, "Save draft"), (PUBLISH, "Publish")), required=False)
+
+ def __init__(self, *args: Any, terms: Terms, can_publish: bool, **kwargs: Any) -> None:
+ """Bind terms and expose publication fields only to administrators."""
+ super().__init__(*args, **kwargs)
+ self.terms = terms
+ if not can_publish:
+ for field in ("under_review", "is_public", "version", "notes"):
+ del self.fields[field]
+ cast("forms.ChoiceField", self.fields["action"]).choices = ((self.SAVE, "Save draft"),)
+
+ def clean_markdown(self) -> str:
+ """Normalize line endings; terms don't carry signature placeholders."""
+ markdown: str = self.cleaned_data["markdown"].replace("\r\n", "\n")
+ _check_placeholders(markdown, required=False)
+ _check_markdown(markdown)
+ return markdown
+
+ def clean_is_public(self) -> bool:
+ """Check visibility without changing the terms before the form is valid."""
+ is_public: bool = self.cleaned_data["is_public"]
+ self.terms.validate_is_public(is_public)
+ return is_public
+
+ def clean(self) -> dict[str, Any]:
+ """Require a new version label, notes, and a change before publishing."""
+ cleaned = cast("dict[str, Any]", super().clean())
+ if cleaned.get("action") != self.PUBLISH:
+ return cleaned
+ version = cleaned.get("version")
+ if not version:
+ self.add_error("version", "Enter a label for the new version.")
+ elif TermsVersion.objects.filter(terms=self.terms, version=version).exists():
+ self.add_error("version", "This label is already used; published versions never change.")
+ if not cleaned.get("notes", "").strip():
+ self.add_error("notes", "Describe what changed.")
+ current = self.terms.current_version
+ if current and cleaned.get("markdown") == current.markdown:
+ self.add_error("markdown", "The text is the same as the current version.")
+ return cleaned
diff --git a/apps/agreements/forms/validation.py b/apps/agreements/forms/validation.py
new file mode 100644
index 000000000..7bbb01bc8
--- /dev/null
+++ b/apps/agreements/forms/validation.py
@@ -0,0 +1,27 @@
+"""Placeholder validation shared by editable agreements, contracts, and terms."""
+
+from django.core.exceptions import ValidationError
+
+from apps.agreements.documents import EFFECTIVE_DATE, SIGNATURES, render_html
+
+
+def _check_placeholders(markdown: str, *, required: bool) -> None:
+ """Documents carry the signature placeholder exactly once; staff text written elsewhere, never."""
+ count = markdown.count(SIGNATURES)
+ if required and count != 1:
+ msg = f"Keep {SIGNATURES} exactly once, on its own line: the signature block goes there."
+ raise ValidationError(msg)
+ if not required and (count or EFFECTIVE_DATE in markdown):
+ msg = f"Remove {SIGNATURES} and {EFFECTIVE_DATE}; they are added automatically."
+ raise ValidationError(msg)
+
+
+def _check_markdown(markdown: str) -> None:
+ """Report unsupported document content without hiding renderer failures."""
+ try:
+ render_html(markdown)
+ except RuntimeError as error:
+ if "Agreement documents cannot embed images or load external resources." not in str(error):
+ raise
+ msg = "Remove embedded images. Agreement documents cannot load images or external resources."
+ raise ValidationError(msg) from error
diff --git a/apps/agreements/kinds.py b/apps/agreements/kinds.py
new file mode 100644
index 000000000..f2a96113e
--- /dev/null
+++ b/apps/agreements/kinds.py
@@ -0,0 +1,65 @@
+"""The built-in kind for one-off contracts written by PSF staff."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, cast
+
+if TYPE_CHECKING:
+ from apps.agreements.models import TermsVersion
+ from apps.agreements.registry import AgreementDetails
+
+from apps.agreements.documents import PROVIDER_PREAMBLE, SIGNATURES, md
+from apps.agreements.models import CustomContract
+from apps.agreements.registry import Kind, register
+
+
+@register
+class CustomContractKind(Kind[CustomContract]):
+ """A contract written in full by PSF staff, optionally citing published terms."""
+
+ slug = "custom"
+ name = "Custom contract"
+ model = CustomContract
+
+ def details(self, subject: CustomContract) -> AgreementDetails:
+ """Title and counterparty come straight from the draft."""
+ return {
+ "title": subject.title,
+ "counterparty_name": subject.counterparty_name,
+ "counterparty_account": subject.counterparty_account,
+ }
+
+ def cited_terms_versions(self, subject: CustomContract) -> list[TermsVersion]:
+ """Cite the current version of the chosen terms, if any."""
+ return [cast("TermsVersion", subject.terms.current_version)] if subject.terms else []
+
+ def compose(self, subject: CustomContract) -> str:
+ """Parties, an optional citation of the terms, the staff-written text, and signatures."""
+ parties = (
+ f"This agreement is entered into between {PROVIDER_PREAMBLE}, and **{md(subject.counterparty_name)}** "
+ '(the "Counterparty").'
+ )
+ parts = [f"# {md(subject.title)}", "", parties, ""]
+ if subject.terms:
+ version = cast("TermsVersion", subject.terms.current_version)
+ parts += [
+ (
+ f"It incorporates by reference the **{md(subject.terms.title)}**, version {md(version.version)}, "
+ f"published at <{version.permanent_url}>. If this agreement "
+ "conflicts with those terms, this agreement controls."
+ ),
+ "",
+ ]
+ return "\n".join(
+ [
+ *parts,
+ subject.body_markdown.strip(),
+ "",
+ "## Signatures",
+ "",
+ "Each party signs as of the date of the last signature below.",
+ "",
+ SIGNATURES,
+ "",
+ ]
+ )
diff --git a/apps/agreements/management/__init__.py b/apps/agreements/management/__init__.py
new file mode 100644
index 000000000..8fc78c660
--- /dev/null
+++ b/apps/agreements/management/__init__.py
@@ -0,0 +1 @@
+"""Operator tools for private agreement configuration."""
diff --git a/apps/agreements/management/commands/__init__.py b/apps/agreements/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/agreements/management/commands/import_agreement_program.py b/apps/agreements/management/commands/import_agreement_program.py
new file mode 100644
index 000000000..4b413ef56
--- /dev/null
+++ b/apps/agreements/management/commands/import_agreement_program.py
@@ -0,0 +1,72 @@
+"""Import privately supplied program configuration without committing commercial content."""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+from django.core.exceptions import ValidationError
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+
+from apps.agreements.models import Program, Terms, TermsVersion
+
+if TYPE_CHECKING:
+ from django.core.management.base import CommandParser
+
+
+class Command(BaseCommand):
+ """Load a program and optional immutable terms versions from an operator-supplied JSON file."""
+
+ help = "Import an agreement program from private JSON. Use '-' to read stdin. Existing terms versions never change."
+
+ def add_arguments(self, parser: CommandParser) -> None:
+ """Accept a private file path or stdin."""
+ parser.add_argument("path")
+
+ def handle(self, *args: Any, **options: Any) -> None:
+ """Validate and apply the import atomically without printing its private content."""
+ try:
+ text = sys.stdin.read() if options["path"] == "-" else Path(options["path"]).read_text(encoding="utf-8")
+ data = json.loads(text)
+ with transaction.atomic():
+ self._import(data)
+ except (OSError, json.JSONDecodeError, ValidationError, KeyError, TypeError, ValueError) as exc:
+ raise CommandError(str(exc)) from exc
+ self.stdout.write(self.style.SUCCESS("Program configuration imported."))
+
+ @staticmethod
+ def _import(data: object) -> None:
+ if not isinstance(data, dict) or set(data) - {"program", "terms"}:
+ msg = "Expected an object with 'program' and optional 'terms'."
+ raise ValueError(msg)
+ for entry in data.get("terms", []):
+ metadata = {key: entry[key] for key in ("title", "under_review", "is_public") if key in entry}
+ terms, _ = Terms.objects.get_or_create(slug=entry["slug"], defaults=metadata)
+ for key, value in metadata.items():
+ setattr(terms, key, value)
+ terms.full_clean()
+ terms.save()
+ for item in entry.get("versions", []):
+ existing = terms.versions.filter(version=item["version"]).first()
+ if existing:
+ if existing.markdown != item["markdown"]:
+ msg = "An imported terms version differs from the stored version; use a new version label."
+ raise ValueError(msg)
+ continue
+ version = TermsVersion(
+ terms=terms, version=item["version"], markdown=item["markdown"], notes=item.get("notes", "")
+ )
+ version.full_clean(exclude=["sha256"])
+ version.save()
+ fields = data["program"]
+ if not isinstance(fields, dict) or set(fields) - {"slug", "title", "description", "definition", "is_public"}:
+ msg = "Program fields: slug, title, description, definition, is_public."
+ raise ValueError(msg)
+ program = Program.objects.select_for_update().filter(slug=fields["slug"]).first() or Program()
+ for key, value in fields.items():
+ setattr(program, key, value)
+ program.full_clean()
+ program.save()
diff --git a/apps/agreements/migrations/0001_initial.py b/apps/agreements/migrations/0001_initial.py
new file mode 100644
index 000000000..03d3a1f24
--- /dev/null
+++ b/apps/agreements/migrations/0001_initial.py
@@ -0,0 +1,344 @@
+# Generated by Django 5.2.16 on 2026-09-30 16:46
+
+import uuid
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Agreement",
+ fields=[
+ ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ("kind", models.CharField(db_index=True, max_length=32)),
+ ("title", models.CharField(max_length=255)),
+ ("counterparty_name", models.CharField(max_length=255)),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("offered", "Awaiting signature"),
+ ("signed", "Awaiting countersignature"),
+ ("executed", "Executed"),
+ ("declined", "Declined"),
+ ("withdrawn", "Withdrawn"),
+ ],
+ db_index=True,
+ default="offered",
+ max_length=16,
+ ),
+ ),
+ ("document_markdown", models.TextField()),
+ ("document_sha256", models.CharField(max_length=64)),
+ ("revision", models.PositiveIntegerField(default=1)),
+ ("offered_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "signature_method",
+ models.CharField(
+ blank=True,
+ choices=[
+ ("account", "Signed on python.org"),
+ ("link", "Signed on python.org with an emailed link"),
+ ("offline", "Signed copy on file"),
+ ],
+ max_length=16,
+ ),
+ ),
+ ("signer_name", models.CharField(blank=True, max_length=255)),
+ ("signer_title", models.CharField(blank=True, max_length=255)),
+ ("signer_email", models.EmailField(blank=True, max_length=254)),
+ ("signed_at", models.DateTimeField(blank=True, null=True)),
+ ("signed_ip", models.GenericIPAddressField(blank=True, null=True)),
+ ("signed_user_agent", models.CharField(blank=True, max_length=512)),
+ ("countersigner_name", models.CharField(blank=True, max_length=255)),
+ ("countersigner_title", models.CharField(blank=True, max_length=255)),
+ ("countersigned_at", models.DateTimeField(blank=True, null=True)),
+ ("declined_at", models.DateTimeField(blank=True, null=True)),
+ ("decline_reason", models.TextField(blank=True)),
+ ("withdrawn_at", models.DateTimeField(blank=True, null=True)),
+ (
+ "counterparty_account",
+ models.ForeignKey(
+ blank=True,
+ help_text="The python.org account that may view and sign it online.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="agreements_to_sign",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "countersigned_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "declined_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "offered_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ (
+ "signature_recorded_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "withdrawn_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("-offered_at",),
+ "permissions": (("manage_agreement", "Can prepare, edit, send, and countersign agreements"),),
+ },
+ ),
+ migrations.CreateModel(
+ name="SigningLink",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=255)),
+ ("email", models.EmailField(max_length=254)),
+ ("token_sha256", models.CharField(max_length=64, unique=True)),
+ ("expires_at", models.DateTimeField()),
+ ("used_at", models.DateTimeField(blank=True, null=True)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "agreement",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="signing_links",
+ to="agreements.agreement",
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("-created_at",),
+ },
+ ),
+ migrations.CreateModel(
+ name="Terms",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("slug", models.SlugField(unique=True)),
+ ("title", models.CharField(max_length=255)),
+ (
+ "under_review",
+ models.BooleanField(
+ default=False, help_text="Mark the terms as a draft for attorney review wherever they appear."
+ ),
+ ),
+ ("draft_markdown", models.TextField(blank=True, help_text="Working copy; not public until published.")),
+ ("draft_updated_at", models.DateTimeField(blank=True, null=True)),
+ (
+ "draft_updated_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name_plural": "terms",
+ "ordering": ("title",),
+ },
+ ),
+ migrations.CreateModel(
+ name="CustomContract",
+ fields=[
+ ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ("title", models.CharField(max_length=255)),
+ ("counterparty_name", models.CharField(max_length=255, verbose_name="counterparty's legal name")),
+ ("body_markdown", models.TextField(verbose_name="contract text")),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("modified", models.DateTimeField(auto_now=True)),
+ (
+ "agreement",
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="custom_contract",
+ to="agreements.agreement",
+ ),
+ ),
+ (
+ "counterparty_account",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ (
+ "terms",
+ models.ForeignKey(
+ blank=True,
+ help_text="Standing terms this contract cites and incorporates, if any.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="agreements.terms",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("-created",),
+ },
+ ),
+ migrations.CreateModel(
+ name="TermsVersion",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("version", models.SlugField(max_length=64)),
+ ("markdown", models.TextField()),
+ ("sha256", models.CharField(editable=False, max_length=64)),
+ ("notes", models.TextField(blank=True, help_text="What changed from the previous version.")),
+ ("published_at", models.DateTimeField(default=django.utils.timezone.now)),
+ (
+ "published_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "terms",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name="versions", to="agreements.terms"
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("-published_at", "-pk"),
+ },
+ ),
+ migrations.AddField(
+ model_name="agreement",
+ name="terms_versions",
+ field=models.ManyToManyField(blank=True, related_name="agreements", to="agreements.termsversion"),
+ ),
+ migrations.CreateModel(
+ name="AgreementRevision",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("revision", models.PositiveIntegerField()),
+ ("markdown", models.TextField()),
+ ("sha256", models.CharField(max_length=64)),
+ ("note", models.CharField(max_length=255)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "agreement",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, related_name="revisions", to="agreements.agreement"
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("-revision",),
+ "constraints": [models.UniqueConstraint(fields=("agreement", "revision"), name="one_row_per_revision")],
+ },
+ ),
+ migrations.CreateModel(
+ name="SignedCopy",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "kind",
+ models.CharField(
+ choices=[("customer", "Signed by the counterparty"), ("executed", "Signed by both parties")],
+ max_length=16,
+ ),
+ ),
+ ("filename", models.CharField(max_length=255)),
+ ("content", models.BinaryField()),
+ ("sha256", models.CharField(max_length=64)),
+ ("uploaded_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "agreement",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="signed_copies",
+ to="agreements.agreement",
+ ),
+ ),
+ (
+ "uploaded_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ],
+ options={
+ "verbose_name_plural": "signed copies",
+ "constraints": [
+ models.UniqueConstraint(fields=("agreement", "kind"), name="one_signed_copy_of_each_kind")
+ ],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="termsversion",
+ constraint=models.UniqueConstraint(fields=("terms", "version"), name="one_terms_version_per_label"),
+ ),
+ ]
diff --git a/apps/agreements/migrations/0002_configurable_programs.py b/apps/agreements/migrations/0002_configurable_programs.py
new file mode 100644
index 000000000..e0d24acfd
--- /dev/null
+++ b/apps/agreements/migrations/0002_configurable_programs.py
@@ -0,0 +1,141 @@
+# Generated by Django 5.2.16 on 2026-09-30 23:29
+
+import uuid
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+import apps.agreements.orders.catalog
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("agreements", "0001_initial"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Program",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("slug", models.SlugField(unique=True)),
+ ("title", models.CharField(max_length=255)),
+ ("description", models.TextField(blank=True)),
+ (
+ "is_public",
+ models.BooleanField(
+ default=False,
+ help_text="Allow anyone to browse this program and signed-in customers to start orders. Does not publish its terms. Leave unchecked for staff-prepared orders only.",
+ ),
+ ),
+ (
+ "definition",
+ models.JSONField(
+ help_text="Catalog, pricing rules and document copy.",
+ validators=[apps.agreements.orders.catalog.validate_catalog],
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("title",),
+ },
+ ),
+ migrations.AddField(
+ model_name="terms",
+ name="is_public",
+ field=models.BooleanField(
+ default=False,
+ help_text="Make published versions readable by everyone. Otherwise only staff and the parties may read them.",
+ ),
+ ),
+ migrations.CreateModel(
+ name="Order",
+ fields=[
+ ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("modified", models.DateTimeField(auto_now=True)),
+ ("legal_name", models.CharField(max_length=255, verbose_name="legal name")),
+ ("jurisdiction", models.CharField(max_length=100, verbose_name="state or country of organization")),
+ (
+ "entity_type",
+ models.CharField(
+ help_text="For example: corporation or limited liability company.", max_length=100
+ ),
+ ),
+ ("address", models.TextField(verbose_name="principal place of business")),
+ ("covered_entities", models.TextField(help_text="One name per line.", verbose_name="covered entities")),
+ ("discount", models.CharField(default="none", max_length=64)),
+ ("authorized_contacts", models.JSONField(default=list)),
+ ("billing_contact_name", models.CharField(max_length=255)),
+ ("billing_contact_email", models.EmailField(max_length=254)),
+ (
+ "notices_contact",
+ models.TextField(blank=True, help_text="Only if different from the principal place of business."),
+ ),
+ ("catalog_snapshot", models.JSONField(blank=True, default=dict, editable=False)),
+ ("program_title_snapshot", models.CharField(blank=True, editable=False, max_length=255)),
+ (
+ "agreement",
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="order",
+ to="agreements.agreement",
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="orders_created",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "customer_account",
+ models.ForeignKey(
+ blank=True,
+ help_text="The python.org account that may view the order and sign it online.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="agreement_orders",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "program",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, related_name="orders", to="agreements.program"
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("-created",),
+ },
+ ),
+ migrations.CreateModel(
+ name="OrderLine",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("agreement", models.SlugField(max_length=64)),
+ ("tier", models.CharField(max_length=64)),
+ ("addons", models.JSONField(blank=True, default=dict)),
+ ("special_terms", models.TextField(blank=True, help_text="Set by staff only; leave empty for none.")),
+ ("pricing", models.JSONField(blank=True, default=dict, editable=False)),
+ (
+ "order",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, related_name="agreements", to="agreements.order"
+ ),
+ ),
+ ],
+ options={
+ "constraints": [
+ models.UniqueConstraint(fields=("order", "agreement"), name="one_order_line_per_agreement")
+ ],
+ },
+ ),
+ ]
diff --git a/apps/agreements/migrations/0003_agreement_groups.py b/apps/agreements/migrations/0003_agreement_groups.py
new file mode 100644
index 000000000..613b7daa5
--- /dev/null
+++ b/apps/agreements/migrations/0003_agreement_groups.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from django.db import migrations
+
+if TYPE_CHECKING:
+ from django.apps.registry import Apps
+ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def create_agreement_groups(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ groups = apps.get_model("auth", "Group").objects.using(schema_editor.connection.alias)
+ for name in ("Agreements Editors", "Agreements Administrators"):
+ groups.get_or_create(name=name)
+ apps.get_model("auth", "Permission").objects.using(schema_editor.connection.alias).filter(
+ content_type__app_label="agreements", content_type__model="agreement", codename="manage_agreement"
+ ).delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("agreements", "0002_configurable_programs"),
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(name="agreement", options={"ordering": ("-offered_at",)}),
+ migrations.RunPython(create_agreement_groups, migrations.RunPython.noop),
+ ]
diff --git a/apps/agreements/migrations/__init__.py b/apps/agreements/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/agreements/models/__init__.py b/apps/agreements/models/__init__.py
new file mode 100644
index 000000000..7171500f9
--- /dev/null
+++ b/apps/agreements/models/__init__.py
@@ -0,0 +1,25 @@
+"""Models registered by the agreements Django app."""
+
+from apps.agreements.models.agreements import (
+ Agreement,
+ AgreementRevision,
+ CustomContract,
+ SignedCopy,
+ SigningLink,
+)
+from apps.agreements.models.orders import Order, OrderLine, Program
+from apps.agreements.models.terms import CANONICAL_ORIGIN, Terms, TermsVersion
+
+__all__ = [
+ "CANONICAL_ORIGIN",
+ "Agreement",
+ "AgreementRevision",
+ "CustomContract",
+ "Order",
+ "OrderLine",
+ "Program",
+ "SignedCopy",
+ "SigningLink",
+ "Terms",
+ "TermsVersion",
+]
diff --git a/apps/agreements/models/agreements.py b/apps/agreements/models/agreements.py
new file mode 100644
index 000000000..24fb5015a
--- /dev/null
+++ b/apps/agreements/models/agreements.py
@@ -0,0 +1,292 @@
+"""Agreements the PSF signs, the published terms they cite, and their signatures.
+
+Domain drafts (configured orders, custom contracts, or sponsorships) register
+a ``Kind``. Offering freezes the document as an ``Agreement``. Until it is signed, PSF staff can
+edit it for that one counterparty; every edit is kept as a revision. The counterparty signs on
+python.org, with an account or a one-time emailed link, or outside it, in which case the signed
+copy is uploaded. The PSF countersigns.
+
+Standing terms are edited here too and published as versions. A new version applies to
+documents offered afterwards; a published version never changes, because signed documents
+cite it by address and SHA-256.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import re
+import uuid
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from django.contrib.auth.models import AnonymousUser
+
+ from apps.agreements.models.orders import Order
+ from apps.agreements.registry import Kind
+ from apps.users.models import User
+
+from django.conf import settings
+from django.db import models
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.functional import cached_property
+
+from apps.agreements.auth import can_prepare
+from apps.agreements.registry import get_kind
+
+_DOCUMENT_REFERENCE = re.compile(r"^\*Reference ([A-F0-9]{8})\*$", re.MULTILINE)
+
+
+class Agreement(models.Model):
+ """One document offered to one counterparty, and its signatures."""
+
+ class Status(models.TextChoices):
+ """Lifecycle after the document is offered."""
+
+ OFFERED = "offered", "Awaiting signature"
+ SIGNED = "signed", "Awaiting countersignature"
+ EXECUTED = "executed", "Executed"
+ DECLINED = "declined", "Declined"
+ WITHDRAWN = "withdrawn", "Withdrawn"
+
+ class SignatureMethod(models.TextChoices):
+ """How the counterparty signed."""
+
+ ACCOUNT = "account", "Signed on python.org"
+ LINK = "link", "Signed on python.org with an emailed link"
+ OFFLINE = "offline", "Signed copy on file"
+
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ kind = models.CharField(max_length=32, db_index=True)
+ title = models.CharField(max_length=255)
+ counterparty_name = models.CharField(max_length=255)
+ counterparty_account = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT,
+ null=True,
+ blank=True,
+ related_name="agreements_to_sign",
+ help_text="The python.org account that may view and sign it online.",
+ )
+ status = models.CharField(max_length=16, choices=Status.choices, default=Status.OFFERED, db_index=True)
+
+ document_markdown = models.TextField()
+ document_sha256 = models.CharField(max_length=64)
+ revision = models.PositiveIntegerField(default=1)
+ terms_versions = models.ManyToManyField("agreements.TermsVersion", blank=True, related_name="agreements")
+
+ offered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+")
+ offered_at = models.DateTimeField(auto_now_add=True)
+
+ signature_method = models.CharField(max_length=16, choices=SignatureMethod.choices, blank=True)
+ signer_name = models.CharField(max_length=255, blank=True)
+ signer_title = models.CharField(max_length=255, blank=True)
+ signer_email = models.EmailField(blank=True)
+ signed_at = models.DateTimeField(null=True, blank=True)
+ signed_ip = models.GenericIPAddressField(null=True, blank=True)
+ signed_user_agent = models.CharField(max_length=512, blank=True)
+ signature_recorded_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+"
+ )
+
+ countersigned_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+"
+ )
+ countersigner_name = models.CharField(max_length=255, blank=True)
+ countersigner_title = models.CharField(max_length=255, blank=True)
+ countersigned_at = models.DateTimeField(null=True, blank=True)
+
+ declined_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+"
+ )
+ declined_at = models.DateTimeField(null=True, blank=True)
+ decline_reason = models.TextField(blank=True)
+
+ withdrawn_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+"
+ )
+ withdrawn_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ """Newest first; management access comes from the agreement groups."""
+
+ ordering = ("-offered_at",)
+
+ def __str__(self) -> str:
+ """Return the title and counterparty."""
+ return f"{self.title}: {self.counterparty_name}"
+
+ def get_absolute_url(self) -> str:
+ """Return the agreement page."""
+ return reverse("agreements:detail", kwargs={"pk": self.pk})
+
+ @property
+ def reference(self) -> str:
+ """Use the reference printed in the frozen document, when it has one."""
+ match = _DOCUMENT_REFERENCE.search(self.document_markdown)
+ return match[1] if match else str(self.pk).split("-")[0].upper()
+
+ @cached_property
+ def kind_obj(self) -> Kind[Any]:
+ """Return the registered kind."""
+ return get_kind(self.kind)
+
+ @cached_property
+ def subject(self) -> CustomContract | Order | None:
+ """Return the domain object this agreement was offered for, while it still points here."""
+ return self.kind_obj.subject(self)
+
+ @property
+ def subject_url(self) -> str:
+ """Where the counterparty and staff follow this agreement: the domain page if there is one."""
+ return self.subject.get_absolute_url() if self.subject else self.get_absolute_url()
+
+ @property
+ def is_editable(self) -> bool:
+ """The text can change, and the agreement be signed, only while it awaits signature."""
+ return self.status == self.Status.OFFERED
+
+ def is_counterparty(self, user: User | AnonymousUser) -> bool:
+ """Whether ``user`` is the account that may sign online."""
+ return self.counterparty_account_id is not None and self.counterparty_account_id == user.pk
+
+ def can_view(self, user: User | AnonymousUser) -> bool:
+ """Allow agreement groups, the counterparty, and the subject's customer."""
+ return can_prepare(user) or self.is_counterparty(user) or self.kind_obj.can_view(user, self)
+
+
+class AgreementRevision(models.Model):
+ """One version of an agreement's text before signature: the offer and each staff edit."""
+
+ agreement = models.ForeignKey(Agreement, on_delete=models.CASCADE, related_name="revisions")
+ revision = models.PositiveIntegerField()
+ markdown = models.TextField()
+ sha256 = models.CharField(max_length=64)
+ note = models.CharField(max_length=255)
+ created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+")
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ """Newest first."""
+
+ ordering = ("-revision",)
+ constraints = (models.UniqueConstraint(fields=("agreement", "revision"), name="one_row_per_revision"),)
+
+ def __str__(self) -> str:
+ """Return the agreement and revision."""
+ return f"{self.agreement}, revision {self.revision}"
+
+
+class SignedCopy(models.Model):
+ """A signed copy received outside python.org, for example through DocuSign or on paper.
+
+ Stored in the database rather than media storage: media is public, these are contracts.
+ """
+
+ class Kind(models.TextChoices):
+ """Which signatures the copy carries."""
+
+ CUSTOMER = "customer", "Signed by the counterparty"
+ EXECUTED = "executed", "Signed by both parties"
+
+ MAX_BYTES = 20 * 1024 * 1024
+
+ agreement = models.ForeignKey(Agreement, on_delete=models.PROTECT, related_name="signed_copies")
+ kind = models.CharField(max_length=16, choices=Kind.choices)
+ filename = models.CharField(max_length=255)
+ content = models.BinaryField()
+ sha256 = models.CharField(max_length=64)
+ uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+")
+ uploaded_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ """One copy of each kind per agreement."""
+
+ verbose_name_plural = "signed copies"
+ constraints = (models.UniqueConstraint(fields=("agreement", "kind"), name="one_signed_copy_of_each_kind"),)
+
+ def __str__(self) -> str:
+ """Return the agreement and kind."""
+ return f"{self.agreement}: {self.get_kind_display()}"
+
+
+class SigningLink(models.Model):
+ """A single-use link emailed to a named signatory who may not have a python.org account.
+
+ Only a hash of the token is stored; the token itself exists only in the email.
+ """
+
+ agreement = models.ForeignKey(Agreement, on_delete=models.CASCADE, related_name="signing_links")
+ name = models.CharField(max_length=255)
+ email = models.EmailField()
+ token_sha256 = models.CharField(max_length=64, unique=True)
+ expires_at = models.DateTimeField()
+ used_at = models.DateTimeField(null=True, blank=True)
+ created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+")
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ """Newest first."""
+
+ ordering = ("-created_at",)
+
+ def __str__(self) -> str:
+ """Return the recipient."""
+ return f"{self.name} <{self.email}>"
+
+ @staticmethod
+ def hash_token(token: str) -> str:
+ """Return the stored form of a token."""
+ return hashlib.sha256(token.encode()).hexdigest()
+
+ @property
+ def is_usable(self) -> bool:
+ """Unused, unexpired, and its agreement still awaits signature."""
+ return (
+ self.used_at is None
+ and self.expires_at > timezone.now()
+ and self.agreement.status == Agreement.Status.OFFERED
+ )
+
+
+class CustomContract(models.Model):
+ """A one-off contract written by PSF staff, for example with a PyCon venue or vendor."""
+
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ title = models.CharField(max_length=255)
+ counterparty_name = models.CharField("counterparty's legal name", max_length=255)
+ counterparty_account = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+"
+ )
+ terms = models.ForeignKey(
+ "agreements.Terms",
+ on_delete=models.PROTECT,
+ null=True,
+ blank=True,
+ help_text="Standing terms this contract cites and incorporates, if any.",
+ )
+ body_markdown = models.TextField("contract text")
+ created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+")
+ created = models.DateTimeField(auto_now_add=True)
+ modified = models.DateTimeField(auto_now=True)
+ agreement = models.OneToOneField(
+ Agreement, on_delete=models.PROTECT, null=True, blank=True, related_name="custom_contract"
+ )
+
+ class Meta:
+ """Newest first."""
+
+ ordering = ("-created",)
+
+ def __str__(self) -> str:
+ """Return the title and counterparty."""
+ return f"{self.title}: {self.counterparty_name}"
+
+ def get_absolute_url(self) -> str:
+ """Return the draft page, which links to the agreement once offered."""
+ return reverse("agreements:custom_detail", kwargs={"pk": self.pk})
+
+ @property
+ def status(self) -> str:
+ """Draft until offered, then the agreement's status."""
+ return self.agreement.status if self.agreement else "draft"
diff --git a/apps/agreements/models/orders.py b/apps/agreements/models/orders.py
new file mode 100644
index 000000000..de728adeb
--- /dev/null
+++ b/apps/agreements/models/orders.py
@@ -0,0 +1,268 @@
+"""Database-configured programs and the orders prepared from them."""
+
+from __future__ import annotations
+
+import uuid
+from decimal import Decimal
+from typing import TYPE_CHECKING, Any, cast
+
+if TYPE_CHECKING:
+ from django.contrib.auth.models import AnonymousUser
+ from django.db.models import QuerySet
+
+ from apps.agreements.models.agreements import Agreement
+ from apps.agreements.models.terms import TermsVersion
+ from apps.agreements.orders.catalog import Agreement as CatalogAgreement
+ from apps.agreements.orders.pricing import FeeKey, Quote, QuoteSnapshot
+ from apps.users.models import User
+
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils.functional import cached_property
+
+from apps.agreements.auth import can_prepare, is_administrator
+from apps.agreements.models.terms import Terms
+from apps.agreements.orders.catalog import Catalog, validate_catalog
+from apps.agreements.orders.pricing import build_quote, fee_totals, money
+
+
+class Program(models.Model):
+ """Configure an order builder without putting its commercial content in source control."""
+
+ slug = models.SlugField(unique=True)
+ title = models.CharField(max_length=255)
+ description = models.TextField(blank=True)
+ is_public = models.BooleanField(
+ default=False,
+ help_text="Allow anyone to browse this program and signed-in customers to start orders. "
+ "Does not publish its terms. Leave unchecked for staff-prepared orders only.",
+ )
+ definition = models.JSONField(validators=[validate_catalog], help_text="Catalog, pricing rules and document copy.")
+
+ class Meta:
+ """List programs by title."""
+
+ ordering = ("title",)
+
+ def __str__(self) -> str:
+ """Return the staff-managed program title."""
+ return self.title
+
+ def get_absolute_url(self) -> str:
+ """Return the access-controlled program page."""
+ return reverse("agreements:program_detail", kwargs={"slug": self.slug})
+
+ @classmethod
+ def visible_to(cls, user: User | AnonymousUser) -> QuerySet[Program]:
+ """Return programs the visitor can browse, never implicitly exposing private catalogs."""
+ return cls.objects.all() if can_prepare(user) else cls.objects.filter(is_public=True)
+
+ @cached_property
+ def catalog(self) -> Catalog:
+ """Parse the validated definition once per loaded program."""
+ return Catalog(self.definition)
+
+ def clean(self) -> None:
+ """Require a valid catalog and existing, versioned terms before accepting configuration."""
+ super().clean()
+ try:
+ catalog = Catalog(self.definition)
+ except ValidationError as exc:
+ raise ValidationError({"definition": exc.messages}) from exc
+ slugs = {item.terms_slug for item in catalog.agreements.values()}
+ available = set(Terms.objects.filter(slug__in=slugs, versions__isnull=False).values_list("slug", flat=True))
+ missing = sorted(slugs - available)
+ if missing:
+ raise ValidationError({"definition": f"Publish a terms version first for: {', '.join(missing)}."})
+ if self.is_public and Terms.objects.filter(slug__in=slugs, is_public=False).exists():
+ raise ValidationError({"is_public": "Make every cited set of terms public before publishing the program."})
+
+
+class Order(models.Model):
+ """One customer's selections and the frozen configuration behind their Order Form."""
+
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ program = models.ForeignKey(Program, on_delete=models.PROTECT, related_name="orders")
+ created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="orders_created")
+ customer_account = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT,
+ null=True,
+ blank=True,
+ related_name="agreement_orders",
+ help_text="The python.org account that may view the order and sign it online.",
+ )
+ created = models.DateTimeField(auto_now_add=True)
+ modified = models.DateTimeField(auto_now=True)
+ legal_name = models.CharField("legal name", max_length=255)
+ jurisdiction = models.CharField("state or country of organization", max_length=100)
+ entity_type = models.CharField(max_length=100, help_text="For example: corporation or limited liability company.")
+ address = models.TextField("principal place of business")
+ covered_entities = models.TextField("covered entities", help_text="One name per line.")
+ discount = models.CharField(max_length=64, default="none")
+ authorized_contacts = models.JSONField(default=list)
+ billing_contact_name = models.CharField(max_length=255)
+ billing_contact_email = models.EmailField()
+ notices_contact = models.TextField(blank=True, help_text="Only if different from the principal place of business.")
+ agreement = models.OneToOneField(
+ "agreements.Agreement", on_delete=models.PROTECT, null=True, blank=True, related_name="order"
+ )
+ catalog_snapshot = models.JSONField(default=dict, blank=True, editable=False)
+ program_title_snapshot = models.CharField(max_length=255, blank=True, editable=False)
+
+ class Meta:
+ """List newest orders first."""
+
+ ordering = ("-created",)
+
+ def __str__(self) -> str:
+ """Return the customer for listings."""
+ return self.legal_name
+
+ def get_absolute_url(self) -> str:
+ """Return the customer's order page."""
+ return reverse("agreements:order_detail", kwargs={"pk": self.pk})
+
+ @property
+ def configuration(self) -> dict[str, Any]:
+ """Read offered configuration from the snapshot, not a later catalog revision."""
+ return self.catalog_snapshot or self.program.definition
+
+ @cached_property
+ def catalog(self) -> Catalog:
+ """Parse the configuration used by this order."""
+ return Catalog(self.configuration)
+
+ @property
+ def program_title(self) -> str:
+ """Keep the program's name stable once offered."""
+ return self.program_title_snapshot or self.program.title
+
+ @property
+ def reference(self) -> str:
+ """Return a short reference suitable for quoting."""
+ return str(self.pk).split("-")[0].upper()
+
+ @property
+ def status(self) -> str:
+ """Use the agreement status after offering; otherwise the order is a draft."""
+ return self.agreement.status if self.agreement else "draft"
+
+ @cached_property
+ def agreement_list(self) -> list[OrderLine]:
+ """Return selections in configured order, independent of database JSON ordering."""
+ positions = {slug: index for index, slug in enumerate(self.catalog.agreements)}
+ lines = list(self.agreements.all())
+ for line in lines:
+ line.order = self
+ return sorted(lines, key=lambda line: positions.get(line.agreement, len(positions)))
+
+ @property
+ def is_editable(self) -> bool:
+ """Allow selection changes only while the order is a draft."""
+ return self.agreement_id is None
+
+ def is_customer(self, user: User | AnonymousUser) -> bool:
+ """Check whether the visitor is the linked customer account."""
+ return self.customer_account_id is not None and self.customer_account_id == user.pk
+
+ def can_view(self, user: User | AnonymousUser) -> bool:
+ """Restrict orders to agreement preparers and their linked customer."""
+ return user.is_authenticated and (can_prepare(user) or self.is_customer(user))
+
+ def can_edit(self, user: User | AnonymousUser) -> bool:
+ """Allow an authorized viewer to change a draft."""
+ return self.is_editable and self.can_view(user)
+
+ def can_offer(self, user: User | AnonymousUser) -> bool:
+ """Allow only administrators or the linked customer to offer a draft."""
+ return self.is_editable and user.is_authenticated and (is_administrator(user) or self.is_customer(user))
+
+ @property
+ def organization_list(self) -> list[str]:
+ """Return the covered entity names."""
+ return [line.strip() for line in self.covered_entities.splitlines() if line.strip()]
+
+ @property
+ def term_months(self) -> int:
+ """Return the selected term from the order's configuration."""
+ return self.catalog.discount(self.discount).term_months
+
+ def total(self, key: FeeKey) -> Decimal:
+ """Sum annual or term totals across the selected agreements."""
+ return sum((line.pricing_value(key) for line in self.agreement_list), Decimal(0))
+
+ @cached_property
+ def fee_totals(self) -> dict[str, Decimal]:
+ """Separate annual and one-time fees across current or frozen selections."""
+ totals = {"annual": Decimal(0), "one_time": Decimal(0)}
+ for line in self.agreement_list:
+ for key, amount in line.fee_totals.items():
+ totals[key] += amount
+ return totals
+
+ @property
+ def term_total_display(self) -> str:
+ """Format the combined initial-term fees."""
+ return money(self.total("term_total"))
+
+
+class OrderLine(models.Model):
+ """One selected agreement and the fees fixed when the Order Form is offered."""
+
+ order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="agreements")
+ agreement = models.SlugField(max_length=64)
+ tier = models.CharField(max_length=64)
+ addons = models.JSONField(default=dict, blank=True)
+ special_terms = models.TextField(blank=True, help_text="Set by staff only; leave empty for none.")
+ pricing = models.JSONField(default=dict, blank=True, editable=False)
+
+ class Meta:
+ """Allow one selection of each agreement per order."""
+
+ constraints = (models.UniqueConstraint(fields=("order", "agreement"), name="one_order_line_per_agreement"),)
+
+ def __str__(self) -> str:
+ """Return the customer and selection."""
+ return f"{self.order.legal_name}: {self.agreement}"
+
+ @cached_property
+ def agreement_obj(self) -> CatalogAgreement:
+ """Return this agreement's configured definition."""
+ return self.order.catalog.agreements[self.agreement]
+
+ @property
+ def tier_name(self) -> str:
+ """Return the selected tier's name."""
+ return self.agreement_obj.tier(self.tier).name
+
+ @cached_property
+ def cited_terms(self) -> TermsVersion | None:
+ """Return the fixed version after offering or the current draft candidate."""
+ if self.order.agreement_id:
+ return (
+ cast("Agreement", self.order.agreement)
+ .terms_versions.select_related("terms")
+ .get(terms__slug=self.agreement_obj.terms_slug)
+ )
+ return self.agreement_obj.terms.current_version
+
+ def quote(self) -> Quote:
+ """Calculate fees from this order's catalog."""
+ return build_quote(self.agreement_obj, self.tier, self.order.discount, self.addons)
+
+ @property
+ def pricing_snapshot(self) -> QuoteSnapshot:
+ """Return frozen fees after offering or the current draft's quote."""
+ return self.pricing or self.quote().as_dict()
+
+ @cached_property
+ def fee_totals(self) -> dict[str, Decimal]:
+ """Read recurring fees separately from one-time charges in any snapshot."""
+ return fee_totals(self.pricing_snapshot)
+
+ def pricing_value(self, key: FeeKey) -> Decimal:
+ """Read a numeric fee total."""
+ return Decimal(self.pricing_snapshot[key])
diff --git a/apps/agreements/models/terms.py b/apps/agreements/models/terms.py
new file mode 100644
index 000000000..a318afbc4
--- /dev/null
+++ b/apps/agreements/models/terms.py
@@ -0,0 +1,117 @@
+"""Published terms and their immutable versions."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.functional import cached_property
+
+from apps.agreements.documents import sha256
+from apps.agreements.orders.catalog import Catalog
+
+# Documents are rendered on staging and locally too; they must always cite production.
+CANONICAL_ORIGIN = "https://www.python.org"
+
+
+class Terms(models.Model):
+ """Standing terms with explicit publication and immutable document versions."""
+
+ slug = models.SlugField(unique=True)
+ title = models.CharField(max_length=255)
+ is_public = models.BooleanField(
+ default=False,
+ help_text="Make published versions readable by everyone. Otherwise only staff and the parties may read them.",
+ )
+ under_review = models.BooleanField(
+ default=False, help_text="Mark the terms as a draft for attorney review wherever they appear."
+ )
+ draft_markdown = models.TextField(blank=True, help_text="Working copy; not public until published.")
+ draft_updated_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+"
+ )
+ draft_updated_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ """Terms are listed by title."""
+
+ ordering = ("title",)
+ verbose_name_plural = "terms"
+
+ def __str__(self) -> str:
+ """Return the title."""
+ return self.title
+
+ def get_absolute_url(self) -> str:
+ """Return the public page for the current version."""
+ return reverse("agreements:terms", kwargs={"slug": self.slug})
+
+ def validate_is_public(self, is_public: bool) -> None:
+ """Keep terms readable while any public program cites their slug."""
+ if is_public or self.pk is None:
+ return
+ from apps.agreements.models.orders import Program
+
+ for definition in Program.objects.filter(is_public=True).values_list("definition", flat=True):
+ catalog = Catalog(definition)
+ if any(item.terms_slug == self.slug for item in catalog.agreements.values()):
+ message = "Make every program that cites these terms private before making the terms private."
+ raise ValidationError(message)
+
+ def clean(self) -> None:
+ """Validate publication settings in model forms, including the admin."""
+ super().clean()
+ try:
+ self.validate_is_public(self.is_public)
+ except ValidationError as exc:
+ raise ValidationError({"is_public": exc.messages}) from exc
+
+ @cached_property
+ def current_version(self) -> TermsVersion | None:
+ """Return the version documents offered now cite."""
+ return self.versions.order_by("-published_at", "-pk").first()
+
+
+class TermsVersion(models.Model):
+ """One published version of terms. It never changes: signed documents cite it."""
+
+ terms = models.ForeignKey(Terms, on_delete=models.PROTECT, related_name="versions")
+ version = models.SlugField(max_length=64)
+ markdown = models.TextField()
+ sha256 = models.CharField(max_length=64, editable=False)
+ notes = models.TextField(blank=True, help_text="What changed from the previous version.")
+ published_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+"
+ )
+ published_at = models.DateTimeField(default=timezone.now)
+
+ class Meta:
+ """Newest first; a version label is used once per terms."""
+
+ ordering = ("-published_at", "-pk")
+ constraints = (models.UniqueConstraint(fields=("terms", "version"), name="one_terms_version_per_label"),)
+
+ def __str__(self) -> str:
+ """Return the terms and version."""
+ return f"{self.terms.title}, version {self.version}"
+
+ def save(self, *args: Any, **kwargs: Any) -> None:
+ """Hash the text on publication and refuse any later change."""
+ if self.pk:
+ msg = "A published terms version never changes; publish a new version instead."
+ raise ValueError(msg)
+ self.sha256 = sha256(self.markdown)
+ super().save(*args, **kwargs)
+
+ def get_absolute_url(self) -> str:
+ """Return the permanent page for this version."""
+ return reverse("agreements:terms_version", kwargs={"slug": self.terms.slug, "version": self.version})
+
+ @property
+ def permanent_url(self) -> str:
+ """Absolute, permanent address that documents cite."""
+ return CANONICAL_ORIGIN + self.get_absolute_url()
diff --git a/apps/agreements/notifications.py b/apps/agreements/notifications.py
new file mode 100644
index 000000000..50e284a5f
--- /dev/null
+++ b/apps/agreements/notifications.py
@@ -0,0 +1,64 @@
+"""Emails to signatories: a one-time signing link, and the executed copy."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from django.conf import settings
+from django.core.mail import EmailMessage
+from django.template.loader import render_to_string
+from django.urls import reverse
+
+from apps.agreements.documents import final_markdown, render_pdf, terms_download_markdown
+from apps.agreements.models import SignedCopy
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+ from django.http import HttpRequest
+
+ from apps.agreements.models import Agreement, SigningLink
+
+
+def _send(
+ template: str,
+ context: dict[str, object],
+ to: str,
+ attachments: Iterable[tuple[str, str | bytes, str]] = (),
+) -> None:
+ subject = " ".join(render_to_string(f"agreements/email/{template}_subject.txt", context).split())
+ body = render_to_string(f"agreements/email/{template}.txt", context)
+ email = EmailMessage(subject=subject, body=body, from_email=settings.DEFAULT_FROM_EMAIL, to=[to])
+ for attachment in attachments:
+ email.attach(*attachment)
+ email.send()
+
+
+def send_signing_link(request: HttpRequest, link: SigningLink, token: str) -> None:
+ """Email a signatory their one-time link; ``token`` exists only here and in the email."""
+ url = request.build_absolute_uri(reverse("agreements:sign_link", kwargs={"token": token}))
+ _send("signing_link", {"link": link, "agreement": link.agreement, "url": url}, link.email)
+
+
+def send_executed_copy(agreement: Agreement) -> None:
+ """Email the executed PDF and every immutable terms version it incorporates."""
+ if not agreement.signer_email:
+ return
+ copy = agreement.signed_copies.filter(kind=SignedCopy.Kind.EXECUTED).first()
+ pdf = bytes(copy.content) if copy is not None else render_pdf(final_markdown(agreement))
+ versions = agreement.terms_versions.select_related("terms")
+ attachments = [(f"psf-agreement-{agreement.reference}.pdf", pdf, "application/pdf")]
+ attachments.extend(
+ (
+ f"{version.terms.slug}-{version.version}.pdf",
+ render_pdf(terms_download_markdown(version)),
+ "application/pdf",
+ )
+ for version in versions
+ )
+ _send(
+ "executed",
+ {"agreement": agreement, "terms_versions": versions},
+ agreement.signer_email,
+ attachments=attachments,
+ )
diff --git a/apps/agreements/orders/__init__.py b/apps/agreements/orders/__init__.py
new file mode 100644
index 000000000..db64924f9
--- /dev/null
+++ b/apps/agreements/orders/__init__.py
@@ -0,0 +1 @@
+"""Configurable catalogs, pricing, and order preparation."""
diff --git a/apps/agreements/orders/catalog.py b/apps/agreements/orders/catalog.py
new file mode 100644
index 000000000..61dae58d4
--- /dev/null
+++ b/apps/agreements/orders/catalog.py
@@ -0,0 +1,568 @@
+"""Validated, database-configured agreement catalogs and pricing definitions."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from decimal import Decimal, InvalidOperation
+from typing import TYPE_CHECKING, Any, NoReturn, cast
+
+from django.core.exceptions import ValidationError
+
+if TYPE_CHECKING:
+ from collections.abc import Collection, Iterator
+
+ from apps.agreements.models import Terms
+
+
+MONTHS_PER_YEAR = 12
+PERCENT_SCALE = 100
+MAX_NESTING = 32
+RESPONSE_TARGET_COUNT = 4
+CURRENCY_WHOLE_DIGITS = 16
+CURRENCY_DECIMAL_PLACES = 2
+CHOICE_PAIR_SIZE = 2
+
+RULE_FIELDS = {
+ "fixed": {"amount"},
+ "quantity": {"parameter", "unit_amount", "base_amount", "included"},
+ "brackets": {"parameter", "bands", "overflow_basis"},
+ "choice": {"parameter", "options"},
+ "sum": {"components"},
+ "unpriced": set(),
+}
+
+
+@dataclass(frozen=True)
+class Tier:
+ """One service tier with its annual fee and response commitments."""
+
+ key: str
+ name: str
+ annual_fee: Decimal
+ response_targets: tuple[str, str, str, str]
+ fair_use: int
+ includes: str = ""
+
+
+@dataclass(frozen=True)
+class Service:
+ """A service available at every tier or at an explicit subset."""
+
+ key: str
+ name: str
+ description: str
+ tiers: tuple[str, ...] = ()
+
+ def included_at(self, tier_key: str) -> bool:
+ """Return whether this service is included at the selected tier."""
+ return not self.tiers or tier_key in self.tiers
+
+
+@dataclass(frozen=True)
+class Param:
+ """One typed add-on input and its optional activation conditions."""
+
+ key: str
+ label: str
+ kind: str
+ choices: tuple[tuple[str, str], ...] = ()
+ help_text: str = ""
+ minimum: int = 1
+ required_when: dict[str, str | int | list[str]] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class AddOn:
+ """An additional service with declarative pricing and parameter definitions."""
+
+ key: str
+ name: str
+ description: str
+ price_summary: str
+ pricing: dict[str, Any]
+ params: tuple[Param, ...] = ()
+ included_in_tiers: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True)
+class Discount:
+ """A nonstacking tier-fee discount and its associated initial term."""
+
+ key: str
+ name: str
+ percent: int
+ term_months: int
+ requires_attestation: bool = False
+
+
+@dataclass(frozen=True)
+class Agreement:
+ """An agreement's ordered services, tier choices, and pricing options."""
+
+ slug: str
+ title: str
+ short_name: str
+ tagline: str
+ terms_slug: str
+ tiers: tuple[Tier, ...]
+ services: tuple[Service, ...]
+ discounts: tuple[Discount, ...]
+ default_tier: str
+ addons: tuple[AddOn, ...] = ()
+
+ @property
+ def terms(self) -> Terms:
+ """Return the published terms identified by this agreement."""
+ from apps.agreements.models import Terms
+
+ return Terms.objects.get(slug=self.terms_slug)
+
+ def tier(self, key: str) -> Tier:
+ """Return the named tier or raise a selection validation error."""
+ return _lookup(self.tiers, key, "tier")
+
+ def discount(self, key: str) -> Discount:
+ """Return the named discount or raise a selection validation error."""
+ return _lookup(self.discounts, key, "discount")
+
+ def addon(self, key: str) -> AddOn:
+ """Return the named add-on or raise a selection validation error."""
+ return _lookup(self.addons, key, "add-on")
+
+ def services_at(self, tier_key: str) -> list[Service]:
+ """Return services included at a valid tier."""
+ self.tier(tier_key)
+ return [service for service in self.services if service.included_at(tier_key)]
+
+ @property
+ def service_rows(self) -> list[tuple[Service, str]]:
+ """Pair each service with its restricted tier names for display."""
+ return [
+ (service, " and ".join(t.name for t in self.tiers if t.key in service.tiers) if service.tiers else "")
+ for service in self.services
+ ]
+
+
+def _lookup[CatalogItem: (Tier, Discount, AddOn)](items: tuple[CatalogItem, ...], key: str, label: str) -> CatalogItem:
+ for item in items:
+ if item.key == key:
+ return item
+ message = f"Unknown {label}: {key!r}."
+ raise ValidationError(message)
+
+
+def _error(path: str, message: str) -> NoReturn:
+ message = f"{path}: {message}"
+ raise ValidationError(message)
+
+
+def _object(value: object, path: str, allowed: Collection[str], required: Collection[str] = ()) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ _error(path, "must be an object.")
+ unknown = value.keys() - allowed
+ if unknown:
+ _error(path, f"unknown field(s): {', '.join(sorted(map(str, unknown)))}.")
+ missing = set(required) - value.keys()
+ if missing:
+ _error(path, f"missing field(s): {', '.join(sorted(missing))}.")
+ return value
+
+
+def _list(value: object, path: str, nonempty: bool = False) -> list[Any]:
+ if not isinstance(value, list) or (nonempty and not value):
+ _error(path, "must be a nonempty list." if nonempty else "must be a list.")
+ return value
+
+
+def _text(value: object, path: str, blank: bool = False) -> str:
+ if not isinstance(value, str) or (not blank and not value.strip()):
+ _error(path, "must be text." if blank else "must be nonempty text.")
+ return value
+
+
+def _key(value: Any, path: str) -> str:
+ _text(value, path)
+ if not re.fullmatch(r"[-a-zA-Z0-9_]+", value):
+ _error(path, "must contain only letters, numbers, hyphens, and underscores.")
+ return value
+
+
+def _integer(value: object, path: str, minimum: int = 0) -> int:
+ if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
+ _error(path, f"must be an integer of at least {minimum}.")
+ return value
+
+
+def _boolean(value: object, path: str) -> bool:
+ if not isinstance(value, bool):
+ _error(path, "must be true or false.")
+ return value
+
+
+def _decimal(value: object, path: str) -> Decimal:
+ if not isinstance(value, str):
+ _error(path, "must be a decimal string.")
+ try:
+ amount = Decimal(value)
+ except InvalidOperation:
+ _error(path, "must be a finite, nonnegative decimal amount.")
+ if not amount.is_finite() or amount < 0:
+ _error(path, "must be a finite, nonnegative decimal amount.")
+ # Monetary arithmetic and cent rounding must remain exact in Decimal's context.
+ if amount.adjusted() >= CURRENCY_WHOLE_DIGITS or cast("int", amount.as_tuple().exponent) < -CURRENCY_DECIMAL_PLACES:
+ _error(path, "must have at most 16 whole-number digits and two decimal places.")
+ return amount
+
+
+def _unique(
+ records: object, path: str, key: str = "key", nonempty: bool = False
+) -> Iterator[tuple[dict[str, Any], str]]:
+ seen = set()
+ for index, record in enumerate(_list(records, path, nonempty)):
+ here = f"{path}[{index}]"
+ if not isinstance(record, dict):
+ _error(here, "must be an object.")
+ name = _key(record.get(key), f"{here}.{key}")
+ if name in seen:
+ _error(here, f"duplicate {key}: {name}.")
+ seen.add(name)
+ yield record, here
+
+
+def _tier_refs(value: object, path: str, tier_keys: set[str]) -> tuple[str, ...]:
+ refs = _list(value, path)
+ seen = set()
+ for ref in refs:
+ _key(ref, path)
+ if ref not in tier_keys:
+ _error(path, f"unknown tier: {ref}.")
+ if ref in seen:
+ _error(path, f"duplicate tier: {ref}.")
+ seen.add(ref)
+ return tuple(refs)
+
+
+def _param(row: dict[str, Any], path: str) -> Param:
+ _object(
+ row,
+ path,
+ {"key", "label", "kind", "choices", "help_text", "minimum", "required_when"},
+ {"key", "label", "kind"},
+ )
+ kind = _text(row["kind"], f"{path}.kind")
+ if kind not in {"choice", "count", "text", "lines"}:
+ _error(f"{path}.kind", "must be choice, count, text, or lines.")
+ choices: list[tuple[str, str]] = []
+ for choice in _list(row.get("choices", []), f"{path}.choices", nonempty=kind == "choice"):
+ if not isinstance(choice, list) or len(choice) != CHOICE_PAIR_SIZE:
+ _error(f"{path}.choices", "each choice must be a [value, label] pair.")
+ value = _text(choice[0], f"{path}.choices.value")
+ label = _text(choice[1], f"{path}.choices.label")
+ if any(existing[0] == value for existing in choices):
+ _error(f"{path}.choices", f"duplicate choice: {value}.")
+ choices.append((value, label))
+ if choices and kind != "choice":
+ _error(path, "choices are only valid for choice parameters.")
+ condition = row.get("required_when", {})
+ if not isinstance(condition, dict):
+ _error(f"{path}.required_when", "must be an object of parameter values.")
+ return Param(
+ key=row["key"],
+ label=_text(row["label"], f"{path}.label"),
+ kind=kind,
+ choices=tuple(choices),
+ help_text=_text(row.get("help_text", ""), f"{path}.help_text", blank=True),
+ minimum=_integer(row.get("minimum", 1), f"{path}.minimum"),
+ required_when=condition,
+ )
+
+
+def _param_conditions(param: Param, params: dict[str, Param], path: str) -> None:
+ for key, value in param.required_when.items():
+ if not isinstance(key, str) or key not in params:
+ _error(path, f"unknown parameter: {key!r}.")
+ other = params[key]
+ if other.kind == "choice":
+ if not isinstance(value, str) or value not in dict(other.choices):
+ _error(path, f"invalid choice for {key}.")
+ elif other.kind == "count":
+ _integer(value, path, other.minimum)
+ elif other.kind == "text":
+ _text(value, path)
+ else:
+ for line in _list(value, path, nonempty=True):
+ _text(line, path)
+
+
+def _params(records: object, path: str) -> tuple[Param, ...]:
+ result = tuple(_param(row, here) for row, here in _unique(records, path))
+ by_key = {param.key: param for param in result}
+ for param in result:
+ _param_conditions(param, by_key, f"{path}.{param.key}.required_when")
+ visited: set[str] = set()
+
+ def visit(key: str, pending: set[str]) -> None:
+ if key in pending:
+ _error(path, "required_when dependencies must not form a cycle.")
+ if len(pending) > MAX_NESTING:
+ _error(path, f"required_when dependencies may nest at most {MAX_NESTING} levels.")
+ if key not in visited:
+ for dependency in by_key[key].required_when:
+ visit(dependency, pending | {key})
+ visited.add(key)
+
+ for key in by_key:
+ visit(key, set())
+ return result
+
+
+def _rule_shape(rule: object, path: str, depth: int) -> str:
+ if depth > MAX_NESTING:
+ _error(path, f"pricing rules may nest at most {MAX_NESTING} levels.")
+ if not isinstance(rule, dict):
+ _error(path, "must be a pricing rule object.")
+ kind = _text(rule.get("type"), f"{path}.type")
+ if kind not in RULE_FIELDS:
+ _error(path, f"unknown pricing rule type: {kind}.")
+ common = {"type"} if kind == "choice" else {"type", "recurring", "basis", "detail"}
+ required = RULE_FIELDS[kind] - {"base_amount", "included"}
+ _object(rule, path, common | RULE_FIELDS[kind], required | {"type"})
+ return kind
+
+
+def _rule_parameter(rule: dict[str, Any], params: dict[str, Param], path: str, conditions: dict[str, str]) -> None:
+ parameter = _key(rule["parameter"], f"{path}.parameter")
+ if parameter not in params:
+ _error(path, f"unknown parameter: {parameter}.")
+ param = params[parameter]
+ kinds = {"choice"} if rule["type"] == "choice" else {"count", "lines"}
+ if param.kind not in kinds:
+ _error(path, f"{rule['type']} cannot use a {param.kind} parameter.")
+ if any(conditions.get(key) != value for key, value in param.required_when.items()):
+ _error(path, f"parameter {parameter} is not guaranteed to be active in this pricing branch.")
+
+
+def _leaf_rule(rule: dict[str, Any], path: str) -> None:
+ kind = rule["type"]
+ if kind == "fixed":
+ _decimal(rule["amount"], f"{path}.amount")
+ elif kind == "quantity":
+ _decimal(rule["unit_amount"], f"{path}.unit_amount")
+ _decimal(rule.get("base_amount", "0"), f"{path}.base_amount")
+ _integer(rule.get("included", 0), f"{path}.included")
+ elif kind == "brackets":
+ previous = -1
+ for index, band in enumerate(_list(rule["bands"], f"{path}.bands", nonempty=True)):
+ here = f"{path}.bands[{index}]"
+ _object(band, here, {"up_to", "amount"}, {"up_to", "amount"})
+ threshold = _integer(band["up_to"], f"{here}.up_to")
+ if threshold <= previous:
+ _error(here, "bracket limits must be strictly increasing.")
+ previous = threshold
+ _decimal(band["amount"], f"{here}.amount")
+ _text(rule["overflow_basis"], f"{path}.overflow_basis")
+
+
+def _choice_rule(
+ rule: dict[str, Any], params: dict[str, Param], path: str, conditions: dict[str, str], depth: int
+) -> set[bool]:
+ parameter = rule["parameter"]
+ options = rule["options"]
+ if not isinstance(options, dict) or options.keys() != dict(params[parameter].choices).keys():
+ _error(f"{path}.options", "must contain exactly the parameter's choice values.")
+ recurrences: set[bool] = set()
+ for value, branch in options.items():
+ recurrences.update(
+ _rule(branch, params, f"{path}.options.{value}", {**conditions, parameter: value}, depth + 1)
+ )
+ return recurrences
+
+
+def _rule(
+ rule: dict[str, Any],
+ params: dict[str, Param],
+ path: str,
+ conditions: dict[str, str] | None = None,
+ depth: int = 0,
+) -> set[bool]:
+ kind = _rule_shape(rule, path, depth)
+ conditions = conditions or {}
+ if kind in {"quantity", "brackets", "choice"}:
+ _rule_parameter(rule, params, path, conditions)
+ if kind == "choice":
+ return _choice_rule(rule, params, path, conditions, depth)
+ recurring = _boolean(rule.get("recurring", True), f"{path}.recurring")
+ _text(rule.get("basis", ""), f"{path}.basis", blank=True)
+ _text(rule.get("detail", ""), f"{path}.detail", blank=True)
+ if kind == "sum":
+ for index, component in enumerate(_list(rule["components"], f"{path}.components", nonempty=True)):
+ recurrences = _rule(component, params, f"{path}.components[{index}]", conditions, depth + 1)
+ if recurrences != {recurring}:
+ _error(path, "summed components must have the same recurrence; use separate add-ons otherwise.")
+ else:
+ _leaf_rule(rule, path)
+ return {recurring}
+
+
+def _discount(row: dict[str, Any], path: str) -> Discount:
+ _object(
+ row,
+ path,
+ {"key", "name", "percent", "term_months", "requires_attestation"},
+ {"key", "name", "percent", "term_months"},
+ )
+ percent = _integer(row["percent"], f"{path}.percent")
+ if percent > PERCENT_SCALE:
+ _error(f"{path}.percent", f"must be between 0 and {PERCENT_SCALE}.")
+ months = _integer(row["term_months"], f"{path}.term_months", MONTHS_PER_YEAR)
+ if months % MONTHS_PER_YEAR:
+ _error(f"{path}.term_months", f"must be a positive multiple of {MONTHS_PER_YEAR}.")
+ return Discount(
+ key=row["key"],
+ name=_text(row["name"], f"{path}.name"),
+ percent=percent,
+ term_months=months,
+ requires_attestation=_boolean(row.get("requires_attestation", False), f"{path}.requires_attestation"),
+ )
+
+
+def _tier(row: dict[str, Any], path: str) -> Tier:
+ _object(
+ row,
+ path,
+ {"key", "name", "annual_fee", "response_targets", "fair_use", "includes"},
+ {"key", "name", "annual_fee", "response_targets", "fair_use"},
+ )
+ targets = _list(row["response_targets"], f"{path}.response_targets")
+ if len(targets) != RESPONSE_TARGET_COUNT:
+ _error(f"{path}.response_targets", "must contain exactly four response targets.")
+ return Tier(
+ key=row["key"],
+ name=_text(row["name"], f"{path}.name"),
+ annual_fee=_decimal(row["annual_fee"], f"{path}.annual_fee"),
+ response_targets=cast(
+ "tuple[str, str, str, str]", tuple(_text(target, f"{path}.response_targets") for target in targets)
+ ),
+ fair_use=_integer(row["fair_use"], f"{path}.fair_use"),
+ includes=_text(row.get("includes", ""), f"{path}.includes", blank=True),
+ )
+
+
+def _service(row: dict[str, Any], path: str, tier_keys: set[str]) -> Service:
+ _object(row, path, {"key", "name", "description", "tiers"}, {"key", "name", "description"})
+ return Service(
+ key=row["key"],
+ name=_text(row["name"], f"{path}.name"),
+ description=_text(row["description"], f"{path}.description", blank=True),
+ tiers=_tier_refs(row.get("tiers", []), f"{path}.tiers", tier_keys),
+ )
+
+
+def _addon(row: dict[str, Any], path: str, tier_keys: set[str]) -> AddOn:
+ _object(
+ row,
+ path,
+ {"key", "name", "description", "price_summary", "params", "included_in_tiers", "pricing"},
+ {"key", "name", "description", "price_summary", "pricing"},
+ )
+ params = _params(row.get("params", []), f"{path}.params")
+ _rule(row["pricing"], {param.key: param for param in params}, f"{path}.pricing")
+ return AddOn(
+ key=row["key"],
+ name=_text(row["name"], f"{path}.name"),
+ description=_text(row["description"], f"{path}.description", blank=True),
+ price_summary=_text(row["price_summary"], f"{path}.price_summary", blank=True),
+ params=params,
+ pricing=row["pricing"],
+ included_in_tiers=_tier_refs(row.get("included_in_tiers", []), f"{path}.included_in_tiers", tier_keys),
+ )
+
+
+def _agreement(row: dict[str, Any], path: str, discounts: tuple[Discount, ...]) -> Agreement:
+ _object(
+ row,
+ path,
+ {"slug", "title", "short_name", "tagline", "terms_slug", "default_tier", "tiers", "services", "addons"},
+ {"slug", "title", "terms_slug", "tiers"},
+ )
+ tiers = tuple(_tier(tier, here) for tier, here in _unique(row["tiers"], f"{path}.tiers", nonempty=True))
+ tier_keys = {tier.key for tier in tiers}
+ default_tier = _key(row.get("default_tier", tiers[0].key), f"{path}.default_tier")
+ if default_tier not in tier_keys:
+ _error(f"{path}.default_tier", f"unknown tier: {default_tier}.")
+ services = tuple(
+ _service(service, here, tier_keys) for service, here in _unique(row.get("services", []), f"{path}.services")
+ )
+ addons = tuple(_addon(addon, here, tier_keys) for addon, here in _unique(row.get("addons", []), f"{path}.addons"))
+ title = _text(row["title"], f"{path}.title")
+ return Agreement(
+ slug=row["slug"],
+ title=title,
+ short_name=_text(row.get("short_name", title), f"{path}.short_name"),
+ tagline=_text(row.get("tagline", ""), f"{path}.tagline", blank=True),
+ terms_slug=_key(row["terms_slug"], f"{path}.terms_slug"),
+ tiers=tiers,
+ services=services,
+ discounts=discounts,
+ addons=addons,
+ default_tier=default_tier,
+ )
+
+
+class Catalog:
+ """Parse a definition once, preserving its agreement, tier, and add-on ordering."""
+
+ order_title: str
+ order_intro: str
+ covered_entities_label: str
+ covered_entities_help: str
+ attestation_label: str
+ attestation_help: str
+
+ METADATA = {
+ "order_title": "Service order",
+ "order_intro": "Select the services for this order.",
+ "covered_entities_label": "Covered entities",
+ "covered_entities_help": "Enter one entity per line.",
+ "attestation_label": "I confirm eligibility for the selected discount.",
+ "attestation_help": "Confirm that the customer qualifies for this discount.",
+ }
+ PAYMENT = {
+ "annual": "Annual fees are payable in advance.",
+ "multi_year": "Fees for the initial term are payable in advance.",
+ "renewal": "Renewal is subject to the applicable agreement terms.",
+ "other_charges": "Other charges are payable under the applicable agreement terms.",
+ }
+
+ def __init__(self, definition: object) -> None:
+ """Validate and parse a JSON-compatible program definition."""
+ definition = _object(
+ definition,
+ "catalog",
+ {"agreements", "discounts", "payment"} | self.METADATA.keys(),
+ {"agreements", "discounts"},
+ )
+ for key, default in self.METADATA.items():
+ setattr(self, key, _text(definition.get(key, default), f"catalog.{key}", blank=True))
+ payment = _object(definition.get("payment", {}), "catalog.payment", self.PAYMENT.keys())
+ self.payment = {
+ key: _text(payment.get(key, default), f"catalog.payment.{key}", blank=True)
+ for key, default in self.PAYMENT.items()
+ }
+ self.discounts = tuple(
+ _discount(row, path) for row, path in _unique(definition["discounts"], "catalog.discounts", nonempty=True)
+ )
+ self.agreements = {
+ row["slug"]: _agreement(row, path, self.discounts)
+ for row, path in _unique(definition["agreements"], "catalog.agreements", "slug", nonempty=True)
+ }
+
+ def discount(self, key: str) -> Discount:
+ """Return the named shared discount or raise a validation error."""
+ return _lookup(self.discounts, key, "discount")
+
+
+def validate_catalog(value: object) -> None:
+ """Validate a Django JSONField value and report malformed definitions."""
+ Catalog(value)
diff --git a/apps/agreements/orders/documents.py b/apps/agreements/orders/documents.py
new file mode 100644
index 000000000..4b14e772e
--- /dev/null
+++ b/apps/agreements/orders/documents.py
@@ -0,0 +1,187 @@
+"""Compose an Order Form from its program configuration and frozen selections."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, cast
+
+from django.core.exceptions import ValidationError
+from num2words import num2words
+
+from apps.agreements.documents import (
+ EFFECTIVE_DATE,
+ PROVIDER_PREAMBLE,
+ SIGNATURES,
+ amounts_table,
+ final_markdown,
+ md,
+ preview_markdown,
+ table,
+)
+from apps.agreements.orders.pricing import fee_totals, money
+
+if TYPE_CHECKING:
+ from apps.agreements.models import Order, OrderLine, TermsVersion
+ from apps.agreements.orders.pricing import QuoteSnapshot
+
+ONE_YEAR = 12
+
+
+def _months(n: int) -> str:
+ return f"{num2words(n).capitalize()} ({n}) months"
+
+
+def _contact(name: str, email: str) -> str:
+ return f"{md(name)} ({md(email)})"
+
+
+def _customer_section(order: Order) -> list[str]:
+ address = ", ".join(part.strip() for part in order.address.splitlines() if part.strip())
+ contacts = "; ".join(_contact(c["name"], c["email"]) for c in order.authorized_contacts)
+ notices = md(order.notices_contact) if order.notices_contact.strip() else "The principal place of business above."
+ return table(
+ ("Customer", ""),
+ [
+ ("Legal name", f"**{md(order.legal_name)}**"),
+ ("Organized as", f"A {md(order.jurisdiction)} {md(order.entity_type)}"),
+ ("Principal place of business", md(address)),
+ (md(order.catalog.covered_entities_label), ", ".join(md(entity) for entity in order.organization_list)),
+ ("Authorized contacts", contacts),
+ ("Billing contact", _contact(order.billing_contact_name, order.billing_contact_email)),
+ ("Notices contact", notices),
+ ],
+ )
+
+
+def _term_section(order: Order) -> list[str]:
+ first = order.agreement_list[0].pricing_snapshot
+ payment = order.catalog.payment
+ invoicing = payment["annual"] if first["term_months"] <= ONE_YEAR else payment["multi_year"]
+ discount = (
+ f"{md(first['discount_name'])} ({first['discount_percent']}%), applied to Service Tier fees only."
+ if first["discount_percent"]
+ else "None."
+ )
+ return table(
+ ("Term and payment", ""),
+ [
+ ("Effective Date", EFFECTIVE_DATE),
+ ("Initial Term", f"{_months(first['term_months'])}."),
+ ("Renewal", md(payment["renewal"])),
+ ("Discount", discount),
+ ("Invoicing", md(invoicing)),
+ ("Other charges", md(payment["other_charges"])),
+ ],
+ )
+
+
+def _fee_rows(pricing: QuoteSnapshot) -> list[tuple[str, str]]:
+ rows = [(f"Service Tier fee: {md(pricing['tier_name'])}", f"{md(pricing['display']['tier_fee'])} per year")]
+ for item in pricing["items"]:
+ label = md(item["name"]) + (f" — {md(item['detail'])}" if item["detail"] else "")
+ amount = md(item["basis"]) if item["amount"] is None else f"{md(item['display'])} {md(item['basis'])}"
+ rows.append((f"Additional service: {label}", amount))
+ if pricing["discount_percent"]:
+ rows.append((md(pricing["discount_name"]), f"less {md(pricing['display']['discount_amount'])}"))
+ totals = fee_totals(pricing)
+ rows.append(("**Annual fees**", f"**{money(totals['annual'])}**"))
+ if totals["one_time"]:
+ rows.append(("One-time fees", money(totals["one_time"])))
+ if pricing["term_months"] > ONE_YEAR or any(not item["recurring"] for item in pricing["items"]):
+ rows.append((f"Initial Term total ({pricing['term_months']} months)", md(pricing["display"]["term_total"])))
+ return rows
+
+
+def _parameter_rows(line: OrderLine) -> list[tuple[str, str]]:
+ rows = []
+ for addon in line.agreement_obj.addons:
+ selected = line.addons.get(addon.key)
+ if selected is None:
+ continue
+ for param in addon.params:
+ if param.key not in selected:
+ continue
+ value = selected[param.key]
+ if param.kind == "choice":
+ value = dict(param.choices).get(cast("str", value), value)
+ elif param.kind == "lines":
+ value = "; ".join(cast("list[str]", value))
+ rows.append((f"{md(addon.name)} — {md(param.label)}", md(str(value))))
+ return rows
+
+
+def _agreement_section(line: OrderLine) -> list[str]:
+ agreement, terms = line.agreement_obj, cast("TermsVersion", line.cited_terms)
+ tier = agreement.tier(line.tier)
+ targets = "; ".join(
+ f"{severity}: {md(target)}"
+ for severity, target in zip(("P1", "P2", "P3", "P4"), tier.response_targets, strict=True)
+ )
+ rows = [
+ ("Service Tier", f"**{md(tier.name)}**"),
+ ("Response time targets", targets),
+ ("Fair-use threshold", f"{tier.fair_use} requests per year"),
+ ("Included services", "; ".join(md(service.name) for service in agreement.services_at(line.tier)) or "None."),
+ *_parameter_rows(line),
+ ("Special terms", md(line.special_terms.strip()) if line.special_terms.strip() else "None."),
+ ]
+ return [
+ f"## {md(agreement.short_name)}",
+ "",
+ f"Terms: **{md(terms.terms.title)}**, version {md(terms.version)}, published at <{terms.permanent_url}>.",
+ "",
+ *table((md(agreement.short_name), ""), rows),
+ *amounts_table(("Fees", ""), _fee_rows(line.pricing_snapshot)),
+ ]
+
+
+def _total_section(order: Order) -> list[str]:
+ rows = [(md(line.agreement_obj.short_name), money(line.fee_totals["annual"])) for line in order.agreement_list]
+ rows.append(("**Total annual fees**", f"**{money(order.fee_totals['annual'])}**"))
+ if order.fee_totals["one_time"]:
+ rows.append(("Total one-time fees", money(order.fee_totals["one_time"])))
+ if order.term_months > ONE_YEAR or any(
+ not item["recurring"] for line in order.agreement_list for item in line.pricing_snapshot["items"]
+ ):
+ rows.append((f"Total for the Initial Term ({order.term_months} months)", md(order.term_total_display)))
+ return ["## Order total", "", *amounts_table(("Agreement", "Fees"), rows)]
+
+
+def compose_order_form_markdown(order: Order) -> str:
+ """Return the document to hash and freeze, with date and signature placeholders."""
+ lines = order.agreement_list
+ if not lines:
+ msg = "Choose at least one agreement before preparing the Order Form."
+ raise ValidationError(msg)
+ header = [f"# {md(order.catalog.order_title)}", ""]
+ if any(cast("TermsVersion", line.cited_terms).terms.under_review for line in lines):
+ header += ["**DRAFT — For Attorney Review**", ""]
+ header += [f"*Reference {order.reference}*", ""]
+ agreements = " and ".join(md(line.agreement_obj.title) for line in lines)
+ intro = (
+ f"This Order Form is entered into between {PROVIDER_PREAMBLE}, and the Customer named below. "
+ f"It orders {agreements}, under the terms cited for each agreement below."
+ )
+ parts = [*header, md(order.catalog.order_intro), "", intro, "", *_customer_section(order), *_term_section(order)]
+ for line in lines:
+ parts += _agreement_section(line)
+ if len(lines) > 1:
+ parts += _total_section(order)
+ parts += [
+ "## Signatures",
+ "",
+ (
+ "Each Party signs this Order Form, and so enters into each agreement ordered on it, as of the "
+ "Effective Date: the date of the last signature below."
+ ),
+ "",
+ SIGNATURES,
+ "",
+ ]
+ return "\n".join(parts)
+
+
+def order_form_markdown(order: Order) -> str:
+ """Use the offered document after freezing, otherwise compose a draft preview."""
+ if order.agreement:
+ return final_markdown(order.agreement)
+ return preview_markdown(compose_order_form_markdown(order), order.legal_name)
diff --git a/apps/agreements/orders/forms.py b/apps/agreements/orders/forms.py
new file mode 100644
index 000000000..ae32d6475
--- /dev/null
+++ b/apps/agreements/orders/forms.py
@@ -0,0 +1,388 @@
+"""Catalog-driven order forms; signature forms are shared with all agreements."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, cast
+
+from django import forms
+from django.core.exceptions import ValidationError
+from django.db import transaction
+
+from apps.agreements.forms.accounts import account_for_email
+from apps.agreements.models import Order, OrderLine
+from apps.agreements.orders.pricing import normalize_params
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from django.forms import BoundField
+ from django.http import QueryDict
+
+ from apps.agreements.models import Program
+ from apps.agreements.orders.catalog import Agreement, Catalog, Param
+ from apps.agreements.orders.pricing import ParamValue
+ from apps.users.models import User
+
+MAX_CONTACTS = 5
+MAX_ENTITIES = 50
+MAX_NAME_LENGTH = 100
+
+
+def _lines(value: str | None) -> list[str]:
+ return [line.strip() for line in (value or "").splitlines() if line.strip()]
+
+
+class OrderForm(forms.ModelForm):
+ """Customer details, contacts, discount eligibility, and selected agreements."""
+
+ instance: Order
+
+ agreements = forms.MultipleChoiceField(
+ widget=forms.CheckboxSelectMultiple,
+ error_messages={"required": "Choose at least one service package."},
+ )
+ discount = forms.ChoiceField(widget=forms.RadioSelect)
+ discount_attestation = forms.BooleanField(required=False)
+
+ class Meta:
+ """Expose customer fields; configure selection fields from the catalog."""
+
+ model = Order
+ fields = (
+ "legal_name",
+ "jurisdiction",
+ "entity_type",
+ "address",
+ "covered_entities",
+ "discount",
+ "billing_contact_name",
+ "billing_contact_email",
+ "notices_contact",
+ )
+ widgets = {
+ "address": forms.Textarea(attrs={"rows": 3}),
+ "covered_entities": forms.Textarea(attrs={"rows": 3, "spellcheck": "false"}),
+ "notices_contact": forms.Textarea(attrs={"rows": 2}),
+ }
+
+ def __init__(
+ self,
+ *args: Any,
+ catalog: Catalog,
+ agreements: dict[str, Agreement],
+ can_link_accounts: bool = False,
+ **kwargs: Any,
+ ) -> None:
+ """Configure available selections, account linkage and contact rows."""
+ super().__init__(*args, **kwargs)
+ self.catalog = catalog
+ cast("forms.MultipleChoiceField", self.fields["agreements"]).choices = [
+ (a.slug, a.short_name) for a in agreements.values()
+ ]
+ cast("forms.ChoiceField", self.fields["discount"]).choices = [(d.key, d.name) for d in catalog.discounts]
+ self.fields["covered_entities"].label = catalog.covered_entities_label
+ self.fields["covered_entities"].help_text = catalog.covered_entities_help
+ self.fields["discount_attestation"].label = catalog.attestation_label
+ self.fields["discount_attestation"].help_text = catalog.attestation_help
+ order = self.instance
+ editing = not order._state.adding # noqa: SLF001 - Django model state API
+ if can_link_accounts:
+ account = order.customer_account
+ self.fields["customer_account_email"] = forms.EmailField(
+ required=False,
+ label="Customer's python.org account",
+ help_text="Email of the account that may view and sign this order online. "
+ "Leave empty to collect the signature outside python.org.",
+ initial=account.email if account else "",
+ )
+ if editing:
+ self.initial["agreements"] = [
+ line.agreement for line in order.agreement_list if line.agreement in agreements
+ ]
+ self.fields["discount_attestation"].initial = any(
+ discount.key == order.discount and discount.requires_attestation for discount in catalog.discounts
+ )
+ else:
+ self.initial["discount"] = catalog.discounts[0].key
+ existing = order.authorized_contacts if editing else []
+ for i in range(1, MAX_CONTACTS + 1):
+ contact = existing[i - 1] if i <= len(existing) else {}
+ self.fields[f"contact_{i}_name"] = forms.CharField(
+ required=False, max_length=255, label="Name", initial=contact.get("name")
+ )
+ self.fields[f"contact_{i}_email"] = forms.EmailField(
+ required=False, label="Email", initial=contact.get("email")
+ )
+
+ def contact_rows(self) -> list[tuple[BoundField, BoundField]]:
+ """Pair each contact's fields for the template."""
+ return [(self[f"contact_{i}_name"], self[f"contact_{i}_email"]) for i in range(1, MAX_CONTACTS + 1)]
+
+ def clean_covered_entities(self) -> str:
+ """Require a bounded list of covered entities."""
+ entities = _lines(self.cleaned_data["covered_entities"])
+ if not entities:
+ msg = "List at least one covered entity."
+ raise ValidationError(msg)
+ if len(entities) > MAX_ENTITIES:
+ msg = f"List at most {MAX_ENTITIES} covered entities."
+ raise ValidationError(msg)
+ if any(len(entity) > MAX_NAME_LENGTH for entity in entities):
+ msg = f"Enter one name per line; names are at most {MAX_NAME_LENGTH} characters."
+ raise ValidationError(msg)
+ return "\n".join(entities)
+
+ def _clean_contacts(self) -> list[dict[str, str]]:
+ contacts: list[dict[str, str]] = []
+ incomplete = False
+ for i in range(1, MAX_CONTACTS + 1):
+ name = (self.cleaned_data.get(f"contact_{i}_name") or "").strip()
+ email = self.cleaned_data.get(f"contact_{i}_email") or ""
+ if name and email:
+ contacts.append({"name": name, "email": email})
+ elif name or email:
+ incomplete = True
+ missing = f"contact_{i}_email" if name else f"contact_{i}_name"
+ self.add_error(missing, "Enter both a name and an email address for this contact.")
+ if not contacts and not incomplete:
+ self.add_error("contact_1_name", "Add at least one authorized contact.")
+ return contacts
+
+ def clean_customer_account_email(self) -> User | None:
+ """Resolve the supplied address to one customer account."""
+ email = self.cleaned_data["customer_account_email"]
+ return account_for_email(email) if email else None
+
+ def clean(self) -> dict[str, Any]:
+ """Validate discount eligibility and authorized contacts."""
+ cleaned = cast("dict[str, Any]", super().clean())
+ discount = cleaned.get("discount")
+ if (
+ discount
+ and self.catalog.discount(discount).requires_attestation
+ and not cleaned.get("discount_attestation")
+ ):
+ self.add_error("discount_attestation", "Confirm eligibility to use the selected discount.")
+ cleaned["contacts"] = self._clean_contacts()
+ return cleaned
+
+
+class AgreementForm(forms.Form):
+ """Tier and add-on selections generated from one catalog agreement."""
+
+ tier = forms.ChoiceField(widget=forms.RadioSelect)
+
+ def __init__(
+ self,
+ *args: Any,
+ agreement: Agreement,
+ line: OrderLine | None = None,
+ staff: bool = False,
+ **kwargs: Any,
+ ) -> None:
+ """Create configured fields and identify inactive conditional parameters."""
+ cast("Callable[..., None]", super().__init__)(*args, prefix=agreement.slug, **kwargs)
+ self.agreement = agreement
+ cast("forms.ChoiceField", self.fields["tier"]).choices = [(t.key, t.name) for t in agreement.tiers]
+ self.initial["tier"] = line.tier if line else agreement.default_tier
+ if staff:
+ self.fields["special_terms"] = forms.CharField(
+ required=False,
+ max_length=2000,
+ widget=forms.Textarea(attrs={"rows": 2}),
+ help_text="Printed on the Order Form for this agreement. Leave empty for none.",
+ initial=line.special_terms if line else "",
+ )
+ selected = line.addons if line else {}
+ tier = self.data.get(self.add_prefix("tier")) if self.is_bound else self.initial["tier"]
+ self._inactive_parameters: list[forms.Field] = []
+ for addon in agreement.addons:
+ chosen = selected.get(addon.key)
+ name = f"addon_{addon.key}"
+ self.fields[name] = forms.BooleanField(required=False, label=addon.name, initial=chosen is not None)
+ active = bool(self.data.get(self.add_prefix(name))) and tier not in addon.included_in_tiers
+ for param in addon.params:
+ field = self._param_field(param, (chosen or {}).get(param.key))
+ if self.is_bound and (
+ not active
+ or not all(
+ self.data.get(self.add_prefix(f"{name}_{key}")) == str(value)
+ for key, value in param.required_when.items()
+ )
+ ):
+ self._inactive_parameters.append(field)
+ self.fields[f"{name}_{param.key}"] = field
+
+ def full_clean(self) -> None:
+ """Ignore inactive values when validating, but keep rendered inputs editable."""
+ for field in self._inactive_parameters:
+ field.disabled = True
+ try:
+ super().full_clean()
+ finally:
+ for field in self._inactive_parameters:
+ field.disabled = False
+
+ @staticmethod
+ def _param_field(param: Param, initial: ParamValue | None) -> forms.Field:
+ field: forms.Field
+ if param.kind == "choice":
+ field = forms.ChoiceField(choices=param.choices, required=False, initial=initial or param.choices[0][0])
+ elif param.kind == "count":
+ field = forms.IntegerField(required=False, min_value=param.minimum, initial=initial)
+ elif param.kind == "lines":
+ field = forms.CharField(
+ required=False,
+ widget=forms.Textarea(attrs={"rows": 3}),
+ initial="\n".join(cast("list[str]", initial) or []),
+ )
+ else:
+ field = forms.CharField(required=False, max_length=200, initial=initial)
+ field.label, field.help_text = param.label, param.help_text
+ field.widget.attrs["data-param-key"] = param.key
+ return field
+
+ def tier_options(self) -> list[dict[str, Any]]:
+ """Pair every tier with the services and extras its fee includes."""
+ return [
+ {
+ "tier": tier,
+ "services": self.agreement.services_at(tier.key),
+ "included_addons": [addon for addon in self.agreement.addons if tier.key in addon.included_in_tiers],
+ }
+ for tier in self.agreement.tiers
+ ]
+
+ def has_included_extras(self) -> bool:
+ """Show the comparison row only when a tier includes something extra."""
+ return any(tier.includes for tier in self.agreement.tiers) or any(
+ addon.included_in_tiers for addon in self.agreement.addons
+ )
+
+ def addon_fields(self) -> list[dict[str, Any]]:
+ """Pair each add-on with its checkbox and parameter fields."""
+ return [
+ {
+ "addon": addon,
+ "checkbox": self[f"addon_{addon.key}"],
+ "params": [self[f"addon_{addon.key}_{p.key}"] for p in addon.params],
+ }
+ for addon in self.agreement.addons
+ ]
+
+ def clean(self) -> dict[str, Any]:
+ """Reject unknown selections and normalize active add-on parameters."""
+ cleaned = cast("dict[str, Any]", super().clean())
+ tier = cleaned.get("tier")
+ addons: dict[str, dict[str, ParamValue]] = {}
+ known = {self.add_prefix(name) for name in self.fields}
+ for name in self.data:
+ if name.startswith(self.add_prefix("addon_")) and name not in known:
+ self.add_error(None, "Unknown additional service or parameter.")
+ for addon in self.agreement.addons:
+ name = f"addon_{addon.key}"
+ if not cleaned.get(name) or tier in addon.included_in_tiers:
+ continue
+ params = {
+ param.key: cleaned.get(f"{name}_{param.key}")
+ for param in addon.params
+ if not self.fields[f"{name}_{param.key}"].disabled
+ }
+ if any(f"{name}_{param.key}" in self.errors for param in addon.params):
+ continue
+ try:
+ addons[addon.key] = normalize_params(addon, params)
+ except ValidationError as exc:
+ self.add_error(name, ValidationError(exc.messages))
+ cleaned["addons"] = addons
+ return cleaned
+
+
+class OrderBuilder:
+ """An order form and one form for each available agreement."""
+
+ def __init__(
+ self,
+ data: QueryDict | None = None,
+ *,
+ program: Program,
+ order: Order | None = None,
+ staff: bool = False,
+ can_link_accounts: bool = False,
+ ) -> None:
+ """Restrict the builder to the program and any private-order scope."""
+ self.order, self.program, self.staff = order, program, staff
+ self.can_link_accounts = can_link_accounts
+ self.catalog = order.catalog if order else program.catalog
+ lines = {line.agreement: line for line in order.agreement_list} if order else {}
+ self.agreements = {
+ slug: agreement
+ for slug, agreement in self.catalog.agreements.items()
+ if not order or program.is_public or staff or slug in lines
+ }
+ self.order_form = OrderForm(
+ data,
+ instance=order,
+ catalog=self.catalog,
+ agreements=self.agreements,
+ can_link_accounts=can_link_accounts,
+ )
+ self.agreement_forms = {
+ slug: AgreementForm(data, agreement=agreement, line=lines.get(slug), staff=staff)
+ for slug, agreement in self.agreements.items()
+ }
+
+ @property
+ def chosen(self) -> list[str]:
+ """Return only available, selected agreement slugs."""
+ if self.order_form.is_bound:
+ return [
+ slug
+ for slug in self.agreements
+ if slug in cast("QueryDict", self.order_form.data).getlist("agreements")
+ ]
+ return list(self.order_form.initial.get("agreements", []))
+
+ @property
+ def parameter_conditions(self) -> dict[str, dict[str, dict[str, dict[str, ParamValue]]]]:
+ """Declarative field visibility rules for the live builder."""
+ return {
+ slug: {
+ addon.key: {param.key: param.required_when for param in addon.params if param.required_when}
+ for addon in agreement.addons
+ }
+ for slug, agreement in self.agreements.items()
+ }
+
+ def is_valid(self) -> bool:
+ """Validate customer details and every selected agreement."""
+ forms_to_check = [self.order_form, *(self.agreement_forms[slug] for slug in self.chosen)]
+ return all([form.is_valid() for form in forms_to_check]) # noqa: C419 - collect every form's errors
+
+ @property
+ def has_errors(self) -> bool:
+ """Report errors from customer details or selected agreements."""
+ return bool(self.order_form.errors) or any(self.agreement_forms[slug].errors for slug in self.chosen)
+
+ @transaction.atomic
+ def save(self, user: User) -> Order:
+ """Persist the draft and selected lines atomically."""
+ order = cast("Order", self.order_form.save(commit=False))
+ order.program = self.program
+ if order.created_by_id is None:
+ order.created_by = user
+ if self.can_link_accounts:
+ order.customer_account = self.order_form.cleaned_data["customer_account_email"]
+ elif not self.staff and order._state.adding: # noqa: SLF001 - Django model state API
+ order.customer_account = user
+ order.authorized_contacts = self.order_form.cleaned_data["contacts"]
+ order.save()
+ chosen = self.order_form.cleaned_data["agreements"]
+ order.agreements.exclude(agreement__in=chosen).delete()
+ for slug in chosen:
+ data = self.agreement_forms[slug].cleaned_data
+ defaults = {"tier": data["tier"], "addons": data["addons"]}
+ if self.staff:
+ defaults["special_terms"] = data["special_terms"].strip()
+ OrderLine.objects.update_or_create(order=order, agreement=slug, defaults=defaults)
+ return order
diff --git a/apps/agreements/orders/kinds.py b/apps/agreements/orders/kinds.py
new file mode 100644
index 000000000..332501908
--- /dev/null
+++ b/apps/agreements/orders/kinds.py
@@ -0,0 +1,77 @@
+"""Offer configured orders through the shared signing workflow."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import TYPE_CHECKING, cast
+
+if TYPE_CHECKING:
+ from django.contrib.auth.models import AnonymousUser
+
+ from apps.agreements.models import Agreement, TermsVersion
+ from apps.agreements.registry import AgreementDetails
+ from apps.users.models import User
+
+from django.core.exceptions import ValidationError
+
+from apps.agreements.models import Order, Program
+from apps.agreements.orders.documents import compose_order_form_markdown
+from apps.agreements.registry import Kind, register
+
+
+@register
+class OrderKind(Kind[Order]):
+ """Freeze configuration and fees for a customer's selected agreements."""
+
+ slug = "order"
+ name = "Order Form"
+ model = Order
+
+ def details(self, subject: Order) -> AgreementDetails:
+ """Use the configured document title and the customer's signatory account."""
+ return {
+ "title": subject.catalog.order_title,
+ "counterparty_name": subject.legal_name,
+ "counterparty_account": subject.customer_account,
+ }
+
+ def cited_terms_versions(self, subject: Order) -> list[TermsVersion]:
+ """Use exactly the terms versions resolved while composing this offer."""
+ return [cast("TermsVersion", line.cited_terms) for line in subject.agreement_list]
+
+ def freeze(self, subject: Order) -> None:
+ """Pin the catalog and all fee schedules before generating the signed document."""
+ program = Program.objects.select_for_update().get(pk=subject.program_id)
+ subject.catalog_snapshot = deepcopy(program.definition)
+ subject.program_title_snapshot = program.title
+ subject.__dict__.pop("catalog", None)
+ subject.save(update_fields=["catalog_snapshot", "program_title_snapshot"])
+ lines = subject.agreement_list
+ if not lines:
+ msg = "Choose at least one agreement before offering this order."
+ raise ValidationError(msg)
+ for line in lines:
+ if line.agreement not in subject.catalog.agreements:
+ msg = "The available selections changed. Edit this draft before offering it."
+ raise ValidationError(msg)
+ if line.cited_terms is None:
+ msg = "Publish a terms version for every selected agreement before offering this order."
+ raise ValidationError(msg)
+ line.pricing = line.quote().as_dict()
+ line.save(update_fields=["pricing"])
+
+ def release(self, subject: Order) -> None:
+ """Use current configuration again after withdrawing an unsigned offer."""
+ subject.agreements.update(pricing={})
+ subject.catalog_snapshot = {}
+ subject.program_title_snapshot = ""
+ subject.save(update_fields=["catalog_snapshot", "program_title_snapshot"])
+
+ def compose(self, subject: Order) -> str:
+ """Compose the order from its selected agreements."""
+ return compose_order_form_markdown(subject)
+
+ def can_view(self, user: User | AnonymousUser, agreement: Agreement) -> bool:
+ """Use the order's group and linked-customer access rules."""
+ order = self.subject(agreement)
+ return order is not None and order.can_view(user)
diff --git a/apps/agreements/orders/pricing.py b/apps/agreements/orders/pricing.py
new file mode 100644
index 000000000..c79a1936c
--- /dev/null
+++ b/apps/agreements/orders/pricing.py
@@ -0,0 +1,365 @@
+"""Exact fee calculations from validated, declarative catalog rules."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from decimal import Decimal, localcontext
+from typing import Any, Literal, NoReturn, TypedDict, cast
+
+from django.core.exceptions import ValidationError
+
+from apps.agreements.orders.catalog import MONTHS_PER_YEAR, PERCENT_SCALE, AddOn, Agreement, Param
+
+CENTS = Decimal("0.01")
+
+ParamValue = str | int | list[str]
+type FeeKey = Literal[
+ "tier_fee", "subtotal", "discount_amount", "total_annual", "term_total", "discount_percent", "term_months"
+]
+
+
+class LineItemSnapshot(TypedDict):
+ """Serialized charge retained in an offered order."""
+
+ name: str
+ detail: str
+ amount: str | None
+ display: str
+ recurring: bool
+ basis: str
+
+
+class QuoteDisplay(TypedDict):
+ """Formatted monetary fields for order presentation."""
+
+ tier_fee: str
+ subtotal: str
+ discount_amount: str
+ total_annual: str
+ term_total: str
+
+
+class QuoteSnapshot(TypedDict):
+ """Frozen quote schema stored with each order line."""
+
+ tier_name: str
+ tier_fee: str
+ items: list[LineItemSnapshot]
+ subtotal: str
+ discount_name: str
+ discount_percent: int
+ discount_amount: str
+ total_annual: str
+ term_months: int
+ term_total: str
+ display: QuoteDisplay
+
+
+def _precision(*amounts: Decimal) -> int:
+ """Allow exact sums, products, and cent rounding beyond the default context."""
+ return max(
+ 28, sum(len(amount.as_tuple().digits) + abs(cast("int", amount.as_tuple().exponent)) for amount in amounts) + 4
+ )
+
+
+def money(amount: Decimal | str | float) -> str:
+ """Format a decimal amount as dollars, omitting whole-dollar cents."""
+ amount = Decimal(amount)
+ with localcontext() as context:
+ context.prec = _precision(amount)
+ amount = amount.quantize(CENTS)
+ if amount == amount.to_integral():
+ return f"${amount:,.0f}"
+ return f"${amount:,.2f}"
+
+
+def fee_totals(pricing: QuoteSnapshot) -> dict[str, Decimal]:
+ """Separate recurring and one-time fees without changing stored first-year totals."""
+ first_year = Decimal(pricing["total_annual"])
+ amounts = [
+ Decimal(item["amount"]) for item in pricing["items"] if item["amount"] is not None and not item["recurring"]
+ ]
+ with localcontext() as context:
+ context.prec = _precision(first_year, *amounts)
+ one_time = sum(amounts, Decimal(0))
+ return {"annual": first_year - one_time, "one_time": one_time}
+
+
+@dataclass(frozen=True)
+class LineItem:
+ """One additional-service charge, or an explicit separately quoted item."""
+
+ name: str
+ detail: str
+ amount: Decimal | None
+ recurring: bool
+ basis: str
+
+
+@dataclass(frozen=True)
+class Quote:
+ """A complete fee schedule with tier-only discounts and term totals."""
+
+ tier_name: str
+ tier_fee: Decimal
+ items: tuple[LineItem, ...]
+ discount_name: str
+ discount_percent: int
+ discount_amount: Decimal
+ term_months: int
+
+ @property
+ def subtotal(self) -> Decimal:
+ """Return the tier fee plus every priced additional service."""
+ amounts = [self.tier_fee, *(item.amount for item in self.items if item.amount is not None)]
+ with localcontext() as context:
+ context.prec = _precision(*amounts)
+ return sum(amounts, Decimal(0))
+
+ @property
+ def total_annual(self) -> Decimal:
+ """Return the first year's fees after the tier discount."""
+ subtotal = self.subtotal
+ with localcontext() as context:
+ context.prec = _precision(subtotal, self.discount_amount)
+ return subtotal - self.discount_amount
+
+ @property
+ def term_years(self) -> int:
+ """Return the number of whole years in the initial term."""
+ return self.term_months // MONTHS_PER_YEAR
+
+ @property
+ def term_total(self) -> Decimal:
+ """Repeat recurring charges across the term and charge other items once."""
+ priced = [item for item in self.items if item.amount is not None]
+ with localcontext() as context:
+ context.prec = _precision(
+ self.tier_fee,
+ self.discount_amount,
+ Decimal(self.term_years),
+ *(cast("Decimal", item.amount) for item in priced),
+ )
+ recurring = self.tier_fee - self.discount_amount
+ recurring += sum((cast("Decimal", item.amount) for item in priced if item.recurring), Decimal(0))
+ one_time = sum((cast("Decimal", item.amount) for item in priced if not item.recurring), Decimal(0))
+ return recurring * self.term_years + one_time
+
+ @property
+ def unpriced_items(self) -> list[LineItem]:
+ """Return selected services whose fees are not determined in advance."""
+ return [item for item in self.items if item.amount is None]
+
+ def as_dict(self) -> QuoteSnapshot:
+ """Return a JSON-safe fee snapshot for storage and the order builder."""
+ return {
+ "tier_name": self.tier_name,
+ "tier_fee": format(self.tier_fee, "f"),
+ "items": [
+ {
+ "name": item.name,
+ "detail": item.detail,
+ "amount": None if item.amount is None else format(item.amount, "f"),
+ "display": item.basis if item.amount is None else money(item.amount),
+ "recurring": item.recurring,
+ "basis": item.basis,
+ }
+ for item in self.items
+ ],
+ "subtotal": format(self.subtotal, "f"),
+ "discount_name": self.discount_name,
+ "discount_percent": self.discount_percent,
+ "discount_amount": format(self.discount_amount, "f"),
+ "total_annual": format(self.total_annual, "f"),
+ "term_months": self.term_months,
+ "term_total": format(self.term_total, "f"),
+ "display": {
+ "tier_fee": money(self.tier_fee),
+ "subtotal": money(self.subtotal),
+ "discount_amount": money(self.discount_amount),
+ "total_annual": money(self.total_annual),
+ "term_total": money(self.term_total),
+ },
+ }
+
+
+def _invalid(message: str) -> NoReturn:
+ raise ValidationError(message)
+
+
+def _quantity(params: dict[str, ParamValue], key: str) -> int:
+ value = params[key]
+ return len(value) if isinstance(value, list) else cast("int", value)
+
+
+def _validate_sum_quantities(rule: dict[str, Any], params: dict[str, ParamValue]) -> list[int]:
+ """Require a positive quantity in each sum, even when its price is zero."""
+ kind = rule["type"]
+ if kind in {"quantity", "brackets"}:
+ return [_quantity(params, rule["parameter"])]
+ if kind == "choice":
+ return _validate_sum_quantities(rule["options"][params[rule["parameter"]]], params)
+ if kind == "sum":
+ counts = [count for child in rule["components"] for count in _validate_sum_quantities(child, params)]
+ if counts and not any(counts):
+ _invalid("Select at least one item for the additional service.")
+ return counts
+ return []
+
+
+def _clean_count(value: object, param: Param, addon: AddOn) -> int:
+ if value is None or value == "":
+ if param.minimum == 0:
+ return 0
+ _invalid(f"{addon.name}: {param.label} is required.")
+ if not isinstance(value, int) or isinstance(value, bool):
+ if not isinstance(value, str):
+ _invalid(f"{addon.name}: {param.label} must be a whole number.")
+ try:
+ value = int(value)
+ except ValueError:
+ message = f"{addon.name}: {param.label} must be a whole number."
+ raise ValidationError(message) from None
+ if value < param.minimum:
+ _invalid(f"{addon.name}: {param.label} must be at least {param.minimum}.")
+ return value
+
+
+def _clean_lines(value: object, param: Param, addon: AddOn) -> list[str]:
+ if isinstance(value, str):
+ value = value.splitlines()
+ if not isinstance(value, list) or any(not isinstance(line, str) for line in value):
+ _invalid(f"{addon.name}: {param.label} must contain one text value per line.")
+ value = [line.strip() for line in value if line.strip()]
+ if not value:
+ _invalid(f"{addon.name}: {param.label} is required.")
+ return value
+
+
+def _clean_value(value: object, param: Param, addon: AddOn) -> ParamValue:
+ if param.kind == "count":
+ return _clean_count(value, param, addon)
+ if param.kind == "lines":
+ return _clean_lines(value, param, addon)
+ if param.kind == "choice":
+ if not isinstance(value, str) or value not in dict(param.choices):
+ _invalid(f"{addon.name}: select a valid {param.label}.")
+ return value
+ if not isinstance(value, str) or not value.strip():
+ _invalid(f"{addon.name}: {param.label} is required.")
+ return value.strip()
+
+
+def normalize_params(addon: AddOn, params: object) -> dict[str, ParamValue]:
+ """Validate selected parameters and omit those whose conditions do not apply."""
+ if not isinstance(params, dict):
+ _invalid(f"{addon.name}: parameters must be an object.")
+ by_key = {param.key: param for param in addon.params}
+ unknown = params.keys() - by_key.keys()
+ if unknown:
+ _invalid(f"{addon.name}: unknown parameter(s): {', '.join(sorted(map(str, unknown)))}.")
+ cleaned: dict[str, ParamValue] = {}
+ visited: set[str] = set()
+
+ def clean(key: str) -> None:
+ if key in visited:
+ return
+ param = by_key[key]
+ for dependency in param.required_when:
+ clean(dependency)
+ visited.add(key)
+ if any(cleaned.get(dependency) != value for dependency, value in param.required_when.items()):
+ return
+ cleaned[key] = _clean_value(params.get(key), param, addon)
+
+ for param in addon.params:
+ clean(param.key)
+ _validate_sum_quantities(addon.pricing, cleaned)
+ return cleaned
+
+
+def _price_rule(rule: dict[str, Any], params: dict[str, ParamValue]) -> tuple[Decimal | None, bool, str, str]:
+ kind = rule["type"]
+ if kind == "choice":
+ return _price_rule(rule["options"][params[rule["parameter"]]], params)
+ recurring = rule.get("recurring", True)
+ basis = rule.get("basis") or ("per year" if recurring else "one-time")
+ detail = rule.get("detail", "")
+ amount: Decimal | None
+ count: Decimal | int
+ if kind == "fixed":
+ amount = Decimal(rule["amount"])
+ elif kind == "quantity":
+ count = Decimal(max(0, _quantity(params, rule["parameter"]) - rule.get("included", 0)))
+ unit = Decimal(rule["unit_amount"])
+ base = Decimal(rule.get("base_amount", "0"))
+ with localcontext() as context:
+ context.prec = _precision(count, unit, base)
+ amount = base + count * unit
+ elif kind == "brackets":
+ count = _quantity(params, rule["parameter"])
+ amount = next((Decimal(band["amount"]) for band in rule["bands"] if count <= band["up_to"]), None)
+ if amount is None:
+ basis = rule["overflow_basis"]
+ elif kind == "sum":
+ components = [_price_rule(component, params) for component in rule["components"]]
+ amounts = [component[0] for component in components]
+ if any(amount is None for amount in amounts):
+ amount = None
+ else:
+ with localcontext() as context:
+ context.prec = _precision(*cast("list[Decimal]", amounts))
+ amount = sum(cast("list[Decimal]", amounts), Decimal(0))
+ if not detail:
+ detail = "; ".join(component[3] for component in components if component[3])
+ else:
+ amount = None
+ basis = rule.get("basis") or "Quoted separately"
+ return amount, recurring, basis, detail
+
+
+def _price_addon(addon: AddOn, params: object, tier_key: str) -> LineItem:
+ params = normalize_params(addon, params)
+ amount, recurring, basis, detail = _price_rule(addon.pricing, params)
+ selections = []
+ for param in addon.params:
+ if param.key not in params:
+ continue
+ value = params[param.key]
+ if param.kind == "choice":
+ value = dict(param.choices)[cast("str", value)]
+ elif param.kind == "lines":
+ value = ", ".join(cast("list[str]", value))
+ selections.append(f"{param.label}: {value}")
+ if tier_key in addon.included_in_tiers:
+ amount, basis, detail = Decimal(0), "included", "Included at this service tier"
+ detail = "; ".join(part for part in (detail, *selections) if part)
+ return LineItem(addon.name, detail, amount, recurring, basis)
+
+
+def build_quote(agreement: Agreement, tier_key: str, discount_key: str, addons: object = None) -> Quote:
+ """Price all selections in catalog order; discounts affect only the tier fee."""
+ tier = agreement.tier(tier_key)
+ discount = agreement.discount(discount_key)
+ selected = {} if addons is None else addons
+ if not isinstance(selected, dict):
+ _invalid("Selected additional services must be an object.")
+ known = {addon.key for addon in agreement.addons}
+ unknown = selected.keys() - known
+ if unknown:
+ _invalid(f"Unknown additional service(s): {', '.join(sorted(map(str, unknown)))}.")
+ items = tuple(
+ _price_addon(addon, selected[addon.key], tier_key) for addon in agreement.addons if addon.key in selected
+ )
+ with localcontext() as context:
+ context.prec = _precision(tier.annual_fee)
+ discount_amount = (tier.annual_fee * discount.percent / PERCENT_SCALE).quantize(CENTS)
+ return Quote(
+ tier_name=tier.name,
+ tier_fee=tier.annual_fee,
+ items=items,
+ discount_name=discount.name,
+ discount_percent=discount.percent,
+ discount_amount=discount_amount,
+ term_months=discount.term_months,
+ )
diff --git a/apps/agreements/orders/urls.py b/apps/agreements/orders/urls.py
new file mode 100644
index 000000000..dedbcf91c
--- /dev/null
+++ b/apps/agreements/orders/urls.py
@@ -0,0 +1,20 @@
+"""Program and order routes included in the agreements namespace."""
+
+from django.urls import path
+
+from apps.agreements.orders import views
+
+urlpatterns = [
+ path("programs/", views.program_list, name="program_list"),
+ path("programs//", views.program_detail, name="program_detail"),
+ path("programs//orders/new/", views.order_create, name="order_create"),
+ path("programs//quote/", views.quote, name="quote"),
+ path("orders/", views.order_list, name="order_list"),
+ path("orders/all/", views.staff_orders, name="staff_orders"),
+ path("orders//", views.order_detail, name="order_detail"),
+ path("orders//edit/", views.order_edit, name="order_edit"),
+ path("orders//sign/", views.order_sign, name="order_sign"),
+ path("orders//offer/", views.order_offer, name="order_offer"),
+ path("orders//delete/", views.order_delete, name="order_delete"),
+ path("orders//document.", views.order_document, name="order_document"),
+]
diff --git a/apps/agreements/orders/views.py b/apps/agreements/orders/views.py
new file mode 100644
index 000000000..650d79a27
--- /dev/null
+++ b/apps/agreements/orders/views.py
@@ -0,0 +1,359 @@
+"""Program browsing, order selection, and shared agreement signing entry points."""
+
+from __future__ import annotations
+
+from decimal import Decimal
+from typing import TYPE_CHECKING, Any, cast
+from uuid import UUID
+
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.views import redirect_to_login
+from django.core.exceptions import ValidationError
+from django.db import transaction
+from django.db.models import Prefetch, Q
+from django.http import Http404, JsonResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.views.decorators.cache import never_cache
+from django.views.decorators.http import require_GET, require_POST
+
+from apps.agreements import documents as agreement_documents
+from apps.agreements import workflow
+from apps.agreements.auth import can_prepare, is_administrator, preparer_required
+from apps.agreements.documents import sha256
+from apps.agreements.forms.signing import SignForm
+from apps.agreements.models import Order, OrderLine, Program
+from apps.agreements.orders import documents
+from apps.agreements.orders.forms import OrderBuilder
+from apps.agreements.orders.pricing import build_quote, money
+from apps.agreements.registry import get_kind
+from apps.agreements.views.helpers import action_forms, file_response, signature_of
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+ from django.contrib.auth.models import AnonymousUser
+ from django.db.models import QuerySet
+ from django.http import HttpRequest, HttpResponse
+
+ from apps.users.models import User
+
+KIND = "order"
+
+
+def _orders() -> QuerySet[Order]:
+ return Order.objects.select_related("program", "created_by", "customer_account", "agreement").prefetch_related(
+ Prefetch("agreements", queryset=OrderLine.objects.all())
+ )
+
+
+def _own_orders(user: User | AnonymousUser) -> QuerySet[Order]:
+ if not user.is_authenticated:
+ return _orders().none()
+ owned = Q(customer_account=user)
+ if can_prepare(user):
+ owned |= Q(created_by=user)
+ return _orders().filter(owned)
+
+
+def _order_rows(orders: Iterable[Order]) -> list[dict[str, Any]]:
+ """Keep stale drafts reachable without presenting an invented price."""
+ rows: list[dict[str, Any]] = []
+ for order in orders:
+ row: dict[str, Any] = {"order": order}
+ try:
+ row["selections"] = [(line.agreement_obj.short_name, line.tier_name) for line in order.agreement_list]
+ row["fees"] = order.fee_totals
+ except (ValidationError, KeyError):
+ if not order.is_editable:
+ raise
+ row["review_selections"] = True
+ rows.append(row)
+ return rows
+
+
+def _order_or_404(request: HttpRequest, pk: UUID, *, for_update: bool = False) -> Order:
+ orders = _orders() if can_prepare(request.user) else _own_orders(request.user)
+ if for_update:
+ orders = orders.select_for_update(of=("self",))
+ return get_object_or_404(orders, pk=pk)
+
+
+def _program_or_404(request: HttpRequest, slug: str) -> Program:
+ return get_object_or_404(Program.visible_to(request.user), slug=slug)
+
+
+@never_cache
+@require_GET
+def program_list(request: HttpRequest) -> HttpResponse:
+ """List only programs the visitor may browse."""
+ return render(
+ request,
+ "agreements/orders/program_list.html",
+ {
+ "programs": Program.visible_to(request.user),
+ "nav": "programs",
+ },
+ )
+
+
+@never_cache
+@require_GET
+def program_detail(request: HttpRequest, slug: str) -> HttpResponse:
+ """Compare a program's configured agreements and the visitor's recent orders."""
+ program = _program_or_404(request, slug)
+ agreements = list(program.catalog.agreements.values())
+ return render(
+ request,
+ "agreements/orders/overview.html",
+ {
+ "program": program,
+ "catalog": program.catalog,
+ "agreements": agreements,
+ "under_review": any(agreement.terms.under_review for agreement in agreements),
+ "orders": _order_rows(_own_orders(request.user).filter(program=program)[:5]),
+ "nav": "programs",
+ },
+ )
+
+
+@never_cache
+@require_GET
+def quote(request: HttpRequest, slug: str) -> JsonResponse:
+ """Price valid selections without exposing a private program to unrelated visitors."""
+ order = None
+ if request.GET.get("order"):
+ try:
+ pk = UUID(request.GET["order"])
+ except ValueError:
+ raise Http404 from None
+ order = _order_or_404(request, pk)
+ if order.program.slug != slug or not order.can_edit(request.user):
+ raise Http404
+ program = order.program
+ else:
+ program = _program_or_404(request, slug)
+ builder = OrderBuilder(
+ request.GET,
+ program=program,
+ order=order,
+ staff=can_prepare(request.user),
+ can_link_accounts=is_administrator(request.user),
+ )
+ selections = builder.order_form.fields["agreements"]
+ errors: dict[str, dict[str, list[dict[str, str]]] | list[str]] = {}
+ try:
+ # No selection is a useful empty quote, not a submitted order.
+ chosen = selections.clean(request.GET.getlist("agreements")) if request.GET.getlist("agreements") else []
+ discount = builder.order_form.fields["discount"].clean(request.GET.get("discount"))
+ except ValidationError as exc:
+ return JsonResponse({"errors": {"selections": exc.messages}}, status=400)
+ quotes: list[dict[str, Any]] = []
+ for key in builder.agreements:
+ if key not in chosen:
+ continue
+ form = builder.agreement_forms[key]
+ if not form.is_valid():
+ errors[key] = form.errors.get_json_data()
+ continue
+ try:
+ priced = build_quote(
+ builder.agreements[key], form.cleaned_data["tier"], discount, form.cleaned_data["addons"]
+ )
+ except ValidationError as exc:
+ errors[key] = exc.messages
+ continue
+ quotes.append({"slug": key, "name": builder.agreements[key].short_name, **priced.as_dict()})
+ if errors:
+ return JsonResponse({"errors": errors}, status=400)
+ total_annual = sum((Decimal(q["total_annual"]) for q in quotes), Decimal(0))
+ term_total = sum((Decimal(q["term_total"]) for q in quotes), Decimal(0))
+ return JsonResponse(
+ {
+ "agreements": quotes,
+ "term_months": builder.catalog.discount(discount).term_months,
+ "display": {"total_annual": money(total_annual), "term_total": money(term_total)},
+ }
+ )
+
+
+def _builder(request: HttpRequest, program: Program, order: Order | None = None) -> HttpResponse:
+ staff = can_prepare(request.user)
+ builder = OrderBuilder(
+ request.POST or None,
+ program=program,
+ order=order,
+ staff=staff,
+ can_link_accounts=is_administrator(request.user),
+ )
+ if not order:
+ preselect = [slug for slug in request.GET.getlist("agreement") if slug in builder.agreements]
+ builder.order_form.initial["agreements"] = preselect
+ if request.method == "POST" and builder.is_valid():
+ return redirect(builder.save(cast("User", request.user)))
+ return render(
+ request,
+ "agreements/orders/order_form.html",
+ {
+ "builder": builder,
+ "form": builder.order_form,
+ "program": program,
+ "catalog": builder.catalog,
+ "agreements": list(builder.agreements.values()),
+ "order": order,
+ "staff": staff,
+ "nav": "orders",
+ "can_browse_program": program.is_public or staff,
+ },
+ )
+
+
+@never_cache
+def order_create(request: HttpRequest, slug: str) -> HttpResponse:
+ """Start a public order, or a staff-prepared private order."""
+ program = _program_or_404(request, slug)
+ if not request.user.is_authenticated:
+ return redirect_to_login(request.get_full_path())
+ return _builder(request, program)
+
+
+@never_cache
+@login_required
+@transaction.atomic
+def order_edit(request: HttpRequest, pk: UUID) -> HttpResponse:
+ """Change selections until the Order Form is offered for signature."""
+ order = _order_or_404(request, pk, for_update=request.method == "POST")
+ if not order.can_edit(request.user):
+ messages.error(request, "This Order Form is out for signature. Withdraw it to change the selections.")
+ return redirect(order)
+ return _builder(request, order.program, order)
+
+
+@never_cache
+@login_required
+def order_list(request: HttpRequest) -> HttpResponse:
+ """List the customer's orders, including drafts needing revised selections."""
+ return render(
+ request,
+ "agreements/orders/order_list.html",
+ {
+ "orders": _order_rows(_own_orders(request.user)),
+ "nav": "orders",
+ },
+ )
+
+
+@never_cache
+@login_required
+def order_detail(request: HttpRequest, pk: UUID, sign_form: SignForm | None = None, status: int = 200) -> HttpResponse:
+ """Review the order and use the common account, link, or signed-copy actions."""
+ order = _order_or_404(request, pk)
+ user = request.user
+ context: dict[str, Any] = {
+ "order": order,
+ "is_customer": order.is_customer(user),
+ "can_prepare": can_prepare(user),
+ "is_administrator": is_administrator(user),
+ "can_offer": order.can_offer(user),
+ "can_edit": order.can_edit(user),
+ "nav": "staff" if can_prepare(user) and not order.is_customer(user) else "orders",
+ "next": order.get_absolute_url(),
+ }
+ try:
+ markdown = documents.order_form_markdown(order)
+ except (ValidationError, KeyError):
+ if not order.is_editable:
+ raise
+ context["configuration_error"] = "The available selections have changed. Edit this draft before signing."
+ else:
+ context["html"], context["metadata_hidden"] = agreement_documents.render_agreement_preview(markdown)
+ context["lines"] = order.agreement_list
+ if order.agreement:
+ context.update(action_forms(request, order.agreement))
+ elif order.is_customer(user):
+ context["draft_sign_form"] = sign_form or SignForm(
+ initial={
+ "document_sha256": sha256(documents.compose_order_form_markdown(order)),
+ "signer_name": cast("User", user).get_full_name(),
+ }
+ )
+ return render(request, "agreements/orders/order_detail.html", context, status=status)
+
+
+@never_cache
+@login_required
+@require_POST
+@transaction.atomic
+def order_sign(request: HttpRequest, pk: UUID) -> HttpResponse:
+ """Freeze and sign the reviewed draft in one atomic transition."""
+ order = _order_or_404(request, pk, for_update=True)
+ if not order.is_customer(request.user) or not order.is_editable:
+ raise Http404
+ form = SignForm(request.POST)
+ if not form.is_valid():
+ return order_detail(request, pk, sign_form=form, status=400)
+ try:
+ with transaction.atomic():
+ agreement = workflow.offer(get_kind(KIND), order, user=cast("User", request.user))
+ workflow.sign(agreement, signature_of(request, form), seen_sha256=form.cleaned_data["document_sha256"])
+ except (workflow.InvalidTransitionError, ValidationError) as exc:
+ messages.error(request, "; ".join(exc.messages) if isinstance(exc, ValidationError) else str(exc))
+ else:
+ messages.success(request, "Signed. The PSF will now countersign the Order Form.")
+ return redirect(order)
+
+
+@never_cache
+@login_required
+@require_POST
+@transaction.atomic
+def order_offer(request: HttpRequest, pk: UUID) -> HttpResponse:
+ """Fix the text for an account, a signing link, or an externally signed copy."""
+ order = _order_or_404(request, pk, for_update=True)
+ if not order.can_offer(request.user):
+ raise Http404
+ try:
+ workflow.offer(get_kind(KIND), order, user=cast("User", request.user))
+ except (workflow.InvalidTransitionError, ValidationError) as exc:
+ messages.error(request, "; ".join(exc.messages) if isinstance(exc, ValidationError) else str(exc))
+ else:
+ messages.success(request, "The Order Form is ready to sign. Its text is fixed until it is signed or withdrawn.")
+ return redirect(order)
+
+
+@login_required
+@require_POST
+@transaction.atomic
+def order_delete(request: HttpRequest, pk: UUID) -> HttpResponse:
+ """Discard an authorized draft without touching offered agreements."""
+ order = _order_or_404(request, pk, for_update=True)
+ if not order.can_edit(request.user):
+ raise Http404
+ order.delete()
+ messages.success(request, "Draft discarded.")
+ return redirect("agreements:order_list")
+
+
+@login_required
+def order_document(request: HttpRequest, pk: UUID, fmt: str) -> HttpResponse:
+ """Download the authorized order's draft or frozen document."""
+ order = _order_or_404(request, pk)
+ if fmt not in agreement_documents.RENDERERS:
+ raise Http404
+ try:
+ markdown = documents.order_form_markdown(order)
+ except (ValidationError, KeyError):
+ if not order.is_editable:
+ raise
+ messages.error(request, "The available selections have changed. Edit this draft before downloading it.")
+ return redirect(order)
+ return file_response(
+ agreement_documents.RENDERERS[fmt](markdown), fmt, f"order-form-{order.reference}-{order.status}"
+ )
+
+
+@never_cache
+@preparer_required
+def staff_orders(request: HttpRequest) -> HttpResponse:
+ """List all orders for agreement managers."""
+ return render(request, "agreements/orders/staff_queue.html", {"orders": _order_rows(_orders()), "nav": "staff"})
diff --git a/apps/agreements/pandoc_filters/agreement.lua b/apps/agreements/pandoc_filters/agreement.lua
new file mode 100644
index 000000000..3c6ccc218
--- /dev/null
+++ b/apps/agreements/pandoc_filters/agreement.lua
@@ -0,0 +1,26 @@
+-- Layout and safety for agreement documents.
+-- ::: sub third-level clauses: indented in DOCX/PDF, a styled div in HTML
+-- ::: pagebreak hard page break in DOCX/PDF, dropped in HTML
+-- Documents may be written by PSF staff, so they cannot embed images: pandoc would fetch them.
+
+local function is_paged()
+ return FORMAT == "docx" or FORMAT == "latex" or FORMAT == "pdf"
+end
+
+function Image(element)
+ error("Agreement documents cannot embed images or load external resources.")
+end
+
+function Div(el)
+ if el.classes:includes("pagebreak") then
+ if FORMAT == "docx" then
+ return pandoc.RawBlock("openxml", '')
+ elseif is_paged() then
+ return pandoc.RawBlock("latex", "\\newpage")
+ end
+ return {}
+ end
+ if el.classes:includes("sub") and is_paged() then
+ return pandoc.BlockQuote(el.content)
+ end
+end
diff --git a/apps/agreements/registry.py b/apps/agreements/registry.py
new file mode 100644
index 000000000..abd4f3feb
--- /dev/null
+++ b/apps/agreements/registry.py
@@ -0,0 +1,75 @@
+"""Kinds of agreement: how a domain app drafts, freezes, and follows up on its documents.
+
+A domain model (a configured order, a custom contract, or a sponsorship) holds the
+selections and has a ``OneToOneField`` named ``agreement`` to ``agreements.Agreement``. It
+stays editable until it is offered; the workflow then freezes it and sets that field.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, TypedDict, cast
+
+if TYPE_CHECKING:
+ from django.contrib.auth.models import AnonymousUser
+
+ from apps.agreements.models import Agreement, CustomContract, Order, TermsVersion
+ from apps.users.models import User
+
+
+class AgreementDetails(TypedDict):
+ """Counterparty identity supplied by a registered draft kind."""
+
+ title: str
+ counterparty_name: str
+ counterparty_account: User | None
+
+
+_KINDS: dict[str, Kind[Any]] = {}
+
+
+class Kind[Subject: (CustomContract, Order)]:
+ """Base class for a registered kind of agreement."""
+
+ slug = ""
+ name = ""
+ model: type[Subject] = cast("type[Subject]", None)
+
+ def details(self, subject: Subject) -> AgreementDetails:
+ """Return ``title``, ``counterparty_name``, and ``counterparty_account`` for a new agreement."""
+ raise NotImplementedError
+
+ def compose(self, subject: Subject) -> str:
+ """Return the document's markdown, with ``documents.SIGNATURES`` where the signatures go."""
+ raise NotImplementedError
+
+ def cited_terms_versions(self, subject: Subject) -> list[TermsVersion]:
+ """Return the ``TermsVersion`` objects the document cites."""
+ return []
+
+ def freeze(self, subject: Subject) -> None:
+ """Fix anything the document depends on, such as prices, when it is offered."""
+
+ def release(self, subject: Subject) -> None:
+ """Undo ``freeze`` when an offer is withdrawn and the subject becomes editable again."""
+
+ def executed(self, agreement: Agreement) -> None:
+ """Act on a fully signed agreement, for example by activating a sponsorship."""
+
+ def can_view(self, user: User | AnonymousUser, agreement: Agreement) -> bool:
+ """Whether a user other than PSF staff and the counterparty's account may view it."""
+ return False
+
+ def subject(self, agreement: Agreement) -> Subject | None:
+ """Return the domain object the agreement was offered for, if it still points at it."""
+ return self.model.objects.filter(agreement=agreement).first()
+
+
+def register[K: Kind[Any]](kind_class: type[K]) -> type[K]:
+ """Register a ``Kind`` subclass under its slug."""
+ _KINDS[kind_class.slug] = kind_class()
+ return kind_class
+
+
+def get_kind(slug: str) -> Kind[Any]:
+ """Return the registered kind for ``slug``."""
+ return _KINDS[slug]
diff --git a/apps/agreements/static/agreements/agreements.css b/apps/agreements/static/agreements/agreements.css
new file mode 100644
index 000000000..0fdff344e
--- /dev/null
+++ b/apps/agreements/static/agreements/agreements.css
@@ -0,0 +1,160 @@
+/* Agreement pages: documents, signing, and records. Layered on python.org's style.css.
+ Pages that use these pieces add the "agreements" class to . */
+
+.agreements [hidden] { display: none !important; }
+
+/* Keep ordinary prose intact; long addresses and URLs may still wrap to fit. */
+.agreements :is(p, dd, blockquote, address) {
+ -webkit-hyphens: none;
+ hyphens: none;
+ overflow-wrap: break-word;
+}
+.agreements :is(h1, h2, h3, .breadcrumbs) { overflow-wrap: anywhere; }
+
+.agreements .num { text-align: right; white-space: nowrap; }
+.agreements .actions { display: flex; flex-wrap: wrap; gap: 0.5em; align-items: center; }
+.agreements .actions .button,
+.agreements .actions form { display: inline-block; margin: 0; }
+.agreements .actions button { margin: 0; }
+.agreements .actions .discard-form button { min-height: 44px; padding: 0.5em; border: 0; background: none; box-shadow: none; color: #74333b !important; text-shadow: none; }
+.agreements .actions .discard-form button:is(:hover, :focus) { background: none; color: #74333b !important; text-decoration: underline; }
+.agreements .introduction { font-size: 1.125em; }
+.agreements .aside-note { color: #666; }
+
+/* Primary link buttons match python.org's blue submit buttons. */
+.agreements a.button.button-primary {
+ color: #e6e8ea !important;
+ text-shadow: none;
+ background-color: #2b5b84;
+ background-image: linear-gradient(#3776ab 10%, #2b5b84 90%);
+ border: 1px solid #3776ab;
+ border-top-color: #3d83be;
+ border-left-color: #3d83be;
+}
+.agreements a.button.button-primary:hover,
+.agreements a.button.button-primary:focus {
+ color: #f2f4f6 !important;
+ background-color: #244e71;
+ background-image: linear-gradient(#316998 10%, #244e71 90%);
+}
+
+/* Section navigation: mark the current page like the site's sitetree menus. */
+.agreements .section-nav .selected > a { font-family: SourceSansProBold, Arial, sans-serif; color: #222; }
+.agreements .account-widget p { margin-bottom: 0.65em; }
+.agreements .account-widget .button { display: inline-block; }
+.agreements .contents-widget .section-nav a { padding: 0.2em 0; line-height: 1.3; border-bottom: 0; }
+.agreements .psf-sidebar-widget p { color: #f2f4f6; }
+
+/* Status pills use the site's feedback palette. */
+.status-pill {
+ display: inline-block;
+ padding: 0.1em 0.6em;
+ border-radius: 6px;
+ font-family: SourceSansProBold, Arial, sans-serif;
+ font-size: 0.85em;
+ white-space: nowrap;
+ vertical-align: 0.1em;
+ line-height: 1.4;
+}
+.status-success { background-color: #d6e7bf; border: 1px solid #82b043; color: #3d5a1a; }
+.status-notice { background-color: #fff7dc; border: 1px solid #ffd343; color: #6b5200; }
+.status-error { background-color: #ecd4d7; border: 1px solid #b55863; color: #74333b; }
+.status-neutral { background-color: #e6e8ea; border: 1px solid #caccce; color: #444; }
+
+/* ---------- Tables ---------- */
+
+.agreements .table-scroll { overflow-x: auto; }
+.agreements .order-table { table-layout: fixed; min-width: 36em; }
+.agreements .order-table .status-pill { white-space: normal; }
+
+.agreements .order-table,
+.agreements .facts,
+.agreements .record { margin: 0.5em 0 1.3125em; }
+.agreements .order-table th,
+.agreements .order-table td,
+.agreements .facts th,
+.agreements .facts td,
+.agreements .record th,
+.agreements .record td { padding: 0.45em 0.6em; border-bottom: 1px solid #e6e8ea; }
+.agreements .order-table th,
+.agreements .order-table td { overflow-wrap: anywhere; }
+.agreements .order-table thead th,
+.agreements .facts thead th { background-color: #e6e8ea; color: #444; font-family: SourceSansProBold, Arial, sans-serif; width: auto; }
+.agreements .facts tbody th,
+.agreements .record th { width: 32%; color: #666; font-weight: normal; font-family: SourceSansProRegular, Arial, sans-serif; }
+.agreements .facts tfoot th,
+.agreements .facts tfoot td { font-family: SourceSansProBold, Arial, sans-serif; color: #222; border-bottom: 0; }
+.agreements .record caption { text-align: left; font-family: SourceSansProBold, Arial, sans-serif; color: #222; padding: 0.5em 0; }
+.agreements .order-table .ref { display: block; font-size: 0.8em; color: #666; font-family: monospace; }
+
+/* ---------- Document text ---------- */
+
+.agreements .terms-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.5em 1.5em; color: #666; }
+.agreements .agreement-meta { color: #666; margin-bottom: 0.5em; }
+.agreements .agreement-text h1 { font-size: 1.5em; }
+.agreements .agreement-text h2 { font-size: 1.3125em; font-family: SourceSansProBold, Arial, sans-serif; margin-top: 1.75em; }
+.agreements .agreement-text .sub { margin-left: 1.75em; }
+.agreements .agreement-text .sub p { margin-bottom: 0.4em; }
+.agreements .agreement-text table th,
+.agreements .agreement-text table td { border: 1px solid #caccce; padding: 0.4em 0.6em; }
+.agreements .agreement-text table thead th { background-color: #e6e8ea; }
+.agreements .next-step .button { display: inline-block; margin-top: 0.5em; }
+.agreements .agreement-scroll {
+ max-height: 32em;
+ overflow: auto;
+ padding: 0 1.25em;
+ margin-bottom: 1.3125em;
+ border: 1px solid #caccce;
+ border-top: 5px solid #caccce;
+}
+.agreements .agreement-details summary { cursor: pointer; color: #3776ab; margin-bottom: 0.875em; }
+.agreements .agreement-block { border-top: 5px solid #caccce; margin-top: 1.75em; padding-top: 0.25em; }
+
+/* ---------- Forms ---------- */
+
+.agreements .help { color: #666; font-size: 0.9em; margin-bottom: 0.65em; }
+.agreements .req { color: #999; font-weight: normal; font-family: SourceSansProRegular, Arial, sans-serif; }
+.agreements .field label { color: #444; }
+.agreements .field input,
+.agreements .field textarea,
+.agreements .field select { margin-bottom: 0.25em; }
+.agreements .field .help { margin-bottom: 0.25em; }
+.agreements .field { margin-bottom: 0.65em; }
+.agreements .field-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 1em; }
+.agreements .has-error input,
+.agreements .has-error textarea { border-color: #b55863; }
+.agreements label.choice {
+ display: flex; gap: 0.6em; align-items: flex-start; margin: 0;
+ color: #444; font-weight: normal; cursor: pointer;
+}
+.agreements label.choice input { flex: none; margin: 0.3em 0 0; }
+.agreements .choice-help { color: #666; font-size: 0.9em; }
+.agreements .form-actions { display: flex; flex-wrap: wrap; gap: 1em; align-items: center; border-top: 5px solid #caccce; padding-top: 1em; margin-top: 1.75em; }
+.agreements .form-actions button { margin-bottom: 0; }
+.agreements textarea.markdown-editor { font-family: monospace; font-size: 0.9em; line-height: 1.45; width: 100%; }
+
+/* ---------- Signing and staff actions ---------- */
+
+.agreements .discard-form { margin-top: 0.5em; }
+.agreements .sign-form { margin-bottom: 1.3125em; }
+.agreements .sign-form .accept { padding: 0.75em; border: 1px solid #caccce; border-radius: 6px; margin-bottom: 0.875em; }
+.agreements .sign-form .accept.has-error { border-color: #b55863; }
+.agreements .signing-as p { margin-bottom: 0.35em; }
+.agreements .countersign,
+.agreements .staff-panel { border-top: 5px solid #ffd343; margin-top: 1.75em; padding-top: 0.25em; }
+.agreements .decline summary { cursor: pointer; color: #b55863; }
+.agreements .offline-signing details > summary { cursor: pointer; color: #3776ab; margin-bottom: 0.875em; }
+
+/* ---------- Revision and terms history ---------- */
+
+.agreements .history { list-style: none; margin: 0; padding: 0; }
+.agreements .history > li { border-bottom: 1px solid #e6e8ea; padding: 0.55em 0; }
+.agreements .history summary { cursor: pointer; }
+.agreements .diff { font-size: 0.8em; line-height: 1.4; max-height: 28em; overflow: auto; background-color: #f2f4f6; padding: 0.5em; white-space: pre-wrap; }
+.agreements .diff .add { background-color: #d6e7bf; display: block; }
+.agreements .diff .del { background-color: #ecd4d7; display: block; }
+.agreements .diff .hunk { color: #666; display: block; }
+
+@media (max-width: 800px) {
+ .agreements .field-row { grid-template-columns: minmax(0, 1fr); }
+}
diff --git a/apps/agreements/static/agreements/package-builder.css b/apps/agreements/static/agreements/package-builder.css
new file mode 100644
index 000000000..4840a9618
--- /dev/null
+++ b/apps/agreements/static/agreements/package-builder.css
@@ -0,0 +1,606 @@
+/* Program and order pages. Layered on python.org's style.css and agreements/agreements.css. */
+
+/* ---------- Program list and overview ---------- */
+
+.agreement-orders .tier-table { margin: 0.5em 0 1.3125em; }
+.agreement-orders .tier-table th,
+.agreement-orders .tier-table td { padding: 0.45em 0.6em; border-bottom: 1px solid #e6e8ea; }
+.agreement-orders .tier-table thead th { background-color: #e6e8ea; color: #444; font-family: SourceSansProBold, Arial, sans-serif; }
+.agreement-orders .agreement-summary { border-top: 5px solid #caccce; padding-top: 0.5em; margin-top: 1.75em; }
+.agreement-orders .agreement-summary h2 { margin-top: 0.25em; }
+.agreement-orders .agreement-services { color: #666; font-size: 0.9em; }
+
+.program-overview .po-invite {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 1em 2em;
+ margin: 1.5em 0 2.25em;
+ padding: 1.25em 1.5em 1.35em;
+ background-color: #e9f1f8;
+ border-top: 5px solid #ffd343;
+}
+.program-overview .po-invite-title { margin: 0 0 0.2em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.3125em; color: #222; }
+.program-overview .po-invite p { margin: 0; color: #444; }
+.program-overview .po-invite-cta { margin: 0; white-space: nowrap; padding: 0.55em 1.25em; font-size: 1.0625em; }
+.program-overview .po-tagline { margin-bottom: 0.5em; }
+.program-overview .po-table-wrap { max-width: 100%; overflow-x: auto; }
+.program-overview .po-terms { font-size: 0.9em; color: #666; }
+
+@media (max-width: 40em) {
+ .program-overview .po-invite { grid-template-columns: minmax(0, 1fr); }
+ .program-overview .po-invite-cta { justify-self: start; }
+}
+
+/* ---------- Order page ---------- */
+
+.agreement-orders .order-meta { color: #666; }
+.agreement-orders .order-orgs { color: #666; font-size: 0.95em; }
+.agreement-orders .num { font-variant-numeric: tabular-nums; }
+
+@media (max-width: 800px) {
+ .agreement-orders .tier-table { display: block; overflow-x: auto; }
+}
+
+/* ================================================================
+ Package builder (order form)
+ ================================================================ */
+
+.pb-page {
+ --pb-blue: #3776ab;
+ --pb-blue-dark: #2b5b84;
+ --pb-ink: #222;
+ --pb-text: #444;
+ --pb-muted: #666;
+ --pb-faint: #888;
+ --pb-line: #e6e8ea;
+ --pb-line-strong: #caccce;
+ --pb-wash: #f2f4f6;
+ --pb-tint: #e9f1f8;
+ --pb-tint-line: #c2d9ec;
+ --pb-yellow: #ffd343;
+ --pb-green: #3d5a1a;
+ --pb-green-wash: #edf4e3;
+ --pb-red: #b55863;
+ --pb-red-ink: #74333b;
+ --pb-amber-ink: #6b5200;
+ --pb-amber-wash: #fff7dc;
+ --pb-ease: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.pb-page .pb-intro { max-width: 46em; }
+.pb-page .pb-intro .page-title { margin-bottom: 0.5em; }
+.pb-page .pb-intro .introduction { margin-bottom: 0.5em; }
+.pb-page .pb-lede { color: var(--pb-muted); margin-bottom: 1.75em; max-width: 42em; }
+
+.package-builder { margin: 0; }
+.package-builder fieldset { border: 0; margin: 0; padding: 0; min-inline-size: 0; }
+.package-builder legend { padding: 0; }
+.package-builder input,
+.package-builder textarea { caret-color: var(--pb-blue); }
+.package-builder :focus-visible { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; }
+.package-builder input[type="checkbox"],
+.package-builder input[type="radio"] { padding: 0; }
+/* These controls draw the focus ring on their whole card instead. */
+.package-builder :is(.pb-tier-input, .pb-include input, .pb-term-option input):focus-visible { outline: none; }
+.package-builder .errorlist { list-style: none; margin: 0.25em 0 0.5em; padding: 0; font-size: 0.9em; color: var(--pb-red-ink); }
+.package-builder .errorlist li { margin: 0; }
+.package-builder .help,
+.package-builder .pb-help { color: var(--pb-muted); font-size: 0.9375em; line-height: 1.55; }
+.package-builder .pb-help { max-width: 44em; margin: 0 0 1em; }
+
+/* Error summary */
+.package-builder .pb-errors { margin-bottom: 2em; }
+.package-builder .pb-errors:focus { outline: 3px solid var(--pb-red); outline-offset: 2px; }
+.package-builder .pb-errors-title { margin: 0 0 0.35em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.125em; color: var(--pb-red-ink); }
+.package-builder .pb-errors ul { margin: 0; padding-left: 1.2em; list-style: disc; }
+.package-builder .pb-errors li { margin: 0.15em 0; line-height: 1.5; }
+
+/* One focused task beside the running package; without JavaScript, tasks stack. */
+.package-builder .pb-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2em; }
+.package-builder .pb-layout > [data-step-panel] { min-width: 0; grid-column: 1; }
+.package-builder.is-enhanced .pb-layout > [data-step-panel] { grid-row: 1; }
+.package-builder .pb-summary { grid-column: 1; }
+.package-builder.is-enhanced .pb-summary { grid-row: 2; }
+.package-builder .pb-steps { position: relative; display: flex; gap: 1.5em; overflow-x: auto; scrollbar-width: thin; margin: 0 0 0.75em; border-bottom: 1px solid var(--pb-line); }
+.text .package-builder .pb-steps button {
+ flex: 1 0 auto;
+ white-space: nowrap;
+ margin: 0;
+ min-height: 48px;
+ padding: 0.5em 0.25em;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ border-radius: 0;
+ background: none;
+ box-shadow: none;
+ color: var(--pb-muted) !important;
+ font: inherit;
+ font-size: 0.9375em;
+ text-shadow: none;
+}
+.text .package-builder .pb-steps button:is(:hover, :focus, :active) { background: none; box-shadow: none; color: var(--pb-blue-dark) !important; }
+.text .package-builder .pb-steps button[aria-current="step"] { border-bottom-color: var(--pb-blue); color: var(--pb-blue-dark) !important; font-family: SourceSansProBold, Arial, sans-serif; }
+.package-builder .pb-progress-text { margin: 0 0 1.5em; color: var(--pb-muted); font-size: 0.875em; }
+.package-builder.is-enhanced .pb-section { border-top: 0; padding-top: 0; }
+.package-builder.is-enhanced #review .pb-actions > button { display: none; }
+@media (min-width: 60em) {
+ .package-builder .pb-layout { grid-template-columns: minmax(0, 1fr) minmax(17em, 20em); column-gap: 2.5em; }
+ .package-builder .pb-summary,
+ .package-builder.is-enhanced .pb-summary { grid-column: 2; grid-row: 1; position: sticky; top: 1em; align-self: start; }
+ .package-builder .pb-summary-panel { max-height: calc(100vh - 7em); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--pb-line-strong) transparent; }
+}
+
+.package-builder .pb-section { border-top: 1px solid var(--pb-line-strong); padding-top: 1.25em; }
+.package-builder .pb-section > h2,
+.package-builder .pb-section-head h2 { margin: 0 0 0.3em; font-size: 1.5em; color: var(--pb-ink); }
+
+/* ---------- Service family ---------- */
+
+.package-builder .pb-family {
+ container: pbfamily / inline-size;
+ margin-top: 1.5em;
+ padding-top: 1.5em;
+ border-top: 1px solid var(--pb-line-strong);
+ scroll-margin-top: 1em;
+}
+.package-builder .pb-section-head + .pb-family,
+.package-builder .pb-section-head + .errorlist + .pb-family { border-top: 0; padding-top: 0.25em; margin-top: 0.5em; }
+
+.package-builder .pb-family-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75em 2em;
+ margin-bottom: 1em;
+}
+.package-builder .pb-family-intro { flex: 1 1 22em; min-width: 0; }
+.package-builder .pb-family-title { margin: 0; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.25em; line-height: 1.2; color: var(--pb-ink); }
+.package-builder .pb-family-tagline { margin: 0.35em 0 0; color: var(--pb-muted); font-size: 0.9375em; line-height: 1.5; }
+.package-builder .pb-family-terms { margin: 0.2em 0 0; font-size: 0.875em; color: var(--pb-muted); line-height: 1.5; }
+
+.package-builder .pb-include {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5em;
+ margin: 0;
+ padding: 0.45em 0.9em 0.45em 0.7em;
+ border: 1px solid var(--pb-line-strong);
+ border-radius: 999px;
+ background-color: #fff;
+ color: var(--pb-text);
+ font-family: SourceSansProBold, Arial, sans-serif;
+ font-weight: normal;
+ font-size: 0.9375em;
+ cursor: pointer;
+ transition: background-color 0.18s var(--pb-ease), border-color 0.18s var(--pb-ease), color 0.18s var(--pb-ease);
+}
+.package-builder .pb-include input { width: 1.05em; height: 1.05em; margin: 0; accent-color: #fff; }
+.package-builder .pb-include:hover { border-color: var(--pb-blue); color: var(--pb-blue-dark); }
+.package-builder .pb-include:has(input:focus-visible) { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; }
+.package-builder .pb-include:has(input:checked) { background-color: var(--pb-blue); border-color: var(--pb-blue); color: #fff; }
+.package-builder .pb-include input:not(:checked) { accent-color: var(--pb-blue); }
+
+.package-builder .pb-family > .errorlist { margin: 0 0 0.75em; }
+
+/* ---------- Compact tier choices and on-demand comparisons ---------- */
+
+.package-builder .pb-tier-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 11em), 1fr)); gap: 0.65em; }
+.package-builder .pb-tier {
+ position: relative;
+ min-width: 0;
+ padding: 0.85em;
+ background-color: #fff;
+ border: 1px solid var(--pb-line-strong);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.18s var(--pb-ease), border-color 0.18s var(--pb-ease);
+}
+.package-builder .pb-tier:hover { border-color: var(--pb-blue); }
+.package-builder .pb-tier:has(.pb-tier-input:focus-visible) { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; }
+.package-builder .pb-family:has(.pb-include-input:checked) .pb-tier:has(.pb-tier-input:checked) { border-color: var(--pb-blue); background-color: var(--pb-tint); }
+.package-builder .pb-tier-head { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.5em; }
+.package-builder .pb-tier-input { width: 1.1em; height: 1.1em; margin: 0; accent-color: var(--pb-blue); cursor: pointer; }
+.package-builder .pb-tier-name { margin: 0; color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; font-weight: normal; font-size: 1em; line-height: 1.25; cursor: pointer; overflow-wrap: break-word; }
+.package-builder .pb-tier-name::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
+.package-builder .pb-fee { grid-column: 1 / -1; margin: 0.25em 0 0; line-height: 1.3; }
+.package-builder .pb-fee-amount { color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.375em; font-variant-numeric: tabular-nums; }
+.package-builder .pb-fee-unit { color: var(--pb-muted); font-size: 0.8125em; }
+.package-builder .pb-tier-facts { margin: 0.8em 0 0; padding: 0; border: 0; font-size: 0.8125em; line-height: 1.45; }
+.package-builder .pb-tier-facts dt { margin: 0.5em 0 0; padding: 0; border: 0; color: var(--pb-muted); font-family: SourceSansProRegular, Arial, sans-serif; }
+.package-builder .pb-tier-facts dd { margin: 0; padding: 0; border: 0; color: var(--pb-text); }
+@container pbfamily (max-width: 35em) {
+ .package-builder .pb-tier-options { grid-template-columns: minmax(0, 1fr); }
+ .package-builder .pb-tier-head { grid-template-columns: auto minmax(0, 1fr) auto; }
+ .package-builder .pb-fee { grid-column: 3; margin: 0; }
+ .package-builder .pb-fee-amount { font-size: 1.125em; }
+ .package-builder .pb-tier-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75em; margin-top: 0.65em; }
+ .package-builder .pb-tier-facts dt { margin-top: 0; }
+}
+.package-builder .pb-staff-details { margin-top: 1.5em; border-bottom: 1px solid var(--pb-line); }
+.package-builder .pb-staff-details > summary { padding: 0.7em 0; color: var(--pb-blue-dark); font-size: 0.9375em; font-family: SourceSansProBold, Arial, sans-serif; cursor: pointer; }
+.package-builder .pb-staff-details[open] { padding-bottom: 1em; }
+.package-builder .pb-comparison-details > summary { padding: 0.75em 0; margin-top: 0.25em; color: var(--pb-blue-dark); font-size: 0.9375em; cursor: pointer; }
+.package-builder .pb-comparison-details .pb-secondary { width: 100%; min-height: 44px; justify-content: center; white-space: nowrap; }
+.package-builder .pb-extra-family + .pb-extra-family { margin-top: 1.5em; }
+.package-builder .pb-extra-family > h3 { margin: 0 0 0.75em; font-size: 1.125em; font-family: SourceSansProBold, Arial, sans-serif; }
+.package-builder #pb-extras-empty { padding: 1.5em 0; color: var(--pb-muted); }
+.package-builder .pb-comparison-scroll { max-width: 100%; overflow-x: auto; }
+.package-builder .pb-comparison { width: 100%; min-width: 54em; table-layout: fixed; margin: 0.75em 0; border-collapse: collapse; font-size: 0.875em; line-height: 1.4; }
+.package-builder .pb-comparison-label { width: 38%; }
+.package-builder .pb-comparison :is(th, td) { padding: 0.65em; border-bottom: 1px solid var(--pb-line); text-align: left; vertical-align: top; }
+.package-builder .pb-comparison thead th { background: var(--pb-wash); color: var(--pb-ink); }
+.package-builder .pb-comparison th[scope="row"] { width: 38%; font-family: SourceSansProRegular, Arial, sans-serif; font-weight: normal; }
+.package-builder .pb-comparison td { min-width: 6em; }
+.package-builder .pb-comparison .pb-yes { color: var(--pb-blue-dark); }
+.package-builder .pb-comparison .pb-no { color: var(--pb-muted); }
+
+.package-builder .pb-mark {
+ flex: none;
+ display: inline-block;
+ width: 1.05em;
+ height: 1.05em;
+ margin-right: 0.3em;
+ vertical-align: -0.15em;
+ background-color: currentColor;
+ -webkit-mask: var(--pb-icon) center / contain no-repeat;
+ mask: var(--pb-icon) center / contain no-repeat;
+}
+.package-builder .pb-yes .pb-mark {
+ color: var(--pb-blue);
+ --pb-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.2 8.6l3 3L12.8 5' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+}
+.package-builder .pb-no .pb-mark,
+.package-builder .pb-none .pb-mark {
+ color: var(--pb-faint);
+ --pb-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 8h7' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");
+}
+.package-builder .pb-also-list { margin: 0; padding: 0; list-style: none; }
+.package-builder .pb-also-list li { margin: 0; color: var(--pb-text); line-height: 1.4; }
+.package-builder .pb-also-list li + li { margin-top: 0.3em; }
+.package-builder .pb-glossary { margin: 1em 0 0; }
+.package-builder .pb-glossary-title { margin: 0 0 0.35em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.9375em; color: var(--pb-ink); }
+.package-builder .pb-glossary dl { margin: 0; font-size: 0.9em; line-height: 1.5; }
+.package-builder .pb-glossary dt { margin-top: 0.4em; border: 0; color: var(--pb-ink); }
+.package-builder .pb-glossary dd { margin: 0; padding: 0; border: 0; color: var(--pb-muted); }
+.package-builder .pb-glossary-tiers { font-family: SourceSansProRegular, Arial, sans-serif; color: var(--pb-blue-dark); font-size: 0.9em; margin-left: 0.3em; }
+
+
+/* ---------- Extras ---------- */
+
+.package-builder .pb-extras { margin-top: 0.75em; }
+.package-builder .pb-extras-title { margin: 0 0 0.2em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.125em; color: var(--pb-ink); }
+.package-builder .pb-extras-help { margin: 0 0 0.85em; max-width: 46em; color: var(--pb-muted); font-size: 0.9375em; line-height: 1.55; }
+.package-builder .pb-extra-list { margin: 0; padding: 0; list-style: none; }
+.package-builder .pb-extra {
+ min-width: 0;
+ margin: 0;
+ padding: 0.85em 0;
+ border-bottom: 1px solid var(--pb-line);
+ line-height: 1.5;
+}
+.package-builder .pb-extra:last-child { border-bottom: 0; }
+.package-builder .pb-extra.is-included { color: var(--pb-muted); }
+.package-builder .pb-extra.has-error { border-bottom-color: var(--pb-red); }
+
+.package-builder .pb-extra-main { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 0.6em; }
+.package-builder .pb-extra-toggle { grid-row: 1 / span 4; padding-top: 0.2em; }
+.package-builder .pb-extra-toggle input { width: 1.1em; height: 1.1em; margin: 0; accent-color: var(--pb-blue); cursor: pointer; }
+.package-builder .pb-extra-toggle input:disabled { cursor: default; }
+.package-builder .pb-extra-label { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.15em 1em; margin: 0; color: var(--pb-ink); font-weight: normal; cursor: pointer; line-height: 1.4; }
+.package-builder .pb-extra.is-included .pb-extra-label { cursor: default; }
+.package-builder .pb-extra-name { display: block; font-family: SourceSansProBold, Arial, sans-serif; }
+.package-builder .pb-extra-price { color: var(--pb-blue-dark); font-size: 0.875em; }
+.package-builder .pb-extra-desc { margin: 0.3em 0 0; color: var(--pb-muted); font-size: 0.875em; line-height: 1.5; }
+.package-builder .pb-extra-tiers { margin: 0.35em 0 0; color: var(--pb-green); font-size: 0.8125em; line-height: 1.45; }
+.package-builder .pb-join + .pb-join::before { content: ", "; }
+.package-builder .pb-extra-now {
+ justify-self: start;
+ margin: 0.45em 0 0;
+ padding: 0.1em 0.55em;
+ background-color: var(--pb-green-wash);
+ border: 1px solid #b9d39a;
+ border-radius: 6px;
+ color: var(--pb-green);
+ font-size: 0.8125em;
+ line-height: 1.5;
+}
+.package-builder .pb-extra.is-included .pb-extra-tiers { display: none; }
+.package-builder .pb-extra > .errorlist { margin: 0.5em 0 0; }
+
+.package-builder .pb-params {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 10em), 1fr));
+ gap: 0 0.9em;
+ margin: 0.75em 0 0;
+ padding-top: 0.7em;
+ border-top: 1px solid var(--pb-line);
+}
+.package-builder.is-enhanced .pb-params:not([hidden]) { animation: pb-reveal 0.22s var(--pb-ease); }
+.package-builder:not(.is-enhanced) .pb-extra:not(:has(.pb-extra-toggle input:checked)) .pb-params { display: none; }
+.package-builder .pb-param { margin: 0 0 0.35em; }
+.package-builder .pb-param label { margin: 0 0 0.2em; color: var(--pb-text); font-family: SourceSansProBold, Arial, sans-serif; font-weight: normal; font-size: 0.875em; }
+.package-builder .pb-param input,
+.package-builder .pb-param select,
+.package-builder .pb-param textarea { width: 100%; margin: 0; padding: 0.45em 0.6em; font-size: 0.9375em; }
+.package-builder .pb-param select { padding: 0.4em; border: 1px solid var(--pb-line-strong); border-radius: 6px; background-color: #fff; }
+.package-builder .pb-param textarea { min-height: 4.5em; resize: vertical; }
+.package-builder .pb-param .help { margin: 0.2em 0 0; font-size: 0.8125em; }
+.package-builder .pb-param.has-error :is(input, select, textarea) { border-color: var(--pb-red); }
+
+@keyframes pb-reveal {
+ from { opacity: 0; transform: translateY(-0.25em); }
+ to { opacity: 1; transform: none; }
+}
+
+/* ---------- Staff-only fields ---------- */
+
+.package-builder .pb-staff { margin-top: 1em; padding: 0; }
+.package-builder .pb-staff > legend { float: left; width: 100%; margin: 0 0 0.5em; font-family: SourceSansProBold, Arial, sans-serif; color: var(--pb-ink); }
+.package-builder .pb-staff > legend + * { clear: both; }
+.package-builder .pb-staff-tag { margin-left: 0.4em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.85em; color: var(--pb-muted); }
+.package-builder .pb-staff textarea,
+.package-builder .pb-staff input { background-color: #fff; }
+
+/* ---------- Term and discount ---------- */
+
+.package-builder .pb-term-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 16em), 1fr)); gap: 0.6em; }
+.package-builder .pb-term-option {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: baseline;
+ gap: 0.15em 0.55em;
+ margin: 0;
+ padding: 0.75em 0.9em;
+ background-color: #fff;
+ border: 1px solid var(--pb-line-strong);
+ border-radius: 6px;
+ color: var(--pb-text);
+ font-weight: normal;
+ cursor: pointer;
+ transition: border-color 0.18s var(--pb-ease), background-color 0.18s var(--pb-ease);
+}
+.package-builder .pb-term-option:hover { border-color: #999; }
+.package-builder .pb-term-option:has(input:checked) { background-color: var(--pb-tint); border-color: var(--pb-blue); box-shadow: inset 0 0 0 1px var(--pb-blue); }
+.package-builder .pb-term-option:has(input:focus-visible) { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; }
+.package-builder .pb-term-option input { margin: 0; width: 1.05em; height: 1.05em; accent-color: var(--pb-blue); align-self: center; }
+.package-builder .pb-term-name { color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; }
+.package-builder .pb-term-detail { grid-column: 2; color: var(--pb-muted); font-size: 0.875em; line-height: 1.45; }
+.package-builder .pb-attest { margin-top: 0.85em; padding: 0.75em 0.9em; background-color: var(--pb-tint); border: 1px solid var(--pb-tint-line); border-radius: 6px; }
+.package-builder .pb-attest.has-error { background-color: #f8eef0; border-color: var(--pb-red); }
+.package-builder:not(.is-enhanced) .pb-term:not(:has(input[data-attestation="true"]:checked)) .pb-attest:not(.has-error) { display: none; }
+.package-builder .pb-attest label.choice input { width: auto; }
+
+/* ---------- Your package ---------- */
+
+.package-builder .pb-summary { min-width: 0; scroll-margin-top: 1em; }
+.package-builder .pb-summary-panel {
+ padding: 1em 1.15em 1.1em;
+ background-color: #fff;
+ border: 1px solid var(--pb-line);
+ border-top: 5px solid var(--pb-yellow);
+}
+.package-builder .pb-summary-title { margin: 0 0 0.35em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.3125em; color: var(--pb-ink); }
+.package-builder .pb-summary-title:focus { outline: none; }
+.package-builder .pb-summary-title:focus-visible { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; }
+.package-builder .pb-summary-empty,
+.package-builder .pb-empty p { margin: 0 0 0.5em; color: var(--pb-muted); font-size: 0.9375em; line-height: 1.55; }
+.package-builder .pb-empty { padding: 0.5em 0 0.25em; }
+.package-builder .pb-empty .pb-empty-title { margin-bottom: 0.15em; color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; }
+
+.package-builder .pb-sum-family { padding: 0.75em 0 0.6em; border-top: 1px solid var(--pb-line); animation: pb-reveal 0.2s var(--pb-ease); }
+.package-builder .pb-summary-body > .pb-sum-family:first-child { border-top: 0; padding-top: 0.25em; }
+.package-builder .pb-sum-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75em; }
+.package-builder .pb-sum-name { margin: 0; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.0625em; color: var(--pb-ink); }
+.package-builder .pb-sum-actions { display: inline-flex; gap: 0.75em; font-size: 0.875em; white-space: nowrap; }
+
+.package-builder .pb-linkbutton,
+.text .package-builder .pb-linkbutton {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ background: none;
+ border: 0;
+ box-shadow: none;
+ color: var(--pb-red-ink) !important;
+ font: inherit;
+ text-shadow: none;
+ cursor: pointer;
+}
+.package-builder .pb-linkbutton:hover,
+.package-builder .pb-linkbutton:focus-visible { text-decoration: underline; text-underline-offset: 0.15em; }
+
+.package-builder .pb-lines { margin: 0; padding: 0; list-style: none; }
+.package-builder .pb-line {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0 0.75em;
+ margin: 0;
+ padding: 0.2em 0;
+ font-size: 0.9375em;
+ line-height: 1.45;
+}
+.package-builder .pb-line-name { color: var(--pb-text); overflow-wrap: break-word; }
+.package-builder .pb-line-detail { display: block; color: var(--pb-muted); font-size: 0.8125em; }
+.package-builder .pb-line-tier .pb-line-name { color: var(--pb-ink); }
+.package-builder .pb-line-included .pb-amount { color: var(--pb-green); }
+.package-builder .pb-line-discount .pb-amount { color: var(--pb-green); }
+.package-builder .pb-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--pb-ink); transition: opacity 0.2s var(--pb-ease); }
+.package-builder .pb-amount-unit { color: var(--pb-muted); font-size: 0.8125em; }
+.package-builder .pb-line:has(.is-unpriced) { grid-template-columns: minmax(0, 1fr); }
+.package-builder .pb-amount.is-unpriced { color: var(--pb-amber-ink); white-space: normal; text-align: left; font-size: 0.875em; }
+.package-builder .pb-skeleton {
+ display: inline-block;
+ width: 3.5em;
+ height: 0.8em;
+ vertical-align: -0.05em;
+ border-radius: 3px;
+ background: linear-gradient(90deg, var(--pb-line) 25%, var(--pb-wash) 50%, var(--pb-line) 75%) 0 0 / 200% 100%;
+ animation: pb-shimmer 1.1s ease-in-out infinite;
+}
+@keyframes pb-shimmer { to { background-position: -200% 0; } }
+
+.package-builder .pb-totals { margin-top: 0.6em; padding-top: 0.65em; border-top: 2px solid var(--pb-ink); }
+.package-builder .pb-total-rows { margin: 0; }
+.package-builder .pb-total { display: flex; justify-content: space-between; align-items: baseline; gap: 0.75em; padding: 0.1em 0; }
+.package-builder .pb-total dt,
+.package-builder .pb-total dd { margin: 0; padding: 0; border: 0; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.9375em; color: var(--pb-text); }
+.package-builder .pb-total dd { font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--pb-ink); transition: opacity 0.2s var(--pb-ease); }
+.package-builder .pb-total-grand { margin-top: 0.3em; align-items: flex-end; }
+.package-builder .pb-total-grand dt { color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; line-height: 1.3; }
+.package-builder .pb-total-grand dd { color: var(--pb-blue); font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.75em; line-height: 1.1; letter-spacing: -0.01em; }
+.package-builder .pb-total-note { margin: 0.5em 0 0; color: var(--pb-amber-ink); font-size: 0.8125em; line-height: 1.45; }
+.package-builder .pb-summary.is-pending :is(.pb-amount, .pb-total dd) { opacity: 0.45; }
+
+.package-builder .pb-summary-status {
+ margin-top: 0.75em;
+ padding: 0.55em 0.75em;
+ background-color: var(--pb-amber-wash);
+ border: 1px solid var(--pb-yellow);
+ border-radius: 6px;
+ color: var(--pb-amber-ink);
+ font-size: 0.875em;
+ line-height: 1.5;
+}
+.package-builder .pb-summary-status.is-error { background-color: #f8eef0; border-color: var(--pb-red); color: var(--pb-red-ink); }
+.package-builder .pb-summary-status p { margin: 0; }
+.package-builder .pb-summary-status .pb-status-title { font-family: SourceSansProBold, Arial, sans-serif; }
+.package-builder .pb-summary-status ul { margin: 0.25em 0 0; padding-left: 1.1em; list-style: disc; }
+.package-builder .pb-summary-status li { margin: 0.1em 0; }
+
+.package-builder .pb-summary-foot { margin: 0.85em 0 0; color: var(--pb-muted); font-size: 0.8125em; line-height: 1.5; }
+
+/* ---------- Organization ---------- */
+
+.package-builder .pb-org { container: pborganization / inline-size; }
+.package-builder .pb-org > .pb-help { margin-bottom: 1.5em; }
+.package-builder .pb-group { padding-top: 1.25em; border-top: 1px solid var(--pb-line); }
+.package-builder .pb-group + .pb-group { margin-top: 1.75em; }
+.package-builder .pb-group > legend { float: left; width: 100%; margin: 0 0 1em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.125em; color: var(--pb-ink); }
+.package-builder .pb-group > legend + * { clear: both; }
+.package-builder .pb-group .pb-help { margin-bottom: 1em; }
+.package-builder .pb-fields { display: grid; gap: 1.25em; }
+.package-builder .pb-org .field-row { grid-template-columns: minmax(0, 1fr); gap: 1.25em; }
+.package-builder .pb-org .field { min-width: 0; margin: 0; }
+.package-builder .pb-org .field label { display: block; margin: 0 0 0.4em; line-height: 1.4; }
+.package-builder .pb-org .req { color: var(--pb-muted); white-space: nowrap; }
+.package-builder .pb-org .field :is(input, textarea) {
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 44px;
+ margin: 0;
+ padding: 0.6em 0.75em;
+ border: 1px solid var(--pb-line-strong);
+ border-radius: 4px;
+ background: #fff;
+ color: var(--pb-ink);
+ font-size: 1em;
+ line-height: 1.5;
+ box-shadow: none;
+}
+.package-builder .pb-org .field textarea { resize: vertical; }
+.package-builder .pb-org .field .help { margin: 0.4em 0 0; font-size: 0.875em; }
+.package-builder .pb-org .field.has-error :is(input, textarea) { border-color: var(--pb-red); }
+.package-builder .pb-contact { margin: 0 0 1em; }
+.package-builder .pb-contact > legend { margin-bottom: 0.5em; color: var(--pb-muted); font-size: 0.875em; }
+@container pborganization (min-width: 40em) {
+ .package-builder .pb-org .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+.package-builder .field label { font-weight: normal; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.9375em; }
+.package-builder .field input,
+.package-builder .field textarea { margin-bottom: 0.25em; }
+
+.package-builder .pb-secondary,
+.text .package-builder .pb-secondary {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4em;
+ margin: 0.35em 0 0;
+ padding: 0.4em 0.9em;
+ background: #fff;
+ border: 1px solid var(--pb-line-strong);
+ border-radius: 6px;
+ box-shadow: none;
+ color: var(--pb-blue-dark) !important;
+ font: inherit;
+ font-size: 0.9375em;
+ text-shadow: none;
+ cursor: pointer;
+ transition: border-color 0.18s var(--pb-ease), background-color 0.18s var(--pb-ease);
+}
+.package-builder .pb-secondary:hover { border-color: var(--pb-blue); background-color: var(--pb-tint); }
+
+/* ---------- Review ---------- */
+
+.package-builder .pb-agreements { margin: 0 0 0.75em; padding: 0; list-style: none; }
+.package-builder .pb-agreements li {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.2em 0.75em;
+ margin: 0;
+ padding: 0.6em 0;
+ border-bottom: 1px solid var(--pb-line);
+}
+.package-builder .pb-agreements li:first-child { border-top: 1px solid var(--pb-line); }
+.package-builder .pb-agreement-title { color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; }
+.package-builder .pb-agreement-for { color: var(--pb-muted); font-size: 0.9em; }
+.package-builder .pb-agreements a { margin-left: auto; font-size: 0.9375em; }
+.package-builder .pb-agreements-empty { color: var(--pb-muted); font-style: italic; }
+.package-builder .pb-actions { margin-top: 1.25em; }
+.package-builder .pb-fineprint { margin: 0.6em 0 0; color: var(--pb-muted); font-size: 0.875em; }
+
+/* Persistent navigation: price and actions share one footer on small screens. */
+.package-builder .pb-wizard-actions {
+ position: sticky;
+ bottom: 0;
+ z-index: 10;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75em;
+ margin-top: 2em;
+ padding: 0.85em 0 calc(0.85em + env(safe-area-inset-bottom, 0px));
+ background: #fff;
+ border-top: 1px solid var(--pb-line-strong);
+}
+.package-builder .pb-wizard-actions > button { min-height: 44px; margin: 0; }
+.package-builder .pb-wizard-actions > [data-step-next] { margin-left: auto; }
+.package-builder .pb-bar { display: flex; align-items: center; justify-content: space-between; gap: 1em; min-width: 0; }
+.package-builder .pb-bar-text { margin: 0; line-height: 1.3; min-width: 0; }
+.package-builder .pb-bar-label { color: var(--pb-muted); font-size: 0.8125em; margin-right: 0.4em; }
+.package-builder .pb-bar-total { color: var(--pb-blue); font-family: SourceSansProBold, Arial, sans-serif; font-variant-numeric: tabular-nums; }
+.package-builder .pb-bar-link { flex: none; font-size: 0.875em; font-family: SourceSansProBold, Arial, sans-serif; }
+
+@media (max-width: 59.99em) {
+ .package-builder .pb-wizard-actions .pb-bar { order: 3; flex-basis: 100%; }
+}
+@media (min-width: 60em) {
+ .package-builder .pb-bar { display: none !important; }
+}
+
+@media (max-width: 30em) {
+ .pb-page { padding-inline: 1em; }
+ .package-builder .pb-family-title { font-size: 1.3125em; }
+ .package-builder .pb-include { width: 100%; justify-content: flex-start; border-radius: 6px; }
+ .package-builder .pb-summary-panel { padding: 0.85em 0.9em 1em; }
+ .package-builder .pb-agreements a { margin-left: 0; }
+}
+
+/* ---------- Motion, contrast modes ---------- */
+
+@media (prefers-reduced-motion: reduce) {
+ .package-builder *,
+ .package-builder *::before,
+ .package-builder *::after { animation: none !important; transition: none !important; }
+}
+
+@media (forced-colors: active) {
+ .package-builder .pb-mark { forced-color-adjust: none; background-color: CanvasText; }
+ .package-builder .pb-family:has(.pb-include-input:checked) .pb-tier:has(.pb-tier-input:checked),
+ .package-builder .pb-extra.is-selected,
+ .package-builder .pb-term-option:has(input:checked) { outline: 3px solid Highlight; }
+ .package-builder .pb-skeleton { background: GrayText; }
+}
diff --git a/apps/agreements/static/agreements/package-builder.js b/apps/agreements/static/agreements/package-builder.js
new file mode 100644
index 000000000..74ea2ea70
--- /dev/null
+++ b/apps/agreements/static/agreements/package-builder.js
@@ -0,0 +1,803 @@
+// Package builder: focused screens retain the native form and its selections, and the summary is
+// rebuilt from the form itself, then priced by the server's quote endpoint.
+(() => {
+ "use strict";
+
+ const form = document.getElementById("order-form");
+ if (!form || !form.classList.contains("package-builder")) return;
+ form.classList.add("is-enhanced");
+
+ const $$ = (selector, root = form) => [...root.querySelectorAll(selector)];
+ const conditions = JSON.parse(document.getElementById("order-parameter-conditions")?.textContent || "{}");
+
+ const summary = document.getElementById("package-summary");
+ const summaryTitle = document.getElementById("summary-title");
+ const body = document.getElementById("pb-summary-body");
+ const totals = document.getElementById("pb-totals");
+ const status = document.getElementById("pb-quote-status");
+ const announcer = document.getElementById("pb-announce");
+ const attest = document.getElementById("discount-attest");
+ const bar = document.getElementById("pb-bar");
+ const barTotal = document.getElementById("pb-bar-total");
+ const agreementsEmpty = document.getElementById("pb-agreements-empty");
+
+ const families = $$("[data-family]").map((el) => {
+ const extraGroup = document.getElementById(`extras-${el.dataset.family}`);
+ return {
+ el,
+ slug: el.dataset.family,
+ name: el.dataset.familyName,
+ title: el.dataset.familyTitle,
+ include: el.querySelector(".pb-include-input"),
+ radios: $$(".pb-tier-input", el),
+ extraGroup,
+ extras: extraGroup ? $$(".pb-extra", extraGroup) : [],
+ };
+ });
+
+ const extraBox = (row) => row.querySelector(".pb-extra-toggle input[type='checkbox']");
+ const extraName = (row) => row.querySelector(".pb-extra-name").textContent.trim();
+ const tierOf = (family) => family.radios.find((radio) => radio.checked) || null;
+ const tierColumn = (radio) => radio.closest(".pb-tier");
+ const included = () => families.filter((family) => family.include.checked && tierOf(family));
+ const familyOf = (node) => {
+ const group = node.closest("[data-family], [data-extras-for]");
+ return group && families.find((family) => family.slug === (group.dataset.family || group.dataset.extrasFor));
+ };
+
+ function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined && text !== null) node.textContent = text;
+ return node;
+ }
+
+ function announce(message) {
+ // Clearing first makes repeated identical messages audible.
+ announcer.textContent = "";
+ window.setTimeout(() => {
+ announcer.textContent = message;
+ }, 60);
+ }
+
+ // ---------- Selection state ----------
+
+ // A family is in the package exactly when its checkbox is checked and one tier is chosen. Removing
+ // a family unchecks its tier but remembers it, so adding it back restores every earlier choice.
+ function ensureTier(family) {
+ if (tierOf(family)) return;
+ const key = family.el.dataset.lastTier || family.el.dataset.defaultTier;
+ const radio = family.radios.find((candidate) => candidate.value === key) || family.radios[0];
+ if (radio) radio.checked = true;
+ }
+
+ function setIncluded(family, on) {
+ family.include.checked = on;
+ if (on) {
+ ensureTier(family);
+ return;
+ }
+ const current = tierOf(family);
+ if (current) {
+ family.el.dataset.lastTier = current.value;
+ current.checked = false;
+ }
+ }
+
+ function paramKind(field) {
+ if (field.tagName === "TEXTAREA") return "lines";
+ if (field.tagName === "SELECT") return "choice";
+ return field.type === "number" ? "count" : "text";
+ }
+
+ function syncParams(family, row, active) {
+ const fields = $$("[data-param-key]", row);
+ if (!fields.length) return;
+ const byKey = Object.fromEntries(fields.map((field) => [field.dataset.paramKey, field]));
+ const rules = conditions[family.slug]?.[row.dataset.addon] || {};
+ const memo = {};
+ // Mirror the server: a condition holds only when the parameter it depends on is itself submitted.
+ const applies = (key, seen = new Set()) => {
+ if (key in memo) return memo[key];
+ if (seen.has(key)) return false;
+ seen.add(key);
+ memo[key] = Object.entries(rules[key] || {}).every(
+ ([dependency, value]) =>
+ byKey[dependency] && applies(dependency, seen) && byKey[dependency].value === String(value),
+ );
+ return memo[key];
+ };
+ fields.forEach((field) => {
+ const applicable = applies(field.dataset.paramKey);
+ field.closest(".pb-param").hidden = !applicable;
+ field.disabled = !active || !applicable;
+ });
+ const params = row.querySelector(".pb-params");
+ if (params) params.hidden = !active;
+ }
+
+ function syncFamily(family) {
+ const on = family.include.checked;
+ const tier = on ? tierOf(family) : null;
+ family.el.classList.toggle("is-included", Boolean(on && tier));
+ if (family.extraGroup) family.extraGroup.hidden = !tier;
+ family.extras.forEach((row) => {
+ const box = extraBox(row);
+ const includedIn = (row.dataset.includedIn || "").split(" ").filter(Boolean);
+ const withTier = Boolean(tier) && includedIn.includes(tier.value);
+ box.disabled = !tier || withTier;
+ row.classList.toggle("is-included", withTier);
+ row.classList.toggle("is-selected", on && box.checked && !withTier);
+ row.classList.toggle("is-kept", !on && box.checked);
+ const now = row.querySelector(".pb-extra-now");
+ if (now) {
+ now.hidden = !withTier;
+ if (withTier) now.querySelector(".pb-extra-now-tier").textContent = tierColumn(tier).dataset.tierName;
+ }
+ syncParams(family, row, Boolean(tier) && box.checked && !withTier);
+ });
+ }
+
+ function syncDiscount() {
+ if (!attest) return;
+ const needed = form.querySelector("input[name='discount']:checked")?.dataset.attestation === "true";
+ attest.hidden = !needed;
+ attest.querySelectorAll("input").forEach((input) => {
+ input.disabled = !needed;
+ });
+ }
+
+ function syncAgreements() {
+ const chosen = new Set(included().map((family) => family.slug));
+ $$("[data-agreement-for]").forEach((item) => {
+ item.hidden = !chosen.has(item.dataset.agreementFor);
+ });
+ if (agreementsEmpty) agreementsEmpty.hidden = chosen.size > 0;
+ }
+
+ function syncAll() {
+ families.forEach(syncFamily);
+ syncDiscount();
+ syncAgreements();
+ const extrasEmpty = document.getElementById("pb-extras-empty");
+ if (extrasEmpty) extrasEmpty.hidden = included().some((family) => family.extras.length);
+ }
+
+ // ---------- Contacts ----------
+
+ function setupContacts() {
+ const rows = $$(".pb-contact");
+ const button = document.getElementById("add-contact");
+ if (!button || !rows.length) return;
+ const used = (row) => $$("input", row).some((input) => input.value.trim()) || row.querySelector(".errorlist");
+ let visible = Math.max(1, ...rows.map((row, index) => (used(row) ? index + 1 : 0)));
+ const render = () => {
+ rows.forEach((row, index) => {
+ row.hidden = index >= visible;
+ });
+ button.hidden = visible >= rows.length;
+ };
+ button.addEventListener("click", () => {
+ visible = Math.min(rows.length, visible + 1);
+ render();
+ rows[visible - 1].querySelector("input")?.focus();
+ });
+ render();
+ return (target) => {
+ const index = rows.findIndex((row) => row.contains(target));
+ if (index < visible) return;
+ visible = index + 1;
+ render();
+ };
+ }
+
+ // ---------- Steps and in-page navigation ----------
+
+ function setupSteps(revealContact) {
+ const steps = ["services", "extras", "term", "organization", "contacts"];
+ const names = ["Services", "Extras", "Term", "Organization", "Contacts & billing"];
+ const panels = $$("[data-step-panel]");
+ const navigation = form.querySelector(".pb-steps");
+ const progress = document.getElementById("pb-progress-text");
+ const actions = document.getElementById("pb-wizard-actions");
+ const back = actions.querySelector("[data-step-back]");
+ const forward = actions.querySelector("[data-step-next]");
+ const errors = document.getElementById("pb-errors");
+ const panelFor = (step) => panels.find((panel) => panel.dataset.stepPanel === step);
+ const currentIndex = () => steps.indexOf(form.dataset.step);
+
+ function focusTarget(target) {
+ const heading = target.matches("section, aside, fieldset")
+ ? target.querySelector("h2, h3, legend")
+ : null;
+ const focus = heading || target;
+ if (!focus.matches("a[href], button, input, select, textarea, summary, [tabindex]")) {
+ focus.tabIndex = -1;
+ }
+ focus.focus({ preventScroll: true });
+ requestAnimationFrame(() => {
+ (target.matches("[data-step-panel]") ? navigation : focus).scrollIntoView({ block: "start", behavior: "instant" });
+ });
+ }
+
+ function showStep(step) {
+ const panel = panelFor(step);
+ if (!panel) return;
+ const index = steps.indexOf(step);
+ form.dataset.step = step;
+ panels.forEach((candidate) => {
+ candidate.hidden = candidate !== panel;
+ });
+ $$("[data-step-go]", navigation).forEach((button) => {
+ if (button.dataset.stepGo === step) button.setAttribute("aria-current", "step");
+ else button.removeAttribute("aria-current");
+ });
+ const current = navigation.querySelector('[aria-current="step"]');
+ if (current.offsetLeft < navigation.scrollLeft || current.offsetLeft + current.offsetWidth > navigation.scrollLeft + navigation.clientWidth) {
+ navigation.scrollLeft = current.offsetLeft;
+ }
+ progress.textContent = `Step ${index + 1} of ${steps.length} — ${names[index]}`;
+ back.hidden = index === 0;
+ back.textContent = `Back to ${names[index - 1]?.toLowerCase() || "services"}`;
+ forward.type = step === "contacts" ? "submit" : "button";
+ forward.textContent = step === "contacts" ? "Continue to review" : `Continue to ${names[index + 1].toLowerCase()}`;
+ }
+
+ function openDisclosures(target) {
+ let disclosure = target.closest("details");
+ while (disclosure) {
+ disclosure.open = true;
+ disclosure = disclosure.parentElement?.closest("details");
+ }
+ }
+
+ function revealTarget(target, focus = true) {
+ // An old extras link must not silently add a service that has since been removed.
+ const extraGroup = target.closest("[data-extras-for]");
+ const family = extraGroup && familyOf(extraGroup);
+ if (family && (!family.include.checked || !tierOf(family))) target = family.el;
+ const panel = target.closest("[data-step-panel]");
+ if (panel) showStep(panel.dataset.stepPanel);
+ openDisclosures(target);
+ revealContact?.(target);
+ if (focus) focusTarget(target);
+ }
+
+ function hashTarget(hash) {
+ if (!hash || hash === "#") return null;
+ try {
+ const target = document.getElementById(decodeURIComponent(hash.slice(1)));
+ return target && form.contains(target) ? target : null;
+ } catch {
+ return null;
+ }
+ }
+
+ function navigate(target) {
+ const hash = `#${encodeURIComponent(target.id)}`;
+ if (window.location.hash !== hash) window.history.pushState(null, "", hash);
+ revealTarget(target);
+ }
+
+ function packageReady() {
+ const unresolved = families.find((family) => family.include.checked && !tierOf(family));
+ if (!unresolved && included().length) return true;
+ const message = unresolved
+ ? `Choose a tier for ${unresolved.name} before continuing.`
+ : "Choose at least one service tier before continuing.";
+ state.failure = message;
+ render();
+ const family = unresolved || families[0];
+ const target = family?.radios[0] || family?.include || panelFor("services");
+ revealTarget(target);
+ announce(message);
+ return false;
+ }
+
+ function validFields(root) {
+ const invalid = $$("input, select, textarea", root)
+ .find((field) => field.willValidate && !field.validity.valid);
+ if (!invalid) return true;
+ revealTarget(invalid);
+ invalid.reportValidity();
+ return false;
+ }
+
+ function goToStep(step) {
+ const index = steps.indexOf(step);
+ if (index < 0) return;
+ if (index > 0 && !packageReady()) return;
+ if (index > currentIndex() && !validFields(panelFor(form.dataset.step))) return;
+ navigate(panelFor(step));
+ }
+
+ function advance() {
+ const index = currentIndex();
+ if (index < steps.length - 1) goToStep(steps[index + 1]);
+ }
+
+ navigation.hidden = false;
+ progress.hidden = false;
+ actions.hidden = false;
+ // Validate one revealed field at a time, rather than letting the browser focus hidden screens.
+ form.noValidate = true;
+ $$("[data-step-go]", navigation).forEach((button) => {
+ button.addEventListener("click", () => goToStep(button.dataset.stepGo));
+ });
+ back.addEventListener("click", () => goToStep(steps[currentIndex() - 1]));
+ forward.addEventListener("click", (event) => {
+ if (forward.type !== "submit") {
+ event.preventDefault();
+ advance();
+ }
+ });
+ $$("[data-choose-family][data-choose-tier]").forEach((button) => {
+ button.hidden = false;
+ button.addEventListener("click", () => {
+ const family = families.find((candidate) => candidate.slug === button.dataset.chooseFamily);
+ const radio = family?.radios.find((candidate) => candidate.value === button.dataset.chooseTier);
+ if (!radio) return;
+ radio.checked = true;
+ family.include.checked = true;
+ syncAll();
+ refresh(0);
+ announce(`${family.name} added at the ${tierColumn(radio).dataset.tierName} tier.`);
+ });
+ });
+
+ document.addEventListener("click", (event) => {
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ const link = event.target.closest("a[href]");
+ if (!link || link.hasAttribute("download") || (link.target && link.target !== "_self")) return;
+ const url = new URL(link.href, window.location.href);
+ if (url.origin !== window.location.origin || url.pathname !== window.location.pathname || url.search !== window.location.search) return;
+ const target = hashTarget(url.hash);
+ if (!target) return;
+ event.preventDefault();
+ navigate(target);
+ });
+
+ window.addEventListener("hashchange", () => {
+ revealTarget(hashTarget(window.location.hash) || panelFor("services"));
+ });
+
+ form.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter" || event.isComposing || event.defaultPrevented || form.dataset.step === "contacts") return;
+ if (!event.target.matches("input:not([type='button']):not([type='submit']):not([type='reset'])")) return;
+ event.preventDefault();
+ advance();
+ });
+ form.addEventListener("submit", (event) => {
+ if (form.dataset.step !== "contacts") {
+ event.preventDefault();
+ advance();
+ } else if (!packageReady() || !validFields(form)) {
+ event.preventDefault();
+ }
+ });
+
+ form.addEventListener("invalid", (event) => {
+ revealTarget(event.target, false);
+ }, true);
+
+ $$(".errorlist, [aria-invalid='true']").forEach(openDisclosures);
+ const firstError = errors && $$("a[href^='#']", errors)
+ .map((link) => hashTarget(link.hash))
+ .find(Boolean);
+ const initialTarget = firstError || hashTarget(window.location.hash);
+ showStep("services");
+ if (initialTarget) revealTarget(initialTarget, !errors);
+ errors?.focus();
+ }
+
+ // ---------- Money ----------
+
+ // Exact cent arithmetic on the server's decimal strings; only used to split recurring from one-time.
+ function cents(value) {
+ const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(String(value ?? "").trim());
+ if (!match) return 0n;
+ const fraction = (match[3] || "").padEnd(3, "0");
+ let amount = BigInt(match[2]) * 100n + BigInt(fraction.slice(0, 2));
+ if (Number(fraction[2]) >= 5) amount += 1n;
+ return match[1] ? -amount : amount;
+ }
+
+ function formatCents(amount) {
+ const negative = amount < 0n;
+ const absolute = negative ? -amount : amount;
+ const whole = (absolute / 100n).toLocaleString("en-US");
+ const fraction = absolute % 100n;
+ const text = fraction ? `$${whole}.${String(fraction).padStart(2, "0")}` : `$${whole}`;
+ return negative ? `−${text}` : text;
+ }
+
+ // ---------- Quote ----------
+
+ // Free text never leaves the page: lines and text parameters are replaced by neutral placeholders
+ // that price identically (pricing only counts lines and checks that text is present).
+ function quoteValue(field) {
+ const kind = paramKind(field);
+ if (kind === "lines") {
+ return field.value
+ .split(/\r?\n/)
+ .filter((line) => line.trim())
+ .map(() => "-")
+ .join("\n");
+ }
+ if (kind === "text") return field.value.trim() ? "-" : "";
+ return field.value;
+ }
+
+ function quoteParams(chosen) {
+ const params = new URLSearchParams();
+ chosen.forEach((family) => params.append(family.include.name, family.slug));
+ const discount = form.querySelector("input[name='discount']:checked");
+ if (discount) params.set(discount.name, discount.value);
+ chosen.forEach((family) => {
+ const tier = tierOf(family);
+ params.append(tier.name, tier.value);
+ family.extras.forEach((row) => {
+ const box = extraBox(row);
+ if (!box.checked || box.disabled) return;
+ params.append(box.name, "on");
+ $$("[data-param-key]", row).forEach((field) => {
+ if (!field.disabled) params.append(field.name, quoteValue(field));
+ });
+ });
+ });
+ if (form.dataset.order) params.set("order", form.dataset.order);
+ return params;
+ }
+
+ function fieldLabel(slug, fieldName) {
+ const field = form.querySelector(`[name="${CSS.escape(`${slug}-${fieldName}`)}"]`);
+ const row = field?.closest(".pb-extra");
+ if (!row) return "";
+ const param = field.closest(".pb-param")?.querySelector("label")?.textContent.trim();
+ return param ? `${extraName(row)}, ${param}` : extraName(row);
+ }
+
+ function quoteProblems(payload) {
+ const problems = [];
+ Object.entries(payload?.errors || {}).forEach(([slug, value]) => {
+ const family = families.find((candidate) => candidate.slug === slug);
+ const prefix = family ? `${family.name}: ` : "";
+ if (Array.isArray(value)) {
+ value.forEach((message) => problems.push(prefix + (typeof message === "string" ? message : message.message)));
+ return;
+ }
+ Object.entries(value || {}).forEach(([field, errors]) => {
+ const label = field === "__all__" ? "" : fieldLabel(slug, field);
+ errors.forEach(({ message }) => {
+ const text = label && !message.startsWith(label.split(",")[0]) ? `${label}: ${message}` : message;
+ problems.push(prefix + text);
+ });
+ });
+ });
+ return problems;
+ }
+
+ const state = { quote: null, pending: false, problems: [], failure: "", announced: "" };
+ let timer;
+ let controller;
+ let generation = 0;
+
+ function refresh(delay = 180) {
+ window.clearTimeout(timer);
+ controller?.abort();
+ const current = ++generation;
+ const chosen = included();
+ state.problems = [];
+ state.failure = "";
+ const unresolved = families.filter((family) => family.include.checked && !tierOf(family));
+ if (unresolved.length) {
+ state.quote = null;
+ state.pending = false;
+ state.failure = `Choose a tier for ${unresolved.map((family) => family.name).join(", ")} to calculate the complete package.`;
+ render();
+ return;
+ }
+ if (!chosen.length) {
+ state.quote = null;
+ state.pending = false;
+ render();
+ return;
+ }
+ state.quote = null;
+ state.pending = true;
+ render();
+ timer = window.setTimeout(async () => {
+ controller = new AbortController();
+ try {
+ const response = await fetch(`${form.dataset.quoteUrl}?${quoteParams(chosen)}`, {
+ signal: controller.signal,
+ credentials: "same-origin",
+ headers: { Accept: "application/json" },
+ });
+ const payload = await response.json().catch(() => null);
+ if (current !== generation) return;
+ if (response.ok && payload) {
+ state.quote = payload;
+ } else {
+ state.quote = null;
+ state.problems = response.status === 400 ? quoteProblems(payload) : [];
+ if (!state.problems.length) {
+ state.failure = "Prices are unavailable right now. You can still continue; the next page shows every fee.";
+ }
+ }
+ } catch (error) {
+ if (error.name === "AbortError" || current !== generation) return;
+ state.quote = null;
+ state.failure = "We couldn't reach the price calculator. Check your connection; you can still continue.";
+ }
+ state.pending = false;
+ render();
+ }, delay);
+ }
+
+ // ---------- Summary ----------
+
+ function amountNode(text, unit, className = "") {
+ const cell = el("span", `pb-amount ${className}`.trim());
+ if (text === null) {
+ cell.append(el("span", "pb-skeleton"));
+ cell.setAttribute("aria-label", "Calculating");
+ return cell;
+ }
+ cell.append(el("span", "pb-amount-value", text));
+ if (unit) cell.append(el("span", "pb-amount-unit", ` ${unit}`));
+ return cell;
+ }
+
+ function line(label, amount, { detail = "", className = "" } = {}) {
+ const item = el("li", `pb-line ${className}`.trim());
+ const name = el("span", "pb-line-name", label);
+ if (detail) name.append(el("span", "pb-line-detail", detail));
+ item.append(name, amount);
+ return item;
+ }
+
+ // The summary repeats what the customer chose, in their own words, without sending them anywhere.
+ function localDetail(row) {
+ return $$("[data-param-key]", row)
+ .filter((field) => !field.disabled)
+ .map((field) => {
+ const label = field.closest(".pb-param").querySelector("label").textContent.trim();
+ const kind = paramKind(field);
+ let value = field.value.trim();
+ if (kind === "choice") value = field.selectedOptions[0]?.textContent.trim() || value;
+ if (kind === "lines") value = field.value.split(/\r?\n/).map((part) => part.trim()).filter(Boolean).join(", ");
+ return value ? `${label}: ${value}` : "";
+ })
+ .filter(Boolean);
+ }
+
+ function itemDetail(row, item) {
+ const labels = $$(".pb-param label", row).map((label) => `${label.textContent.trim()}: `);
+ const fromRule = (item?.detail || "")
+ .split("; ")
+ .filter((part) => part && part !== "Included at this service tier" && !labels.some((label) => part.startsWith(label)));
+ return [...fromRule, ...localDetail(row)].join(" · ");
+ }
+
+ function renderFamily(family, quoted) {
+ const tier = tierOf(family);
+ const column = tierColumn(tier);
+ const group = el("section", "pb-sum-family");
+ group.setAttribute("aria-label", family.name);
+ const head = el("div", "pb-sum-head");
+ head.append(el("h3", "pb-sum-name", family.name));
+ const actions = el("span", "pb-sum-actions");
+ const change = el("a", "pb-sum-change", "Change");
+ change.href = `#family-${family.slug}`;
+ change.setAttribute("aria-label", `Change ${family.name}`);
+ change.dataset.focusKey = `change-${family.slug}`;
+ const remove = el("button", "pb-linkbutton", "Remove");
+ remove.type = "button";
+ remove.dataset.remove = family.slug;
+ remove.dataset.focusKey = `remove-${family.slug}`;
+ remove.setAttribute("aria-label", `Remove ${family.name} from your package`);
+ actions.append(change, remove);
+ head.append(actions);
+ group.append(head);
+
+ const list = el("ul", "pb-lines");
+ list.append(
+ line(`${column.dataset.tierName} tier`, amountNode(quoted?.display.tier_fee || column.dataset.fee, "a year"), {
+ className: "pb-line-tier",
+ }),
+ );
+
+ const priced = [...(quoted?.items || [])];
+ family.extras.forEach((row) => {
+ if (row.classList.contains("is-included")) {
+ list.append(line(extraName(row), amountNode("Included", ""), { className: "pb-line-included" }));
+ }
+ });
+ family.extras.forEach((row) => {
+ const box = extraBox(row);
+ if (!box.checked || box.disabled) return;
+ const name = extraName(row);
+ const index = priced.findIndex((item) => item.name === name);
+ const item = index >= 0 ? priced.splice(index, 1)[0] : null;
+ let amount;
+ if (!item) amount = amountNode(state.problems.length || state.failure ? "—" : null, "");
+ else if (item.amount === null) amount = amountNode(item.display, "", "is-unpriced");
+ else amount = amountNode(item.display, item.basis === "per year" ? "a year" : item.basis);
+ list.append(line(name, amount, { detail: itemDetail(row, item) }));
+ });
+
+ if (quoted && Number(quoted.discount_percent) > 0) {
+ list.append(
+ line(`${quoted.discount_name}, ${quoted.discount_percent}% off the tier`, amountNode(`−${quoted.display.discount_amount}`, "a year"), {
+ className: "pb-line-discount",
+ }),
+ );
+ }
+ group.append(list);
+ return group;
+ }
+
+ function renderTotals(chosen) {
+ const quote = state.quote;
+ totals.replaceChildren();
+ totals.hidden = !chosen.length;
+ if (!chosen.length) return "";
+ const rows = el("dl", "pb-total-rows");
+ const add = (label, value, className = "") => {
+ const row = el("div", `pb-total ${className}`.trim());
+ row.append(el("dt", "", label), el("dd", "num", value));
+ rows.append(row);
+ };
+ if (!quote) {
+ const waiting = state.pending ? "Calculating…" : "—";
+ add("Total", waiting, "pb-total-grand");
+ totals.append(rows);
+ return "";
+ }
+ let recurring = 0n;
+ let oneTime = 0n;
+ const unpriced = [];
+ quote.agreements.forEach((agreement) => {
+ recurring += cents(agreement.tier_fee) - cents(agreement.discount_amount);
+ agreement.items.forEach((item) => {
+ if (item.amount === null) unpriced.push(item.name);
+ else if (item.recurring) recurring += cents(item.amount);
+ else oneTime += cents(item.amount);
+ });
+ });
+ const months = Number(quote.term_months) || 12;
+ if (months > 12) {
+ add("Each year", formatCents(recurring));
+ if (oneTime) add("One-time", formatCents(oneTime));
+ add(`${months}-month initial term, prepaid`, quote.display.term_total, "pb-total-grand");
+ } else if (oneTime) {
+ add("Each year", formatCents(recurring));
+ add("One-time", formatCents(oneTime));
+ add("First year", quote.display.total_annual, "pb-total-grand");
+ } else {
+ add("Each year", quote.display.total_annual, "pb-total-grand");
+ }
+ totals.append(rows);
+ if (unpriced.length) {
+ totals.append(el("p", "pb-total-note", `Not included above, quoted separately: ${unpriced.join(", ")}.`));
+ }
+ const grand = rows.querySelector(".pb-total-grand");
+ return `${grand.querySelector("dt").textContent}: ${grand.querySelector("dd").textContent}`;
+ }
+
+ function renderStatus() {
+ status.replaceChildren();
+ status.classList.toggle("is-error", Boolean(state.failure));
+ if (state.problems.length) {
+ status.append(el("p", "pb-status-title", "To price your package:"));
+ const list = el("ul");
+ state.problems.forEach((problem) => list.append(el("li", "", problem)));
+ status.append(list);
+ } else if (state.failure) {
+ status.append(el("p", "", state.failure));
+ }
+ status.hidden = !state.problems.length && !state.failure;
+ }
+
+ function renderEmpty() {
+ const empty = el("div", "pb-empty");
+ empty.append(
+ el("p", "pb-empty-title", "Nothing here yet"),
+ el(
+ "p",
+ "",
+ families.length > 1
+ ? "Choose a tier from any service to start. You can mix tiers across services and add extras."
+ : "Choose a tier to start, then add any extras.",
+ ),
+ );
+ return empty;
+ }
+
+ function render() {
+ const chosen = included();
+ const quoted = new Map((state.quote?.agreements || []).map((agreement) => [agreement.slug, agreement]));
+ summary.classList.toggle("is-pending", state.pending);
+ summary.setAttribute("aria-busy", String(state.pending));
+ const focused = body.contains(document.activeElement) ? document.activeElement.dataset.focusKey : null;
+ body.replaceChildren(...(chosen.length ? chosen.map((family) => renderFamily(family, quoted.get(family.slug))) : [renderEmpty()]));
+ if (focused) body.querySelector(`[data-focus-key="${focused}"]`)?.focus();
+ const spoken = renderTotals(chosen);
+ renderStatus();
+ const grand = totals.querySelector(".pb-total-grand dd");
+ barTotal.textContent = grand && !state.pending ? grand.textContent : "";
+ updateBar();
+ if (spoken && !state.pending && spoken !== state.announced) {
+ state.announced = spoken;
+ announce(spoken);
+ }
+ }
+
+ // ---------- Small-screen package bar ----------
+
+ const wide = window.matchMedia("(min-width: 60em)");
+ const visible = new Set();
+ function updateBar() {
+ bar.hidden = wide.matches || !included().length || visible.size > 0;
+ }
+ if ("IntersectionObserver" in window) {
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => (entry.isIntersecting ? visible.add(entry.target) : visible.delete(entry.target)));
+ updateBar();
+ });
+ [summary, document.getElementById("review")].forEach((target) => target && observer.observe(target));
+ }
+ wide.addEventListener?.("change", updateBar);
+
+ // ---------- Events ----------
+
+ form.addEventListener("change", (event) => {
+ const target = event.target;
+ const family = familyOf(target);
+ if (family && target.closest(".pb-staff")) return;
+ if (family) {
+ if (target === family.include) {
+ setIncluded(family, target.checked);
+ announce(
+ target.checked
+ ? `${family.name} added at the ${tierColumn(tierOf(family)).dataset.tierName} tier.`
+ : `${family.name} removed. Your choices are kept if you add it again.`,
+ );
+ } else if (target.classList.contains("pb-tier-input") && !family.include.checked) {
+ family.include.checked = true;
+ announce(`${family.name} added at the ${tierColumn(target).dataset.tierName} tier.`);
+ }
+ } else if (target.name !== "discount") {
+ return;
+ }
+ syncAll();
+ refresh();
+ });
+
+ form.addEventListener("input", (event) => {
+ if (!event.target.closest(".pb-params")) return;
+ syncAll();
+ refresh(350);
+ });
+
+ summary.addEventListener("click", (event) => {
+ const button = event.target.closest("[data-remove]");
+ if (!button) return;
+ const family = families.find((candidate) => candidate.slug === button.dataset.remove);
+ if (!family) return;
+ setIncluded(family, false);
+ syncAll();
+ refresh(0);
+ announce(`${family.name} removed. Your choices are kept if you add it again.`);
+ summaryTitle.focus();
+ });
+
+ families.forEach((family) => {
+ if (!family.include.checked) setIncluded(family, false);
+ });
+ syncAll();
+ const revealContact = setupContacts();
+ setupSteps(revealContact);
+ refresh(0);
+})();
diff --git a/apps/agreements/templates/agreements/_actions.html b/apps/agreements/templates/agreements/_actions.html
new file mode 100644
index 000000000..d6f5cddca
--- /dev/null
+++ b/apps/agreements/templates/agreements/_actions.html
@@ -0,0 +1,162 @@
+{% comment %}
+Signing and staff actions for one agreement. Context comes from agreements.views.helpers.action_forms;
+pass `next` to return to the including page after each action.
+{% endcomment %}
+{% if agreement.status == "offered" %}
+
+
Ready to sign{% if agreement.revision > 1 %} (revision {{ agreement.revision }}, edited by the PSF){% endif %}. Nothing is binding until both parties sign.
+
+{% elif agreement.status == "signed" %}
+
Signed by {{ agreement.signer_name }} on {{ agreement.signed_at|date:"F j, Y" }}. The PSF reviews and countersigns; it takes effect on the date of the last signature.
+{% elif agreement.status == "executed" %}
+
Both parties have signed. It took effect on {{ agreement.countersigned_at|date:"F j, Y" }}.
+{% elif agreement.status == "declined" %}
+
The PSF declined this: {{ agreement.decline_reason }}
+{% endif %}
+
+{% include "agreements/_record.html" %}
+
+{% if is_administrator and agreement.status == "executed" and agreement.signer_email %}
+
+{% endif %}
+
+{% if countersign_form %}
+
+
Countersign for the Python Software Foundation
+
Check the document, the signatory's identity and authority{% if agreement.signature_method == "offline" %}, the signed copy{% endif %}, and anything the counterparty claimed before you sign.
Download the PDF (or DOCX) of revision {{ agreement.revision }}.
+
Sign it through DocuSign, a procurement system, or on paper, without changing it. If something needs to change, the PSF edits it here first.
+
Upload the signed copy below. The PSF then countersigns.
+
+
+ Upload the signed copy
+
+
+
+{% endif %}
+
+{% if is_administrator and agreement.status == "offered" %}
+
+
PSF staff
+
+ Edit this document
+ Changes apply to this {{ agreement.counterparty_name }} document only. To change the standing terms for everyone, edit the terms.
+
+
Email a signing link
+
For a signatory without a python.org account. The link works once and expires after 14 days.
+
+ {% if links %}
+
+
+
Sent to
Sent
Status
+
+ {% for link in links %}
+
+
{{ link.name }} ({{ link.email }})
+
{{ link.created_at|date:"M j, Y" }} by {{ link.created_by.email }}
Making it ready to sign fixes this text as revision 1. You can then send a signing link, record a signed copy, or {% if contract.counterparty_account %}let {{ contract.counterparty_account.email }} sign on python.org{% else %}name an account that signs online{% endif %}, and still edit it until it is signed.
For one-off contracts, such as with a PyCon venue or vendor. Write the terms below; the parties, any standing terms you cite, and the signature block are added for you. Nothing is sent until you make it ready to sign.
Changes apply to {{ agreement.counterparty_name }}'s document only, as revision {{ agreement.revision|add:1 }}. To change the terms for every future document, edit the terms instead.
+
+
Anyone signing from now on sees the new text. If the counterparty already downloaded revision {{ agreement.revision }} to sign elsewhere, send them the new PDF: a signed copy must be the current revision.
+
+
+ {% if conflict %}
+
+
Your changes have not been saved
+
Someone else changed this document. Review current revision {{ agreement.revision }} and the proposed replacement below. Your submitted text and note are preserved in the form.
+
Merge any changes you want to keep into the document text before saving again. Saving replaces the entire current document; it does not merge changes automatically.
+
+
Current revision {{ agreement.revision }}
+
{{ conflict.current_markdown }}
+
Changes your proposed replacement would make
+
Removed lines are marked with − and added lines with +.
+
{% for tag, line in conflict.diff %}{{ line }}
+{% endfor %}
+ {% endif %}
+
+
+
+ {% if preview %}
+
Preview
+
{{ preview|safe }}
+ {% endif %}
+
+
Revisions
+
+ {% for item in history %}
+
+ Revision {{ item.revision.revision }}, {{ item.revision.created_at|date:"M j, Y, g:i A" }} by {{ item.revision.created_by.email }}: {{ item.revision.note }}
+ {% if item.diff %}
+
+ Changes from revision {{ item.revision.revision|add:-1 }}
+
{% for tag, line in item.diff %}{{ line }}
+{% endfor %}
+
+ {% endif %}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/apps/agreements/templates/agreements/email/executed.txt b/apps/agreements/templates/agreements/email/executed.txt
new file mode 100644
index 000000000..33075b7f9
--- /dev/null
+++ b/apps/agreements/templates/agreements/email/executed.txt
@@ -0,0 +1,8 @@
+{% autoescape off %}Hello {{ agreement.signer_name }},
+
+The Python Software Foundation has countersigned {{ agreement.title }} (reference {{ agreement.reference }}) with {{ agreement.counterparty_name }}. It took effect on {{ agreement.countersigned_at|date:"F j, Y" }}.
+
+The signed copy is attached.{% if terms_versions %} The cited terms versions are also attached, so you can retain them even if their online pages are private.{% endif %} Keep these documents for your records.
+
+Python Software Foundation
+{% endautoescape %}
diff --git a/apps/agreements/templates/agreements/email/executed_subject.txt b/apps/agreements/templates/agreements/email/executed_subject.txt
new file mode 100644
index 000000000..ad22d5751
--- /dev/null
+++ b/apps/agreements/templates/agreements/email/executed_subject.txt
@@ -0,0 +1 @@
+Signed by both parties: {{ agreement.title }}
diff --git a/apps/agreements/templates/agreements/email/signing_link.txt b/apps/agreements/templates/agreements/email/signing_link.txt
new file mode 100644
index 000000000..a2ebc0c3b
--- /dev/null
+++ b/apps/agreements/templates/agreements/email/signing_link.txt
@@ -0,0 +1,17 @@
+{% autoescape off %}Hello {{ link.name }},
+
+The Python Software Foundation has prepared the following for {{ agreement.counterparty_name }} to sign:
+
+ {{ agreement.title }}
+ Reference {{ agreement.reference }}
+
+Review and sign it here:
+
+ {{ url }}
+
+The link works once and expires on {{ link.expires_at|date:"F j, Y" }}. Your name, title, email, the time, and your IP address are recorded with your signature. If you would rather sign through DocuSign or on paper, reply to this email.
+
+If you weren't expecting this, you can ignore it.
+
+Python Software Foundation
+{% endautoescape %}
diff --git a/apps/agreements/templates/agreements/email/signing_link_subject.txt b/apps/agreements/templates/agreements/email/signing_link_subject.txt
new file mode 100644
index 000000000..f819797ab
--- /dev/null
+++ b/apps/agreements/templates/agreements/email/signing_link_subject.txt
@@ -0,0 +1 @@
+Please sign: {{ agreement.title }}
diff --git a/apps/agreements/templates/agreements/orders/_comparison.html b/apps/agreements/templates/agreements/orders/_comparison.html
new file mode 100644
index 000000000..2806e574b
--- /dev/null
+++ b/apps/agreements/templates/agreements/orders/_comparison.html
@@ -0,0 +1,94 @@
+{% load agreements %}{% with agreement=aform.agreement tier_options=aform.tier_options has_included_extras=aform.has_included_extras %}
+
+ Compare service levels for {{ agreement.short_name }}
+
+
+
{{ agreement.short_name }} service levels
+
+
+ {% for tier in agreement.tiers %}
{% endfor %}
+
+
+
+
Service tier
+ {% for tier in agreement.tiers %}
{{ tier.name }}
{% endfor %}
+
+
+
+
+
Annual fee
+ {% for tier in agreement.tiers %}
{{ tier.annual_fee|money }}
{% endfor %}
+
+
+
Critical (P1) response target
+ {% for tier in agreement.tiers %}
{{ tier.response_targets.0 }}
{% endfor %}
+
+
+
High (P2) response target
+ {% for tier in agreement.tiers %}
{{ tier.response_targets.1 }}
{% endfor %}
+
+
+
Medium (P3) response target
+ {% for tier in agreement.tiers %}
{{ tier.response_targets.2 }}
{% endfor %}
+
+
+
Low (P4) response target
+ {% for tier in agreement.tiers %}
{{ tier.response_targets.3 }}
{% endfor %}
+
+
+
Support requests a year Fair-use threshold
+ {% for tier in agreement.tiers %}
{{ tier.fair_use }}
{% endfor %}
+
+ {% for service in agreement.services %}
+
+
{{ service.name }}
+ {% for option in tier_options %}
+ {% if service in option.services %}
+
Included
+ {% else %}
+
Not included
+ {% endif %}
+ {% endfor %}
+
+ {% endfor %}
+ {% if has_included_extras %}
+
+
Also included
+ {% for option in tier_options %}{% with tier=option.tier %}
+
+ {% if tier.includes or option.included_addons %}
+
+ {% if tier.includes %}
{{ tier.includes }}
{% endif %}
+ {% for addon in option.included_addons %}
{{ addon.name }}
{% endfor %}
+
+ {% else %}
+ Nothing else
+ {% endif %}
+
+ {% endwith %}{% endfor %}
+
+ {% endif %}
+
+
+
+
Choose a service tier
+ {% for tier in agreement.tiers %}
+
+ {% endfor %}
+
+
+
+
+ {% if agreement.services %}
+
+
What the services cover
+
+ {% for service, tiers in agreement.service_rows %}
+
Review the Order Form below. It cites the published terms of each agreement, which are part of the contract.{% if can_edit %} Edit selections{% endif %}
+ {% endif %}
+
+
+
Order Form
+
Download: PDF · DOCX{% if not order.agreement %} (preview){% endif %}
+ {% if metadata_hidden %}
For the complete document, download the PDF or DOCX.
{% endif %}
+ {% if order.status == "draft" or order.status == "offered" %}
+
{{ html|safe }}
+ {% else %}
+
+ Read the Order Form
+
{{ html|safe }}
+
+ {% endif %}
+
+
+ {% if order.agreement %}
+ {% include "agreements/_actions.html" with agreement=order.agreement %}
+ {% else %}
+ {% if draft_sign_form %}
+
+
Sign on python.org
+
+
+ {% endif %}
+
+ {% if can_offer %}
+
+
{% if draft_sign_form %}Or sign some other way{% else %}Prepare for signature{% endif %}
+
Making the Order Form ready to sign fixes its text. Then {% if is_administrator %}the PSF can edit it for this customer, email a one-time signing link to anyone, or record a copy signed through DocuSign or on paper{% else %}you can download it to sign through DocuSign, your procurement system, or on paper, and upload the signed copy{% endif %}. It can be withdrawn and changed until it is signed.
Draft terms. Some terms are under review and may change before they are offered.
+ {% endif %}
+
+
+
+
{% if program.is_public %}Build your support package{% else %}Prepare a package for a customer{% endif %}
+
Choose a tier for {% if agreements|length > 1 %}one service or several{% else %}{{ agreements.0.short_name|default:"the service" }}{% endif %}, add any extras, and see your fees as you go. Everything goes on one Order Form, signed once.
Includes {% for service, tiers in agreement.service_rows %}{{ service.name }}{% if tiers %} ({{ tiers }} only){% endif %}{% if not forloop.last %}; {% endif %}{% endfor %}.
+ {% endif %}
+ {% if agreement.addons %}
+
Extras, priced separately: {% for addon in agreement.addons %}{{ addon.name }}{% if not forloop.last %}; {% endif %}{% endfor %}.
+ {% include "agreements/_agreement_table.html" with agreements=awaiting_signature label="Awaiting signature" empty="Nothing is out for signature." %}
+
+ {% if drafts %}
+
It has expired, has already been used, or the document was withdrawn. Ask your contact at the Python Software Foundation for a new link.
+ {% elif signed %}
+
Signed. Thank you.
+
{{ agreement.signer_name }} signed {{ agreement.title }} for {{ agreement.counterparty_name }} (reference {{ agreement.reference }}). The PSF will countersign and email the fully signed copy to {{ agreement.signer_email }}.
+ {% else %}
+
{{ agreement.title }}
+
For {{ agreement.counterparty_name }} · Reference {{ agreement.reference }} · Revision {{ agreement.revision }} · Download: PDF · DOCX
+ {% if metadata_hidden %}
For the complete document, download the PDF or DOCX.
{% endif %}
+
{{ html|safe }}
+ {% if agreement.terms_versions.exists %}
+
Terms included in this agreement
+
{% for version in agreement.terms_versions.all %}
+
Save a draft as often as you like; it stays private. Publishing makes it a new version at a permanent address, and every document offered from then on cites it. Documents already offered or signed keep citing their version.
+
+
+
+ {% if preview %}
+
Preview
+
{{ preview|safe }}
+ {% endif %}
+
+ {% if diff %}
+
Changes from version {{ current.version }}
+
{% for tag, line in diff %}{{ line }}
+{% endfor %}
+ {% endif %}
+
+
Published versions
+
+ {% for version in versions %}
+
Version {{ version.version }}, {{ version.published_at|date:"M j, Y" }}{% if version.published_by %} by {{ version.published_by.email }}{% endif %}{% if version.notes %}: {{ version.notes }}{% endif %}
Standing terms that documents cite. Publishing a new version changes them for every document offered afterwards; documents already offered or signed keep the version they cite. To change one counterparty's document only, edit that agreement instead.
+
+
+
Terms
Current version
Unpublished draft
+
+ {% for terms in all_terms %}
+
+
{{ terms.title }}{% if terms.under_review %}Draft for attorney review{% endif %}
+
{% with current=terms.current_version %}{% if current %}{{ current.version }}, {{ current.published_at|date:"M j, Y" }}{% else %}None{% endif %}{% endwith %}