diff --git a/.gitignore b/.gitignore
index 088084a..18fc305 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,4 +41,8 @@ credentials.json
# Project-specific
*.db-journal
-*.db-wal
\ No newline at end of file
+*.db-wal
+# Credential dumps + backup artifacts — never commit
+*.bak-*
+.fanout-mailboxes.txt
+mailboxes.txt
diff --git a/backend/analysis.py b/backend/analysis.py
new file mode 100644
index 0000000..7c32e7d
--- /dev/null
+++ b/backend/analysis.py
@@ -0,0 +1,320 @@
+"""Deterministic claim analysis: a 0-100 score, a plain-English narrative,
+a recommended starting tier, and a recommended collection letter assembled
+from the tier templates in /root/.hermes/references/dre-letter-templates.md.
+
+Transparent rules engine (no external LLM) so every score is explainable and
+auditable for FDCPA/TDCPA compliance. To move to an LLM later, swap the
+generator behind these functions and keep the return shape identical.
+"""
+from __future__ import annotations
+
+import json
+import uuid
+from datetime import datetime, timezone
+
+TIER_ORDER = ["TIER_1", "TIER_2", "TIER_2_5", "TIER_3", "TIER_4"]
+
+TIER_LABELS = {
+ "TIER_1": "Tier 1 - Soft Touch",
+ "TIER_2": "Tier 2 - Formal Demand",
+ "TIER_2_5": "Tier 2.5 - Lien Threat",
+ "TIER_3": "Tier 3 - Escalation",
+ "TIER_4": "Tier 4 - Legal Action",
+}
+
+TIER_DESCRIPTIONS = {
+ "TIER_1": "Friendly reminder email + ACH payment link (Day 1-5)",
+ "TIER_2": "Formal demand letter via certified mail (Day 7-14)",
+ "TIER_2_5": "Pre-lien notice for construction claims (Day 15-21)",
+ "TIER_3": "Final notice before legal action (Day 21-30)",
+ "TIER_4": "Referral to partner law firm (Day 30+)",
+}
+
+
+def next_tier(current: str) -> str | None:
+ """Return the tier after `current` in the escalation order, or None at Tier 4."""
+ if current not in TIER_ORDER:
+ return None
+ i = TIER_ORDER.index(current)
+ return TIER_ORDER[i + 1] if i + 1 < len(TIER_ORDER) else None
+
+
+def _age_days(invoice_date: str | None) -> int | None:
+ if not invoice_date:
+ return None
+ try:
+ d = datetime.strptime(invoice_date[:10], "%Y-%m-%d")
+ d = d.replace(tzinfo=timezone.utc)
+ return (datetime.now(timezone.utc) - d).days
+ except (ValueError, TypeError):
+ return None
+
+
+def score_claim(*, amount_cents: int, business_type: str, doc_count: int,
+ description: str | None, invoice_date: str | None,
+ client_reference: str | None) -> dict:
+ """Compute a 0-100 analysis score with a component breakdown and narrative.
+
+ Returns:
+ {"score": int, "components": {name: {points, max, note}}, "summary": str,
+ "recommended_tier": str}
+ """
+ components: dict[str, dict] = {}
+
+ # 1. Amount (0-25) — higher balance = higher recovery priority.
+ if amount_cents >= 1_000_000:
+ components["amount"] = {"points": 25, "max": 25, "note": "High-value claim ($10k+)"}
+ elif amount_cents >= 500_000:
+ components["amount"] = {"points": 20, "max": 25, "note": "Significant balance ($5k-$10k)"}
+ elif amount_cents >= 250_000:
+ components["amount"] = {"points": 15, "max": 25, "note": "Moderate balance ($2.5k-$5k)"}
+ elif amount_cents >= 100_000:
+ components["amount"] = {"points": 10, "max": 25, "note": "Low balance ($1k-$2.5k)"}
+ elif amount_cents >= 50_000:
+ components["amount"] = {"points": 6, "max": 25, "note": "Small balance ($500-$1k)"}
+ else:
+ components["amount"] = {"points": 3, "max": 25, "note": "Minimal balance (<$500)"}
+
+ # 2. Documentation (0-25) — evidence quality drives collectability.
+ if doc_count >= 4:
+ components["documentation"] = {"points": 25, "max": 25, "note": f"{doc_count} documents on file"}
+ elif doc_count >= 2:
+ components["documentation"] = {"points": 18, "max": 25, "note": f"{doc_count} documents on file"}
+ elif doc_count == 1:
+ components["documentation"] = {"points": 12, "max": 25, "note": "1 document on file"}
+ else:
+ components["documentation"] = {"points": 5, "max": 25, "note": "No evidence uploaded yet"}
+
+ # 3. Collectability by debtor entity type (0-20).
+ collect = {
+ "LLC": (20, "Registered LLC - assets traceable"),
+ "CORPORATION": (20, "Registered corporation - assets traceable"),
+ "PARTNERSHIP": (16, "Partnership"),
+ "SOLE_PROPRIETORSHIP": (12, "Sole proprietorship"),
+ "INDIVIDUAL": (8, "Individual debtor"),
+ "OTHER": (10, "Other entity type"),
+ }
+ pts, note = collect.get(business_type, (10, "Other entity type"))
+ components["collectability"] = {"points": pts, "max": 20, "note": note}
+
+ # 4. Claim completeness (0-20) — how fully the intake form was filled.
+ comp = 0
+ if description:
+ comp += 8
+ if invoice_date:
+ comp += 6
+ if client_reference:
+ comp += 6
+ components["completeness"] = {
+ "points": comp, "max": 20,
+ "note": "Description, invoice date, and client reference provided",
+ }
+
+ # 5. Debt age (0-10) — fresher debt is more collectable.
+ age = _age_days(invoice_date)
+ if age is None:
+ age_pts, age_note = 2, "Invoice date not provided"
+ elif age <= 90:
+ age_pts, age_note = 10, f"{age} days old (fresh)"
+ elif age <= 180:
+ age_pts, age_note = 8, f"{age} days old"
+ elif age <= 365:
+ age_pts, age_note = 6, f"{age} days old"
+ elif age <= 730:
+ age_pts, age_note = 3, f"{age} days old (stale)"
+ else:
+ age_pts, age_note = 2, f"{age} days old (very stale)"
+ components["age"] = {"points": age_pts, "max": 10, "note": age_note}
+
+ score = sum(c["points"] for c in components.values())
+
+ # Recommended starting tier from score band.
+ if score >= 75:
+ rec = "TIER_1"
+ rec_note = "Strong case; soft touch should resolve"
+ elif score >= 60:
+ rec = "TIER_2"
+ rec_note = "Solid case; begin with formal demand"
+ elif score >= 40:
+ rec = "TIER_3"
+ rec_note = "Moderate case; escalate if initial contact fails"
+ else:
+ rec = "TIER_4"
+ rec_note = "Weak or high-effort case; review carefully before accepting"
+
+ summary = (
+ f"Analysis score {score}/100. {rec_note}. "
+ f"Key factors: {components['amount']['note']}; "
+ f"{components['documentation']['note']}; "
+ f"{components['collectability']['note']}; "
+ f"{components['age']['note']}."
+ )
+
+ return {
+ "score": score,
+ "components": components,
+ "summary": summary,
+ "recommended_tier": rec,
+ }
+
+
+def analyze_and_store(conn, claim_id: str, actor: str = "staff") -> dict:
+ """Run score_claim for a claim and persist score/approval + audit row.
+
+ Single source of truth for analysis persistence, shared by the staff
+ /analyze endpoint (actor="staff") and post-intake auto-analysis
+ (actor="system"). Returns the score_claim() result dict.
+
+ Assumes the caller owns the transaction and will commit.
+ """
+ row = conn.execute(
+ "SELECT c.id, c.amount_cents, c.description, c.invoice_date, c.client_reference, "
+ "d.business_type FROM claims c JOIN debtors d ON d.id = c.debtor_id "
+ "WHERE c.id = ?",
+ (claim_id,),
+ ).fetchone()
+ if row is None:
+ raise ValueError(f"claim not found: {claim_id}")
+ doc_count = conn.execute(
+ "SELECT COUNT(*) AS n FROM documents WHERE claim_id = ?", (claim_id,)
+ ).fetchone()["n"]
+ result = score_claim(
+ amount_cents=row["amount_cents"],
+ business_type=row["business_type"],
+ doc_count=doc_count,
+ description=row["description"],
+ invoice_date=row["invoice_date"],
+ client_reference=row["client_reference"],
+ )
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ conn.execute(
+ "UPDATE claims SET analysis_score = ?, analysis_summary = ?, analysis_components = ?, "
+ "analysis_at = ?, recommended_tier = ?, approval_status = 'PENDING', updated_at = ? WHERE id = ?",
+ (result["score"], result["summary"], json.dumps(result["components"]),
+ now, result["recommended_tier"], now, claim_id),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) "
+ "VALUES (?, 'claim', ?, 'analysis', 'analysis_score', NULL, ?, ?, NULL, ?)",
+ (str(uuid.uuid4()), claim_id, str(result["score"]), actor, now),
+ )
+ return result
+
+
+# ---------------------------------------------------------------
+# Letter templates (Tier 1-4 + 2.5). Placeholders are filled from claim data.
+# FDCPA disclosure + payment link required on every queued letter.
+# ---------------------------------------------------------------
+_FDCPA = "This communication is from a debt collector attempting to collect a debt. Any information obtained will be used for that purpose."
+_PAY = "Payment can be made at: https://pay.debtrecoveryexperts.com"
+
+
+def _letter_template(tier: str) -> tuple[str, str]:
+ """Return (subject, body) for the given tier. Body uses {placeholders}."""
+ if tier == "TIER_1":
+ subject = "Outstanding Balance - {debtor_name}"
+ body = (
+ "This is a friendly reminder regarding an outstanding balance with one of our clients.\n\n"
+ "Client: {client_name}\n"
+ "Amount Due: {amount_display}\n"
+ "Invoice Reference: {invoice_ref}\n\n"
+ "We understand things get overlooked. Please remit payment or contact us to discuss a "
+ "resolution within 5 business days.\n\n"
+ + _PAY + "\n\n" + _FDCPA + "\n\n"
+ "- Debt Recovery Experts"
+ )
+ elif tier == "TIER_2":
+ subject = "FORMAL DEMAND FOR PAYMENT - {debtor_name}"
+ body = (
+ "This letter constitutes formal demand for full payment of the outstanding balance described below.\n\n"
+ "Client: {client_name}\n"
+ "Amount Due: {amount_display}\n"
+ "Invoice Reference: {invoice_ref}\n\n"
+ "Despite previous attempts to resolve this matter amicably, the amount remains unpaid.\n\n"
+ "PLEASE TAKE NOTICE that if the full balance is not received within fourteen (14) calendar days, "
+ "we will proceed with escalated collection measures, including but not limited to referral to our "
+ "legal department, filing of a civil suit to obtain judgment, and placement of liens against real "
+ "or personal property.\n\n"
+ "Contact our offices immediately to arrange payment or discuss a resolution.\n\n"
+ + _PAY + "\n\n" + _FDCPA + "\n\n"
+ "- Debt Recovery Experts\nCollections Department\ncollections@debtrecoveryexperts.com"
+ )
+ elif tier == "TIER_2_5":
+ subject = "NOTICE OF INTENT TO FILE LIEN - {debtor_name}"
+ body = (
+ "This letter serves as formal notice of our intent to file a lien against the property described below.\n\n"
+ "Client: {client_name}\n"
+ "Property/Project: {debtor_address}\n"
+ "Unpaid Amount: {amount_display}\n\n"
+ "Texas Property Code allows for the filing of a mechanic's lien against real property where "
+ "improvements were made and remain unpaid. We have been authorized to take the following actions:\n\n"
+ "- Filing a Sworn Statement of Account with the county clerk\n"
+ "- Recording a mechanic's lien against the property\n"
+ "- Pursuing foreclosure on the lien if necessary\n\n"
+ "A mechanic's lien will attach to the property title, affect your ability to sell or refinance, "
+ "and appear on title searches.\n\n"
+ "To avoid lien filing, full payment must be received within ten (10) calendar days.\n\n"
+ + _PAY + "\n\n" + _FDCPA + "\n\n"
+ "- Debt Recovery Experts\nCollections Department"
+ )
+ elif tier == "TIER_3":
+ subject = "FINAL NOTICE - IMMEDIATE ACTION REQUIRED"
+ body = (
+ "FINAL NOTICE - This is your last opportunity to resolve this matter before legal action.\n\n"
+ "Client: {client_name}\n"
+ "Amount Due: {amount_display}\n"
+ "Original Invoice Date: {invoice_date}\n\n"
+ "Multiple attempts have been made to collect this debt. Despite these efforts, the full balance "
+ "remains unpaid.\n\n"
+ "UNLESS FULL PAYMENT IS RECEIVED WITHIN TEN (10) CALENDAR DAYS, we will refer this matter to our "
+ "legal counsel, initiate civil litigation to obtain a judgment, pursue all available post-judgment "
+ "remedies including wage garnishment, bank account levy, and asset seizure, and report this debt to "
+ "credit reporting agencies.\n\n"
+ "You may be held liable for court costs, attorney's fees, and additional interest.\n\n"
+ "Contact our offices immediately. This is your final opportunity to resolve this without court "
+ "intervention.\n\n"
+ + _PAY + "\n\n" + _FDCPA + "\n\n"
+ "- Debt Recovery Experts\nCollections Department"
+ )
+ else: # TIER_4
+ subject = "LEGAL ACTION - {debtor_name}"
+ body = (
+ "This letter confirms that your account has been referred for legal action.\n\n"
+ "Client: {client_name}\n"
+ "Amount Due: {amount_display}\n"
+ "Legal Reference: {claim_number}\n\n"
+ "Effective immediately, this matter has been forwarded to our legal counsel for lawsuit "
+ "preparation. A civil petition will be filed seeking judgment for the full amount owed, "
+ "pre-judgment interest as allowed by law, court costs and filing fees, and attorney's fees.\n\n"
+ "Upon obtaining a judgment, we will pursue collection through all available legal channels, "
+ "including wage garnishment, bank account levy, lien against real property, and post-judgment "
+ "discovery of assets.\n\n"
+ "All further communication regarding this matter should be directed to our legal counsel.\n\n"
+ + _FDCPA + "\n\n"
+ "- Debt Recovery Experts\nLegal Liaison Division"
+ )
+ return subject, body
+
+
+def recommend_letter(*, tier: str, client_name: str, debtor_name: str,
+ amount_display: str, invoice_ref: str, claim_number: str,
+ debtor_address: str = "") -> dict:
+ """Assemble the recommended letter for a claim's current tier."""
+ if tier not in _letter_tier_map():
+ tier = "TIER_1"
+ subject, body = _letter_template(tier)
+ subject = subject.format(debtor_name=debtor_name)
+ body = body.format(
+ debtor_name=debtor_name,
+ client_name=client_name,
+ amount_display=amount_display,
+ invoice_ref=invoice_ref or "N/A",
+ invoice_date=invoice_ref or "N/A",
+ claim_number=claim_number,
+ debtor_address=debtor_address or "Property address on file",
+ )
+ return {"subject": subject, "body": body, "tier": tier}
+
+
+def _letter_tier_map() -> set[str]:
+ return {"TIER_1", "TIER_2", "TIER_2_5", "TIER_3", "TIER_4"}
diff --git a/backend/auth.py b/backend/auth.py
index 5fce051..a8643af 100644
--- a/backend/auth.py
+++ b/backend/auth.py
@@ -5,10 +5,14 @@ from __future__ import annotations
import hashlib
import hmac
+import json
import logging
import os
import secrets
import time
+import urllib.error
+import urllib.parse
+import urllib.request
from collections import defaultdict, deque
from datetime import datetime, timedelta, timezone
@@ -108,6 +112,106 @@ def verify_staff_key(provided: str | None) -> bool:
return _compare_hash(provided, key)
+# ---------------------------------------------------------------
+# Stack Auth (auth2 / Hexclave) — per-user staff SSO
+# ---------------------------------------------------------------
+STACK_AUTH_API_URL = os.environ.get("STACK_AUTH_API_URL", "https://auth2-api.itpropartner.com").rstrip("/")
+STACK_AUTH_PUBLISHABLE_KEY = os.environ.get("STACK_AUTH_PUBLISHABLE_KEY", "")
+STACK_AUTH_PROJECT_ID = os.environ.get("STACK_AUTH_PROJECT_ID", "internal")
+STACK_AUTH_TEAM = os.environ.get("STACK_AUTH_TEAM", "dre-staff")
+# Emergency owner bypass: these emails are always admitted even if the team
+# membership API is down or membership was accidentally removed.
+STACK_AUTH_OWNER_EMAILS = [
+ e.strip().lower()
+ for e in os.environ.get("STACK_AUTH_OWNER_EMAILS", "").split(",")
+ if e.strip()
+]
+
+
+def _stack_auth_request(method: str, path: str, access_token: str | None = None,
+ body: dict | None = None):
+ """Call the auth2 Stack Auth REST API (client mode). Returns (status, parsed|raw).
+
+ Unauthenticated calls use the publishable client key; authenticated calls use
+ the opaque access token instead. Both carry access-type/project-id headers.
+ """
+ url = STACK_AUTH_API_URL + path
+ headers = {
+ "Content-Type": "application/json",
+ "x-hexclave-access-type": "client",
+ "x-hexclave-project-id": STACK_AUTH_PROJECT_ID,
+ }
+ if access_token:
+ headers["x-hexclave-access-token"] = access_token
+ else:
+ headers["x-hexclave-publishable-client-key"] = STACK_AUTH_PUBLISHABLE_KEY
+ data = json.dumps(body).encode("utf-8") if body is not None else None
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
+ try:
+ resp = urllib.request.urlopen(req, timeout=8)
+ raw = resp.read().decode("utf-8", "replace")
+ code = resp.status
+ except urllib.error.HTTPError as e:
+ code = e.code
+ raw = e.read().decode("utf-8", "replace")
+ except Exception: # noqa: BLE001
+ return None, None
+ try:
+ return code, json.loads(raw)
+ except Exception: # noqa: BLE001
+ return code, raw
+
+
+def stack_auth_user(access_token: str | None) -> tuple[str, dict | None]:
+ """Validate an opaque Stack Auth access token and confirm dre-staff membership.
+
+ Returns one of:
+ ("ok", {"name","email","user_id"}) — valid session AND team member
+ ("denied", None) — valid session but NOT in the team
+ ("invalid", None) — bad/expired token or API unreachable
+ """
+ if not access_token or not STACK_AUTH_PUBLISHABLE_KEY:
+ return "invalid", None
+ code, user = _stack_auth_request("GET", "/api/latest/users/me", access_token=access_token)
+ if code != 200 or not isinstance(user, dict):
+ return "invalid", None
+ q = urllib.parse.quote(STACK_AUTH_TEAM)
+ code, teams = _stack_auth_request("GET", f"/api/latest/teams?user_id=me&query={q}", access_token=access_token)
+ items = teams.get("items") if isinstance(teams, dict) else None
+ member = (code == 200 and isinstance(items, list) and any(
+ str(t.get("display_name", "")).lower() == STACK_AUTH_TEAM.lower() for t in items))
+ email = user.get("primary_email") or ""
+ is_owner = email.lower() in STACK_AUTH_OWNER_EMAILS
+ if not member and not is_owner:
+ return "denied", None
+ return "ok", {
+ "name": user.get("display_name") or email or "DRE Staff",
+ "email": email,
+ "user_id": user.get("id") or "",
+ }
+
+
+def stack_auth_sign_in(email: str, password: str) -> tuple[str, dict | None]:
+ """Password sign-in against Stack Auth, gated to the dre-staff team.
+
+ Returns ("ok", {"token","name","email","user_id"}), ("invalid", None) for bad
+ credentials, or ("denied", None) for a valid account outside the team.
+ """
+ code, d = _stack_auth_request(
+ "POST", "/api/latest/auth/password/sign-in",
+ body={"email": email, "password": password},
+ )
+ if code != 200 or not isinstance(d, dict):
+ return "invalid", None
+ token = d.get("access_token")
+ if not token:
+ return "invalid", None
+ state, user = stack_auth_user(token)
+ if state != "ok":
+ return "denied", None
+ return "ok", {"token": token, **user}
+
+
# ---------------------------------------------------------------
# FastAPI dependencies
# ---------------------------------------------------------------
@@ -119,14 +223,62 @@ def get_client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
-def require_staff(request: Request) -> None:
- """Staff-key auth dependency. Raises 403 if missing/wrong."""
- provided = request.headers.get("x-dre-staff-key")
- if not verify_staff_key(provided):
+def staff_identity(provided: str | None) -> str:
+ """Resolve the acting staff member's display name from the presented key.
+
+ Priority:
+ 1. DRE_STAFF_DIRECTORY — 'KEY=Name ' entries (one per line or
+ semicolon-separated). Enables real per-user RBAC once each staff
+ member has their own key.
+ 2. DRE_STAFF_NAME — a single default name for the shared-key case.
+ 3. "DRE Staff" fallback.
+ """
+ name = os.environ.get("DRE_STAFF_NAME", "").strip()
+ directory = os.environ.get("DRE_STAFF_DIRECTORY", "").strip()
+ if directory and provided:
+ for entry in directory.replace(";", "\n").splitlines():
+ entry = entry.strip()
+ if not entry or "=" not in entry:
+ continue
+ key, _, label = entry.partition("=")
+ if key.strip() == provided:
+ name = label.strip()
+ break
+ return name or "DRE Staff"
+
+
+def require_staff(request: Request) -> str:
+ """Staff auth dependency. Raises 403 if missing/wrong.
+
+ Priority:
+ 1. Stack Auth access token (`x-dre-access-token`) → per-user auth2 SSO,
+ validated server-side and gated to the dre-staff team.
+ 2. Legacy staff key (`x-dre-staff-key`) → script/fallback access.
+
+ Returns the acting staff member's display name so callers can record the
+ actor without manual name entry.
+ """
+ token = request.headers.get("x-dre-access-token")
+ if token:
+ state, user = stack_auth_user(token)
+ if state == "ok":
+ return user["name"]
+ if state == "denied":
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail={"code": "staff_forbidden", "message": "Account is not authorized for staff access."},
+ )
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
- detail={"code": "forbidden", "message": "Valid staff key required."},
+ detail={"code": "staff_auth_expired", "message": "Staff session expired. Please sign in again."},
)
+ provided = request.headers.get("x-dre-staff-key")
+ if verify_staff_key(provided):
+ return staff_identity(provided)
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail={"code": "forbidden", "message": "Valid staff credentials required."},
+ )
def require_client(request: Request) -> dict:
diff --git a/backend/db.py b/backend/db.py
index ffcd73e..d1c414b 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -44,10 +44,11 @@ def get_conn() -> sqlite3.Connection:
def init_db() -> None:
- """Create schema if absent and seed number_sequences for the current year."""
+ """Create schema if absent, migrate, and seed number_sequences for the current year."""
with get_conn() as conn:
schema_sql = _SCHEMA_FILE.read_text()
conn.executescript(schema_sql)
+ _migrate(conn)
# Seed sequences for current year (idempotent)
year = datetime.now(timezone.utc).year
for prefix in ("DRE", "CLT"):
@@ -58,6 +59,48 @@ def init_db() -> None:
conn.commit()
+def _migrate(conn: sqlite3.Connection) -> None:
+ """Add analysis/approval/letter columns to `claims` if they don't already exist.
+
+ SQLite lacks `ADD COLUMN IF NOT EXISTS`, so we inspect PRAGMA table_info first.
+ Idempotent — safe to run on every boot.
+ """
+ existing = {row["name"] for row in conn.execute("PRAGMA table_info(claims)").fetchall()}
+ additions = {
+ "no_other_agency_at": "TEXT",
+ "no_prior_action_at": "TEXT",
+ "analysis_score": "INTEGER",
+ "analysis_summary": "TEXT",
+ "analysis_components": "TEXT",
+ "analysis_at": "TEXT",
+ "recommended_tier": "TEXT",
+ "approval_status": "TEXT NOT NULL DEFAULT 'NONE'",
+ "approval_decision_by": "TEXT",
+ "approval_decision_at": "TEXT",
+ "letter_subject": "TEXT",
+ "letter_body": "TEXT",
+ "letter_tier": "TEXT",
+ "letter_updated_at": "TEXT",
+ }
+ for col, ddl in additions.items():
+ if col not in existing:
+ conn.execute(f"ALTER TABLE claims ADD COLUMN {col} {ddl}")
+
+ # onboarding_docs -> DocuSeal e-sign state (added later than the base schema)
+ existing_od = {row["name"] for row in conn.execute("PRAGMA table_info(onboarding_docs)").fetchall()}
+ od_additions = {
+ "docuseal_submission_id": "TEXT",
+ "docuseal_submitter_id": "TEXT",
+ "docuseal_slug": "TEXT",
+ "docuseal_embed_src": "TEXT",
+ "docuseal_status": "TEXT",
+ "docuseal_sent_at": "TEXT",
+ }
+ for col, ddl in od_additions.items():
+ if col not in existing_od:
+ conn.execute(f"ALTER TABLE onboarding_docs ADD COLUMN {col} {ddl}")
+
+
def new_uuid() -> str:
return str(uuid.uuid4())
diff --git a/backend/docuseal.py b/backend/docuseal.py
new file mode 100644
index 0000000..1e5d82a
--- /dev/null
+++ b/backend/docuseal.py
@@ -0,0 +1,140 @@
+"""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__}")
diff --git a/backend/dreemail.py b/backend/dreemail.py
index f75075a..62f8296 100644
--- a/backend/dreemail.py
+++ b/backend/dreemail.py
@@ -18,6 +18,12 @@ def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
+def _team_recipients() -> list[str]:
+ """Fan-out list from DRE_TEAM_NOTIFY (comma-separated). Defaults to dre@."""
+ raw = _env("DRE_TEAM_NOTIFY", "dre@debtrecoveryexperts.com")
+ return [a.strip() for a in raw.split(",") if a.strip()]
+
+
def send_email(to_addr: str, subject: str, body_text: str, html: str | None = None) -> bool:
"""Send an email via the configured SMTP relay. Returns True on success, False on failure.
Never raises — caller proceeds regardless."""
@@ -47,7 +53,7 @@ def send_email(to_addr: str, subject: str, body_text: str, html: str | None = No
def notify_team_intake(claim_number: str, client_number: str, company_name: str,
amount_cents: int, debtor_name: str) -> bool:
- team = _env("DRE_TEAM_NOTIFY", "dre@debtrecoveryexperts.com")
+ team = _team_recipients()
base = _env("DRE_BASE_URL", "https://my.debtrecoveryexperts.com")
dollars = amount_cents / 100.0
body = (
@@ -66,7 +72,10 @@ def notify_team_intake(claim_number: str, client_number: str, company_name: str,
f"Amount: ${dollars:,.2f}
"
f"Review in portal
"
)
- return send_email(team, f"New DRE Claim: {claim_number}", body, html)
+ ok = True
+ for addr in team:
+ ok = send_email(addr, f"New DRE Claim: {claim_number}", body, html) and ok
+ return ok
def send_magic_link(to_addr: str, raw_token: str, client_number: str) -> bool:
@@ -92,11 +101,14 @@ def send_magic_link(to_addr: str, raw_token: str, client_number: str) -> bool:
def notify_team_message(claim_number: str, subject: str, content: str,
author: str) -> bool:
- team = _env("DRE_TEAM_NOTIFY", "dre@debtrecoveryexperts.com")
+ team = _team_recipients()
body = (
f"New client message on claim {claim_number}.\n\n"
f"From: {author}\n"
f"Subject: {subject}\n\n"
f"{content}\n"
)
- return send_email(team, f"Client message on {claim_number}: {subject}", body)
+ ok = True
+ for addr in team:
+ ok = send_email(addr, f"Client message on {claim_number}: {subject}", body) and ok
+ return ok
diff --git a/backend/intake.py b/backend/intake.py
index 8e3f805..cbdd057 100644
--- a/backend/intake.py
+++ b/backend/intake.py
@@ -11,6 +11,7 @@ from pydantic import ValidationError
from . import auth as authmod
from . import db
from . import dreemail
+from . import analysis
from .db import get_conn, new_uuid, next_sequence_number, utcnow_iso
from .models import IntakeRequest
@@ -93,10 +94,12 @@ async def intake(request: Request):
claim_id = new_uuid()
claim_number = next_sequence_number(conn, "DRE")
conn.execute(
- "INSERT INTO claims (id, claim_number, client_id, debtor_id, amount_cents, currency, status, tier, description, client_reference, invoice_date, date_assigned, date_resolved, twentycrm_id, created_at, updated_at) "
- "VALUES (?, ?, ?, ?, ?, 'USD', 'NEW', 'TIER_1', ?, ?, ?, NULL, NULL, NULL, ?, ?)",
+ "INSERT INTO claims (id, claim_number, client_id, debtor_id, amount_cents, currency, status, tier, description, client_reference, invoice_date, date_assigned, date_resolved, no_other_agency_at, no_prior_action_at, twentycrm_id, created_at, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, 'USD', 'NEW', 'TIER_1', ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?)",
(claim_id, claim_number, client_id, debtor_id, claim_in.amount_cents,
- claim_in.description, claim_in.client_reference, claim_in.invoice_date, now, now),
+ claim_in.description, claim_in.client_reference, claim_in.invoice_date,
+ now if req.no_other_agency_accepted else None,
+ now if req.no_prior_action_accepted else None, now, now),
)
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, old_value, new_value, actor, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
@@ -114,6 +117,15 @@ async def intake(request: Request):
"INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(new_uuid(), "note", note_id, "note_add", "system", now),
)
+
+ # Auto-run AI analysis on submission (score + recommended tier + approval=PENDING).
+ # Deterministic rules engine; no external call. Best-effort: a failure must not
+ # block intake, so we guard and let the claim persist without a score if it errors.
+ try:
+ analysis.analyze_and_store(conn, claim_id, actor="system")
+ except Exception as exc: # noqa: BLE001
+ logger.error("auto-analysis failed for claim %s: %s", claim_number, exc)
+
conn.commit()
# Best-effort team notification
diff --git a/backend/letters.py b/backend/letters.py
new file mode 100644
index 0000000..6ed6dba
--- /dev/null
+++ b/backend/letters.py
@@ -0,0 +1,597 @@
+"""Physical mail queue + LetterStream send integration.
+
+Letters are the paper collection notices mailed to debtors. One row per mailed
+letter in `letters` (lifecycle DRAFT -> APPROVED -> PREAUTH -> SENT, plus
+REJECTED / CANCELLED / ERROR), with tracking scan events in `letter_events`
+pushed by LetterStream's callback.
+
+Send flow is human-in-the-loop on cost (production mode, real money):
+ 1. staff creates a draft letter (content defaults to claims.letter_*, recipient
+ address supplied by staff after client-data + Super Search verification)
+ 2. staff approves -> APPROVED
+ 3. staff clicks "price & queue" -> LetterStream preauth=1, store authcode + cost
+ (status PREAUTH); nothing is released or mailed yet
+ 4. staff confirms -> LetterStream doauth, status SENT, tracking begins
+
+Signatory: Debt Recovery Experts LLC (LETTERSTREAM_SIGNATORY).
+Return address: LETTERSTREAM_RETURN_ADDRESS (colon-delimited addr1:addr2:city:state:zip).
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import uuid
+
+from fastapi import APIRouter, Depends, Request, status
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
+
+from . import auth as authmod
+from . import letterstream
+from .db import get_conn, new_uuid, utcnow_iso
+
+logger = logging.getLogger("dre.letters")
+router = APIRouter()
+
+SIGNATORY = os.environ.get("LETTERSTREAM_SIGNATORY", "Debt Recovery Experts LLC")
+RETURN_ADDR = os.environ.get("LETTERSTREAM_RETURN_ADDRESS", "") # addr1:addr2:city:state:zip
+CALLBACK_KEY = os.environ.get("LETTERSTREAM_CALLBACK_KEY", "")
+LETTERS_DIR = os.environ.get("DRE_LETTERS_DIR", "/opt/dre-portal/data/letters")
+
+MAILTYPES = ("firstclass", "firstclass_hse", "certified", "certnoerr",
+ "postcard", "flat", "propostcard")
+LETTER_STATUSES = ("DRAFT", "APPROVED", "PREAUTH", "SENT",
+ "REJECTED", "CANCELLED", "ERROR")
+
+
+class LetterStreamError(Exception):
+ """Raised on any LetterStream or letter-lifecycle failure."""
+
+
+# ---------------------------------------------------------------
+# Request models (letter endpoints only — kept local to this router)
+# ---------------------------------------------------------------
+class RecipientAddress(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ name: str = Field(..., min_length=1, max_length=200)
+ name2: str | None = Field(None, max_length=200)
+ addr1: str = Field(..., min_length=1, max_length=200)
+ addr2: str | None = Field(None, max_length=200)
+ city: str = Field(..., min_length=1, max_length=100)
+ state: str = Field(..., min_length=2, max_length=2)
+ zip: str = Field(..., min_length=5, max_length=10)
+
+ @field_validator("name", "name2", "addr1", "addr2", "city", "state", "zip")
+ @classmethod
+ def _v(cls, v):
+ return v
+
+
+class LetterCreate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ recipient: RecipientAddress
+ letter_type: str = Field("demand", max_length=40)
+ subject: str | None = Field(None, max_length=200)
+ body: str | None = Field(None, max_length=20000)
+ mailtype: str = "firstclass"
+ coversheet: bool = True
+
+ @field_validator("mailtype")
+ @classmethod
+ def _mt(cls, v):
+ if v not in MAILTYPES:
+ raise ValueError(f"mailtype must be one of {MAILTYPES}")
+ return v
+
+
+class LetterAction(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ reason: str | None = Field(None, max_length=1000)
+
+
+# ---------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------
+def sender_parts() -> list[str]:
+ """Return [name1, name2, addr1, addr2, city, state, zip] for the `from` field."""
+ if not RETURN_ADDR:
+ raise LetterStreamError("LETTERSTREAM_RETURN_ADDRESS is not configured")
+ parts = RETURN_ADDR.split(":")
+ if len(parts) != 5:
+ raise LetterStreamError(
+ "LETTERSTREAM_RETURN_ADDRESS must be addr1:addr2:city:state:zip")
+ addr1, addr2, city, state, zipc = parts
+ return [SIGNATORY, "", addr1, addr2, city, state.upper(), zipc]
+
+
+def sender_string() -> str:
+ return ":".join(sender_parts())
+
+
+def recipient_string(doc_id: str, r: dict) -> str:
+ return ":".join([
+ doc_id, r.get("name", ""), r.get("name2") or "", r.get("addr1", ""),
+ r.get("addr2") or "", r.get("city", ""), r.get("state", "").upper(),
+ r.get("zip", ""),
+ ])
+
+
+def render_letter_pdf(*, sender_block: list[str], recipient_block: list[str],
+ date_str: str, subject: str, body: str) -> tuple[bytes, int]:
+ """Render a single-page (or multi-page) letter PDF. Returns (bytes, page_count).
+
+ Address blocks are placed in the standard #10 double-window positions
+ (verified via LetterStream preflight before first production send).
+ """
+ from fpdf import FPDF
+
+ def _safe(s: str) -> str:
+ return (s or "").encode("latin-1", "replace").decode("latin-1")
+
+ pdf = FPDF(unit="mm", format="Letter")
+ pdf.set_auto_page_break(auto=True, margin=15)
+ pdf.add_page()
+ # Return-address window (top-left)
+ pdf.set_font("Helvetica", size=10)
+ y = 14.0
+ for line in sender_block:
+ pdf.set_xy(14.3, y)
+ pdf.cell(0, 4.2, _safe(line))
+ y += 4.2
+ # Recipient window
+ y = 50.8
+ for line in recipient_block:
+ pdf.set_xy(14.3, y)
+ pdf.cell(0, 4.2, _safe(line))
+ y += 4.2
+ # Date
+ pdf.set_xy(14.3, 76.0)
+ pdf.cell(0, 5, _safe(date_str))
+ # Subject (bold)
+ pdf.set_font("Helvetica", "B", size=12)
+ pdf.set_xy(14.3, 84.0)
+ pdf.multi_cell(180, 6, _safe(subject))
+ # Body
+ pdf.set_font("Helvetica", size=10)
+ pdf.set_xy(14.3, 94.0)
+ pdf.multi_cell(180, 4.6, _safe(body))
+ out = pdf.output(dest="S")
+ return out, len(pdf.pages)
+
+
+def _sender_block() -> list[str]:
+ parts = sender_parts()
+ name1, _name2, addr1, addr2, city, state, zipc = parts
+ lines = [name1]
+ if addr1:
+ lines.append(addr1)
+ if addr2:
+ lines.append(addr2)
+ lines.append(f"{city}, {state} {zipc}")
+ return lines
+
+
+def _recipient_block(r: dict) -> list[str]:
+ lines = [r.get("name", "")]
+ if r.get("name2"):
+ lines.append(r["name2"])
+ lines.append(r.get("addr1", ""))
+ if r.get("addr2"):
+ lines.append(r["addr2"])
+ lines.append(f"{r.get('city', '')}, {r.get('state', '')} {r.get('zip', '')}")
+ return lines
+
+
+def _letter_row(conn, letter_id: str):
+ return conn.execute(
+ "SELECT l.*, c.claim_number FROM letters l "
+ "JOIN claims c ON c.id = l.claim_id WHERE l.id = ?",
+ (letter_id,),
+ ).fetchone()
+
+
+def _serialize(row) -> dict:
+ return {
+ "id": row["id"],
+ "claim_id": row["claim_id"],
+ "claim_number": row["claim_number"],
+ "letter_type": row["letter_type"],
+ "subject": row["subject"],
+ "body": row["body"],
+ "recipient": json.loads(row["recipient_json"]),
+ "sender": json.loads(row["sender_json"]),
+ "mailtype": row["mailtype"],
+ "status": row["status"],
+ "job_id": row["job_id"],
+ "batch_id": row["batch_id"],
+ "doc_id": row["doc_id"],
+ "tracking_no": row["tracking_no"],
+ "cost_cents": row["cost_cents"],
+ "pages": row["pages"],
+ "error": row["error"],
+ "note": row["note"],
+ "created_at": row["created_at"],
+ "updated_at": row["updated_at"],
+ "sent_at": row["sent_at"],
+ }
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/claims/{claim_number}/letters (create draft)
+# ---------------------------------------------------------------
+@router.post("/api/staff/claims/{claim_number}/letters")
+async def create_letter(claim_number: str, request: Request,
+ staff_name: str = Depends(authmod.require_staff)):
+ try:
+ body = await request.json()
+ except Exception:
+ return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY)
+ try:
+ lc = LetterCreate.model_validate(body)
+ except ValidationError as exc:
+ parts = [f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()]
+ return _err("validation_error", "; ".join(parts), status.HTTP_422_UNPROCESSABLE_ENTITY)
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT c.id, c.letter_subject, c.letter_body, d.name AS debtor_name "
+ "FROM claims c JOIN debtors d ON d.id = c.debtor_id WHERE c.claim_number = ?",
+ (claim_number,),
+ ).fetchone()
+ if row is None:
+ return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND)
+ subject = lc.subject or row["letter_subject"] or "Notice from Debt Recovery Experts, LLC"
+ body_text = lc.body or row["letter_body"] or ""
+ if not body_text.strip():
+ return _err("validation_error",
+ "Claim has no letter body; generate a letter first.",
+ status.HTTP_422_UNPROCESSABLE_ENTITY)
+ recipient = lc.recipient.model_dump()
+ # Sender address is global config (LETTERSTREAM_RETURN_ADDRESS), resolved at
+ # send time. A draft records only the signatory; drafting must not block on
+ # the return address, which may not be configured yet.
+ sender = {"name": SIGNATORY, "name2": "", "addr1": "", "addr2": "",
+ "city": "", "state": "", "zip": ""}
+ now = utcnow_iso()
+ letter_id = new_uuid()
+ conn.execute(
+ "INSERT INTO letters (id, claim_id, letter_type, subject, body, recipient_json, "
+ "sender_json, mailtype, status, created_at, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', ?, ?)",
+ (letter_id, row["id"], lc.letter_type, subject, body_text,
+ json.dumps(recipient), json.dumps(sender), lc.mailtype, now, now),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'letter', ?, 'create', 'status', NULL, 'DRAFT', ?, ?)",
+ (new_uuid(), letter_id, staff_name, now),
+ )
+ conn.commit()
+ return await _get_letter(letter_id)
+
+
+# ---------------------------------------------------------------
+# GET /api/staff/letters
+# ---------------------------------------------------------------
+@router.get("/api/staff/letters")
+async def list_letters(request: Request, _staff=Depends(authmod.require_staff)):
+ status_filter = request.query_params.get("status")
+ limit = min(int(request.query_params.get("limit", "50")), 200)
+ offset = max(int(request.query_params.get("offset", "0")), 0)
+ where = ""
+ params: list = []
+ if status_filter:
+ where = "WHERE l.status = ?"
+ params.append(status_filter)
+ from_clause = "FROM letters l JOIN claims c ON c.id = l.claim_id"
+ with get_conn() as conn:
+ rows = conn.execute(
+ f"SELECT l.*, c.claim_number {from_clause} {where} "
+ f"ORDER BY l.created_at DESC LIMIT ? OFFSET ?",
+ tuple(params + [limit, offset]),
+ ).fetchall()
+ total = conn.execute(
+ f"SELECT COUNT(*) AS n {from_clause} {where}", tuple(params),
+ ).fetchone()["n"]
+ return {"letters": [_serialize(r) for r in rows], "total": total,
+ "limit": limit, "offset": offset}
+
+
+# ---------------------------------------------------------------
+# GET /api/staff/letters/{letter_id}
+# ---------------------------------------------------------------
+@router.get("/api/staff/letters/{letter_id}")
+async def get_letter(letter_id: str, _staff=Depends(authmod.require_staff)):
+ return await _get_letter(letter_id)
+
+
+async def _get_letter(letter_id: str):
+ with get_conn() as conn:
+ row = _letter_row(conn, letter_id)
+ if row is None:
+ return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
+ events = conn.execute(
+ "SELECT scan_code, scan_status, scan_date, scan_zip, scan_facility, tracking_id, "
+ "created_at FROM letter_events WHERE letter_id = ? ORDER BY created_at ASC",
+ (letter_id,),
+ ).fetchall()
+ data = _serialize(row)
+ data["events"] = [dict(e) for e in events]
+ return data
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/letters/{letter_id}/approve (DRAFT -> APPROVED)
+# ---------------------------------------------------------------
+@router.post("/api/staff/letters/{letter_id}/approve")
+async def approve_letter(letter_id: str, staff_name: str = Depends(authmod.require_staff)):
+ with get_conn() as conn:
+ row = _letter_row(conn, letter_id)
+ if row is None:
+ return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
+ if row["status"] != "DRAFT":
+ return _err("conflict", f"Letter is {row['status']}, not DRAFT.",
+ status.HTTP_409_CONFLICT)
+ now = utcnow_iso()
+ conn.execute("UPDATE letters SET status = 'APPROVED', updated_at = ? WHERE id = ?",
+ (now, letter_id))
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'letter', ?, 'approve', 'status', 'DRAFT', 'APPROVED', ?, ?)",
+ (new_uuid(), letter_id, staff_name, now),
+ )
+ conn.commit()
+ return await _get_letter(letter_id)
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/letters/{letter_id}/send (preauth — price only)
+# ---------------------------------------------------------------
+@router.post("/api/staff/letters/{letter_id}/send")
+async def send_letter(letter_id: str, staff_name: str = Depends(authmod.require_staff)):
+ with get_conn() as conn:
+ row = _letter_row(conn, letter_id)
+ if row is None:
+ return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
+ if row["status"] not in ("APPROVED", "PREAUTH", "ERROR"):
+ return _err("conflict", f"Letter is {row['status']}; approve it first.",
+ status.HTTP_409_CONFLICT)
+ recipient = json.loads(row["recipient_json"])
+ subject = row["subject"] or ""
+ body = row["body"] or ""
+ try:
+ sender = {k: v for k, v in zip(
+ ("name", "name2", "addr1", "addr2", "city", "state", "zip"), sender_parts())}
+ except LetterStreamError as exc:
+ return _err("config_error", str(exc), status.HTTP_409_CONFLICT)
+ # Render + persist PDF
+ doc_id = new_uuid().replace("-", "")[:16]
+ job = "DRE" + doc_id
+ try:
+ pdf_bytes, pages = render_letter_pdf(
+ sender_block=_sender_block(), recipient_block=_recipient_block(recipient),
+ date_str=utcnow_iso()[:10], subject=subject, body=body,
+ )
+ except Exception as exc:
+ return _err("render_error", f"PDF render failed: {exc}", status.HTTP_500_INTERNAL_SERVER_ERROR)
+ os.makedirs(LETTERS_DIR, exist_ok=True)
+ pdf_path = os.path.join(LETTERS_DIR, f"{letter_id}.pdf")
+ with open(pdf_path, "wb") as fh:
+ fh.write(pdf_bytes)
+ try:
+ resp = letterstream.send_single(
+ pdf_bytes, f"{letter_id}.pdf", job, sender_string(),
+ [recipient_string(doc_id, recipient)], pages,
+ mailtype=row["mailtype"], preauth=True,
+ )
+ except letterstream.LetterStreamError as exc:
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE letters SET status = 'ERROR', error = ?, updated_at = ? WHERE id = ?",
+ (str(exc), now, letter_id),
+ )
+ conn.commit()
+ return _err("letterstream_error", str(exc), status.HTTP_502_BAD_GATEWAY)
+ m = letterstream._first(resp) if resp.get("messages") else {}
+ authcode = m.get("authcode", "")
+ cost = m.get("cost")
+ batch = m.get("batch")
+ docs = m.get("docs", [])
+ ls_doc = docs[0].get("id") if docs else None
+ cost_cents = int(round(float(cost) * 100)) if cost else None
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE letters SET status = 'PREAUTH', job_id = ?, batch_id = ?, doc_id = ?, "
+ "sender_json = ?, authcode = ?, cost_cents = ?, pages = ?, pdf_path = ?, error = NULL, updated_at = ? "
+ "WHERE id = ?",
+ (job, batch, ls_doc, json.dumps(sender), authcode, cost_cents, pages, pdf_path, now, letter_id),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'letter', ?, 'preauth', 'status', NULL, 'PREAUTH', ?, ?)",
+ (new_uuid(), letter_id, staff_name, now),
+ )
+ conn.commit()
+ return await _get_letter(letter_id)
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/letters/{letter_id}/confirm (doauth — release)
+# ---------------------------------------------------------------
+@router.post("/api/staff/letters/{letter_id}/confirm")
+async def confirm_letter(letter_id: str, staff_name: str = Depends(authmod.require_staff)):
+ with get_conn() as conn:
+ row = _letter_row(conn, letter_id)
+ if row is None:
+ return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
+ if row["status"] != "PREAUTH" or not row["authcode"]:
+ return _err("conflict", "Letter has no pending preauth to confirm.",
+ status.HTTP_409_CONFLICT)
+ try:
+ letterstream.doauth(row["authcode"])
+ except letterstream.LetterStreamError as exc:
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE letters SET status = 'ERROR', error = ?, updated_at = ? WHERE id = ?",
+ (str(exc), now, letter_id),
+ )
+ conn.commit()
+ return _err("letterstream_error", str(exc), status.HTTP_502_BAD_GATEWAY)
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE letters SET status = 'SENT', sent_at = ?, updated_at = ? WHERE id = ?",
+ (now, now, letter_id),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'letter', ?, 'confirm', 'status', 'PREAUTH', 'SENT', ?, ?)",
+ (new_uuid(), letter_id, staff_name, now),
+ )
+ conn.commit()
+ return await _get_letter(letter_id)
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/letters/{letter_id}/reject (terminal — REJECTED)
+# ---------------------------------------------------------------
+@router.post("/api/staff/letters/{letter_id}/reject")
+async def reject_letter(letter_id: str, request: Request,
+ staff_name: str = Depends(authmod.require_staff)):
+ reason = ""
+ try:
+ body = await request.json()
+ if isinstance(body, dict):
+ reason = str(body.get("reason") or "").strip()
+ except Exception:
+ pass
+ if not reason:
+ return _err("validation_error", "A reason is required to reject a letter.",
+ status.HTTP_422_UNPROCESSABLE_ENTITY)
+ reason = reason[:1000]
+ with get_conn() as conn:
+ row = _letter_row(conn, letter_id)
+ if row is None:
+ return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
+ if row["status"] in ("SENT", "REJECTED", "CANCELLED"):
+ return _err("conflict", f"Letter is {row['status']}; cannot reject.",
+ status.HTTP_409_CONFLICT)
+ old = row["status"]
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE letters SET status = 'REJECTED', note = ?, error = NULL, updated_at = ? WHERE id = ?",
+ (reason, now, letter_id),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'letter', ?, 'reject', 'status', ?, 'REJECTED', ?, ?)",
+ (new_uuid(), letter_id, old, staff_name, now),
+ )
+ conn.commit()
+ return await _get_letter(letter_id)
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/letters/{letter_id}/cancel (terminal — CANCELLED)
+# ---------------------------------------------------------------
+@router.post("/api/staff/letters/{letter_id}/cancel")
+async def cancel_letter(letter_id: str, request: Request,
+ staff_name: str = Depends(authmod.require_staff)):
+ reason = ""
+ try:
+ body = await request.json()
+ if isinstance(body, dict):
+ reason = str(body.get("reason") or "").strip()
+ except Exception:
+ pass
+ reason = reason[:1000]
+ with get_conn() as conn:
+ row = _letter_row(conn, letter_id)
+ if row is None:
+ return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
+ if row["status"] in ("SENT", "REJECTED", "CANCELLED"):
+ return _err("conflict", f"Letter is {row['status']}; cannot cancel.",
+ status.HTTP_409_CONFLICT)
+ old = row["status"]
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE letters SET status = 'CANCELLED', note = ?, error = NULL, updated_at = ? WHERE id = ?",
+ (reason, now, letter_id),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'letter', ?, 'cancel', 'status', ?, 'CANCELLED', ?, ?)",
+ (new_uuid(), letter_id, old, staff_name, now),
+ )
+ conn.commit()
+ return await _get_letter(letter_id)
+
+
+# ---------------------------------------------------------------
+# POST /api/letters/webhook (PUBLIC — LetterStream tracking callback)
+# ---------------------------------------------------------------
+@router.post("/api/letters/webhook")
+async def letter_webhook(request: Request):
+ ctype = request.headers.get("content-type", "")
+ data: dict = {}
+ if "application/json" in ctype:
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ else:
+ try:
+ form = await request.form()
+ data = {k: v for k, v in form.items()}
+ except Exception:
+ data = {}
+ key = data.get("key", "")
+ if not CALLBACK_KEY or key != CALLBACK_KEY:
+ return JSONResponse(status_code=401,
+ content={"success": False, "reason": "Invalid key"})
+ raw_json = data.get("json", "")
+ payload = None
+ try:
+ payload = json.loads(raw_json) if isinstance(raw_json, str) else raw_json
+ except (ValueError, TypeError):
+ payload = None
+ events: list[dict] = []
+ if isinstance(payload, list):
+ events = [e for e in payload if isinstance(e, dict)]
+ elif isinstance(payload, dict):
+ if isinstance(payload.get("data"), list):
+ events = [e for e in payload["data"] if isinstance(e, dict)]
+ elif payload:
+ events = [payload]
+ with get_conn() as conn:
+ for ev in events:
+ doc_id = str(ev.get("doc_id", ""))
+ job_id = str(ev.get("job_id", ""))
+ letter = None
+ if doc_id:
+ letter = conn.execute("SELECT id FROM letters WHERE doc_id = ?", (doc_id,)).fetchone()
+ if letter is None and job_id:
+ letter = conn.execute("SELECT id FROM letters WHERE job_id = ?", (job_id,)).fetchone()
+ if letter is None:
+ logger.warning("letter webhook: no letter for doc_id=%s job_id=%s", doc_id, job_id)
+ continue
+ conn.execute(
+ "INSERT INTO letter_events (id, letter_id, scan_code, scan_status, scan_date, "
+ "scan_zip, scan_facility, tracking_id, batch_id, job_id, doc_id, raw_json, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (new_uuid(), letter["id"], ev.get("scan_code"), ev.get("scan_status"),
+ ev.get("scan_date"), ev.get("scan_zip"), ev.get("scan_facility"),
+ ev.get("tracking_id"), ev.get("batch_id"), job_id, doc_id,
+ json.dumps(ev), utcnow_iso()),
+ )
+ if ev.get("tracking_id"):
+ conn.execute(
+ "UPDATE letters SET tracking_no = COALESCE(tracking_no, ?), updated_at = ? WHERE id = ?",
+ (str(ev["tracking_id"]), utcnow_iso(), letter["id"]),
+ )
+ conn.commit()
+ return {"success": True, "reason": "Received data"}
+
+
+def _err(code: str, message: str, status_code: int):
+ return JSONResponse(status_code=status_code,
+ content={"error": {"code": code, "message": message}})
diff --git a/backend/letterstream.py b/backend/letterstream.py
new file mode 100644
index 0000000..c9e6b3b
--- /dev/null
+++ b/backend/letterstream.py
@@ -0,0 +1,250 @@
+"""LetterStream mail-fulfillment integration.
+
+Send endpoint: POST https://www.letterstream.com/apis/ (form-encoded or multipart).
+Auth (verified live 2026-08-25):
+ t = unique numeric id (10-18 digits), unique per request
+ s = t[-6:] + api_key + t[:6]
+ h = md5(base64_encode(s))
+Response: XML ... .
+
+Key codes: -100 success, -199 AUTHOK, -200 preauth success, -911 insufficient
+funding, -950 auth fail, -957 DUP, -958 IDOK, -998 improper submission, -999 error.
+"""
+from __future__ import annotations
+
+import base64
+import hashlib
+import os
+import time
+import uuid
+import xml.etree.ElementTree as ET
+import urllib.error
+import urllib.parse
+import urllib.request
+
+ENDPOINT = "https://www.letterstream.com/apis/"
+API_ID = os.environ.get("LETTERSTREAM_API_ID", "")
+API_KEY = os.environ.get("LETTERSTREAM_API_KEY", "")
+UA = "DRE-integration/1.0"
+
+
+class LetterStreamError(Exception):
+ """Raised when LetterStream returns an error or is unreachable."""
+
+
+def auth() -> dict:
+ """Return {'a', 'h', 't'} for a single request. Call once per request."""
+ if not API_ID or not API_KEY:
+ raise LetterStreamError("LETTERSTREAM_API_ID/KEY not configured")
+ t = str(int(time.time() * 1000)) # 13-digit millis, within 10-18 digit spec
+ s = t[-6:] + API_KEY + t[:6]
+ h = hashlib.md5(base64.b64encode(s.encode())).hexdigest()
+ return {"a": API_ID, "h": h, "t": t}
+
+
+def _multipart(items: list[tuple[str, str]], files: dict) -> tuple[bytes, str]:
+ """Build a multipart/form-data body. files: {field: (filename, bytes, content_type)}."""
+ boundary = "----DRE" + uuid.uuid4().hex
+ chunks: list[bytes] = []
+
+ def add(data) -> None:
+ chunks.append(data.encode("utf-8") if isinstance(data, str) else data)
+
+ for k, v in items:
+ add(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n")
+ add(str(v))
+ add("\r\n")
+ for k, (filename, data, ctype) in files.items():
+ add(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"; "
+ f"filename=\"{filename}\"\r\nContent-Type: {ctype}\r\n\r\n")
+ add(data)
+ add("\r\n")
+ add(f"--{boundary}--\r\n")
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
+
+
+def _post(items: list[tuple[str, str]], files: dict | None = None,
+ timeout: int = 60) -> bytes:
+ if files:
+ body, ctype = _multipart(items, files)
+ else:
+ body = urllib.parse.urlencode(items).encode()
+ ctype = "application/x-www-form-urlencoded"
+ req = urllib.request.Request(
+ ENDPOINT, data=body, headers={"Content-Type": ctype, "User-Agent": UA},
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return resp.read()
+ except urllib.error.HTTPError as e:
+ return e.read()
+ except urllib.error.URLError as e:
+ raise LetterStreamError(f"LetterStream unreachable: {e.reason}") from e
+
+
+def _auth_items() -> list[tuple[str, str]]:
+ a = auth()
+ return [("a", a["a"]), ("h", a["h"]), ("t", a["t"])]
+
+
+def parse_xml(raw: bytes | str) -> dict:
+ """Parse the XML response into a convenient dict. Streams (PDF) pass through."""
+ if isinstance(raw, bytes):
+ raw = raw.decode("utf-8", "replace")
+ out: dict = {"raw": raw, "messages": []}
+ if raw.startswith("%PDF"):
+ out["stream"] = "pdf"
+ out["data"] = raw
+ return out
+ try:
+ root = ET.fromstring(raw)
+ except ET.ParseError:
+ out["stream"] = "unknown"
+ return out
+ out["id"] = root.get("id", "")
+ for msg in root.findall("message"):
+ m: dict = {"type": msg.get("type", "")}
+ for tag in ("code", "details", "batch", "quantity", "cost", "authcode",
+ "date", "balance", "testmode", "id", "job"):
+ el = msg.find(tag)
+ if el is not None and el.text is not None:
+ m[tag] = el.text.strip()
+ docs = []
+ for d in msg.findall("doc"):
+ docs.append({c.tag: (c.text or "").strip() for c in d})
+ if docs:
+ m["docs"] = docs
+ out["messages"].append(m)
+ return out
+
+
+def _first(response: dict) -> dict:
+ return response["messages"][0] if response.get("messages") else {}
+
+
+def _raise_on_error(response: dict) -> None:
+ """Raise LetterStreamError if the response is an error message."""
+ for m in response.get("messages", []):
+ code = m.get("code")
+ if m.get("type") == "error" and code not in (None, "-100", "-200"):
+ raise LetterStreamError(f"LetterStream {code}: {m.get('details', '')}")
+
+
+def account_status() -> dict:
+ """Return {'date','id','balance','testmode'}."""
+ items = _auth_items() + [("accountstatus", "1")]
+ resp = parse_xml(_post(items))
+ _raise_on_error(resp)
+ for m in resp.get("messages", []):
+ if m.get("type") == "accountstatus":
+ return {k: m.get(k) for k in ("date", "id", "balance", "testmode")}
+ raise LetterStreamError(f"Unexpected account status response: {resp.get('raw', '')[:200]}")
+
+
+def send_single(pdf_bytes: bytes, filename: str, job: str, sender: str,
+ recipients: list[str], pages: int, mailtype: str = "firstclass",
+ coversheet: str | None = None, duplex: str | None = None,
+ ink: str | None = None, paper: str | None = None,
+ returnenv: str | None = None, preauth: bool = False) -> dict:
+ """Submit one PDF to one or more recipients (method 2).
+
+ sender: 'name_1:name_2:addr_1:addr_2:city:state:zip'
+ recipients: list of 'doc_id:name_1:name_2:addr_1:addr_2:city:state:zip'
+ """
+ items = _auth_items() + [("job", job), ("from", sender), ("pages", str(pages))]
+ for r in recipients:
+ items.append(("to[]", r))
+ if mailtype:
+ items.append(("mailtype", mailtype))
+ if coversheet is not None:
+ items.append(("coversheet", coversheet))
+ if duplex is not None:
+ items.append(("duplex", duplex))
+ if ink is not None:
+ items.append(("ink", ink))
+ if paper is not None:
+ items.append(("paper", paper))
+ if returnenv is not None:
+ items.append(("returnenv", returnenv))
+ if preauth:
+ items.append(("preauth", "1"))
+ files = {"single_file": (filename, pdf_bytes, "application/pdf")}
+ resp = parse_xml(_post(items, files=files))
+ _raise_on_error(resp)
+ return resp
+
+
+def send_batch(zip_bytes: bytes, filename: str) -> dict:
+ """Submit a ZIP archive (PDFs + CSV) via batch method (method 1)."""
+ items = _auth_items()
+ files = {"multi_file": (filename, zip_bytes, "application/zip")}
+ resp = parse_xml(_post(items, files=files))
+ _raise_on_error(resp)
+ return resp
+
+
+def doauth(authcode: str) -> dict:
+ """Release a preauth job into production."""
+ items = _auth_items() + [("doauth", authcode)]
+ resp = parse_xml(_post(items))
+ _raise_on_error(resp)
+ return resp
+
+
+def _tracking_query(**kwargs) -> dict:
+ items = _auth_items() + [(k, v) for k, v in kwargs.items() if v]
+ resp = parse_xml(_post(items))
+ _raise_on_error(resp)
+ return resp
+
+
+def tracking(cert: str | None = None, doc_id: str | None = None,
+ fmt: str = "xml") -> dict:
+ """Tracking info by certified number or doc_id. fmt: 'html'|'xml'|'json'."""
+ args = {}
+ if cert:
+ args["cert"] = cert
+ args["getinfo"] = "trackx" if fmt == "xml" else "track"
+ elif doc_id:
+ args["doc_id"] = doc_id
+ args["getinfo"] = "trackx" if fmt == "xml" else "track"
+ else:
+ raise LetterStreamError("tracking() requires cert or doc_id")
+ if fmt == "json":
+ args["responseformat"] = "json"
+ return _tracking_query(**args)
+
+
+def signature(cert: str | None = None, doc_id: str | None = None) -> bytes:
+ """Return the certified signature file as PDF bytes."""
+ args = {"getinfo": "sig"}
+ if cert:
+ args["cert"] = cert
+ elif doc_id:
+ args["doc_id"] = doc_id
+ else:
+ raise LetterStreamError("signature() requires cert or doc_id")
+ items = _auth_items() + list(args.items())
+ raw = _post(items)
+ if raw[:4] == b"%PDF":
+ return raw
+ resp = parse_xml(raw)
+ _raise_on_error(resp)
+ raise LetterStreamError("No signature file returned")
+
+
+def proof(doc_id: str) -> bytes:
+ """Return the document proof as PDF bytes (base64-decoded when needed)."""
+ items = _auth_items() + [("doc_id", doc_id), ("getinfo", "proof")]
+ raw = _post(items)
+ if raw[:4] == b"%PDF":
+ return raw
+ text = raw.decode("utf-8", "replace").strip()
+ if len(text) > 10000:
+ try:
+ return base64.b64decode(text)
+ except Exception as e:
+ raise LetterStreamError(f"Proof base64 decode failed: {e}") from e
+ resp = parse_xml(raw)
+ _raise_on_error(resp)
+ raise LetterStreamError("No proof returned")
diff --git a/backend/main.py b/backend/main.py
index 5634926..2a238f6 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -28,14 +28,18 @@ app.add_middleware(
allow_headers=["*"],
)
-# Include routers (intake, claims, staff)
+# Include routers (intake, claims, staff, packet)
from .intake import router as intake_router # noqa: E402
from .claims import router as claims_router # noqa: E402
from .staff import router as staff_router # noqa: E402
+from .packet import router as packet_router # noqa: E402
+from .letters import router as letters_router # noqa: E402
app.include_router(intake_router)
app.include_router(claims_router)
app.include_router(staff_router)
+app.include_router(packet_router)
+app.include_router(letters_router)
def _err(code: str, message: str, status_code: int):
@@ -46,6 +50,9 @@ def _err(code: str, message: str, status_code: int):
@app.on_event("startup")
async def _startup():
db.init_db()
+ # Ensure the welcome-packet overrides table exists on existing DBs.
+ from . import packet
+ packet.ensure_packet_table()
logger.info("DRE portal started; db=%s", db.get_db_path())
diff --git a/backend/models.py b/backend/models.py
index dded1c4..bb270b1 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -103,13 +103,15 @@ class IntakeRequest(BaseModel):
debtor: IntakeDebtor
claim: IntakeClaim
tos_accepted: bool = True
+ no_other_agency_accepted: bool
+ no_prior_action_accepted: bool
turnstile_token: str | None = None
- @field_validator("tos_accepted")
+ @field_validator("tos_accepted", "no_other_agency_accepted", "no_prior_action_accepted")
@classmethod
- def _tos(cls, v):
+ def _bool_req(cls, v):
if v is not True:
- raise ValueError("tos_accepted must be true")
+ raise ValueError("must be true")
return v
@@ -175,7 +177,7 @@ class StaffNoteCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
content: str = Field(..., min_length=1, max_length=10000)
visibility: str = "INTERNAL"
- author_name: str = Field(..., min_length=1, max_length=200)
+ author_name: str | None = Field(None, max_length=200)
@field_validator("visibility")
@classmethod
@@ -188,3 +190,46 @@ class StaffNoteCreate(BaseModel):
@classmethod
def _v(cls, v):
return _scan_pii(v)
+
+
+class StaffApproval(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ decision: str
+ staff_name: str | None = Field(None, max_length=200)
+ reason: str | None = Field(None, max_length=500)
+
+ @field_validator("decision")
+ @classmethod
+ def _dec(cls, v):
+ v = v.upper()
+ if v not in ("APPROVE", "REJECT"):
+ raise ValueError("decision must be APPROVE or REJECT")
+ return v
+
+ @field_validator("staff_name", "reason")
+ @classmethod
+ def _v(cls, v):
+ return _scan_pii(v)
+
+
+class StaffLetterUpdate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ subject: str = Field(..., min_length=1, max_length=200)
+ body: str = Field(..., min_length=1, max_length=20000)
+ staff_name: str | None = Field(None, max_length=200)
+
+ @field_validator("subject", "body", "staff_name")
+ @classmethod
+ def _v(cls, v):
+ return _scan_pii(v)
+
+
+class StaffOnboardingUpdate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ received: bool
+ received_by: str | None = Field(None, max_length=200)
+
+ @field_validator("received_by")
+ @classmethod
+ def _v(cls, v):
+ return _scan_pii(v)
diff --git a/backend/packet.py b/backend/packet.py
new file mode 100644
index 0000000..e5fdfb7
--- /dev/null
+++ b/backend/packet.py
@@ -0,0 +1,659 @@
+"""
+DRE Welcome Packet — document templating engine.
+
+Three jobs:
+ 1. Pre-populate the 6 welcome-packet documents from the client's original
+ submission (claims/clients/debtors tables).
+ 2. Let the client edit any field; recovery-impacting changes are written to
+ audit_log so recovery staff can see exactly what changed.
+ 3. Render the filled documents as HTML (live) and PDF (print/sign).
+
+Data model:
+ - FIELD_CATALOG : canonical profile fields (label, source column, type,
+ required, recovery_impact).
+ - DOC_PLACEHOLDERS : per document, ordered list of (placeholder_token -> profile_key).
+ - packet_field_overrides table : stores the client's final value per field,
+ merged over pre-populated submission values.
+
+The markdown templates in docs/welcome-packet/ use {{profile_key}} tokens in the
+cells/words that should be pre-populated. Signature blocks, notary blocks, and
+checkbox lists are completed at signing time and are left as-is.
+"""
+from __future__ import annotations
+
+import os
+import re
+import subprocess
+import uuid
+from datetime import datetime
+
+import markdown
+
+from . import db as dbmod
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi.responses import HTMLResponse, JSONResponse, Response
+
+from . import auth as authmod
+from .db import get_conn
+
+router = APIRouter()
+
+DOCS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docs", "welcome-packet")
+
+DOC_ORDER = [
+ "01-LPOA.md",
+ "02-Terms-of-Service.md",
+ "03-Fee-Schedule.md",
+ "04-Third-Party-Consent.md",
+ "05-Debtor-Info-Sheet.md",
+ "06-ACH-Authorization.md",
+]
+
+DOC_TITLES = {
+ "01-LPOA.md": "Limited Power of Attorney",
+ "02-Terms-of-Service.md": "Terms of Service",
+ "03-Fee-Schedule.md": "Schedule A — Fee Schedule",
+ "04-Third-Party-Consent.md": "Third-Party Sharing Consent",
+ "05-Debtor-Info-Sheet.md": "Debtor Information Sheet",
+ "06-ACH-Authorization.md": "ACH / Disbursement Authorization",
+}
+
+# ---------------------------------------------------------------------------
+# Onboarding paperwork receipt tracking. One row per welcome-packet document
+# per claim; staff mark each doc "received" once the client returns it
+# (e-sign via DocuSeal or manual upload). Recovery escalation is gated on
+# every document being received.
+# ---------------------------------------------------------------------------
+ONBOARDING_DOCS = [
+ ("LPOA", "Limited Power of Attorney"),
+ ("TOS", "Terms of Service"),
+ ("FEE_SCHEDULE", "Schedule A — Fee Schedule"),
+ ("THIRD_PARTY_CONSENT", "Third-Party Sharing Consent"),
+ ("DEBTOR_INFO", "Debtor Information Sheet"),
+ ("ACH", "ACH / Disbursement Authorization"),
+]
+
+
+def ensure_onboarding_docs(conn, claim_id: str) -> None:
+ """Seed the 6 onboarding-doc rows for a claim if they don't exist yet."""
+ now = dbmod.utcnow_iso()
+ for key, _title in ONBOARDING_DOCS:
+ conn.execute(
+ "INSERT OR IGNORE INTO onboarding_docs (id, claim_id, doc_key, status, created_at) "
+ "VALUES (?, ?, ?, 'PENDING', ?)",
+ (dbmod.new_uuid(), claim_id, key, now),
+ )
+
+
+def onboarding_status(conn, claim_id: str) -> dict:
+ """Compute receipt status for a claim's onboarding paperwork."""
+ ensure_onboarding_docs(conn, claim_id)
+ rows = conn.execute(
+ "SELECT doc_key, status, docuseal_submission_id, docuseal_submitter_id, "
+ "docuseal_slug, docuseal_embed_src, docuseal_status, docuseal_sent_at "
+ "FROM onboarding_docs WHERE claim_id = ? ORDER BY rowid",
+ (claim_id,),
+ ).fetchall()
+ title_by_key = {k: t for k, t in ONBOARDING_DOCS}
+ items = []
+ for r in rows:
+ item = {
+ "key": r["doc_key"],
+ "title": title_by_key.get(r["doc_key"], r["doc_key"]),
+ "status": r["status"],
+ }
+ if r["docuseal_submission_id"] is not None:
+ item["docuseal"] = {
+ "submission_id": r["docuseal_submission_id"],
+ "submitter_id": r["docuseal_submitter_id"],
+ "slug": r["docuseal_slug"],
+ "status": r["docuseal_status"],
+ "sent_at": r["docuseal_sent_at"],
+ "signing_url": _docuseal_signing_url(r["docuseal_slug"], r["docuseal_embed_src"]),
+ }
+ items.append(item)
+ received = sum(1 for i in items if i["status"] == "RECEIVED")
+ return {
+ "received": received,
+ "total": len(items),
+ "complete": received == len(items),
+ "outstanding": [i for i in items if i["status"] != "RECEIVED"],
+ "items": items,
+ }
+
+
+def _docuseal_signing_url(slug: str | None, embed_src: str | None) -> str | None:
+ """Canonical public signing URL for a DocuSeal submitter, or None if unsigned."""
+ if slug:
+ return f"https://sign.debtrecoveryexperts.com/s/{slug}"
+ return embed_src
+
+# ---------------------------------------------------------------------------
+# Field catalog. `source` is a "table.column" path resolvable against a claim
+# row joined with client + debtor. `recovery_impact` marks fields where a change
+# alters WHO we pursue, HOW MUCH, WHEN it was due, or whether a personal
+# guarantee exists — i.e. anything that changes our collection strategy.
+# ---------------------------------------------------------------------------
+FIELD_CATALOG = {
+ # --- Client identity ---
+ "client_company_name": {"label": "Client Legal Name", "source": "client.company_name", "type": "text", "required": True, "recovery_impact": False},
+ "client_entity_type": {"label": "Client Entity Type", "source": None, "type": "text", "required": False, "recovery_impact": False},
+ "client_contact_name": {"label": "Client Contact / Print Name", "source": "client.contact_name", "type": "text", "required": True, "recovery_impact": False},
+ # --- Debtor identity (recovery-critical) ---
+ "debtor_name": {"label": "Debtor Legal Name", "source": "debtor.name", "type": "text", "required": True, "recovery_impact": True},
+ "debtor_entity_type": {"label": "Debtor Entity Type", "source": "debtor.business_type", "type": "text", "required": False, "recovery_impact": True},
+ "debtor_dba": {"label": "DBA / Trade Name", "source": None, "type": "text", "required": False, "recovery_impact": True},
+ "debtor_state": {"label": "State of Formation", "source": None, "type": "text", "required": False, "recovery_impact": True},
+ "debtor_registered_agent": {"label": "Registered Agent Name", "source": None, "type": "text", "required": False, "recovery_impact": True},
+ "debtor_registered_agent_address": {"label": "Registered Agent Address", "source": None, "type": "text", "required": False, "recovery_impact": True},
+ "debtor_ein": {"label": "EIN / Tax ID", "source": None, "type": "text", "required": False, "recovery_impact": True},
+ "debtor_website": {"label": "Debtor Website", "source": None, "type": "text", "required": False, "recovery_impact": False},
+ "debtor_contact_name": {"label": "Debtor Primary Contact", "source": None, "type": "text", "required": False, "recovery_impact": True},
+ "debtor_contact_title": {"label": "Debtor Contact Title", "source": None, "type": "text", "required": False, "recovery_impact": False},
+ "debtor_contact_phone": {"label": "Debtor Direct Phone", "source": "debtor.contact_phone", "type": "text", "required": False, "recovery_impact": True},
+ "debtor_contact_email": {"label": "Debtor Email", "source": "debtor.contact_email", "type": "text", "required": False, "recovery_impact": True},
+ "debtor_address": {"label": "Debtor Business Address", "source": "debtor.physical_address", "type": "text", "required": False, "recovery_impact": True},
+ "debtor_city_state_zip": {"label": "Debtor City / State / ZIP", "source": None, "type": "text", "required": False, "recovery_impact": True},
+ "debtor_alt_address": {"label": "Debtor Alternate Address", "source": None, "type": "text", "required": False, "recovery_impact": False},
+ "personal_guarantee": {"label": "Personal Guarantee Exists?", "source": None, "type": "checkbox", "options": ["Yes", "No", "Unsure"], "required": True, "recovery_impact": True},
+ "personal_guarantee_signer": {"label": "Personal Guarantee Signer", "source": None, "type": "text", "required": False, "recovery_impact": True},
+ "debtor_bank": {"label": "Debtor Bank / FI", "source": None, "type": "text", "required": False, "recovery_impact": False},
+ "debtor_ar_lenders": {"label": "Debtor Known AR / Lenders", "source": None, "type": "text", "required": False, "recovery_impact": False},
+ # --- Claim substance (recovery-critical) ---
+ "claim_amount_cents": {"label": "Claim Amount", "source": "claim.amount_cents", "type": "money", "required": True, "recovery_impact": True},
+ "invoice_date": {"label": "Invoice / Contract Date", "source": "claim.invoice_date", "type": "date", "required": False, "recovery_impact": True},
+ "dre_claim_number": {"label": "DRE Claim Number", "source": "claim.claim_number", "type": "text", "required": True, "recovery_impact": False},
+ # --- ACH / disbursement (not recovery strategy, but completion-gated) ---
+ "account_holder_name": {"label": "Account Holder Legal Name", "source": "client.company_name", "type": "text", "required": True, "recovery_impact": False},
+ "account_holder_entity_type": {"label": "Account Holder Entity Type", "source": None, "type": "text", "required": False, "recovery_impact": False},
+ "bank_name": {"label": "Bank Name", "source": None, "type": "text", "required": True, "recovery_impact": False},
+ "account_type": {"label": "Account Type", "source": None, "type": "checkbox", "options": ["Checking", "Savings"], "required": True, "recovery_impact": False},
+ "routing_number": {"label": "Routing (ABA) Number", "source": None, "type": "text", "required": True, "recovery_impact": False},
+ "account_number": {"label": "Account Number", "source": None, "type": "text", "required": True, "recovery_impact": False},
+}
+
+# Ordered placeholders per document. Each token in the markdown maps to a
+# profile key. Only text/money/date fields are substituted inline; checkboxes
+# and signatures are completed at signing.
+DOC_PLACEHOLDERS = {
+ "01-LPOA.md": [
+ ("client_legal_name", "client_company_name"),
+ ("client_entity_type", "client_entity_type"),
+ ("debtor_legal_name", "debtor_name"),
+ ("claim_amount", "claim_amount_cents"),
+ ("invoice_date", "invoice_date"),
+ ("dre_claim_number", "dre_claim_number"),
+ ],
+ "02-Terms-of-Service.md": [],
+ "03-Fee-Schedule.md": [],
+ "04-Third-Party-Consent.md": [],
+ "05-Debtor-Info-Sheet.md": [
+ ("debtor_legal_name", "debtor_name"),
+ ("debtor_entity_type", "debtor_entity_type"),
+ ("debtor_dba", "debtor_dba"),
+ ("debtor_state", "debtor_state"),
+ ("debtor_registered_agent", "debtor_registered_agent"),
+ ("debtor_registered_agent_address", "debtor_registered_agent_address"),
+ ("debtor_ein", "debtor_ein"),
+ ("debtor_website", "debtor_website"),
+ ("debtor_contact_name", "debtor_contact_name"),
+ ("debtor_contact_title", "debtor_contact_title"),
+ ("debtor_contact_phone", "debtor_contact_phone"),
+ ("debtor_contact_email", "debtor_contact_email"),
+ ("debtor_address", "debtor_address"),
+ ("debtor_city_state_zip", "debtor_city_state_zip"),
+ ("debtor_alt_address", "debtor_alt_address"),
+ ("debtor_bank", "debtor_bank"),
+ ("debtor_ar_lenders", "debtor_ar_lenders"),
+ ("personal_guarantee_signer", "personal_guarantee_signer"),
+ ],
+ "06-ACH-Authorization.md": [
+ ("account_holder_name", "account_holder_name"),
+ ("account_holder_entity_type", "account_holder_entity_type"),
+ ("bank_name", "bank_name"),
+ ("routing_number", "routing_number"),
+ ("account_number", "account_number"),
+ ],
+}
+
+# ---------------------------------------------------------------------------
+# Migration
+# ---------------------------------------------------------------------------
+PACKET_TABLE_SQL = """
+CREATE TABLE IF NOT EXISTS packet_field_overrides (
+ id TEXT PRIMARY KEY,
+ claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
+ profile_key TEXT NOT NULL,
+ value TEXT,
+ updated_by TEXT NOT NULL DEFAULT 'CLIENT' CHECK (updated_by IN ('CLIENT','STAFF')),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE(claim_id, profile_key)
+);
+"""
+
+
+def ensure_packet_table():
+ with dbmod.get_conn() as conn:
+ conn.execute(PACKET_TABLE_SQL)
+ conn.commit()
+
+
+# ---------------------------------------------------------------------------
+# Value resolution
+# ---------------------------------------------------------------------------
+def _format_value(profile_key: str, raw) -> str:
+ """Format a raw DB value for display in the document."""
+ if raw is None:
+ return ""
+ if FIELD_CATALOG[profile_key]["type"] == "money":
+ try:
+ cents = int(raw)
+ return f"${cents / 100:,.2f}"
+ except (TypeError, ValueError):
+ return str(raw)
+ if FIELD_CATALOG[profile_key]["type"] == "date":
+ s = str(raw)
+ # dates may be ISO or already human; try to render nicely
+ for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
+ try:
+ return datetime.strptime(s, fmt).strftime("%B %d, %Y")
+ except ValueError:
+ continue
+ return s
+ return str(raw)
+
+
+def resolve_prepopulated(claim_row: dict, profile_key: str) -> str:
+ """Pull the pre-populated value for a field from the original submission row."""
+ source = FIELD_CATALOG[profile_key]["source"]
+ if not source:
+ return ""
+ table, column = source.split(".", 1)
+ raw = claim_row.get(column)
+ return _format_value(profile_key, raw)
+
+
+def load_claim_row(conn, claim_number: str) -> dict:
+ row = conn.execute(
+ """
+ SELECT c.claim_number, c.amount_cents, c.invoice_date, c.client_reference,
+ c.description, c.status, c.tier, c.created_at,
+ cl.company_name AS company_name, cl.contact_name AS contact_name,
+ cl.email AS client_email, cl.phone AS client_phone,
+ d.name AS name, d.business_type AS business_type,
+ d.contact_email AS contact_email, d.contact_phone AS contact_phone,
+ d.physical_address AS physical_address
+ FROM claims c
+ JOIN clients cl ON cl.id = c.client_id
+ JOIN debtors d ON d.id = c.debtor_id
+ WHERE c.claim_number = ?
+ """,
+ (claim_number,),
+ ).fetchone()
+ if row is None:
+ raise KeyError(claim_number)
+ return dict(row)
+
+
+def merged_values(conn, claim_number: str) -> tuple[dict, dict]:
+ """Pre-populated submission values, overlaid with client overrides."""
+ row = load_claim_row(conn, claim_number)
+ values = {}
+ for key in FIELD_CATALOG:
+ values[key] = resolve_prepopulated(row, key)
+ claim_id = _claim_id(conn, claim_number)
+ overrides = conn.execute(
+ "SELECT profile_key, value FROM packet_field_overrides WHERE claim_id = ?",
+ (claim_id,),
+ ).fetchall()
+ for o in overrides:
+ values[o["profile_key"]] = o["value"] or ""
+ return values, row
+
+
+def _claim_id(conn, claim_number: str) -> str:
+ return conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()["id"]
+
+
+# ---------------------------------------------------------------------------
+# Rendering
+# ---------------------------------------------------------------------------
+def fill_document(doc_filename: str, values: dict) -> str:
+ """Substitute {{token}} placeholders in a markdown doc with values."""
+ path = os.path.join(DOCS_DIR, doc_filename)
+ with open(path, "r", encoding="utf-8") as fh:
+ text = fh.read()
+
+ placeholders = DOC_PLACEHOLDERS.get(doc_filename, [])
+ for token, profile_key in placeholders:
+ val = values.get(profile_key, "")
+ # Pre-populated value, or a blank fill-line if the client hasn't supplied it.
+ display = val if val != "" else "________________________"
+ text = text.replace("{{" + token + "}}", display)
+
+ # Any leftover un-mapped tokens -> blank
+ text = re.sub(r"\{\{[a-z0-9_]+\}\}", "________________________", text)
+ return text
+
+
+def document_to_html(doc_filename: str, values: dict) -> str:
+ md_text = fill_document(doc_filename, values)
+ html = markdown.markdown(md_text, extensions=["tables", "sane_lists"])
+ return html
+
+
+def packet_html(conn, claim_number: str) -> str:
+ """Render all 6 documents as one styled HTML page (live preview)."""
+ values, row = merged_values(conn, claim_number)
+ sections = []
+ for doc in DOC_ORDER:
+ body = document_to_html(doc, values)
+ sections.append(
+ f''
+ f'{DOC_TITLES[doc]}
'
+ f'{body}
'
+ )
+ return _wrap_html(row, "\n".join(sections))
+
+
+def _wrap_html(row: dict, body: str) -> str:
+ client = row.get("company_name") or row.get("contact_name") or "Client"
+ debtor = row.get("name") or "Debtor"
+ return f"""
+
+
+Welcome Packet — {row.get('claim_number')}
+
+
+
+
Debt Recovery Experts — Welcome Packet
+
Claim {row.get('claim_number')} · Client: {client} · Debtor: {debtor}
+
+
+{body}
+
+
+"""
+
+
+def render_pdf(html: str, out_path: str) -> str:
+ """Render HTML -> PDF via headless Chromium. Returns the output path."""
+ tmp_html = out_path + ".html"
+ with open(tmp_html, "w", encoding="utf-8") as fh:
+ fh.write(html)
+ subprocess.run(
+ [
+ "/usr/bin/chromium", "--headless", "--no-sandbox", "--disable-gpu",
+ "--print-to-pdf=" + out_path, "--no-pdf-header-footer", tmp_html,
+ ],
+ check=True, capture_output=True, timeout=60,
+ )
+ os.remove(tmp_html)
+ return out_path
+
+
+# ---------------------------------------------------------------------------
+# Change logging
+# ---------------------------------------------------------------------------
+def log_field_change(conn, claim_id: str, profile_key: str, old_value: str,
+ new_value: str, actor: str) -> None:
+ """Write an audit_log entry. recovery_impact is recorded in `reason` so it's
+ queryable: recovery-impacting changes carry reason='recovery-impacting'."""
+ field = FIELD_CATALOG.get(profile_key, {})
+ reason = "recovery-impacting" if field.get("recovery_impact") else None
+ conn.execute(
+ """
+ INSERT INTO audit_log (id, entity_type, entity_id, action, field,
+ old_value, new_value, actor, reason, created_at)
+ VALUES (?, 'claim', ?, 'packet_field_update', ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ uuid.uuid4().hex, claim_id, profile_key,
+ old_value if old_value is not None else None,
+ new_value if new_value is not None else None,
+ actor, reason, dbmod.utcnow_iso(),
+ ),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Submission status — the "visual indicator" of what the client has submitted.
+# ---------------------------------------------------------------------------
+def submission_status(conn, claim_id: str, values: dict) -> dict:
+ """Compute which documents the client has submitted so far.
+
+ Sources:
+ - `documents` table: claim substantiation uploads (uploaded_by=CLIENT).
+ - effective field values (pre-populated + client overrides): a required
+ field counts as supplied whenever it has a non-empty value.
+ - `tos_accepted_at` / `no_other_agency_at` / `no_prior_action_at`: checkboxes
+ the client confirmed during intake.
+ """
+ docs = conn.execute(
+ "SELECT original_name, uploaded_by, created_at FROM documents WHERE claim_id = ? ORDER BY created_at ASC",
+ (claim_id,),
+ ).fetchall()
+ client_docs = [d for d in docs if d["uploaded_by"] == "CLIENT"]
+
+ overrides = conn.execute(
+ "SELECT profile_key, value, updated_by FROM packet_field_overrides WHERE claim_id = ?",
+ (claim_id,),
+ ).fetchall()
+ overridden_keys = {o["profile_key"] for o in overrides if (o["value"] or "").strip()}
+
+ # tos_accepted_at lives on clients; no_other_agency_at / no_prior_action_at on claims.
+ row = conn.execute(
+ "SELECT cl.tos_accepted_at, c.no_other_agency_at, c.no_prior_action_at "
+ "FROM claims c JOIN clients cl ON cl.id = c.client_id WHERE c.id = ?",
+ (claim_id,),
+ ).fetchone()
+
+ tos = bool(row and row["tos_accepted_at"])
+ no_other_agency = bool(row and row["no_other_agency_at"])
+ no_prior_action = bool(row and row["no_prior_action_at"])
+
+ # A required field is "supplied" when its effective value is non-empty
+ # (either pre-populated from the submission or overridden by the client).
+ required = [key for key, meta in FIELD_CATALOG.items() if meta.get("required")]
+ supplied_fields = [k for k in required if (values.get(k) or "").strip()]
+ empty_required = [k for k in required if not (values.get(k) or "").strip()]
+
+ return {
+ "documents_submitted": len(client_docs),
+ "documents": [
+ {"name": d["original_name"], "uploaded_by": d["uploaded_by"], "at": d["created_at"]}
+ for d in docs
+ ],
+ "fields_supplied": len(supplied_fields),
+ "fields_supplied_list": supplied_fields,
+ "fields_overridden_list": sorted(overridden_keys),
+ "fields_required_total": len(required),
+ "fields_required_missing": empty_required,
+ "tos_accepted": tos,
+ "no_other_agency_confirmed": no_other_agency,
+ "no_prior_action_confirmed": no_prior_action,
+ "complete": len(empty_required) == 0 and len(client_docs) > 0,
+ }
+
+
+# ---------------------------------------------------------------------------
+# API endpoints (client-scoped + staff)
+# ---------------------------------------------------------------------------
+def _claim_row_for_client(conn, claim_number: str, client_id: str):
+ row = conn.execute(
+ "SELECT id FROM claims WHERE claim_number = ? AND client_id = ?",
+ (claim_number, client_id),
+ ).fetchone()
+ if row is None:
+ raise HTTPException(status_code=404, detail={"code": "not_found", "message": "Claim not found."})
+ return row["id"]
+
+
+def _claim_row_any(conn, claim_number: str):
+ row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()
+ if row is None:
+ raise HTTPException(status_code=404, detail={"code": "not_found", "message": "Claim not found."})
+ return row["id"]
+
+
+@router.get("/api/claims/{claim_number}/packet/fields")
+def get_packet_fields(claim_number: str, session: dict = Depends(authmod.require_client)):
+ """Return the field catalog with pre-populated + client-overridden values,
+ plus a submission-status summary (the visual indicator)."""
+ with get_conn() as conn:
+ claim_id = _claim_row_for_client(conn, claim_number, session["client_id"])
+ values, row = merged_values(conn, claim_number)
+ overrides = {
+ o["profile_key"]: o["value"]
+ for o in conn.execute(
+ "SELECT profile_key, value FROM packet_field_overrides WHERE claim_id = ?", (claim_id,)
+ ).fetchall()
+ }
+ status_summary = submission_status(conn, claim_id, values)
+
+ fields = []
+ for key, meta in FIELD_CATALOG.items():
+ fields.append({
+ "key": key,
+ "label": meta["label"],
+ "type": meta["type"],
+ "required": meta["required"],
+ "recovery_impact": meta["recovery_impact"],
+ "options": meta.get("options"),
+ "value": values.get(key, ""),
+ "source": meta.get("source"),
+ "is_override": key in overrides,
+ })
+ return {"claim_number": claim_number, "fields": fields, "submission": status_summary}
+
+
+@router.patch("/api/claims/{claim_number}/packet/fields")
+async def update_packet_field(claim_number: str, request: Request,
+ session: dict = Depends(authmod.require_client)):
+ """Client edits a packet field. Recovery-impacting changes are written to
+ audit_log so recovery staff see exactly what changed."""
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse(status_code=422, content={"error": {"code": "validation_error", "message": "Invalid JSON body."}})
+ key = body.get("key")
+ value = body.get("value")
+ if key not in FIELD_CATALOG:
+ return JSONResponse(status_code=422, content={"error": {"code": "validation_error", "message": "Unknown field."}})
+ if value is None or (isinstance(value, str) and value.strip() == ""):
+ value = ""
+ else:
+ value = str(value).strip()
+
+ with get_conn() as conn:
+ claim_id = _claim_row_for_client(conn, claim_number, session["client_id"])
+ old = conn.execute(
+ "SELECT value FROM packet_field_overrides WHERE claim_id = ? AND profile_key = ?",
+ (claim_id, key),
+ ).fetchone()
+ old_value = old["value"] if old else None
+ # If no override exists yet, the document was showing the pre-populated
+ # submission value — capture that as the true "before" for the audit trail.
+ if old_value is None:
+ try:
+ values, _ = merged_values(conn, claim_number)
+ old_value = values.get(key) or None
+ except KeyError:
+ old_value = None
+ now = dbmod.utcnow_iso()
+ conn.execute(
+ """
+ INSERT INTO packet_field_overrides (id, claim_id, profile_key, value, updated_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'CLIENT', ?, ?)
+ ON CONFLICT(claim_id, profile_key) DO UPDATE SET value=excluded.value,
+ updated_by=excluded.updated_by, updated_at=excluded.updated_at
+ """,
+ (uuid.uuid4().hex, claim_id, key, value, now, now),
+ )
+ # Log recovery-impacting changes only when the value actually changed.
+ if old_value != value:
+ log_field_change(conn, claim_id, key, old_value, value, "client")
+ conn.commit()
+ return {"key": key, "value": value, "saved": True}
+
+
+@router.get("/api/claims/{claim_number}/packet/preview")
+def get_packet_preview(claim_number: str, session: dict = Depends(authmod.require_client)):
+ """Live HTML preview of the filled packet."""
+ with get_conn() as conn:
+ _claim_row_for_client(conn, claim_number, session["client_id"])
+ html_out = packet_html(conn, claim_number)
+ return HTMLResponse(content=html_out)
+
+
+@router.get("/api/claims/{claim_number}/packet.pdf")
+def get_packet_pdf(claim_number: str, session: dict = Depends(authmod.require_client)):
+ """PDF of the filled packet (generated on demand via headless Chromium)."""
+ import tempfile
+ with get_conn() as conn:
+ _claim_row_for_client(conn, claim_number, session["client_id"])
+ html_out = packet_html(conn, claim_number)
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tf:
+ out_path = tf.name
+ try:
+ render_pdf(html_out, out_path)
+ with open(out_path, "rb") as fh:
+ data = fh.read()
+ finally:
+ os.remove(out_path)
+ return Response(content=data, media_type="application/pdf",
+ headers={"Content-Disposition": f'inline; filename="{claim_number}-welcome-packet.pdf"'})
+
+
+# --- Staff endpoints (staff key auth) ---
+@router.get("/api/staff/claims/{claim_number}/packet/preview")
+def staff_packet_preview(claim_number: str, _staff: None = Depends(authmod.require_staff)):
+ with get_conn() as conn:
+ _claim_row_any(conn, claim_number)
+ html_out = packet_html(conn, claim_number)
+ return HTMLResponse(content=html_out)
+
+
+@router.get("/api/staff/claims/{claim_number}/packet.pdf")
+def staff_packet_pdf(claim_number: str, _staff: None = Depends(authmod.require_staff)):
+ import tempfile
+ with get_conn() as conn:
+ _claim_row_any(conn, claim_number)
+ html_out = packet_html(conn, claim_number)
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tf:
+ out_path = tf.name
+ try:
+ render_pdf(html_out, out_path)
+ with open(out_path, "rb") as fh:
+ data = fh.read()
+ finally:
+ os.remove(out_path)
+ return Response(content=data, media_type="application/pdf",
+ headers={"Content-Disposition": f'inline; filename="{claim_number}-welcome-packet.pdf"'})
diff --git a/backend/packet_fields.json b/backend/packet_fields.json
new file mode 100644
index 0000000..4646ee6
--- /dev/null
+++ b/backend/packet_fields.json
@@ -0,0 +1,228 @@
+{
+ "account_holder_entity_type": {
+ "label": "Account Holder Entity Type",
+ "recovery_impact": false,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "account_holder_name": {
+ "label": "Account Holder Legal Name",
+ "recovery_impact": false,
+ "required": true,
+ "source": "client.company_name",
+ "type": "text"
+ },
+ "account_number": {
+ "label": "Account Number",
+ "recovery_impact": false,
+ "required": true,
+ "source": null,
+ "type": "text"
+ },
+ "account_type": {
+ "label": "Account Type",
+ "options": [
+ "Checking",
+ "Savings"
+ ],
+ "recovery_impact": false,
+ "required": true,
+ "source": null,
+ "type": "checkbox"
+ },
+ "bank_name": {
+ "label": "Bank Name",
+ "recovery_impact": false,
+ "required": true,
+ "source": null,
+ "type": "text"
+ },
+ "claim_amount_cents": {
+ "label": "Claim Amount",
+ "recovery_impact": true,
+ "required": true,
+ "source": "claim.amount_cents",
+ "type": "money"
+ },
+ "client_company_name": {
+ "label": "Client Legal Name",
+ "recovery_impact": false,
+ "required": true,
+ "source": "client.company_name",
+ "type": "text"
+ },
+ "client_contact_name": {
+ "label": "Client Contact / Print Name",
+ "recovery_impact": false,
+ "required": true,
+ "source": "client.contact_name",
+ "type": "text"
+ },
+ "client_entity_type": {
+ "label": "Client Entity Type",
+ "recovery_impact": false,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_address": {
+ "label": "Debtor Business Address",
+ "recovery_impact": true,
+ "required": false,
+ "source": "debtor.physical_address",
+ "type": "text"
+ },
+ "debtor_alt_address": {
+ "label": "Debtor Alternate Address",
+ "recovery_impact": false,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_ar_lenders": {
+ "label": "Debtor Known AR / Lenders",
+ "recovery_impact": false,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_bank": {
+ "label": "Debtor Bank / FI",
+ "recovery_impact": false,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_city_state_zip": {
+ "label": "Debtor City / State / ZIP",
+ "recovery_impact": true,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_contact_email": {
+ "label": "Debtor Email",
+ "recovery_impact": true,
+ "required": false,
+ "source": "debtor.contact_email",
+ "type": "text"
+ },
+ "debtor_contact_name": {
+ "label": "Debtor Primary Contact",
+ "recovery_impact": true,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_contact_phone": {
+ "label": "Debtor Direct Phone",
+ "recovery_impact": true,
+ "required": false,
+ "source": "debtor.contact_phone",
+ "type": "text"
+ },
+ "debtor_contact_title": {
+ "label": "Debtor Contact Title",
+ "recovery_impact": false,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_dba": {
+ "label": "DBA / Trade Name",
+ "recovery_impact": true,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_ein": {
+ "label": "EIN / Tax ID",
+ "recovery_impact": true,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_entity_type": {
+ "label": "Debtor Entity Type",
+ "recovery_impact": true,
+ "required": false,
+ "source": "debtor.business_type",
+ "type": "text"
+ },
+ "debtor_name": {
+ "label": "Debtor Legal Name",
+ "recovery_impact": true,
+ "required": true,
+ "source": "debtor.name",
+ "type": "text"
+ },
+ "debtor_registered_agent": {
+ "label": "Registered Agent Name",
+ "recovery_impact": true,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_registered_agent_address": {
+ "label": "Registered Agent Address",
+ "recovery_impact": true,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_state": {
+ "label": "State of Formation",
+ "recovery_impact": true,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "debtor_website": {
+ "label": "Debtor Website",
+ "recovery_impact": false,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "dre_claim_number": {
+ "label": "DRE Claim Number",
+ "recovery_impact": false,
+ "required": true,
+ "source": "claim.claim_number",
+ "type": "text"
+ },
+ "invoice_date": {
+ "label": "Invoice / Contract Date",
+ "recovery_impact": true,
+ "required": false,
+ "source": "claim.invoice_date",
+ "type": "date"
+ },
+ "personal_guarantee": {
+ "label": "Personal Guarantee Exists?",
+ "options": [
+ "Yes",
+ "No",
+ "Unsure"
+ ],
+ "recovery_impact": true,
+ "required": true,
+ "source": null,
+ "type": "checkbox"
+ },
+ "personal_guarantee_signer": {
+ "label": "Personal Guarantee Signer",
+ "recovery_impact": true,
+ "required": false,
+ "source": null,
+ "type": "text"
+ },
+ "routing_number": {
+ "label": "Routing (ABA) Number",
+ "recovery_impact": false,
+ "required": true,
+ "source": null,
+ "type": "text"
+ }
+}
\ No newline at end of file
diff --git a/backend/schema.sql b/backend/schema.sql
index 6849384..c3da853 100644
--- a/backend/schema.sql
+++ b/backend/schema.sql
@@ -50,6 +50,24 @@ CREATE TABLE IF NOT EXISTS claims (
invoice_date TEXT, -- ISO date
date_assigned TEXT, -- set when moved out of NEW
date_resolved TEXT, -- set on SETTLED/CLOSED/WRITE_OFF
+ -- Client signed representations at intake (added via db._migrate on existing DBs)
+ no_other_agency_at TEXT, -- confirmed no other agency/attorney engaged
+ no_prior_action_at TEXT, -- confirmed no prior litigation/judgment/bankruptcy
+ -- AI analysis + approval (added via db._migrate on existing DBs)
+ analysis_score INTEGER, -- 0-100, NULL until analyzed
+ analysis_summary TEXT, -- plain-English narrative
+ analysis_components TEXT, -- JSON breakdown of component scores
+ analysis_at TEXT, -- ISO timestamp of analysis
+ recommended_tier TEXT, -- AI-recommended starting tier
+ approval_status TEXT NOT NULL DEFAULT 'NONE'
+ CHECK (approval_status IN ('NONE','PENDING','APPROVED','REJECTED')),
+ approval_decision_by TEXT, -- staff name who approved/rejected
+ approval_decision_at TEXT,
+ -- AI-recommended letter (staff-customizable)
+ letter_subject TEXT,
+ letter_body TEXT,
+ letter_tier TEXT, -- tier the letter was generated for
+ letter_updated_at TEXT,
twentycrm_id TEXT, -- nullable
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -137,3 +155,80 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
+
+-- Welcome-packet field overrides: client's final value per profile field,
+-- merged over pre-populated submission values at render time.
+CREATE TABLE IF NOT EXISTS packet_field_overrides (
+ id TEXT PRIMARY KEY,
+ claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
+ profile_key TEXT NOT NULL,
+ value TEXT,
+ updated_by TEXT NOT NULL DEFAULT 'CLIENT' CHECK (updated_by IN ('CLIENT','STAFF')),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE(claim_id, profile_key)
+);
+CREATE INDEX IF NOT EXISTS idx_packet_overrides_claim ON packet_field_overrides(claim_id);
+
+-- Onboarding paperwork receipt tracking. One row per welcome-packet document
+-- per claim; staff mark each doc "received" once the client returns it.
+CREATE TABLE IF NOT EXISTS onboarding_docs (
+ id TEXT PRIMARY KEY,
+ claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
+ doc_key TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','RECEIVED')),
+ received_by TEXT,
+ received_at TEXT,
+ created_at TEXT NOT NULL,
+ UNIQUE(claim_id, doc_key)
+);
+CREATE INDEX IF NOT EXISTS idx_onboarding_claim ON onboarding_docs(claim_id);
+
+-- Physical mail queue (LetterStream). Lifecycle: DRAFT -> APPROVED -> PREAUTH -> SENT
+-- (+ REJECTED / CANCELLED / ERROR). One row per mailed letter.
+CREATE TABLE IF NOT EXISTS letters (
+ id TEXT PRIMARY KEY, -- uuid4
+ claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
+ letter_type TEXT NOT NULL DEFAULT 'demand',
+ subject TEXT,
+ body TEXT, -- rendered letter body snapshot
+ recipient_json TEXT NOT NULL, -- structured {name,name2,addr1,addr2,city,state,zip}
+ sender_json TEXT NOT NULL, -- structured {name,addr1,addr2,city,state,zip}
+ mailtype TEXT NOT NULL DEFAULT 'firstclass',
+ status TEXT NOT NULL DEFAULT 'DRAFT'
+ CHECK (status IN ('DRAFT','APPROVED','PREAUTH','SENT','REJECTED','CANCELLED','ERROR')),
+ job_id TEXT, -- LetterStream unique job name
+ batch_id TEXT, -- LetterStream batch id
+ doc_id TEXT, -- LetterStream doc id
+ tracking_no TEXT, -- USPS tracking (certified mail)
+ cost_cents INTEGER, -- pricing from preauth/response
+ authcode TEXT, -- preauth authcode (release via doauth)
+ pages INTEGER,
+ pdf_path TEXT, -- rendered PDF on disk
+ error TEXT,
+ note TEXT, -- reject/cancel reason (terminal states)
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ sent_at TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_letters_claim ON letters(claim_id);
+CREATE INDEX IF NOT EXISTS idx_letters_status ON letters(status);
+CREATE INDEX IF NOT EXISTS idx_letters_doc ON letters(doc_id);
+
+-- LetterStream tracking scan events (pushed via callback every 4h).
+CREATE TABLE IF NOT EXISTS letter_events (
+ id TEXT PRIMARY KEY,
+ letter_id TEXT NOT NULL REFERENCES letters(id) ON DELETE CASCADE,
+ scan_code TEXT,
+ scan_status TEXT,
+ scan_date TEXT,
+ scan_zip TEXT,
+ scan_facility TEXT,
+ tracking_id TEXT,
+ batch_id TEXT,
+ job_id TEXT,
+ doc_id TEXT,
+ raw_json TEXT,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_letter_events_letter ON letter_events(letter_id);
diff --git a/backend/staff.py b/backend/staff.py
index 6b69ce0..26c7149 100644
--- a/backend/staff.py
+++ b/backend/staff.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import html
+import json
import logging
import os
import uuid
@@ -14,16 +15,172 @@ from pydantic import ValidationError
from . import auth as authmod
from . import db
from .db import get_conn, get_upload_dir, new_uuid, utcnow_iso
-from .models import StaffClaimPatch, StaffNoteCreate
+from .models import StaffApproval, StaffClaimPatch, StaffLetterUpdate, StaffNoteCreate, StaffOnboardingUpdate
from .claims import ( # noqa: E402
MAX_FILE_BYTES, ALLOWED_EXT, MAGIC_BYTES, EXT_TO_KIND, STATUS_LABELS,
TIER_STEPS, _money, _err,
)
+from . import analysis
+from . import packet
+from . import docuseal
logger = logging.getLogger("dre.staff")
router = APIRouter()
+def _json_or_none(raw: str | None):
+ """Parse a JSON TEXT column safely, returning None on empty/invalid."""
+ if not raw:
+ return None
+ try:
+ import json
+ return json.loads(raw)
+ except (ValueError, TypeError):
+ return None
+
+
+def _tier_advance_gate(conn, row) -> str | None:
+ """Return a human-readable blocker if recovery may NOT advance tier, else None.
+
+ Rules: the claim must be approved & accepted (approval APPROVED + status
+ ACTIVE) AND all onboarding paperwork must be returned before escalation.
+ """
+ if row["approval_status"] != "APPROVED":
+ return "Cannot advance tier until the claim is approved and accepted."
+ if row["status"] != "ACTIVE":
+ return "Cannot advance tier until the claim is accepted (status ACTIVE)."
+ ob = packet.onboarding_status(conn, row["id"])
+ if not ob["complete"]:
+ outstanding = ", ".join(i["title"] for i in ob["outstanding"])
+ return f"Cannot advance tier until all onboarding paperwork is returned. Outstanding: {outstanding}."
+ return None
+
+
+# ---------------------------------------------------------------
+# GET /api/staff/me (acting staff identity)
+# ---------------------------------------------------------------
+@router.get("/api/staff/me")
+async def staff_me(staff_name: str = Depends(authmod.require_staff)):
+ return {"name": staff_name}
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/claims/{claim_number}/onboarding/send
+# Create DocuSeal signature requests for all pending onboarding docs,
+# prefilled from the client's submission, and email the signing links.
+# NOTE: registered BEFORE the {doc_key} route below so "send" is not
+# swallowed as a doc_key path segment.
+# ---------------------------------------------------------------
+@router.post("/api/staff/claims/{claim_number}/onboarding/send")
+async def staff_send_onboarding(claim_number: str, request: Request,
+ staff_name: str = Depends(authmod.require_staff)):
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+ send_email = bool(body.get("send_email", True))
+ with get_conn() as conn:
+ row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()
+ if row is None:
+ return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND)
+ claim_id = row["id"]
+ packet.ensure_onboarding_docs(conn, claim_id)
+ client = conn.execute(
+ "SELECT email, contact_name FROM clients cl JOIN claims c ON c.client_id = cl.id WHERE c.id = ?",
+ (claim_id,),
+ ).fetchone()
+ if client is None:
+ return _err("validation_error", "Claim has no linked client.", status.HTTP_422_UNPROCESSABLE_ENTITY)
+ if not client["email"]:
+ return _err("validation_error", "Client has no email on file.", status.HTTP_422_UNPROCESSABLE_ENTITY)
+ values, _vals_row = packet.merged_values(conn, claim_number)
+ pending = conn.execute(
+ "SELECT doc_key FROM onboarding_docs WHERE claim_id = ? AND status != 'RECEIVED' ORDER BY rowid",
+ (claim_id,),
+ ).fetchall()
+ if not pending:
+ return {"claim_number": claim_number, "sent": [], "errors": [],
+ "message": "No pending onboarding documents to send."}
+ title_by_key = dict(packet.ONBOARDING_DOCS)
+ sent = []
+ errors = []
+ for p in pending:
+ doc_key = p["doc_key"]
+ try:
+ sub = docuseal.create_submission(
+ doc_key, client["email"], client["contact_name"] or client["email"],
+ values, send_email=send_email,
+ )
+ except docuseal.DocuSealError as exc:
+ errors.append({"doc_key": doc_key, "error": str(exc)})
+ continue
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE onboarding_docs SET docuseal_submission_id = ?, docuseal_submitter_id = ?, "
+ "docuseal_slug = ?, docuseal_embed_src = ?, docuseal_status = ?, docuseal_sent_at = ? "
+ "WHERE claim_id = ? AND doc_key = ?",
+ (str(sub.get("submission_id")), str(sub.get("id")), sub.get("slug"),
+ sub.get("embed_src"), sub.get("status"), sub.get("sent_at") or now,
+ claim_id, doc_key),
+ )
+ sent.append({
+ "doc_key": doc_key,
+ "title": title_by_key.get(doc_key, doc_key),
+ "submission_id": sub.get("submission_id"),
+ "status": sub.get("status"),
+ "signing_url": docuseal.signing_url(sub.get("slug"), sub.get("embed_src")),
+ "sent_email": send_email,
+ })
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'claim', ?, 'onboarding_send', NULL, NULL, ?, ?, ?)",
+ (new_uuid(), claim_id, f"{len(sent)} doc(s) sent", staff_name, utcnow_iso()),
+ )
+ conn.commit()
+ return {"claim_number": claim_number, "sent": sent, "errors": errors}
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/claims/{claim_number}/onboarding/{doc_key}
+# Mark a welcome-packet document received (or not).
+# ---------------------------------------------------------------
+@router.post("/api/staff/claims/{claim_number}/onboarding/{doc_key}")
+async def staff_set_onboarding(claim_number: str, doc_key: str, request: Request,
+ staff_name: str = Depends(authmod.require_staff)):
+ if doc_key not in [k for k, _t in packet.ONBOARDING_DOCS]:
+ return _err("validation_error", "Unknown document key.", status.HTTP_422_UNPROCESSABLE_ENTITY)
+ try:
+ body = await request.json()
+ except Exception:
+ return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY)
+ try:
+ upd = StaffOnboardingUpdate.model_validate(body)
+ except ValidationError as exc:
+ parts = [f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()]
+ return _err("validation_error", "; ".join(parts), status.HTTP_422_UNPROCESSABLE_ENTITY)
+ with get_conn() as conn:
+ row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()
+ if row is None:
+ return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND)
+ packet.ensure_onboarding_docs(conn, row["id"])
+ now = utcnow_iso()
+ new_status = "RECEIVED" if upd.received else "PENDING"
+ received_by = (upd.received_by or staff_name) if upd.received else None
+ received_at = now if upd.received else None
+ conn.execute(
+ "UPDATE onboarding_docs SET status = ?, received_by = ?, received_at = ? "
+ "WHERE claim_id = ? AND doc_key = ?",
+ (new_status, received_by, received_at, row["id"], doc_key),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'claim', ?, 'onboarding_doc', ?, NULL, ?, ?, ?)",
+ (new_uuid(), row["id"], doc_key, new_status, staff_name, now),
+ )
+ conn.commit()
+ return await staff_get_claim(claim_number)
+
+
# ---------------------------------------------------------------
# GET /api/staff/claims
# ---------------------------------------------------------------
@@ -48,8 +205,9 @@ async def staff_list_claims(request: Request, _staff=Depends(authmod.require_sta
params.extend([like, like, like])
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
sql = (
- "SELECT c.claim_number, c.status, c.tier, c.amount_cents, c.created_at, c.date_resolved, "
- "c.date_assigned, cl.company_name, cl.client_number, d.name AS debtor_name, d.business_type "
+ "SELECT c.id AS claim_id, c.claim_number, c.status, c.tier, c.amount_cents, c.created_at, c.date_resolved, "
+ "c.date_assigned, c.analysis_score, c.approval_status, c.recommended_tier, "
+ "cl.company_name, cl.client_number, d.name AS debtor_name, d.business_type "
"FROM claims c JOIN clients cl ON cl.id = c.client_id JOIN debtors d ON d.id = c.debtor_id "
f"{where_sql} ORDER BY c.created_at DESC LIMIT ? OFFSET ?"
)
@@ -57,15 +215,20 @@ async def staff_list_claims(request: Request, _staff=Depends(authmod.require_sta
with get_conn() as conn:
rows = conn.execute(sql, tuple(params)).fetchall()
total = conn.execute(f"SELECT COUNT(*) AS n FROM claims c JOIN clients cl ON cl.id=c.client_id JOIN debtors d ON d.id=c.debtor_id {where_sql}", tuple(params[:-2])).fetchone()["n"]
- return {
- "claims": [
- {
+ claims = []
+ for r in rows:
+ ob = packet.onboarding_status(conn, r["claim_id"])
+ claims.append({
"claim_number": r["claim_number"],
"status": r["status"],
"status_label": STATUS_LABELS.get(r["status"], r["status"]),
"tier": r["tier"],
+ "tier_label": analysis.TIER_LABELS.get(r["tier"], r["tier"]),
"amount_cents": r["amount_cents"],
"amount_display": _money(r["amount_cents"]),
+ "analysis_score": r["analysis_score"],
+ "approval_status": r["approval_status"],
+ "recommended_tier": r["recommended_tier"],
"company_name": r["company_name"],
"client_number": r["client_number"],
"debtor_name": r["debtor_name"],
@@ -73,9 +236,14 @@ async def staff_list_claims(request: Request, _staff=Depends(authmod.require_sta
"created_at": r["created_at"],
"date_assigned": r["date_assigned"],
"date_resolved": r["date_resolved"],
- }
- for r in rows
- ],
+ "onboarding": {
+ "received": ob["received"],
+ "total": ob["total"],
+ "complete": ob["complete"],
+ },
+ })
+ return {
+ "claims": claims,
"total": total,
"limit": limit,
"offset": offset,
@@ -113,12 +281,16 @@ async def staff_get_claim(claim_number: str, _staff=Depends(authmod.require_staf
"ORDER BY created_at ASC",
(row["id"],),
).fetchall()
+ onboarding = packet.onboarding_status(conn, row["id"])
return {
"claim_number": row["claim_number"],
"status": row["status"],
"status_label": STATUS_LABELS.get(row["status"], row["status"]),
"tier": row["tier"],
"tier_step": TIER_STEPS.get(row["tier"], 1),
+ "tier_label": analysis.TIER_LABELS.get(row["tier"], row["tier"]),
+ "tier_description": analysis.TIER_DESCRIPTIONS.get(row["tier"], ""),
+ "next_tier": analysis.next_tier(row["tier"]),
"amount_cents": row["amount_cents"],
"amount_display": _money(row["amount_cents"]),
"description": row["description"],
@@ -127,6 +299,25 @@ async def staff_get_claim(claim_number: str, _staff=Depends(authmod.require_staf
"date_assigned": row["date_assigned"],
"date_resolved": row["date_resolved"],
"created_at": row["created_at"],
+ "analysis": {
+ "score": row["analysis_score"],
+ "summary": row["analysis_summary"],
+ "components": _json_or_none(row["analysis_components"]),
+ "at": row["analysis_at"],
+ "recommended_tier": row["recommended_tier"],
+ },
+ "approval": {
+ "status": row["approval_status"],
+ "decision_by": row["approval_decision_by"],
+ "decision_at": row["approval_decision_at"],
+ },
+ "letter": {
+ "subject": row["letter_subject"],
+ "body": row["letter_body"],
+ "tier": row["letter_tier"],
+ "updated_at": row["letter_updated_at"],
+ },
+ "onboarding": onboarding,
"client": {
"client_number": row["client_number"],
"company_name": row["company_name"],
@@ -167,7 +358,7 @@ async def staff_get_claim(claim_number: str, _staff=Depends(authmod.require_staf
# PATCH /api/staff/claims/{claim_number}
# ---------------------------------------------------------------
@router.patch("/api/staff/claims/{claim_number}")
-async def staff_patch_claim(claim_number: str, request: Request, _staff=Depends(authmod.require_staff)):
+async def staff_patch_claim(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)):
try:
body = await request.json()
except Exception:
@@ -193,7 +384,7 @@ async def staff_patch_claim(claim_number: str, request: Request, _staff=Depends(
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- (new_uuid(), "claim", claim_id, "status_change", "status", row["status"], patch.status, "staff", patch.reason, now),
+ (new_uuid(), "claim", claim_id, "status_change", "status", row["status"], patch.status, staff_name, patch.reason, now),
)
new_status = patch.status
# set date_assigned on first move out of NEW
@@ -210,10 +401,20 @@ async def staff_patch_claim(claim_number: str, request: Request, _staff=Depends(
)
# Tier change
if patch.tier is not None and patch.tier != row["tier"]:
+ # Gate escalation: recovery may not escalate until the claim is
+ # approved & accepted AND all onboarding paperwork is returned.
+ try:
+ escalating = analysis.TIER_ORDER.index(patch.tier) > analysis.TIER_ORDER.index(row["tier"])
+ except ValueError:
+ escalating = False
+ if escalating:
+ gate = _tier_advance_gate(conn, row)
+ if gate is not None:
+ return _err("conflict", gate, status.HTTP_409_CONFLICT)
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- (new_uuid(), "claim", claim_id, "status_change", "tier", row["tier"], patch.tier, "staff", patch.reason, now),
+ (new_uuid(), "claim", claim_id, "status_change", "tier", row["tier"], patch.tier, staff_name, patch.reason, now),
)
new_tier = patch.tier
conn.execute(
@@ -252,11 +453,210 @@ async def staff_get_claim_inner(claim_number: str):
}
+# ---------------------------------------------------------------
+# POST /api/staff/claims/{claim_number}/analyze (AI score)
+# ---------------------------------------------------------------
+@router.post("/api/staff/claims/{claim_number}/analyze")
+async def staff_analyze_claim(claim_number: str, _staff=Depends(authmod.require_staff)):
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT id FROM claims WHERE claim_number = ?", (claim_number,)
+ ).fetchone()
+ if row is None:
+ return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND)
+ analysis.analyze_and_store(conn, row["id"], actor="staff")
+ conn.commit()
+ return await staff_get_claim(claim_number)
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/claims/{claim_number}/approve (accept / reject)
+# ---------------------------------------------------------------
+@router.post("/api/staff/claims/{claim_number}/approve")
+async def staff_approve_claim(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)):
+ try:
+ body = await request.json()
+ except Exception:
+ return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY)
+ try:
+ approval = StaffApproval.model_validate(body)
+ except ValidationError as exc:
+ parts = [f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()]
+ return _err("validation_error", "; ".join(parts), status.HTTP_422_UNPROCESSABLE_ENTITY)
+ with get_conn() as conn:
+ row = conn.execute("SELECT * FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()
+ if row is None:
+ return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND)
+ if row["analysis_score"] is None:
+ return _err("conflict", "Claim has not been analyzed yet. Run analysis first.", status.HTTP_409_CONFLICT)
+ claim_id = row["id"]
+ actor = approval.staff_name or staff_name
+ now = utcnow_iso()
+ if approval.decision == "APPROVE":
+ new_approval = "APPROVED"
+ new_status = "ACTIVE"
+ # Ensure date_assigned is set on first move out of NEW
+ date_assigned = row["date_assigned"] or now
+ conn.execute(
+ "UPDATE claims SET approval_status = ?, approval_decision_by = ?, approval_decision_at = ?, "
+ "status = ?, date_assigned = ?, updated_at = ? WHERE id = ?",
+ (new_approval, actor, now, new_status, date_assigned, now, claim_id),
+ )
+ conn.execute(
+ "INSERT INTO case_notes (id, claim_id, author_type, author_name, subject, content, visibility, twentycrm_id, created_at) "
+ "VALUES (?, ?, 'SYSTEM', 'System', NULL, ?, 'SHARED', NULL, ?)",
+ (new_uuid(), claim_id, f"Claim approved by {actor} and moved to In Progress.", now),
+ )
+ # Seed the onboarding paperwork checklist on approval.
+ packet.ensure_onboarding_docs(conn, claim_id)
+ else: # REJECT
+ new_approval = "REJECTED"
+ new_status = "REJECTED"
+ conn.execute(
+ "UPDATE claims SET approval_status = ?, approval_decision_by = ?, approval_decision_at = ?, "
+ "status = ?, date_resolved = ?, updated_at = ? WHERE id = ?",
+ (new_approval, actor, now, new_status, now, now, claim_id),
+ )
+ conn.execute(
+ "INSERT INTO case_notes (id, claim_id, author_type, author_name, subject, content, visibility, twentycrm_id, created_at) "
+ "VALUES (?, ?, 'SYSTEM', 'System', NULL, ?, 'SHARED', NULL, ?)",
+ (new_uuid(), claim_id, f"Claim rejected by {actor}.", now),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) "
+ "VALUES (?, 'claim', ?, 'status_change', 'approval_status', ?, ?, ?, ?, ?)",
+ (new_uuid(), claim_id, row["approval_status"], new_approval, actor, approval.reason, now),
+ )
+ conn.commit()
+ return await staff_get_claim(claim_number)
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/claims/{claim_number}/letter (generate recommended letter)
+# Optional JSON body: {"tier": "TIER_2"} to generate for an explicit tier.
+# Defaults to the claim's current tier when omitted.
+# ---------------------------------------------------------------
+@router.post("/api/staff/claims/{claim_number}/letter")
+async def staff_generate_letter(claim_number: str, request: Request, _staff=Depends(authmod.require_staff)):
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+ override_tier = (body or {}).get("tier")
+ if override_tier is not None and override_tier not in analysis.TIER_ORDER:
+ return _err("validation_error", "tier must be one of: " + ", ".join(analysis.TIER_ORDER), status.HTTP_422_UNPROCESSABLE_ENTITY)
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT c.id, c.claim_number, c.tier, c.amount_cents, c.client_reference, c.invoice_date, "
+ "cl.company_name, d.name AS debtor_name, d.physical_address "
+ "FROM claims c JOIN clients cl ON cl.id = c.client_id JOIN debtors d ON d.id = c.debtor_id "
+ "WHERE c.claim_number = ?",
+ (claim_number,),
+ ).fetchone()
+ if row is None:
+ return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND)
+ letter = analysis.recommend_letter(
+ tier=override_tier or row["tier"],
+ client_name=row["company_name"],
+ debtor_name=row["debtor_name"],
+ amount_display=_money(row["amount_cents"]),
+ invoice_ref=row["client_reference"],
+ claim_number=row["claim_number"],
+ debtor_address=row["physical_address"],
+ )
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE claims SET letter_subject = ?, letter_body = ?, letter_tier = ?, letter_updated_at = ?, updated_at = ? WHERE id = ?",
+ (letter["subject"], letter["body"], letter["tier"], now, now, row["id"]),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'claim', ?, 'letter_generate', 'letter', NULL, ?, 'staff', ?)",
+ (new_uuid(), row["id"], letter["tier"], now),
+ )
+ conn.commit()
+ return await staff_get_claim(claim_number)
+
+
+# ---------------------------------------------------------------
+# PUT /api/staff/claims/{claim_number}/letter (save customized letter)
+# ---------------------------------------------------------------
+@router.put("/api/staff/claims/{claim_number}/letter")
+async def staff_save_letter(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)):
+ try:
+ body = await request.json()
+ except Exception:
+ return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY)
+ try:
+ letter = StaffLetterUpdate.model_validate(body)
+ except ValidationError as exc:
+ parts = [f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()]
+ return _err("validation_error", "; ".join(parts), status.HTTP_422_UNPROCESSABLE_ENTITY)
+ actor = letter.staff_name or staff_name
+ with get_conn() as conn:
+ row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()
+ if row is None:
+ return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND)
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE claims SET letter_subject = ?, letter_body = ?, letter_updated_at = ?, updated_at = ? WHERE id = ?",
+ (letter.subject, letter.body, now, now, row["id"]),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
+ "VALUES (?, 'claim', ?, 'letter_edit', 'letter_body', NULL, 'edited', ?, ?)",
+ (new_uuid(), row["id"], actor, now),
+ )
+ conn.commit()
+ return await staff_get_claim(claim_number)
+
+
+# ---------------------------------------------------------------
+# POST /api/staff/claims/{claim_number}/advance-tier (manual escalation)
+# ---------------------------------------------------------------
+@router.post("/api/staff/claims/{claim_number}/advance-tier")
+async def staff_advance_tier(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)):
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+ actor = (body or {}).get("staff_name") or staff_name
+ reason = (body or {}).get("reason")
+ with get_conn() as conn:
+ row = conn.execute("SELECT * FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()
+ if row is None:
+ return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND)
+ nxt = analysis.next_tier(row["tier"])
+ if nxt is None:
+ return _err("conflict", "Claim is already at the final tier (Tier 4 - Legal Action).", status.HTTP_409_CONFLICT)
+ gate = _tier_advance_gate(conn, row)
+ if gate is not None:
+ return _err("conflict", gate, status.HTTP_409_CONFLICT)
+ now = utcnow_iso()
+ conn.execute(
+ "UPDATE claims SET tier = ?, updated_at = ? WHERE id = ?",
+ (nxt, now, row["id"]),
+ )
+ conn.execute(
+ "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) "
+ "VALUES (?, 'claim', ?, 'status_change', 'tier', ?, ?, ?, ?, ?)",
+ (new_uuid(), row["id"], row["tier"], nxt, actor, reason, now),
+ )
+ conn.execute(
+ "INSERT INTO case_notes (id, claim_id, author_type, author_name, subject, content, visibility, twentycrm_id, created_at) "
+ "VALUES (?, ?, 'SYSTEM', 'System', NULL, ?, 'SHARED', NULL, ?)",
+ (new_uuid(), row["id"],
+ f"Case escalated from {analysis.TIER_LABELS.get(row['tier'], row['tier'])} to {analysis.TIER_LABELS.get(nxt, nxt)}.", now),
+ )
+ conn.commit()
+ return await staff_get_claim(claim_number)
+
+
# ---------------------------------------------------------------
# POST /api/staff/claims/{claim_number}/notes
# ---------------------------------------------------------------
@router.post("/api/staff/claims/{claim_number}/notes")
-async def staff_add_note(claim_number: str, request: Request, _staff=Depends(authmod.require_staff)):
+async def staff_add_note(claim_number: str, request: Request, staff_name: str = Depends(authmod.require_staff)):
try:
body = await request.json()
except Exception:
@@ -266,6 +666,7 @@ async def staff_add_note(claim_number: str, request: Request, _staff=Depends(aut
except ValidationError as exc:
parts = [f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()]
return _err("validation_error", "; ".join(parts), status.HTTP_422_UNPROCESSABLE_ENTITY)
+ actor = note.author_name or staff_name
with get_conn() as conn:
row = conn.execute("SELECT id FROM claims WHERE claim_number = ?", (claim_number,)).fetchone()
if row is None:
@@ -276,17 +677,17 @@ async def staff_add_note(claim_number: str, request: Request, _staff=Depends(aut
conn.execute(
"INSERT INTO case_notes (id, claim_id, author_type, author_name, subject, content, visibility, twentycrm_id, created_at) "
"VALUES (?, ?, 'STAFF', ?, NULL, ?, ?, NULL, ?)",
- (note_id, claim_id, note.author_name, note.content, note.visibility, now),
+ (note_id, claim_id, actor, note.content, note.visibility, now),
)
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)",
- (new_uuid(), "note", note_id, "note_add", f"staff:{note.author_name}", now),
+ (new_uuid(), "note", note_id, "note_add", f"staff:{actor}", now),
)
conn.commit()
return {
"id": note_id,
"author_type": "STAFF",
- "author_name": note.author_name,
+ "author_name": actor,
"content": html.escape(note.content),
"visibility": note.visibility,
"created_at": now,
diff --git a/compliance/DRE_Compliance_Manual.md b/compliance/DRE_Compliance_Manual.md
index 3ab08aa..4a7f838 100644
--- a/compliance/DRE_Compliance_Manual.md
+++ b/compliance/DRE_Compliance_Manual.md
@@ -684,6 +684,35 @@ DRE must verify the following BEFORE initiating collection:
- Are documents legible and authentic?
- Are electronic records timestamped?
+### 11.4 Post-Approval Onboarding Packet
+
+Upon claim approval (post AI analysis and leadership sign-off), DRE sends the
+client an electronic-only onboarding packet. Each form may be signed and
+submitted separately, but **recovery does not begin until ALL required documents
+are received**. The claim holds in an "onboarding pending" state until the gate
+clears; no tier escalation or debtor contact fires before completion.
+
+Fee disclosure is a **signed schedule attached to the Terms of Service** — not a
+standalone document. Texas requires contingency-fee disclosure in writing; an
+attached schedule satisfies it and keeps the packet to one fewer signature.
+
+| Group | Document | Purpose |
+|---|---|---|
+| **A. Engagement** | LPOA (executed + notarized via Proof RON) | Core authorization to collect (TX Est. Code § 751.0021) |
+| | Fee disclosure schedule (attached to ToS) | Required-in-writing fee disclosure |
+| | Executed Terms of Service | Engagement terms |
+| | Third-party sharing consent | Proof.com / LetterStream / partner law firm |
+| **B. Evidence** | Statement of account / aging report | Exact amount + age |
+| | Contract, invoice, PO, change orders | Proves the debt and terms |
+| | Proof of delivery / signed receipts / completion sign-off | Defeats "never received it" |
+| | Full correspondence chain (email/text) | Shows debtor acknowledgment |
+| | Payment history | Separates paid vs outstanding |
+| **C. Debtor dossier** | Debtor information sheet | Entity name, type, agent, addresses, contacts |
+| | Personal guarantee (if any) | Unlocks consumer credit reporting (FCRA) |
+| | Known assets / banking relationships | Feeds skip tracing / asset scans |
+| **D. Financial** | IRS Form W-9 | Required before any disbursement (Stripe Connect) |
+| | ACH / disbursement authorization + banking details | Destination for client share |
+
---
## 12. DOCUMENT HANDLING & RETENTION
@@ -980,6 +1009,29 @@ DRE shall maintain a separate **Trust Account** (IOLTA or equivalent) for client
- Trust account must be interest-bearing (if appropriate)
- Interest earned on trust accounts must be accounted for per client agreement
+#### 17.1.1 Two-Account Structure (approved 2026-08-22)
+DRE operates two bank accounts, both checking (do NOT use a savings account for
+client funds — Reg D withdrawal limits and funds that must move quickly are a
+bad match):
+
+| Account | Type | Holds | Permitted activity |
+|---|---|---|---|
+| Operating | Checking | DRE's own money — fees, payroll, software | Never receives client funds |
+| Client trust/escrow | Checking | Recovered funds before disbursement | Fee sweep to operating + client disbursement only |
+
+- **Same bank is acceptable** (instant/free transfers); the segregation of the
+ account itself is what matters, not the institution.
+- The trust account must be **labeled trust/escrow** at the bank so the account
+ type is on record and client funds are insulated from any levy/freeze against
+ the operating account.
+- **Stripe Connect settlement routing:** all recovery proceeds settle into the
+ client trust account, NOT operating. DRE's fee is then swept to operating;
+ the client's share is disbursed from the trust account. If Stripe Connect does
+ not support automatic split, perform a manual sweep on each settlement.
+- Texas Finance Code Ch. 392 does not expressly mandate a separate trust account
+ the way some states do, but the LPOA fiduciary relationship makes segregation
+ the defensible posture. Confirm with attorney/CPA during compliance review.
+
### 17.2 Disbursement Timeline
| Milestone | Deadline |
diff --git a/docs/letter-queue-scope.md b/docs/letter-queue-scope.md
new file mode 100644
index 0000000..cd148de
--- /dev/null
+++ b/docs/letter-queue-scope.md
@@ -0,0 +1,136 @@
+# Letter Queue - Backend Scope (draft)
+
+Date: 2026-08-24
+Author: Sho'Nuff (draft for Germaine review)
+
+## Current state (ground truth, verified 2026-08-24)
+
+- **Send pipeline is BUILT and verified end-to-end.** Two new modules:
+ - `/opt/dre-portal/app/letterstream.py` — LetterStream integration (auth, account
+ status, send single/batch, preauth/doauth, tracking, signature, proof).
+ - `/opt/dre-portal/app/letters.py` — router: letters queue lifecycle, PDF render
+ (fpdf2 2.8.8), LetterStream send, and the callback receiver.
+- **Live DB tables exist:** `letters` and `letter_events` (added to `schema.sql`,
+ applied on restart).
+- **LetterStream API contract fully recovered** from the user-supplied
+ `api_fulfillment.pdf` (21 pages): `POST https://www.letterstream.com/apis/` with
+ `a`=api_id, `h`=hash, `t`=unique_id. Hash = `md5(base64_encode(last6(t) . api_key
+ . first6(t)))`. Verified three ways: PDF formula + live `AUTHOK` + user screenshot
+ sample vector.
+- **Account live-verified:** balance `$100.00`, `testmode=disabled`. No documents
+ submitted, no charges incurred.
+- Letter *content* still lives on `claims` (subject/body/tier) and is generated by
+ `analysis.recommend_letter()` (deterministic, no LLM) — the `letters` queue is the
+ physical-send layer on top.
+- **Queue UI is wired and smoke-tested (2026-08-25, task ls7).** `letter-queue.html` is
+ a functional queue: filterable list, status badges, Approve / Price & Queue /
+ Confirm & Mail / Track actions, and a New Letter modal (claim picker + structured
+ recipient + mail class). `GET /api/staff/letters` now joins `claims` to expose
+ `claim_number`. Verified via live API lifecycle + Node render harness + 9-page HTTP
+ smoke (all 200).
+- LetterStream callback is **NOT enabled yet** — the receiver is live and tested, but
+ the callback toggle in the LetterStream dashboard stays off until after the first
+ real send is confirmed.
+
+## Target state
+
+A durable letter queue: many letters per claim, each with a lifecycle
+(draft -> approved -> queued -> sent -> delivered/failed), sendable via
+LetterStream certified mail, with FDCPA-compliant content and a full audit trail.
+
+## 1. Data model - new `letters` table
+
+| Column | Type | Purpose |
+|---|---|---|
+| id | TEXT PK | uuid4 |
+| claim_id | TEXT FK -> claims.id | owning claim |
+| letter_type | TEXT | demand / escalation / validation / custom |
+| tier | TEXT | tier the letter was generated for |
+| subject | TEXT | letter subject |
+| body | TEXT | letter body (markdown or plain) |
+| status | TEXT | draft / approved / queued / sending / sent / delivered / failed |
+| recipient_name | TEXT | debtor or registered-agent name |
+| addr1, addr2, city, state, zip | TEXT | mailing address |
+| letterstream_job_id | TEXT | LetterStream job reference (nullable) |
+| tracking_number | TEXT | USPS tracking (nullable) |
+| sent_at | TEXT | ISO timestamp (nullable) |
+| delivered_at | TEXT | ISO timestamp (nullable) |
+| error | TEXT | last send error (nullable) |
+| created_by | TEXT | RBAC actor name |
+| created_at / updated_at | TEXT | ISO timestamps |
+
+Migration: keep the four claim columns as the "current draft" during transition,
+then deprecate them once the queue is live. No destructive drop until the queue
+is proven in production.
+
+## 2. API
+
+- `GET /api/staff/claims/{n}/letters` - list letters for a claim
+- `POST /api/staff/claims/{n}/letters/generate` - generate a draft row via
+ `recommend_letter()` (inserts, does not overwrite the claim columns)
+- `PUT /api/staff/letters/{id}` - edit a draft
+- `POST /api/staff/letters/{id}/queue` - mark queued (requires full mailing address)
+- `POST /api/staff/letters/{id}/send` - call LetterStream, store job_id + tracking
+- `POST /api/letters/webhook` - LetterStream status callback (delivered / failed)
+- `GET /api/staff/letters` - global queue across claims (feeds letter-queue.html)
+
+Idempotency: `send` is guarded by status (only `queued` -> `sending`), so a
+double-click cannot mail a letter twice. Store `letterstream_job_id` before
+marking sent.
+
+## 3. Send pipeline (LetterStream)
+
+Order of work:
+
+1. Verify the existing `LETTERSTREAM_API_KEY` against their API (is it valid,
+ what account, what products are enabled).
+2. Map their REST surface: auth method, endpoint shape, certified vs
+ first-class, PDF upload vs HTML/plain rendering, return address handling,
+ tracking + status webhook. Do not assume - confirm from their docs or a test
+ call.
+3. PDF generation: render the letter body (reportlab or weasyprint) with DRE
+ letterhead, or pass content to LetterStream to render.
+4. Address handling: return address (DRE office / PO box) and debtor mailing
+ address must both be resolved before send.
+5. Status sync: webhook or poll updates `status`, `tracking_number`,
+ `delivered_at`.
+
+## 4. Compliance (FDCPA)
+
+- Every first-contact letter MUST carry the 1692g validation notice: amount of
+ debt, creditor name, 30-day dispute right, right to request verification.
+- No false, deceptive, or misleading language (1692e); no threats of action DRE
+ does not intend to take.
+- Human sign-off gate: a letter cannot move to `queued` until status is
+ `approved` (actor recorded).
+- Full immutable audit log (actor + timestamp + old/new) - the `audit_log`
+ pattern already exists and extends here.
+
+## 5. UI (letter-queue.html) — DONE 2026-08-25 (ls7)
+
+`/var/www/internal/letter-queue.html` is live:
+- filterable list (status) with per-status summary chips
+- Approve (DRAFT) / Price & Queue (APPROVED/PREAUTH/ERROR) / Confirm & Mail (PREAUTH) / Reject / Cancel / Track actions
+- status badges (DRAFT/APPROVED/PREAUTH/SENT/REJECTED/CANCELLED/ERROR)
+- tracking timeline (USPS scan events) in the detail panel
+- New Letter modal: claim picker (prefills debtor name), structured recipient, mail class
+
+Reject/cancel implemented 2026-08-25: `POST /api/staff/letters/{id}/reject` (requires `reason`) and
+`POST /api/staff/letters/{id}/cancel` (optional `reason`) move DRAFT/APPROVED/PREAUTH/ERROR letters to
+REJECTED/CANCELLED, persist the reason in `letters.note`, and write an `audit_log` row. Guards return 409
+for SENT/REJECTED/CANCELLED and 422 for a missing reject reason. Queue UI has Reject (red) / Cancel buttons
+for all non-terminal states.
+
+## 6. Decisions locked (2026-08-24)
+
+1. **LetterStream key** — valid and live; `$100.00` balance, `testmode=disabled`.
+2. **Return address** — Germaine provides it 2026-08-25. Set as
+ `LETTERSTREAM_RETURN_ADDRESS` in `.env`; drafting does NOT block on it, only
+ `send` does (clean 409 until configured).
+3. **Signatory** — `Debt Recovery Experts LLC` (no named individual). Default in code;
+ overridable via `LETTERSTREAM_SIGNATORY` in `.env`.
+4. **Address verification / NCOA** — none. Pull debtor/return-address data from the
+ client's claim info + Super Search.
+5. **FDCPA 1692g notice** — mandatory on first contact (DRE is a third-party debt
+ collector). Content is generated by `analysis.recommend_letter()`; final validation-
+ notice wording vs welcome-packet copy still to be finalized (task ls8).
diff --git a/docs/letterstream-api-contract.md b/docs/letterstream-api-contract.md
new file mode 100644
index 0000000..8aca107
--- /dev/null
+++ b/docs/letterstream-api-contract.md
@@ -0,0 +1,138 @@
+# LetterStream API Contract (verified live)
+
+Date: 2026-08-25
+Source: `api_fulfillment.pdf` (LetterStream "Mail Fulfillment by LetterStream — Integration API", Feb 3 2023) + live verification against the account.
+
+## Credentials
+- `API_ID` (8 chars), `API_KEY` (18 chars) — in `/opt/dre-portal/.env` as `LETTERSTREAM_API_ID` / `LETTERSTREAM_API_KEY`.
+- Account funded: balance `$100.00`, `testmode=disabled` (LIVE/production mode) as of 2026-08-25.
+
+## Endpoint
+- Base: `https://www.letterstream.com/apis/` (or `/apis/index.php`). **POST only** (form-encoded or multipart).
+- Response: XML `...`.
+- `responseformat=json` returns JSON instead of XML.
+
+## Auth (VERIFIED 2026-08-25)
+Three form fields on every request:
+- `a` = api_id
+- `t` = unique id — numeric, **10–18 digits**, accepted only once (duplicate → `-957 DUP`). Use `time()`-style value.
+- `h` = hash, computed as:
+
+```php
+$unique_id = time(); // 10-18 digit numeric, unique per request
+$string_to_hash = substr($unique_id,-6) . $api_key . substr($unique_id,0,6);
+$hash = md5(base64_encode($string_to_hash));
+```
+
+Python equivalent:
+```python
+import hashlib, base64
+s = t[-6:] + api_key + t[:6]
+h = hashlib.md5(base64.b64encode(s.encode())).hexdigest()
+```
+
+### Auth response codes
+- `-199` `AUTHOK` — account good, connection successful
+- `-958` `IDOK` — api_id found but hash lookup failed (wrong hash)
+- `-957` `DUP` — unique id duplicate
+- `-950` `Unable to authenticate`
+- `BAD` — api_id not valid
+- `-998` `Improper submission format` — auth valid but args don't form a valid request
+- `-999` `unknown submission error`
+
+## Send method 1 — Batch (ZIP) [preferred for volume]
+`POST` with `multi_file` = a `.zip` archive containing one PDF per recipient + one CSV data file. CSV filename becomes the batch id (must be unique). 50MB cap. CSV columns (Table 4.1.1):
+
+| # | Column | Required | Notes |
+|---|---|---|---|
+| 1 | UniqueDocId | yes | alphanumeric, max 20 chars, unique to any active/mailed job |
+| 2 | PDFFileName | yes | filename of the PDF inside the zip |
+| 3 | RecipientName1 | yes | |
+| 4 | RecipientName2 | optional | |
+| 5 | RecipientAddr1 | yes | |
+| 6 | RecipientAddr2 | optional | suite # |
+| 7 | RecipientCity | yes | |
+| 8 | RecipientState | yes | 2-char alpha |
+| 9 | RecipientZip | yes | 5–10 numeric + "-" |
+| 10 | SenderName1 | yes | |
+| 11 | SenderName2 | optional | |
+| 12 | SenderAddr1 | yes | |
+| 13 | SenderAddr2 | optional | |
+| 14 | SenderCity | yes | |
+| 15 | SenderState | yes | 2-char alpha |
+| 16 | SenderZip | yes | |
+| 17 | PageCount | yes | numeric |
+| 18 | MailType | no | `firstclass` \| `firstclass_hse` \| `certified` \| `certnoerr` \| `postcard` \| `flat` \| `propostcard` (default `firstclass`) |
+| 19 | CoverSheet | no | `Y`\|`N` (default `Y`) |
+| 20 | Duplex | no | `Y`\|`N` (default `N`) |
+| 21 | Ink | no | `B`\|`C` (default `B`) |
+| 22 | Paper | no | see options (default `W`) |
+| 23 | ReturnEnvelope | no | `Y`\|`9RWS`\|`9LWS`\|`634`\|`634_12PK`\|`N` (default `N`) |
+| 24 | Affidavit | no | `A`\|`N` (default `N`) |
+
+## Send method 2 — HTTP POST (single file) [≤50/day, low volume]
+`POST` (multipart or form-encoded). Required fields:
+- `a`, `h`, `t` — auth
+- `job` — **unique** job name (unique across all active/mailed jobs)
+- `to[]` — array of recipient address strings (repeat the field per recipient)
+- `from` — single sender/return address (max 1)
+- `single_file` — the PDF to mail (multipart file OR base64 blob)
+- `pages` — number of pages in the PDF
+
+Optional: `mailtype` (default `firstclass`), `coversheet` (default true), `duplex`, `ink`, `paper`, `returnenv`, `preauth`.
+
+### Address string format (`to[]` and `from`)
+Colon or pipe delimited (don't mix):
+```
+# recipient (doc_id included):
+doc_id:name_1:name_2:address_1:address_2:city:state:zip
+# sender (no doc_id):
+name_1:name_2:address_1:address_2:city:state:zip
+```
+`doc_id` must be unique per recipient (same spec as UniqueDocId). Only domestic addresses eligible for certified mail.
+
+## Preauth (price-before-release)
+- Submit with `preauth=1` → processed but NOT released to production; returns `-200` + `authcode` + pricing.
+- Authorize/release by resubmitting `doauth=`.
+
+## Submission response codes
+- `-100` success → includes ``, ``, ``, ``
+- `-200` preauth success / preauth authorization success
+- `-911` insufficient funding (items held until funds added)
+
+## Mail types (cost/features)
+- `firstclass` — First Class Letter (#10 2-window)
+- `firstclass_hse` — First Class Letter "Homeowner Statement Enclosed" endorsement
+- `certified` — Certified w/ Electronic Return Receipt (#10 3-window, tracking #)
+- `certnoerr` — Certified WITHOUT e-Return Receipt (no signature collected)
+- `postcard` — 5.5"x4.25" 100# cardstock
+- `flat` — 10x13 windowed flat (up to 75 sheets, coversheet by default)
+- `propostcard` — pro postcard
+
+## Tracking / status queries (POST, all with a/h/t)
+- `cert=&getinfo=track` → HTML tracking (or `getinfo=trackx` XML; `responseformat=json` for JSON)
+- `doc_id=&getinfo=track` → job status (non-certified)
+- `cert=...&getinfo=sig` → signature file (streamed PDF)
+- `doc_id=...&getinfo=proof` → document proof (base64 streamed PDF)
+- `batchstatus=` / `jobstatus=` / `docstatus=` → stage-of-production status
+- `accountstatus=1` → account balance (``, ``)
+
+USPS tracking numbers: 22 digits since March 2018 (older 20-digit still valid).
+
+## Document preflight
+`POST` with `preflight=visual` (or `auto` coming soon) + `preflight_file` (PDF) + optional `display=true`. Returns marked-up PDF showing window placement. Used for template verification, not every submission.
+
+## Callback / webhook (tracking push) — receive side
+See "API PUSH" section below (contract from account "API Callback Settings" page):
+
+- LetterStream PUSHES tracking data to our endpoint (HTTP POST) every 4 hours (and heartbeat when idle).
+- POST fields: `key`, `api_version`, `timestamp`, `json`.
+- `json` = JSON string of tracking line items: `batch_id`, `job_id`, `doc_id`, `tracking_id`, `scan_date`, `scan_zip`, `scan_facility`, `scan_code`, `scan_status`.
+- scan_codes reference: https://postalpro.usps.com/product-tracking-and-reporting/scan-events-descriptions
+- Required response: HTTP 200 + `{"success":true,"reason":"Received data"}`.
+- `key` = our callback auth string (`LETTERSTREAM_CALLBACK_KEY` in `.env`, 48 hex chars, generated 2026-08-25).
+- Enable must stay OFF until our receiver is live.
+
+## Implementation
+- Python module: `/opt/dre-portal/app/letterstream.py` (mirrors `docuseal.py` style).
+- Auth formula verified live 2026-08-25 (AUTHOK + balance returned).
diff --git a/docs/welcome-packet/01-LPOA.md b/docs/welcome-packet/01-LPOA.md
new file mode 100644
index 0000000..f5daa8f
--- /dev/null
+++ b/docs/welcome-packet/01-LPOA.md
@@ -0,0 +1,85 @@
+# LIMITED POWER OF ATTORNEY
+## Debt Recovery Experts, LLC
+
+**THIS LIMITED POWER OF ATTORNEY ("LPOA")** is made and entered into by and between the undersigned principal (the "Client") and **Debt Recovery Experts, LLC**, a limited liability company (the "Company").
+
+### 1. Appointment of Agent
+
+The Client hereby appoints the Company, and its authorized officers, employees, and designated representatives, as the Client's true and lawful attorney-in-fact, **limited strictly to the matters set forth below**, with full power and authority to act in the Client's name, place, and stead.
+
+### 2. Scope of Authority (LIMITED)
+
+The authority granted under this LPOA is limited exclusively to the recovery of the specific debt identified below (the "Claim"):
+
+| Field | Value |
+|---|---|
+| Client Legal Name | {{client_legal_name}} |
+| Client Entity Type | {{client_entity_type}} |
+| Debtor Legal Name | {{debtor_legal_name}} |
+| Claim Amount | {{claim_amount}} |
+| Invoice / Contract Date | {{invoice_date}} |
+| DRE Claim Number | {{dre_claim_number}} |
+
+Specifically, the Company is authorized to:
+
+1. **Demand payment** of the Claim from the Debtor, in writing and verbally.
+2. **Negotiate and settle** the Claim, subject to the settlement authority limits set forth in the Terms of Service.
+3. **Receive payments** on the Claim, including via the Company's designated payment processor, and deposit such payments into the Company's trust/escrow account for disbursement to the Client in accordance with the signed Fee Schedule.
+4. **Execute and deliver** documents incidental to collection of the Claim, including demand letters, settlement agreements, payment acknowledgments, and releases limited to the Claim.
+5. **Engage third-party service providers** (remote online notary, certified mail vendor, and partner law firm) as reasonably necessary to collect the Claim, in accordance with the signed Third-Party Sharing Consent.
+6. **Refer the Claim to legal counsel** for further action if the Claim reaches Tier 4, in accordance with the Terms of Service.
+
+### 3. Express Limitations (the Company MAY NOT)
+
+Notwithstanding anything to the contrary, the Company is **NOT** authorized to:
+
+1. Borrow money, mortgage property, or create any lien or security interest in the Client's name (other than filing a mechanic's or materialman's lien in the ordinary course of collecting the Claim, and only through licensed counsel).
+2. Sell, transfer, or convey any real or personal property of the Client.
+3. Make gifts of the Client's property.
+4. Settle the Claim for less than the minimum settlement authority stated in the Terms of Service without the Client's separate written approval.
+5. Commence litigation in the Client's name; litigation is referred to licensed counsel under a separate engagement.
+
+### 4. Duration and Revocation
+
+This LPOA becomes effective upon execution and **notarization**, and remains in effect until: (a) the Claim is fully resolved (recovered in full, settled, or determined uncollectible and closed); or (b) the Client revokes this LPOA in writing delivered to the Company. Revocation does not affect acts lawfully taken before receipt of the revocation.
+
+### 5. Governing Law
+
+This LPOA is governed by the laws of the State of Texas, including Chapter 751 of the Texas Estates Code (Statutory Durable Power of Attorney requirements).
+
+### 6. Acknowledgment
+
+The Client acknowledges that the Company is a third-party debt collector acting on the Client's behalf with respect to the Claim, and that the Client remains ultimately responsible for the accuracy of the information provided concerning the Claim.
+
+---
+
+**IN WITNESS WHEREOF**, the Client has executed this Limited Power of Attorney as of the date set forth below.
+
+| | |
+|---|---|
+| **Client Signature** | **Date** |
+| ________________________ | ________________________ |
+| **Print Name** | **Title** |
+| ________________________ | ________________________ |
+
+---
+
+## NOTARY ACKNOWLEDGMENT
+
+State of ________________
+County of ________________
+
+This instrument was acknowledged before me on ____________ (date) by ________________________ (name of person), in the capacity of ________________________ for ________________________ (entity name), as the act of such entity.
+
+| |
+|---|
+| **Notary Public Signature** |
+| ________________________ |
+| **Notary Public Printed Name** |
+| ________________________ |
+| **My commission expires:** ____________ |
+| *(Notary seal)* |
+
+---
+
+*This LPOA requires notarization pursuant to Texas Estates Code § 751.0021. This is the ONLY document in your welcome packet that requires a notary; it is completed online via our remote online notary partner.*
diff --git a/docs/welcome-packet/02-Terms-of-Service.md b/docs/welcome-packet/02-Terms-of-Service.md
new file mode 100644
index 0000000..a5ef402
--- /dev/null
+++ b/docs/welcome-packet/02-Terms-of-Service.md
@@ -0,0 +1,79 @@
+# DEBT RECOVERY EXPERTS, LLC
+## TERMS OF SERVICE
+
+**Last updated: August 23, 2026**
+
+These Terms of Service ("ToS") govern the engagement of **Debt Recovery Experts, LLC** (the "Company," "DRE," "we," or "us") by the client identified below ("Client" or "you"). By signing, you agree to be bound by these terms, including the **Fee Schedule attached hereto as Schedule A** and incorporated by reference.
+
+### 1. Services
+
+DRE provides commercial debt recovery services. Upon approval of your claim, DRE will pursue recovery of the identified debt through a tiered escalation process (demand letters, escalation, lien threat where applicable, and referral to partner legal counsel), as described in the DRE Help & Recovery Guide.
+
+**No Guarantee of Recovery.** DRE makes no representation or guarantee that any claim will be recovered, in whole or in part. Recovery is contingent on the debtor's circumstances and willingness or ability to pay.
+
+### 2. Contingency Fee Basis
+
+DRE's fees are **contingent** — DRE is paid **only if and when money is recovered**. If nothing is recovered, you owe DRE no fee for DRE's services. The applicable fee is determined by the recovery tier at which the claim resolves, as set forth in Schedule A.
+
+### 3. Costs and Expenses
+
+Certain out-of-pocket costs may be deducted from recovered funds before disbursement, regardless of the tier at which the claim resolves:
+
+- Remote online notary fee (one-time, per LPOA execution)
+- Certified mail / LetterStream postage and service fees
+- Court filing fees and recording fees (only if a lien or legal action is pursued)
+
+These costs are itemized on your settlement statement. DRE will not incur non-recoverable third-party costs (such as litigation filing fees) without your prior written approval.
+
+### 4. Settlement Authority
+
+Unless otherwise agreed in writing, DRE may settle the Claim for **no less than 70% of the principal amount** without further approval. Settlements below this threshold, or any settlement involving non-monetary terms, require your separate written approval.
+
+### 5. Disbursement
+
+Recovered funds are received into a DRE operating/trust account, less (a) DRE's contingency fee per Schedule A and (b) itemized costs per Section 3. The balance is disbursed to the bank account you authorize via the ACH/Disbursement Authorization form. Disbursements occur on a defined schedule; you will receive a settlement statement with each disbursement.
+
+### 6. Compliance and Representations
+
+You represent and warrant that:
+
+1. The debt you are referring is a valid, enforceable commercial obligation owed to you.
+2. The information and documentation you provide (amount, aging, contracts, invoices, delivery proof) is true and accurate.
+3. You are authorized to refer the debt and to execute this agreement on behalf of the claimant entity.
+
+You acknowledge that DRE will rely on these representations in its collection efforts, and that providing materially false information may expose you to liability.
+
+### 7. Third-Party Services
+
+To perform the services, DRE may share limited information with third-party service providers (remote online notary, certified mail vendor, partner law firm). Such sharing is governed by the separate Third-Party Sharing Consent you sign. DRE does not sell your information.
+
+### 8. Termination
+
+Either party may terminate this engagement upon written notice. Upon termination, DRE will cease collection activity. Any fees and costs earned or incurred through the date of termination remain due and payable in accordance with Schedule A and Section 3. Termination does not discharge any obligation the debtor has already agreed to satisfy.
+
+### 9. Limitation of Liability; Indemnification
+
+To the maximum extent permitted by law, DRE's aggregate liability arising out of this engagement shall not exceed the total fees actually paid by you to DRE. You agree to indemnify and hold DRE harmless from any claim arising out of your breach of the representations in Section 6.
+
+### 10. Governing Law; Dispute Resolution
+
+This ToS is governed by the laws of the State of Texas. Any dispute arising out of this engagement shall be resolved in the state or federal courts of Texas.
+
+### 11. Entire Agreement
+
+This ToS, together with Schedule A (Fee Schedule), the Limited Power of Attorney, the Third-Party Sharing Consent, and the ACH/Disbursement Authorization, constitutes the entire agreement between the parties and supersedes all prior communications.
+
+---
+
+## CLIENT ACKNOWLEDGMENT
+
+By signing below, the Client acknowledges they have read, understood, and agreed to these Terms of Service, including Schedule A (Fee Schedule), and that the Client has become a customer of Debt Recovery Experts, LLC.
+
+| | |
+|---|---|
+| **Client Signature** | **Date** |
+| ________________________ | ________________________ |
+| **Print Name** | **Title** |
+| ________________________ | ________________________ |
+| **Company (if applicable)** | |
+| ________________________ | |
diff --git a/docs/welcome-packet/03-Fee-Schedule.md b/docs/welcome-packet/03-Fee-Schedule.md
new file mode 100644
index 0000000..7d5e6ac
--- /dev/null
+++ b/docs/welcome-packet/03-Fee-Schedule.md
@@ -0,0 +1,52 @@
+# SCHEDULE A — FEE SCHEDULE
+## Debt Recovery Experts, LLC
+
+This Schedule A is attached to and incorporated into the Terms of Service between the Client and Debt Recovery Experts, LLC. **All fees are contingent** — payable only upon actual recovery of funds from the debtor.
+
+## Contingency Fee by Recovery Tier
+
+The fee is determined by the tier at which the claim resolves (i.e., the point at which the debtor pays):
+
+| Tier | Description | DRE Fee |
+|---|---|---|
+| **Tier 1** | Soft Touch demand | **20-25%** of amount recovered |
+| **Tier 2** | Formal Demand | **30%** of amount recovered |
+| **Tier 2.5** | Lien Threat (construction claims) | **30%** of amount recovered, plus attorney fees only if a lien is actually filed through counsel |
+| **Tier 3** | Final Notice | **33%** of amount recovered |
+| **Tier 4** | Legal Action (referral to counsel) | **15%** DRE referral fee **plus 25%** law firm fee (40% combined) |
+
+## Illustrative Example (Tier 2 resolution, $10,000 claim)
+
+| Item | Amount |
+|---|---|
+| Amount recovered | $10,000.00 |
+| DRE contingency fee (30%) | -$3,000.00 |
+| Certified mail + notary costs | -$75.00 |
+| **Net to Client** | **$6,925.00** |
+
+## Costs and Expenses (deducted from recovery)
+
+These are actual, itemized out-of-pocket costs, not DRE profit:
+
+- Remote online notary: one-time, per LPOA (est. $25)
+- Certified mail / LetterStream postage and service fees (actual)
+- Court filing and recording fees (actual, only if lien or litigation pursued, with prior approval)
+
+## No Recovery, No Fee
+
+If DRE recovers nothing, the Client owes **no contingency fee**. The Client is responsible only for actual out-of-pocket third-party costs already incurred with the Client's prior approval (e.g., litigation filing fees). DRE will not incur such costs without the Client's written approval.
+
+## Loyalty Pricing (optional)
+
+Clients with 3+ prior claims may qualify for reduced Tier 1 pricing (e.g., 25% reduced toward 20% at DRE's discretion).
+
+---
+
+**ACKNOWLEDGMENT:** By signing the Terms of Service, the Client acknowledges receipt of this Fee Schedule and agrees to the fee and cost terms set forth above.
+
+| | |
+|---|---|
+| **Client Signature** | **Date** |
+| ________________________ | ________________________ |
+| **Print Name** | |
+| ________________________ | |
diff --git a/docs/welcome-packet/04-Third-Party-Consent.md b/docs/welcome-packet/04-Third-Party-Consent.md
new file mode 100644
index 0000000..7ab3bbd
--- /dev/null
+++ b/docs/welcome-packet/04-Third-Party-Consent.md
@@ -0,0 +1,32 @@
+# THIRD-PARTY SHARING CONSENT
+## Debt Recovery Experts, LLC
+
+The undersigned Client ("you") authorizes Debt Recovery Experts, LLC ("DRE") to share limited information about you and your claim with the following third-party service providers, **solely as necessary** to perform the debt recovery services described in the Terms of Service:
+
+| Provider | Purpose | Information Shared |
+|---|---|---|
+| **OneNotary** (remote online notary) | Notarize your Limited Power of Attorney | Your name, entity name, and the LPOA document |
+| **LetterStream** (certified mail vendor) | Send certified demand letters and track delivery | Debtor name/address, claim reference |
+| **Partner law firm** (Tier 4 referral) | Provide legal representation for escalated claims | Claim details, documentation, and correspondence |
+
+### What DRE does NOT do
+
+- DRE does **not** sell, rent, or license your information to any third party.
+- DRE does **not** share your information for marketing purposes.
+- DRE shares only the minimum information necessary for each provider to perform its function.
+- DRE does **not** report individual consumer credit information to credit bureaus except as expressly required by law (and only where a valid signed personal guarantee exists).
+
+### Duration
+
+This consent remains in effect for the duration of your engagement with DRE for the applicable claim, and may be revoked in writing at any time. Revocation will not affect disclosures lawfully made before receipt of the revocation.
+
+### Acknowledgment
+
+By signing, you consent to the disclosures described above.
+
+| | |
+|---|---|
+| **Client Signature** | **Date** |
+| ________________________ | ________________________ |
+| **Print Name** | |
+| ________________________ | |
diff --git a/docs/welcome-packet/05-Debtor-Info-Sheet.md b/docs/welcome-packet/05-Debtor-Info-Sheet.md
new file mode 100644
index 0000000..277427e
--- /dev/null
+++ b/docs/welcome-packet/05-Debtor-Info-Sheet.md
@@ -0,0 +1,63 @@
+# DEBTOR INFORMATION SHEET
+## Debt Recovery Experts, LLC
+
+Complete one sheet per debtor. The more complete the information, the faster and more effective the recovery. **Legal entity name must match the entity that actually owes the debt** — this determines the correct registered agent, service address, and (critically) whether a personal guarantee applies.
+
+## Debtor Identification
+
+| Field | Value |
+|---|---|
+| **Legal entity name** (exact) | {{debtor_legal_name}} |
+| Entity type (LLC / Corp / Sole Prop / Partnership / Individual) | {{debtor_entity_type}} |
+| DBA / trade name (if any) | {{debtor_dba}} |
+| State of formation | {{debtor_state}} |
+| Registered agent (name) | {{debtor_registered_agent}} |
+| Registered agent address | {{debtor_registered_agent_address}} |
+| EIN / Tax ID (if known) | {{debtor_ein}} |
+| Website | {{debtor_website}} |
+
+## Contact Information
+
+| Field | Value |
+|---|---|
+| Primary contact name | {{debtor_contact_name}} |
+| Title | {{debtor_contact_title}} |
+| Direct phone | {{debtor_contact_phone}} |
+| Email | {{debtor_contact_email}} |
+| Business address (street) | {{debtor_address}} |
+| City / State / ZIP | {{debtor_city_state_zip}} |
+| Alternate address (branch/warehouse) | {{debtor_alt_address}} |
+
+## Principals / Owners (for personal guarantee determination)
+
+| Name | Title | Phone | Email |
+|---|---|---|---|
+| ________________________ | ______ | ______ | ______ |
+| ________________________ | ______ | ______ | ______ |
+
+## Banking / Payment Relationships (if known)
+
+| Field | Value |
+|---|---|
+| Bank / financial institution | {{debtor_bank}} |
+| Any known accounts receivable / lenders | {{debtor_ar_lenders}} |
+
+## Personal Guarantee
+
+Does a **signed personal guarantee** exist for this debt?
+
+- [ ] **Yes** — a signed written personal guarantee exists (attach a copy). This is critical: without it, DRE cannot pursue an individual's personal credit or assets.
+- [ ] **No** — this is a business-to-business debt only.
+- [ ] **Unsure**
+
+If yes, who signed the guarantee? {{personal_guarantee_signer}}
+
+## Notes
+
+________________________________________________________________________
+
+________________________________________________________________________
+
+---
+
+*Submit this sheet together with your claim substantiation (statement of account, contracts, invoices, proof of delivery, correspondence, and payment history). Recovery cannot begin until the complete packet is received.*
diff --git a/docs/welcome-packet/06-ACH-Authorization.md b/docs/welcome-packet/06-ACH-Authorization.md
new file mode 100644
index 0000000..d1271bc
--- /dev/null
+++ b/docs/welcome-packet/06-ACH-Authorization.md
@@ -0,0 +1,34 @@
+# ACH / DISBURSEMENT AUTHORIZATION
+## Debt Recovery Experts, LLC
+
+This form authorizes Debt Recovery Experts, LLC ("DRE") to disburse recovered funds (net of contingency fee and itemized costs) to the Client's bank account identified below.
+
+## Account Holder Information
+
+| Field | Value |
+|---|---|
+| **Account holder legal name** (must match W-9) | {{account_holder_name}} |
+| Entity type (if business) | {{account_holder_entity_type}} |
+| Bank name | {{bank_name}} |
+| Account type | [ ] Checking [ ] Savings |
+| Routing (ABA) number | {{routing_number}} |
+| Account number | {{account_number}} |
+
+## Authorization
+
+The undersigned authorizes DRE to initiate **credit (deposit) entries only** to the account identified above for the purpose of disbursing settlement proceeds. This authorization is for **deposits only** — DRE is **not** authorized to debit this account for any reason.
+
+This authorization remains in effect until revoked in writing by the undersigned.
+
+## Tax Reporting
+
+The undersigned acknowledges that recovered funds may be subject to tax reporting, and that DRE requires a valid IRS Form W-9 on file before any disbursement. DRE will not disburse funds without a completed W-9.
+
+| | |
+|---|---|
+| **Client Signature** | **Date** |
+| ________________________ | ________________________ |
+| **Print Name** | **Title** |
+| ________________________ | ________________________ |
+| **Company (if applicable)** | |
+| ________________________ | |
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..a79706f
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,86 @@
+# DRE Customer Portal - Static Frontend
+
+Dependency-free HTML/CSS/JS frontend for the Debt Recovery Experts (DRE)
+customer portal. No build step, no frameworks, no npm. Every page loads
+`css/dre.css` and (where interactive) `js/dre-api.js` via plain `
+
+
+