Generating CAM Reconciliation Statements as PDF
Turning a finalized set of allocations into a tenant-ready PDF that prints identically on every run, and that carries a cryptographic fingerprint an auditor can re-verify, is a narrower engineering problem than it looks — and it is the rendering half of tenant statement generation and dispute routing. The parent stage owns the statement data model and the dispute machine; this page is the focused walkthrough of the document itself: how to fold frozen allocations into printable lines, lay them out through a deterministic template, render that template to PDF with a real toolchain, stamp an audit hash onto the page, and prove the printed totals tie back to the pools before the file ever reaches a tenant. A CAM reconciliation PDF is not a report you format for readability and forget; it is the physical artifact a tenant’s auditor holds, annotates, and disputes, so byte-level reproducibility and a verifiable fingerprint are features, not polish.
Context & When to Use This Approach
Reach for this rendering pipeline once the calculation core has finished and every allocation has been frozen. The trigger is a specific handoff: the pro-rata allocation algorithm has produced a per-tenant, per-pool amount that already reconciles to each pool total, the applicable rule versions are pinned, and the immutable audit trail for the run exists. Nothing about the rendering step is allowed to compute a share, apply a cap, or re-derive a denominator — it consumes a settled result and formats it. That constraint is what separates a defensible document generator from a spreadsheet export: any arithmetic performed at print time is arithmetic no auditor watched happen, and a tenant will dispute it on principle.
The requirements that make PDF the right target, rather than an HTML page or a raw CSV, are concrete on a commercial reconciliation:
- A fixed, portable artifact. A tenant’s auditor works from a document that will not reflow or recalculate when reopened months later. PDF pins the layout, the pagination, and the numbers into one immutable file.
- Reproducibility to the byte. Regenerating the same period from the same frozen inputs must produce a document whose content bytes are identical, so a reissued copy can be proven to match the original rather than merely resemble it.
- A verifiable fingerprint. The document must carry a hash computed over its own canonical content, so anyone holding the file can recompute the fingerprint and confirm nothing was altered after issuance — the tamper-evidence a lease audit right implicitly assumes.
- Traceable lines. Each charge line must print the pool it draws from, the basis used for the tenant’s share, and the rule version in force, so a challenge lands on a named ground rather than a vague total.
If your output is an internal working file that will be recomputed anyway, this machinery is overhead — render straight to HTML and skip the fingerprint. Reach for the deterministic-template-plus-hash pattern below only when the document is the thing a tenant relies on and a court might read. The line-item schema this recipe formats is defined once, upstream, in the parent stage; here it is treated as a frozen input, not redesigned.
Step-by-Step Implementation
The pipeline below turns a list of finalized allocations into a fingerprinted PDF whose printed totals provably tie back to the pools. Every monetary value stays in decimal.Decimal — never float — because a document that disagrees with the run by a rounding artifact is a document a tenant will dispute, and the audit hash would differ between two machines that rounded a binary float differently. The deterministic arithmetic this depends on is documented in the Python decimal documentation.
Step 1 — Fold finalized allocations into printable statement lines. A printable line is a thin, presentation-oriented record: a stable label, the pool it came from, a human-readable basis note, the rule version, and the quantized money. It is deliberately smaller than the calculation-side line the engine emits — its only job is to render — and it is sorted into a stable order so the document is deterministic regardless of the order allocations arrive in.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
CENTS = Decimal("0.01")
@dataclass(frozen=True)
class StatementLine:
"""One printable charge line, folded from a finalized allocation.
Presentation only: it never recomputes a share. The basis note is the
human-readable denominator description the tenant's auditor reads, and
rule_version pins the pool to the rule in force at close.
"""
pool_id: str
label: str
basis_note: str # e.g. "3.21% of 412,900 RSF, cap v7"
rule_version: int
amount: Decimal # quantized recoverable, cents
def display_amount(self) -> str:
"""Fixed two-decimal string for the printed line."""
return f"{self.amount.quantize(CENTS, ROUND_HALF_UP):,.2f}"
def fold_lines(allocations: list[dict[str, object]]) -> list[StatementLine]:
"""Fold frozen allocation records into sorted, printable lines."""
lines = [
StatementLine(
pool_id=str(a["pool_id"]),
label=str(a["label"]),
basis_note=str(a["basis_note"]),
rule_version=int(a["rule_version"]), # type: ignore[call-overload]
amount=Decimal(str(a["amount"])).quantize(CENTS, ROUND_HALF_UP),
)
for a in allocations
]
# Stable order by pool id makes the rendered document reproducible.
return sorted(lines, key=lambda ln: ln.pool_id)
Step 2 — Lay the lines out through a deterministic template. The template is a pure function from statement data to a canonical HTML string. Determinism is the whole point: no timestamps in the body, no dictionary iteration order, no locale-dependent formatting, so the same inputs always yield the same markup. That canonical string is both what gets rendered and what gets hashed, which is why it must never contain anything that varies between runs.
from __future__ import annotations
from decimal import Decimal
from html import escape
def render_html(
tenant_id: str,
period: str,
lines: list[StatementLine],
estimated_billed: Decimal,
) -> str:
"""Build the canonical statement HTML — a deterministic pure function."""
obligation = sum((ln.amount for ln in lines), Decimal("0"))
balance = (obligation - estimated_billed).quantize(CENTS)
rows = "".join(
"<tr>"
f"<td>{escape(ln.pool_id)}</td>"
f"<td>{escape(ln.label)}</td>"
f"<td>{escape(ln.basis_note)} (rule v{ln.rule_version})</td>"
f'<td class="num">{escape(ln.display_amount())}</td>'
"</tr>"
for ln in lines
)
# No timestamps or run ids in the body: they would break byte-reproducibility.
return (
"<section class='statement'>"
f"<h1>CAM Reconciliation — Tenant {escape(tenant_id)}, {escape(period)}</h1>"
"<table><thead><tr><th>Pool</th><th>Charge</th>"
"<th>Basis</th><th>Amount</th></tr></thead>"
f"<tbody>{rows}</tbody></table>"
f"<p class='total'>Reconciled obligation: {obligation.quantize(CENTS):,.2f}</p>"
f"<p class='total'>Estimated billed: {estimated_billed.quantize(CENTS):,.2f}</p>"
f"<p class='balance'>Balance due: {balance:,.2f}</p>"
"</section>"
)
Step 3 — Compute and embed an audit fingerprint. Hash the canonical content — the tenant, the period, and every line’s fields in sorted order — with hashlib, not the rendered PDF bytes. Hashing the content rather than the file insulates the fingerprint from PDF-writer noise (embedded fonts, object ordering, producer strings) so the hash proves the numbers are intact even if two toolchains emit slightly different binary files. The fingerprint is stamped into the document footer and stored alongside the run.
from __future__ import annotations
import hashlib
def audit_fingerprint(
tenant_id: str,
period: str,
lines: list[StatementLine],
) -> str:
"""SHA-256 over canonical content, independent of PDF binary noise."""
hasher = hashlib.sha256()
hasher.update(f"{tenant_id}|{period}".encode("utf-8"))
for ln in sorted(lines, key=lambda x: x.pool_id):
# Fixed field order and the Decimal's string form keep this stable.
payload = f"|{ln.pool_id}|{ln.rule_version}|{ln.amount}"
hasher.update(payload.encode("utf-8"))
return hasher.hexdigest()
def stamp_fingerprint(html: str, fingerprint: str) -> str:
"""Embed the short fingerprint in the document footer for re-verification."""
footer = f"<footer class='audit'>Audit fingerprint: {fingerprint[:16]}</footer>"
return html.replace("</section>", f"{footer}</section>")
Expressed as a formula, the stamped value is , where the canonical bytes are the pipe-delimited content above — never the PDF file. Anyone re-running the fold and the hash over the same frozen allocations reproduces the digest exactly.
Step 4 — Render the fingerprinted template to PDF. With the canonical HTML built and stamped, the render is a thin adapter over a real engine. weasyprint consumes the HTML and its CSS directly and is the natural fit when the template is HTML; reportlab is the alternative when you prefer to draw the document with an explicit layout API rather than from markup. Isolate the choice behind one function so the rest of the pipeline never depends on which engine is installed, and pin the engine version so the binary output stays stable across a reconciliation season.
from __future__ import annotations
from pathlib import Path
PRINT_CSS = """
.statement { font-family: sans-serif; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #999; padding: 4px 8px; text-align: left; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.balance { font-weight: 700; }
.audit { color: #555; font-size: 10px; margin-top: 12px; }
"""
def render_pdf(html: str, out_path: Path) -> Path:
"""Render canonical statement HTML to a PDF file via WeasyPrint.
The engine is isolated here; swapping to reportlab's platypus layout
would change only this function, not the fold, template, or hash.
"""
from weasyprint import HTML, CSS # imported lazily to keep the fold pure
document = HTML(string=html).render(stylesheets=[CSS(string=PRINT_CSS)])
document.write_pdf(target=str(out_path))
return out_path
Step 5 — Verify tie-out before the file leaves the building. Rendering must never be the last step; the document has to prove its printed totals reconstruct the pools and the tenant obligation. Verification recomputes the total from the folded lines and asserts it equals the sum the caller passes in from the frozen run, and it re-derives the fingerprint to confirm the stamped value matches. A statement that fails either check is not written to the outbox.
from __future__ import annotations
from decimal import Decimal
class StatementTieOutError(Exception):
"""Raised when a rendered statement does not reconcile to its run."""
def verify_tie_out(
lines: list[StatementLine],
expected_obligation: Decimal,
fingerprint: str,
tenant_id: str,
period: str,
) -> None:
"""Assert printed totals and fingerprint match the frozen allocations."""
printed = sum((ln.amount for ln in lines), Decimal("0")).quantize(CENTS)
if printed != expected_obligation.quantize(CENTS):
raise StatementTieOutError(
f"{tenant_id}: printed {printed} != run {expected_obligation}"
)
if audit_fingerprint(tenant_id, period, lines) != fingerprint:
raise StatementTieOutError(f"{tenant_id}: fingerprint mismatch — content drifted")
Expressed as an invariant, the document is valid only when equals the reconciled obligation the engine froze, to the cent, and the recomputed digest equals the stamped one.
Gotchas & Known Limitations
- Never recompute at print time. The single most damaging mistake is letting the template or a “helpful” formatter re-derive a share or re-apply a cap. The renderer folds frozen numbers; if a printed figure disagrees with the run, the bug is upstream and the document must not paper over it.
- Hash the content, not the PDF bytes. PDF writers embed timestamps, producer strings, and object orderings that vary between versions and machines. Fingerprinting the raw file makes the hash unreproducible; fingerprint the canonical content so the digest proves the numbers, not the byte layout.
- Kill every source of non-determinism in the template. A
datetime.now()in the body, unsorted dictionary iteration, or locale-dependent number formatting each silently breaks byte-reproducibility. Sort lines by a stable key, format money withDecimaland a fixed pattern, and keep run metadata out of the hashed body. - Float anywhere is a silent divergence. A single
floatin an amount reintroduces the binary driftDecimalexists to remove, and two machines can then produce different fingerprints for the same statement. Keep money asDecimalfrom the allocation handoff through the printed string. - Pin the rendering engine version. WeasyPrint and reportlab evolve their output; an unpinned upgrade mid-season can change pagination or embedded fonts. Pin the version so a reissued document renders the same, and rely on the content hash rather than a file hash for the audit guarantee.
- Do not confuse a reissue with an edit. Regenerating a statement from the same frozen inputs is reproduction and must yield the same fingerprint. A genuine correction changes the inputs and therefore the fingerprint — which is correct — and belongs in the forward-dated adjustment flow, never an in-place rewrite of a closed period.
Verification
Because the PDF becomes the tenant’s evidence, correctness is proven arithmetically and cryptographically, not by opening the file and eyeballing it. Two properties are load-bearing: the printed lines must reconstruct the reconciled obligation to the cent, and the fold-plus-hash must be reproducible so a reissued document is provably identical to the original.
from decimal import Decimal
from pathlib import Path
def test_statement_ties_out_and_reproduces(tmp_path: Path) -> None:
allocations = [
{"pool_id": "U-02", "label": "Utilities", "rule_version": 3,
"basis_note": "3.21% of 412,900 RSF", "amount": "9510.55"},
{"pool_id": "L-04", "label": "Landscaping", "rule_version": 7,
"basis_note": "3.21% of 412,900 RSF, cap v7", "amount": "18240.00"},
]
lines = fold_lines(allocations)
obligation = sum((ln.amount for ln in lines), Decimal("0"))
assert obligation == Decimal("27750.55") # ties to the frozen run
fp = audit_fingerprint("T-1007", "2025", lines)
html = stamp_fingerprint(
render_html("T-1007", "2025", lines, Decimal("26000.00")), fp
)
# Tie-out and fingerprint both hold before the file is written.
verify_tie_out(lines, obligation, fp, "T-1007", "2025")
# Reproducibility: folding and hashing again yields the identical digest.
assert audit_fingerprint("T-1007", "2025", fold_lines(allocations)) == fp
# The stamped short fingerprint is present in the rendered document body.
assert fp[:16] in html
The tie-out assertion catches any line that drifted from the run — a mis-folded amount or a float that rounded differently — and the reproducibility assertion catches any non-determinism that crept into the fold or the hash. A statement that clears both is safe to render and release; one that fails routes back to the run with the tenant id and pool ids attached rather than reaching a tenant. Once the document ties out and its fingerprint is recorded, the file joins the audit trail that a challenge to it will later reference — and the structured pushback that follows a released statement is handled in building a CAM dispute resolution workflow, while the frozen numbers it prints come from tenant statement generation and dispute routing.
Related
- Tenant Statement Generation and Dispute Routing — the parent stage that owns the statement data model and the dispute machine this renderer prints from.
- Building a CAM Dispute Resolution Workflow — the case state machine and triage rules that handle a tenant’s response to the PDF you generate here.
- Implementing Pro-Rata Allocation Algorithms — the finalized allocations this pipeline folds into printable lines but never recomputes.