new-site/scripts/document_gen/templates/sc_coc_pdf_filler.py
justin ad590aab7c feat(sc-coc): SCDMV Certificate of Compliance PDF filler + correct $25 state fee
SC for-hire PROPERTY carriers (not passenger/HHG/hazwaste) register intrastate
via the SCDMV Certificate of Compliance (COC), not a PSC certificate. This adds:
  - sc_coc_pdf_filler.fill_sc_coc(): fills the official SCDMV Form COC from
    intake (business name, officers, physical/mailing address, phone), picks
    New vs Renewal, and stamps the coverage class (E-L low-value / E-LC).
    Field names in the source PDF are auto-generated + offset from their labels;
    mapped here by verified on-page geometry. Verified by render.
  - suggest_coverage_class(): E-L for low-value cargo (scrap/dump/aggregate),
    else E-LC (safer default).
  - gov_fee: SC intrastate fee corrected from $0 placeholder to the real $25
    COC new-application fee (renewals $0), billed at cost.

The carrier's INSURER files the Form E (liability) + Form H (cargo, E-LC only)
directly with SCDMV; we collect the COC app + $25 and submit it.
2026-06-16 09:08:50 -05:00

182 lines
7.7 KiB
Python

"""SCDMV Certificate of Compliance (COC) PDF filler.
The Certificate of Compliance is South Carolina's intrastate for-hire motor
carrier registration for PROPERTY carriers (everyone except passenger,
household-goods, and hazardous-waste-for-disposal carriers — those go to the
PSC instead). It is filed on SCDMV Form COC and mailed with a $25 fee to:
SCDMV, P.O. Box 1498, Blythewood, SC 29016-0027
Two coverage classes:
- E-L : low-value commodities (dump-truck-type, scrap metal, etc.) — NO
cargo insurance required (liability Form E only).
- E-LC : property properly insured for any cargo — needs Form E + Form H.
The carrier's INSURANCE COMPANY (not agent) must file a Form E (liability) and,
if E-LC, a Form H (cargo) directly with SCDMV. SCDMV does NOT accept an ACORD
certificate. We collect the COC application + $25; the insurer files the Form E.
This module fills the official SCDMV Form COC from intake data. Field names in
the source PDF are partly auto-generated ('undefined', '1'..'6'); they are
mapped here by their verified on-page positions:
undefined -> Class E-L checkbox (8x8 box, y=581)
undefined_2 -> Class E-LC checkbox (8x8 box, y=520)
'1'..'6' -> officer/partner name+address lines
Mailing Address (label is mis-assigned) -> PHYSICAL address line (y=159)
undefined_3 -> mailing address line (y=137)
Telephone Number / undefined_4 -> phone / fax
Usage:
from scripts.document_gen.templates.sc_coc_pdf_filler import fill_sc_coc
path = fill_sc_coc(intake, order_number="CO-...", coverage_class="E-L")
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
LOG = logging.getLogger("document_gen.sc_coc")
try:
from pypdf import PdfReader, PdfWriter
except ImportError: # pragma: no cover
try:
from PyPDF2 import PdfReader, PdfWriter # type: ignore
except ImportError:
PdfReader = PdfWriter = None # type: ignore
DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs"
COC_FORM = DOCS_DIR / "SC COC Form.pdf"
OUTPUT_DIR = Path(os.getenv("DOC_OUTPUT_DIR", "/tmp/coc-filings"))
# Low-value commodities that SCDMV classifies as E-L (no cargo insurance). Scrap
# metal, dump-truck aggregates, etc. Used to auto-suggest the coverage class.
E_L_COMMODITY_HINTS = (
"scrap", "metal", "dump", "aggregate", "gravel", "sand", "dirt", "rock",
"debris", "waste" "recycl", "junk", "salvage", "demolition", "asphalt",
"concrete", "mulch", "wood chip", "fill",
)
def suggest_coverage_class(intake: dict) -> str:
"""Best-effort E-L vs E-LC suggestion from the carrier's cargo description.
Defaults to E-LC (the safer, fully-insured class) when unknown so we never
under-state the insurance requirement."""
cargo = " ".join(str(intake.get(k, "")) for k in
("cargo_carried", "commodities", "commodity",
"cargo", "operation_description", "legal_name")).lower()
if any(h in cargo for h in E_L_COMMODITY_HINTS):
return "E-L"
return "E-LC"
def _is_renewal(intake: dict) -> bool:
return str(intake.get("coc_renewal", "")).lower() in ("yes", "true", "1") \
or bool(intake.get("existing_coc_number"))
def fill_sc_coc(intake: dict, order_number: str = "",
coverage_class: str | None = None) -> str:
"""Fill the SCDMV Form COC. Returns the path to the written PDF.
coverage_class: 'E-L' or 'E-LC'. If None, auto-suggested from cargo.
"""
if PdfReader is None:
raise ImportError("pypdf not installed")
if not COC_FORM.exists():
raise FileNotFoundError(f"SC COC form not found: {COC_FORM}")
coverage_class = (coverage_class or suggest_coverage_class(intake)).upper()
reader = PdfReader(str(COC_FORM))
writer = PdfWriter()
writer.clone_document_from_reader(reader)
legal_name = intake.get("legal_name") or intake.get("entity_name") or ""
phys = ", ".join(p for p in [
intake.get("address_street", "") or intake.get("phy_street", ""),
intake.get("address_city", "") or intake.get("phy_city", ""),
f"{intake.get('address_state','') or intake.get('phy_state','')} "
f"{intake.get('address_zip','') or intake.get('phy_zip','')}".strip(),
] if p and p.strip())
mailing = ", ".join(p for p in [
intake.get("mailing_street", ""),
intake.get("mailing_city", ""),
f"{intake.get('mailing_state','')} {intake.get('mailing_zip','')}".strip(),
] if p and p.strip()) or phys
# Officers / partners (up to 6 lines). Accept a list or single signer.
officers = intake.get("officers") or []
if not officers:
signer = intake.get("signer_name") or ""
if signer:
officers = [f"{signer} - {phys}"]
field_updates = {
# Business identity (the source PDF splits the business-name label across
# two text lines, suffixed " 1" / " 2"; put the name on line 1).
"Business Name Corporation Partnership Sole Proprietorship With or Without Trade name 1":
legal_name,
# The form's text fields are auto-named after the label BELOW them, so the
# names are offset by one. Mapped here by verified on-page geometry:
# "Mailing Address" field (y159, full width) -> PHYSICAL address line
# "undefined_3" field (y137) -> MAILING address line
# "Fax Number" field (y80, x170) -> TELEPHONE box
# "undefined_4" field (y80, x417) -> FAX box
"Mailing Address": phys,
"undefined_3": mailing,
"Fax Number": intake.get("phone", "") or intake.get("telephone", ""),
"undefined_4": intake.get("fax", ""),
}
# Officer/partner lines 1..6
for i, line in enumerate(officers[:6], start=1):
field_updates[str(i)] = str(line)
# Checkboxes: new vs renewal, and coverage class.
button_updates = {}
if _is_renewal(intake):
button_updates["I am renewing an existing Certificate of Compliance I understand no fee is required for this submission"] = "/On"
else:
button_updates["I am applying for a Certificate of Compliance for the first time I understand that a 25 fee is required for initial"] = "/On"
# Class E-L (undefined) vs E-LC (undefined_2). These are tiny text boxes in
# the source, so we stamp an "X" into the right one.
if coverage_class == "E-L":
field_updates["undefined"] = "X"
else:
field_updates["undefined_2"] = "X"
# Apply text fields (tolerant of name mismatches across PDF revisions).
for page in writer.pages:
try:
writer.update_page_form_field_values(page, field_updates, auto_regenerate=False)
except Exception as exc: # noqa: BLE001
LOG.debug("COC text fill page warning: %s", exc)
# Apply radio/checkbox states.
for page in writer.pages:
try:
writer.update_page_form_field_values(page, button_updates, auto_regenerate=False)
except Exception:
pass
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
safe = (order_number or legal_name or "coc").replace("/", "_").replace(" ", "_")
out = OUTPUT_DIR / f"SC_COC_{safe}.pdf"
with open(out, "wb") as fh:
writer.write(fh)
LOG.info("[%s] Filled SC COC (%s) -> %s", order_number, coverage_class, out)
return str(out)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
demo = {
"legal_name": "ALLENS SCRAP METAL LLC",
"address_street": "3838 DANNY RD", "address_city": "LORIS",
"address_state": "SC", "address_zip": "29569",
"phone": "8435551234", "signer_name": "Mitchell Allen",
"cargo_carried": "scrap metal",
}
p = fill_sc_coc(demo, order_number="CO-DEMO")
print("wrote", p, "class:", suggest_coverage_class(demo))