- analysis.py: deterministic claim scorer + /analyze /approve /letter /advance-tier endpoints (auto-runs on intake) - packet.py + packet_fields.json: welcome-packet templating engine (6 onboarding docs, field catalog) - letterstream.py + letters.py: certified-mail send pipeline + letter lifecycle (webhook verified) - docuseal.py: DocuSeal signing integration - staff.py/models.py/schema.sql/auth.py: approval actor from staff key, tier gate (APPROVED+ACTIVE+onboarding docs), onboarding_docs table - frontend/: dependency-free static portal (intake, magic-link login/verify, dashboard) - landing-mockups/: 4 design-stance mockups + favicons - legal/: aup/privacy/sms-terms/terms HTML - docs/: letter-queue scope, letterstream API contract, 6 welcome-packet templates - review-dre-landing-2026-08-21.md: 3-variant landing feedback sprint - compliance/DRE_Compliance_Manual.md: updated Source synced from deployed /opt/dre-portal/app/ (was 4 days ahead of git).
141 lines
5.3 KiB
Python
141 lines
5.3 KiB
Python
"""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/<slug> 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__}")
|