Backend Aug 22-25: AI analysis, welcome-packet templating, LetterStream, DocuSeal, staff RBAC + tier gate

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

Source synced from deployed /opt/dre-portal/app/ (was 4 days ahead of git).
This commit is contained in:
root
2026-08-26 02:26:33 -04:00
parent 7a5603b495
commit 7a62b0b340
46 changed files with 11004 additions and 35 deletions
+320
View File
@@ -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"}
+157 -5
View File
@@ -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 <email>' 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:
+44 -1
View File
@@ -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())
+140
View File
@@ -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/<slug> path."""
if slug:
return f"{SIGN_HOST}/s/{slug}"
return embed_src
def create_submission(doc_key: str, email: str, name: str, values: dict,
send_email: bool = True, message: dict | None = None) -> dict:
"""Create one signature request for the client. Returns the submitter object."""
tid = template_id(doc_key)
vals, fields = build_prefill(doc_key, values)
tmpl_name = DOC_MAP[doc_key][0]
payload = {
"template_id": tid,
"send_email": send_email,
"message": message or {
"subject": f"Please review and sign: {tmpl_name}",
"body": (
"Hi {{submitter.name}},\n\n"
"Please open the link below to review the prefilled details and "
"complete your signature.\n\n{{submitter.link}}\n\n"
"Thank you,\nDebt Recovery Experts, LLC"
),
},
"submitters": [
{
"role": "Client",
"email": email,
"name": name,
"values": vals,
"fields": fields,
},
],
}
resp = _request("POST", "/api/submissions", payload)
if isinstance(resp, list) and resp:
return resp[0]
if isinstance(resp, dict):
return resp
raise DocuSealError(f"Unexpected DocuSeal response type: {type(resp).__name__}")
+16 -4
View File
@@ -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"<b>Amount:</b> ${dollars:,.2f}</p>"
f"<p><a href=\"{base}/\">Review in portal</a></p>"
)
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
+15 -3
View File
@@ -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
+597
View File
@@ -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}})
+250
View File
@@ -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 <messages><message type><code><details>...</messages>.
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")
+8 -1
View File
@@ -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())
+49 -4
View File
@@ -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)
+659
View File
@@ -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'<section class="doc" id="{doc.split(".")[0]}">'
f'<div class="doc-head"><h2>{DOC_TITLES[doc]}</h2></div>'
f'<div class="doc-body">{body}</div></section>'
)
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"""<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome Packet — {row.get('claim_number')}</title>
<style>
:root {{ --ink:#1a2233; --muted:#5b6472; --accent:#b45309; --rule:#e2e8f0; }}
* {{ box-sizing:border-box; }}
body {{ font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; color:var(--ink);
margin:0; background:#f4f6f9; line-height:1.55; }}
.packet-head {{ background:linear-gradient(135deg,#16213a,#2b3d5f); color:#fff; padding:2rem 2.5rem; }}
.packet-head h1 {{ margin:0 0 .25rem; font-size:1.6rem; }}
.packet-head .meta {{ color:#cbd5e1; font-size:.9rem; }}
.wrap {{ max-width:900px; margin:0 auto; padding:1.5rem; }}
.doc {{ background:#fff; border:1px solid var(--rule); border-radius:10px; margin-bottom:1.5rem;
box-shadow:0 1px 3px rgba(16,24,40,.06); overflow:hidden; }}
.doc-head {{ padding:1rem 2rem; border-bottom:1px solid var(--rule); background:#fafbfc; }}
.doc-head h2 {{ margin:0; font-size:1.15rem; color:#111827; }}
.doc-body {{ padding:1.5rem 2.5rem; }}
.doc-body h1 {{ font-size:1.4rem; margin-top:0; }}
.doc-body h2 {{ font-size:1.2rem; }}
.doc-body h3 {{ font-size:1.05rem; color:#374151; }}
.doc-body table {{ width:100%; border-collapse:collapse; margin:1rem 0; font-size:.92rem; }}
.doc-body th,.doc-body td {{ border:1px solid #d7dde6; padding:.55rem .7rem; text-align:left; vertical-align:top; }}
.doc-body th {{ background:#f1f5f9; }}
.doc-body hr {{ border:none; border-top:1px solid var(--rule); margin:1.5rem 0; }}
.doc-body em {{ color:var(--muted); }}
.packet-foot {{ text-align:center; color:var(--muted); font-size:.8rem; padding:1rem 2rem 3rem; }}
@media print {{ body {{ background:#fff; }} .doc {{ box-shadow:none; border:none; page-break-after:always; }}
.doc:last-child {{ page-break-after:auto; }} .wrap {{ padding:0; }} }}
</style></head>
<body>
<div class="packet-head">
<h1>Debt Recovery Experts — Welcome Packet</h1>
<div class="meta">Claim {row.get('claim_number')} · Client: {client} · Debtor: {debtor}</div>
</div>
<div class="wrap">
{body}
</div>
<div class="packet-foot">Debt Recovery Experts, LLC · Confidential · Prepared {datetime.utcnow().strftime('%B %d, %Y')}</div>
</body></html>"""
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"'})
+228
View File
@@ -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"
}
}
+95
View File
@@ -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);
+417 -16
View File
@@ -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,