- 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).
321 lines
14 KiB
Python
321 lines
14 KiB
Python
"""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"}
|