"""DocuSeal e-signature integration. Self-hosted DocuSeal: sign.debtrecoveryexperts.com (loopback 127.0.0.1:8094). Auth: X-Auth-Token header. We only CREATE submissions against the 6 pre-authored templates — template creation is not supported on self-hosted via the API, so the templates are authored once in the DocuSeal UI and referenced by name here. Pre-fill: the portal's merged field values (packet.merged_values) map onto the DocuSeal text-field tokens via packet.DOC_PLACEHOLDERS. Signature/date/print-name fields are intentionally left blank — they are completed by the human at signing. """ from __future__ import annotations import json import os import urllib.error import urllib.request from . import packet BASE_URL = os.environ.get("DOCUSEAL_BASE_URL", "http://127.0.0.1:8094").rstrip("/") API_TOKEN = os.environ.get("DOCUSEAL_API_TOKEN", "") SIGN_HOST = os.environ.get("DOCUSEAL_SIGN_HOST", "https://sign.debtrecoveryexperts.com").rstrip("/") # doc_key -> (DocuSeal template name, markdown source filename in docs/welcome-packet/). # Keys mirror packet.ONBOARDING_DOCS; names mirror packet.DOC_TITLES exactly. DOC_MAP = { "LPOA": ("LPOA - Limited Power of Attorney", "01-LPOA.md"), "TOS": ("Terms of Service", "02-Terms-of-Service.md"), "FEE_SCHEDULE": ("Fee Schedule (Schedule A)", "03-Fee-Schedule.md"), "THIRD_PARTY_CONSENT": ("Third-Party Sharing Consent", "04-Third-Party-Consent.md"), "DEBTOR_INFO": ("Debtor Information Sheet", "05-Debtor-Info-Sheet.md"), "ACH": ("ACH / Disbursement Authorization", "06-ACH-Authorization.md"), } class DocuSealError(Exception): """Raised when the DocuSeal API call fails or a template is missing.""" def _request(method: str, path: str, payload: dict | None = None): url = f"{BASE_URL}{path}" data = json.dumps(payload).encode() if payload is not None else None req = urllib.request.Request( url, data=data, headers={ "X-Auth-Token": API_TOKEN, "Content-Type": "application/json", "Accept": "application/json", }, method=method, ) try: with urllib.request.urlopen(req, timeout=60) as resp: body = resp.read().decode() return json.loads(body) if body else None except urllib.error.HTTPError as e: body = e.read().decode(errors="replace") raise DocuSealError(f"DocuSeal HTTP {e.code}: {body[:800]}") from e except urllib.error.URLError as e: raise DocuSealError(f"DocuSeal unreachable: {e.reason}") from e _template_cache: dict[str, int] = {} def template_id(doc_key: str) -> int: """Resolve a doc_key to its DocuSeal template id (cached per process).""" if not API_TOKEN: raise DocuSealError("DOCUSEAL_API_TOKEN is not configured") if doc_key in _template_cache: return _template_cache[doc_key] tmpl_name = DOC_MAP[doc_key][0] resp = _request("GET", "/api/templates") data = resp.get("data", []) if isinstance(resp, dict) else resp for t in data: if t.get("name") == tmpl_name: _template_cache[doc_key] = t["id"] return t["id"] raise DocuSealError(f"No DocuSeal template named: {tmpl_name}") def build_prefill(doc_key: str, values: dict) -> tuple[dict, list[dict]]: """Map portal merged field values to DocuSeal pre-fill tokens for one doc. Returns (values, fields) for the submission payload. Only non-empty values are pre-filled; signature/date/print-name blocks stay blank for the signer. """ _name, filename = DOC_MAP[doc_key] out: dict[str, str] = {} for token, profile_key in packet.DOC_PLACEHOLDERS.get(filename, []): val = values.get(profile_key, "") if val: out[token] = str(val) fields = [{"name": k, "default_value": v} for k, v in out.items()] return out, fields def signing_url(slug: str | None, embed_src: str | None = None) -> str | None: """Public signing URL for a submitter. Prefers the canonical /s/ path.""" if slug: return f"{SIGN_HOST}/s/{slug}" return embed_src def create_submission(doc_key: str, email: str, name: str, values: dict, send_email: bool = True, message: dict | None = None) -> dict: """Create one signature request for the client. Returns the submitter object.""" tid = template_id(doc_key) vals, fields = build_prefill(doc_key, values) tmpl_name = DOC_MAP[doc_key][0] payload = { "template_id": tid, "send_email": send_email, "message": message or { "subject": f"Please review and sign: {tmpl_name}", "body": ( "Hi {{submitter.name}},\n\n" "Please open the link below to review the prefilled details and " "complete your signature.\n\n{{submitter.link}}\n\n" "Thank you,\nDebt Recovery Experts, LLC" ), }, "submitters": [ { "role": "Client", "email": email, "name": name, "values": vals, "fields": fields, }, ], } resp = _request("POST", "/api/submissions", payload) if isinstance(resp, list) and resp: return resp[0] if isinstance(resp, dict): return resp raise DocuSealError(f"Unexpected DocuSeal response type: {type(resp).__name__}")