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:
@@ -42,3 +42,7 @@ credentials.json
|
||||
# Project-specific
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
# Credential dumps + backup artifacts — never commit
|
||||
*.bak-*
|
||||
.fanout-mailboxes.txt
|
||||
mailboxes.txt
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}})
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"'})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -684,6 +684,35 @@ DRE must verify the following BEFORE initiating collection:
|
||||
- Are documents legible and authentic?
|
||||
- Are electronic records timestamped?
|
||||
|
||||
### 11.4 Post-Approval Onboarding Packet
|
||||
|
||||
Upon claim approval (post AI analysis and leadership sign-off), DRE sends the
|
||||
client an electronic-only onboarding packet. Each form may be signed and
|
||||
submitted separately, but **recovery does not begin until ALL required documents
|
||||
are received**. The claim holds in an "onboarding pending" state until the gate
|
||||
clears; no tier escalation or debtor contact fires before completion.
|
||||
|
||||
Fee disclosure is a **signed schedule attached to the Terms of Service** — not a
|
||||
standalone document. Texas requires contingency-fee disclosure in writing; an
|
||||
attached schedule satisfies it and keeps the packet to one fewer signature.
|
||||
|
||||
| Group | Document | Purpose |
|
||||
|---|---|---|
|
||||
| **A. Engagement** | LPOA (executed + notarized via Proof RON) | Core authorization to collect (TX Est. Code § 751.0021) |
|
||||
| | Fee disclosure schedule (attached to ToS) | Required-in-writing fee disclosure |
|
||||
| | Executed Terms of Service | Engagement terms |
|
||||
| | Third-party sharing consent | Proof.com / LetterStream / partner law firm |
|
||||
| **B. Evidence** | Statement of account / aging report | Exact amount + age |
|
||||
| | Contract, invoice, PO, change orders | Proves the debt and terms |
|
||||
| | Proof of delivery / signed receipts / completion sign-off | Defeats "never received it" |
|
||||
| | Full correspondence chain (email/text) | Shows debtor acknowledgment |
|
||||
| | Payment history | Separates paid vs outstanding |
|
||||
| **C. Debtor dossier** | Debtor information sheet | Entity name, type, agent, addresses, contacts |
|
||||
| | Personal guarantee (if any) | Unlocks consumer credit reporting (FCRA) |
|
||||
| | Known assets / banking relationships | Feeds skip tracing / asset scans |
|
||||
| **D. Financial** | IRS Form W-9 | Required before any disbursement (Stripe Connect) |
|
||||
| | ACH / disbursement authorization + banking details | Destination for client share |
|
||||
|
||||
---
|
||||
|
||||
## 12. DOCUMENT HANDLING & RETENTION
|
||||
@@ -980,6 +1009,29 @@ DRE shall maintain a separate **Trust Account** (IOLTA or equivalent) for client
|
||||
- Trust account must be interest-bearing (if appropriate)
|
||||
- Interest earned on trust accounts must be accounted for per client agreement
|
||||
|
||||
#### 17.1.1 Two-Account Structure (approved 2026-08-22)
|
||||
DRE operates two bank accounts, both checking (do NOT use a savings account for
|
||||
client funds — Reg D withdrawal limits and funds that must move quickly are a
|
||||
bad match):
|
||||
|
||||
| Account | Type | Holds | Permitted activity |
|
||||
|---|---|---|---|
|
||||
| Operating | Checking | DRE's own money — fees, payroll, software | Never receives client funds |
|
||||
| Client trust/escrow | Checking | Recovered funds before disbursement | Fee sweep to operating + client disbursement only |
|
||||
|
||||
- **Same bank is acceptable** (instant/free transfers); the segregation of the
|
||||
account itself is what matters, not the institution.
|
||||
- The trust account must be **labeled trust/escrow** at the bank so the account
|
||||
type is on record and client funds are insulated from any levy/freeze against
|
||||
the operating account.
|
||||
- **Stripe Connect settlement routing:** all recovery proceeds settle into the
|
||||
client trust account, NOT operating. DRE's fee is then swept to operating;
|
||||
the client's share is disbursed from the trust account. If Stripe Connect does
|
||||
not support automatic split, perform a manual sweep on each settlement.
|
||||
- Texas Finance Code Ch. 392 does not expressly mandate a separate trust account
|
||||
the way some states do, but the LPOA fiduciary relationship makes segregation
|
||||
the defensible posture. Confirm with attorney/CPA during compliance review.
|
||||
|
||||
### 17.2 Disbursement Timeline
|
||||
|
||||
| Milestone | Deadline |
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Letter Queue - Backend Scope (draft)
|
||||
|
||||
Date: 2026-08-24
|
||||
Author: Sho'Nuff (draft for Germaine review)
|
||||
|
||||
## Current state (ground truth, verified 2026-08-24)
|
||||
|
||||
- **Send pipeline is BUILT and verified end-to-end.** Two new modules:
|
||||
- `/opt/dre-portal/app/letterstream.py` — LetterStream integration (auth, account
|
||||
status, send single/batch, preauth/doauth, tracking, signature, proof).
|
||||
- `/opt/dre-portal/app/letters.py` — router: letters queue lifecycle, PDF render
|
||||
(fpdf2 2.8.8), LetterStream send, and the callback receiver.
|
||||
- **Live DB tables exist:** `letters` and `letter_events` (added to `schema.sql`,
|
||||
applied on restart).
|
||||
- **LetterStream API contract fully recovered** from the user-supplied
|
||||
`api_fulfillment.pdf` (21 pages): `POST https://www.letterstream.com/apis/` with
|
||||
`a`=api_id, `h`=hash, `t`=unique_id. Hash = `md5(base64_encode(last6(t) . api_key
|
||||
. first6(t)))`. Verified three ways: PDF formula + live `AUTHOK` + user screenshot
|
||||
sample vector.
|
||||
- **Account live-verified:** balance `$100.00`, `testmode=disabled`. No documents
|
||||
submitted, no charges incurred.
|
||||
- Letter *content* still lives on `claims` (subject/body/tier) and is generated by
|
||||
`analysis.recommend_letter()` (deterministic, no LLM) — the `letters` queue is the
|
||||
physical-send layer on top.
|
||||
- **Queue UI is wired and smoke-tested (2026-08-25, task ls7).** `letter-queue.html` is
|
||||
a functional queue: filterable list, status badges, Approve / Price & Queue /
|
||||
Confirm & Mail / Track actions, and a New Letter modal (claim picker + structured
|
||||
recipient + mail class). `GET /api/staff/letters` now joins `claims` to expose
|
||||
`claim_number`. Verified via live API lifecycle + Node render harness + 9-page HTTP
|
||||
smoke (all 200).
|
||||
- LetterStream callback is **NOT enabled yet** — the receiver is live and tested, but
|
||||
the callback toggle in the LetterStream dashboard stays off until after the first
|
||||
real send is confirmed.
|
||||
|
||||
## Target state
|
||||
|
||||
A durable letter queue: many letters per claim, each with a lifecycle
|
||||
(draft -> approved -> queued -> sent -> delivered/failed), sendable via
|
||||
LetterStream certified mail, with FDCPA-compliant content and a full audit trail.
|
||||
|
||||
## 1. Data model - new `letters` table
|
||||
|
||||
| Column | Type | Purpose |
|
||||
|---|---|---|
|
||||
| id | TEXT PK | uuid4 |
|
||||
| claim_id | TEXT FK -> claims.id | owning claim |
|
||||
| letter_type | TEXT | demand / escalation / validation / custom |
|
||||
| tier | TEXT | tier the letter was generated for |
|
||||
| subject | TEXT | letter subject |
|
||||
| body | TEXT | letter body (markdown or plain) |
|
||||
| status | TEXT | draft / approved / queued / sending / sent / delivered / failed |
|
||||
| recipient_name | TEXT | debtor or registered-agent name |
|
||||
| addr1, addr2, city, state, zip | TEXT | mailing address |
|
||||
| letterstream_job_id | TEXT | LetterStream job reference (nullable) |
|
||||
| tracking_number | TEXT | USPS tracking (nullable) |
|
||||
| sent_at | TEXT | ISO timestamp (nullable) |
|
||||
| delivered_at | TEXT | ISO timestamp (nullable) |
|
||||
| error | TEXT | last send error (nullable) |
|
||||
| created_by | TEXT | RBAC actor name |
|
||||
| created_at / updated_at | TEXT | ISO timestamps |
|
||||
|
||||
Migration: keep the four claim columns as the "current draft" during transition,
|
||||
then deprecate them once the queue is live. No destructive drop until the queue
|
||||
is proven in production.
|
||||
|
||||
## 2. API
|
||||
|
||||
- `GET /api/staff/claims/{n}/letters` - list letters for a claim
|
||||
- `POST /api/staff/claims/{n}/letters/generate` - generate a draft row via
|
||||
`recommend_letter()` (inserts, does not overwrite the claim columns)
|
||||
- `PUT /api/staff/letters/{id}` - edit a draft
|
||||
- `POST /api/staff/letters/{id}/queue` - mark queued (requires full mailing address)
|
||||
- `POST /api/staff/letters/{id}/send` - call LetterStream, store job_id + tracking
|
||||
- `POST /api/letters/webhook` - LetterStream status callback (delivered / failed)
|
||||
- `GET /api/staff/letters` - global queue across claims (feeds letter-queue.html)
|
||||
|
||||
Idempotency: `send` is guarded by status (only `queued` -> `sending`), so a
|
||||
double-click cannot mail a letter twice. Store `letterstream_job_id` before
|
||||
marking sent.
|
||||
|
||||
## 3. Send pipeline (LetterStream)
|
||||
|
||||
Order of work:
|
||||
|
||||
1. Verify the existing `LETTERSTREAM_API_KEY` against their API (is it valid,
|
||||
what account, what products are enabled).
|
||||
2. Map their REST surface: auth method, endpoint shape, certified vs
|
||||
first-class, PDF upload vs HTML/plain rendering, return address handling,
|
||||
tracking + status webhook. Do not assume - confirm from their docs or a test
|
||||
call.
|
||||
3. PDF generation: render the letter body (reportlab or weasyprint) with DRE
|
||||
letterhead, or pass content to LetterStream to render.
|
||||
4. Address handling: return address (DRE office / PO box) and debtor mailing
|
||||
address must both be resolved before send.
|
||||
5. Status sync: webhook or poll updates `status`, `tracking_number`,
|
||||
`delivered_at`.
|
||||
|
||||
## 4. Compliance (FDCPA)
|
||||
|
||||
- Every first-contact letter MUST carry the 1692g validation notice: amount of
|
||||
debt, creditor name, 30-day dispute right, right to request verification.
|
||||
- No false, deceptive, or misleading language (1692e); no threats of action DRE
|
||||
does not intend to take.
|
||||
- Human sign-off gate: a letter cannot move to `queued` until status is
|
||||
`approved` (actor recorded).
|
||||
- Full immutable audit log (actor + timestamp + old/new) - the `audit_log`
|
||||
pattern already exists and extends here.
|
||||
|
||||
## 5. UI (letter-queue.html) — DONE 2026-08-25 (ls7)
|
||||
|
||||
`/var/www/internal/letter-queue.html` is live:
|
||||
- filterable list (status) with per-status summary chips
|
||||
- Approve (DRAFT) / Price & Queue (APPROVED/PREAUTH/ERROR) / Confirm & Mail (PREAUTH) / Reject / Cancel / Track actions
|
||||
- status badges (DRAFT/APPROVED/PREAUTH/SENT/REJECTED/CANCELLED/ERROR)
|
||||
- tracking timeline (USPS scan events) in the detail panel
|
||||
- New Letter modal: claim picker (prefills debtor name), structured recipient, mail class
|
||||
|
||||
Reject/cancel implemented 2026-08-25: `POST /api/staff/letters/{id}/reject` (requires `reason`) and
|
||||
`POST /api/staff/letters/{id}/cancel` (optional `reason`) move DRAFT/APPROVED/PREAUTH/ERROR letters to
|
||||
REJECTED/CANCELLED, persist the reason in `letters.note`, and write an `audit_log` row. Guards return 409
|
||||
for SENT/REJECTED/CANCELLED and 422 for a missing reject reason. Queue UI has Reject (red) / Cancel buttons
|
||||
for all non-terminal states.
|
||||
|
||||
## 6. Decisions locked (2026-08-24)
|
||||
|
||||
1. **LetterStream key** — valid and live; `$100.00` balance, `testmode=disabled`.
|
||||
2. **Return address** — Germaine provides it 2026-08-25. Set as
|
||||
`LETTERSTREAM_RETURN_ADDRESS` in `.env`; drafting does NOT block on it, only
|
||||
`send` does (clean 409 until configured).
|
||||
3. **Signatory** — `Debt Recovery Experts LLC` (no named individual). Default in code;
|
||||
overridable via `LETTERSTREAM_SIGNATORY` in `.env`.
|
||||
4. **Address verification / NCOA** — none. Pull debtor/return-address data from the
|
||||
client's claim info + Super Search.
|
||||
5. **FDCPA 1692g notice** — mandatory on first contact (DRE is a third-party debt
|
||||
collector). Content is generated by `analysis.recommend_letter()`; final validation-
|
||||
notice wording vs welcome-packet copy still to be finalized (task ls8).
|
||||
@@ -0,0 +1,138 @@
|
||||
# LetterStream API Contract (verified live)
|
||||
|
||||
Date: 2026-08-25
|
||||
Source: `api_fulfillment.pdf` (LetterStream "Mail Fulfillment by LetterStream — Integration API", Feb 3 2023) + live verification against the account.
|
||||
|
||||
## Credentials
|
||||
- `API_ID` (8 chars), `API_KEY` (18 chars) — in `/opt/dre-portal/.env` as `LETTERSTREAM_API_ID` / `LETTERSTREAM_API_KEY`.
|
||||
- Account funded: balance `$100.00`, `testmode=disabled` (LIVE/production mode) as of 2026-08-25.
|
||||
|
||||
## Endpoint
|
||||
- Base: `https://www.letterstream.com/apis/` (or `/apis/index.php`). **POST only** (form-encoded or multipart).
|
||||
- Response: XML `<messages id="..."><message type="...">...</message></messages>`.
|
||||
- `responseformat=json` returns JSON instead of XML.
|
||||
|
||||
## Auth (VERIFIED 2026-08-25)
|
||||
Three form fields on every request:
|
||||
- `a` = api_id
|
||||
- `t` = unique id — numeric, **10–18 digits**, accepted only once (duplicate → `-957 DUP`). Use `time()`-style value.
|
||||
- `h` = hash, computed as:
|
||||
|
||||
```php
|
||||
$unique_id = time(); // 10-18 digit numeric, unique per request
|
||||
$string_to_hash = substr($unique_id,-6) . $api_key . substr($unique_id,0,6);
|
||||
$hash = md5(base64_encode($string_to_hash));
|
||||
```
|
||||
|
||||
Python equivalent:
|
||||
```python
|
||||
import hashlib, base64
|
||||
s = t[-6:] + api_key + t[:6]
|
||||
h = hashlib.md5(base64.b64encode(s.encode())).hexdigest()
|
||||
```
|
||||
|
||||
### Auth response codes
|
||||
- `-199` `AUTHOK` — account good, connection successful
|
||||
- `-958` `IDOK` — api_id found but hash lookup failed (wrong hash)
|
||||
- `-957` `DUP` — unique id duplicate
|
||||
- `-950` `Unable to authenticate`
|
||||
- `BAD` — api_id not valid
|
||||
- `-998` `Improper submission format` — auth valid but args don't form a valid request
|
||||
- `-999` `unknown submission error`
|
||||
|
||||
## Send method 1 — Batch (ZIP) [preferred for volume]
|
||||
`POST` with `multi_file` = a `.zip` archive containing one PDF per recipient + one CSV data file. CSV filename becomes the batch id (must be unique). 50MB cap. CSV columns (Table 4.1.1):
|
||||
|
||||
| # | Column | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | UniqueDocId | yes | alphanumeric, max 20 chars, unique to any active/mailed job |
|
||||
| 2 | PDFFileName | yes | filename of the PDF inside the zip |
|
||||
| 3 | RecipientName1 | yes | |
|
||||
| 4 | RecipientName2 | optional | |
|
||||
| 5 | RecipientAddr1 | yes | |
|
||||
| 6 | RecipientAddr2 | optional | suite # |
|
||||
| 7 | RecipientCity | yes | |
|
||||
| 8 | RecipientState | yes | 2-char alpha |
|
||||
| 9 | RecipientZip | yes | 5–10 numeric + "-" |
|
||||
| 10 | SenderName1 | yes | |
|
||||
| 11 | SenderName2 | optional | |
|
||||
| 12 | SenderAddr1 | yes | |
|
||||
| 13 | SenderAddr2 | optional | |
|
||||
| 14 | SenderCity | yes | |
|
||||
| 15 | SenderState | yes | 2-char alpha |
|
||||
| 16 | SenderZip | yes | |
|
||||
| 17 | PageCount | yes | numeric |
|
||||
| 18 | MailType | no | `firstclass` \| `firstclass_hse` \| `certified` \| `certnoerr` \| `postcard` \| `flat` \| `propostcard` (default `firstclass`) |
|
||||
| 19 | CoverSheet | no | `Y`\|`N` (default `Y`) |
|
||||
| 20 | Duplex | no | `Y`\|`N` (default `N`) |
|
||||
| 21 | Ink | no | `B`\|`C` (default `B`) |
|
||||
| 22 | Paper | no | see options (default `W`) |
|
||||
| 23 | ReturnEnvelope | no | `Y`\|`9RWS`\|`9LWS`\|`634`\|`634_12PK`\|`N` (default `N`) |
|
||||
| 24 | Affidavit | no | `A`\|`N` (default `N`) |
|
||||
|
||||
## Send method 2 — HTTP POST (single file) [≤50/day, low volume]
|
||||
`POST` (multipart or form-encoded). Required fields:
|
||||
- `a`, `h`, `t` — auth
|
||||
- `job` — **unique** job name (unique across all active/mailed jobs)
|
||||
- `to[]` — array of recipient address strings (repeat the field per recipient)
|
||||
- `from` — single sender/return address (max 1)
|
||||
- `single_file` — the PDF to mail (multipart file OR base64 blob)
|
||||
- `pages` — number of pages in the PDF
|
||||
|
||||
Optional: `mailtype` (default `firstclass`), `coversheet` (default true), `duplex`, `ink`, `paper`, `returnenv`, `preauth`.
|
||||
|
||||
### Address string format (`to[]` and `from`)
|
||||
Colon or pipe delimited (don't mix):
|
||||
```
|
||||
# recipient (doc_id included):
|
||||
doc_id:name_1:name_2:address_1:address_2:city:state:zip
|
||||
# sender (no doc_id):
|
||||
name_1:name_2:address_1:address_2:city:state:zip
|
||||
```
|
||||
`doc_id` must be unique per recipient (same spec as UniqueDocId). Only domestic addresses eligible for certified mail.
|
||||
|
||||
## Preauth (price-before-release)
|
||||
- Submit with `preauth=1` → processed but NOT released to production; returns `-200` + `authcode` + pricing.
|
||||
- Authorize/release by resubmitting `doauth=<authcode>`.
|
||||
|
||||
## Submission response codes
|
||||
- `-100` success → includes `<batch>`, `<quantity>`, `<cost>`, `<doc><id><job><cost>`
|
||||
- `-200` preauth success / preauth authorization success
|
||||
- `-911` insufficient funding (items held until funds added)
|
||||
|
||||
## Mail types (cost/features)
|
||||
- `firstclass` — First Class Letter (#10 2-window)
|
||||
- `firstclass_hse` — First Class Letter "Homeowner Statement Enclosed" endorsement
|
||||
- `certified` — Certified w/ Electronic Return Receipt (#10 3-window, tracking #)
|
||||
- `certnoerr` — Certified WITHOUT e-Return Receipt (no signature collected)
|
||||
- `postcard` — 5.5"x4.25" 100# cardstock
|
||||
- `flat` — 10x13 windowed flat (up to 75 sheets, coversheet by default)
|
||||
- `propostcard` — pro postcard
|
||||
|
||||
## Tracking / status queries (POST, all with a/h/t)
|
||||
- `cert=<tracking_number>&getinfo=track` → HTML tracking (or `getinfo=trackx` XML; `responseformat=json` for JSON)
|
||||
- `doc_id=<doc_id>&getinfo=track` → job status (non-certified)
|
||||
- `cert=...&getinfo=sig` → signature file (streamed PDF)
|
||||
- `doc_id=...&getinfo=proof` → document proof (base64 streamed PDF)
|
||||
- `batchstatus=<batch1,batch2>` / `jobstatus=<job1,job2>` / `docstatus=<doc1,doc2>` → stage-of-production status
|
||||
- `accountstatus=1` → account balance (`<balance>`, `<testmode>`)
|
||||
|
||||
USPS tracking numbers: 22 digits since March 2018 (older 20-digit still valid).
|
||||
|
||||
## Document preflight
|
||||
`POST` with `preflight=visual` (or `auto` coming soon) + `preflight_file` (PDF) + optional `display=true`. Returns marked-up PDF showing window placement. Used for template verification, not every submission.
|
||||
|
||||
## Callback / webhook (tracking push) — receive side
|
||||
See "API PUSH" section below (contract from account "API Callback Settings" page):
|
||||
|
||||
- LetterStream PUSHES tracking data to our endpoint (HTTP POST) every 4 hours (and heartbeat when idle).
|
||||
- POST fields: `key`, `api_version`, `timestamp`, `json`.
|
||||
- `json` = JSON string of tracking line items: `batch_id`, `job_id`, `doc_id`, `tracking_id`, `scan_date`, `scan_zip`, `scan_facility`, `scan_code`, `scan_status`.
|
||||
- scan_codes reference: https://postalpro.usps.com/product-tracking-and-reporting/scan-events-descriptions
|
||||
- Required response: HTTP 200 + `{"success":true,"reason":"Received data"}`.
|
||||
- `key` = our callback auth string (`LETTERSTREAM_CALLBACK_KEY` in `.env`, 48 hex chars, generated 2026-08-25).
|
||||
- Enable must stay OFF until our receiver is live.
|
||||
|
||||
## Implementation
|
||||
- Python module: `/opt/dre-portal/app/letterstream.py` (mirrors `docuseal.py` style).
|
||||
- Auth formula verified live 2026-08-25 (AUTHOK + balance returned).
|
||||
@@ -0,0 +1,85 @@
|
||||
# LIMITED POWER OF ATTORNEY
|
||||
## Debt Recovery Experts, LLC
|
||||
|
||||
**THIS LIMITED POWER OF ATTORNEY ("LPOA")** is made and entered into by and between the undersigned principal (the "Client") and **Debt Recovery Experts, LLC**, a limited liability company (the "Company").
|
||||
|
||||
### 1. Appointment of Agent
|
||||
|
||||
The Client hereby appoints the Company, and its authorized officers, employees, and designated representatives, as the Client's true and lawful attorney-in-fact, **limited strictly to the matters set forth below**, with full power and authority to act in the Client's name, place, and stead.
|
||||
|
||||
### 2. Scope of Authority (LIMITED)
|
||||
|
||||
The authority granted under this LPOA is limited exclusively to the recovery of the specific debt identified below (the "Claim"):
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Client Legal Name | {{client_legal_name}} |
|
||||
| Client Entity Type | {{client_entity_type}} |
|
||||
| Debtor Legal Name | {{debtor_legal_name}} |
|
||||
| Claim Amount | {{claim_amount}} |
|
||||
| Invoice / Contract Date | {{invoice_date}} |
|
||||
| DRE Claim Number | {{dre_claim_number}} |
|
||||
|
||||
Specifically, the Company is authorized to:
|
||||
|
||||
1. **Demand payment** of the Claim from the Debtor, in writing and verbally.
|
||||
2. **Negotiate and settle** the Claim, subject to the settlement authority limits set forth in the Terms of Service.
|
||||
3. **Receive payments** on the Claim, including via the Company's designated payment processor, and deposit such payments into the Company's trust/escrow account for disbursement to the Client in accordance with the signed Fee Schedule.
|
||||
4. **Execute and deliver** documents incidental to collection of the Claim, including demand letters, settlement agreements, payment acknowledgments, and releases limited to the Claim.
|
||||
5. **Engage third-party service providers** (remote online notary, certified mail vendor, and partner law firm) as reasonably necessary to collect the Claim, in accordance with the signed Third-Party Sharing Consent.
|
||||
6. **Refer the Claim to legal counsel** for further action if the Claim reaches Tier 4, in accordance with the Terms of Service.
|
||||
|
||||
### 3. Express Limitations (the Company MAY NOT)
|
||||
|
||||
Notwithstanding anything to the contrary, the Company is **NOT** authorized to:
|
||||
|
||||
1. Borrow money, mortgage property, or create any lien or security interest in the Client's name (other than filing a mechanic's or materialman's lien in the ordinary course of collecting the Claim, and only through licensed counsel).
|
||||
2. Sell, transfer, or convey any real or personal property of the Client.
|
||||
3. Make gifts of the Client's property.
|
||||
4. Settle the Claim for less than the minimum settlement authority stated in the Terms of Service without the Client's separate written approval.
|
||||
5. Commence litigation in the Client's name; litigation is referred to licensed counsel under a separate engagement.
|
||||
|
||||
### 4. Duration and Revocation
|
||||
|
||||
This LPOA becomes effective upon execution and **notarization**, and remains in effect until: (a) the Claim is fully resolved (recovered in full, settled, or determined uncollectible and closed); or (b) the Client revokes this LPOA in writing delivered to the Company. Revocation does not affect acts lawfully taken before receipt of the revocation.
|
||||
|
||||
### 5. Governing Law
|
||||
|
||||
This LPOA is governed by the laws of the State of Texas, including Chapter 751 of the Texas Estates Code (Statutory Durable Power of Attorney requirements).
|
||||
|
||||
### 6. Acknowledgment
|
||||
|
||||
The Client acknowledges that the Company is a third-party debt collector acting on the Client's behalf with respect to the Claim, and that the Client remains ultimately responsible for the accuracy of the information provided concerning the Claim.
|
||||
|
||||
---
|
||||
|
||||
**IN WITNESS WHEREOF**, the Client has executed this Limited Power of Attorney as of the date set forth below.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Client Signature** | **Date** |
|
||||
| ________________________ | ________________________ |
|
||||
| **Print Name** | **Title** |
|
||||
| ________________________ | ________________________ |
|
||||
|
||||
---
|
||||
|
||||
## NOTARY ACKNOWLEDGMENT
|
||||
|
||||
State of ________________
|
||||
County of ________________
|
||||
|
||||
This instrument was acknowledged before me on ____________ (date) by ________________________ (name of person), in the capacity of ________________________ for ________________________ (entity name), as the act of such entity.
|
||||
|
||||
| |
|
||||
|---|
|
||||
| **Notary Public Signature** |
|
||||
| ________________________ |
|
||||
| **Notary Public Printed Name** |
|
||||
| ________________________ |
|
||||
| **My commission expires:** ____________ |
|
||||
| *(Notary seal)* |
|
||||
|
||||
---
|
||||
|
||||
*This LPOA requires notarization pursuant to Texas Estates Code § 751.0021. This is the ONLY document in your welcome packet that requires a notary; it is completed online via our remote online notary partner.*
|
||||
@@ -0,0 +1,79 @@
|
||||
# DEBT RECOVERY EXPERTS, LLC
|
||||
## TERMS OF SERVICE
|
||||
|
||||
**Last updated: August 23, 2026**
|
||||
|
||||
These Terms of Service ("ToS") govern the engagement of **Debt Recovery Experts, LLC** (the "Company," "DRE," "we," or "us") by the client identified below ("Client" or "you"). By signing, you agree to be bound by these terms, including the **Fee Schedule attached hereto as Schedule A** and incorporated by reference.
|
||||
|
||||
### 1. Services
|
||||
|
||||
DRE provides commercial debt recovery services. Upon approval of your claim, DRE will pursue recovery of the identified debt through a tiered escalation process (demand letters, escalation, lien threat where applicable, and referral to partner legal counsel), as described in the DRE Help & Recovery Guide.
|
||||
|
||||
**No Guarantee of Recovery.** DRE makes no representation or guarantee that any claim will be recovered, in whole or in part. Recovery is contingent on the debtor's circumstances and willingness or ability to pay.
|
||||
|
||||
### 2. Contingency Fee Basis
|
||||
|
||||
DRE's fees are **contingent** — DRE is paid **only if and when money is recovered**. If nothing is recovered, you owe DRE no fee for DRE's services. The applicable fee is determined by the recovery tier at which the claim resolves, as set forth in Schedule A.
|
||||
|
||||
### 3. Costs and Expenses
|
||||
|
||||
Certain out-of-pocket costs may be deducted from recovered funds before disbursement, regardless of the tier at which the claim resolves:
|
||||
|
||||
- Remote online notary fee (one-time, per LPOA execution)
|
||||
- Certified mail / LetterStream postage and service fees
|
||||
- Court filing fees and recording fees (only if a lien or legal action is pursued)
|
||||
|
||||
These costs are itemized on your settlement statement. DRE will not incur non-recoverable third-party costs (such as litigation filing fees) without your prior written approval.
|
||||
|
||||
### 4. Settlement Authority
|
||||
|
||||
Unless otherwise agreed in writing, DRE may settle the Claim for **no less than 70% of the principal amount** without further approval. Settlements below this threshold, or any settlement involving non-monetary terms, require your separate written approval.
|
||||
|
||||
### 5. Disbursement
|
||||
|
||||
Recovered funds are received into a DRE operating/trust account, less (a) DRE's contingency fee per Schedule A and (b) itemized costs per Section 3. The balance is disbursed to the bank account you authorize via the ACH/Disbursement Authorization form. Disbursements occur on a defined schedule; you will receive a settlement statement with each disbursement.
|
||||
|
||||
### 6. Compliance and Representations
|
||||
|
||||
You represent and warrant that:
|
||||
|
||||
1. The debt you are referring is a valid, enforceable commercial obligation owed to you.
|
||||
2. The information and documentation you provide (amount, aging, contracts, invoices, delivery proof) is true and accurate.
|
||||
3. You are authorized to refer the debt and to execute this agreement on behalf of the claimant entity.
|
||||
|
||||
You acknowledge that DRE will rely on these representations in its collection efforts, and that providing materially false information may expose you to liability.
|
||||
|
||||
### 7. Third-Party Services
|
||||
|
||||
To perform the services, DRE may share limited information with third-party service providers (remote online notary, certified mail vendor, partner law firm). Such sharing is governed by the separate Third-Party Sharing Consent you sign. DRE does not sell your information.
|
||||
|
||||
### 8. Termination
|
||||
|
||||
Either party may terminate this engagement upon written notice. Upon termination, DRE will cease collection activity. Any fees and costs earned or incurred through the date of termination remain due and payable in accordance with Schedule A and Section 3. Termination does not discharge any obligation the debtor has already agreed to satisfy.
|
||||
|
||||
### 9. Limitation of Liability; Indemnification
|
||||
|
||||
To the maximum extent permitted by law, DRE's aggregate liability arising out of this engagement shall not exceed the total fees actually paid by you to DRE. You agree to indemnify and hold DRE harmless from any claim arising out of your breach of the representations in Section 6.
|
||||
|
||||
### 10. Governing Law; Dispute Resolution
|
||||
|
||||
This ToS is governed by the laws of the State of Texas. Any dispute arising out of this engagement shall be resolved in the state or federal courts of Texas.
|
||||
|
||||
### 11. Entire Agreement
|
||||
|
||||
This ToS, together with Schedule A (Fee Schedule), the Limited Power of Attorney, the Third-Party Sharing Consent, and the ACH/Disbursement Authorization, constitutes the entire agreement between the parties and supersedes all prior communications.
|
||||
|
||||
---
|
||||
|
||||
## CLIENT ACKNOWLEDGMENT
|
||||
|
||||
By signing below, the Client acknowledges they have read, understood, and agreed to these Terms of Service, including Schedule A (Fee Schedule), and that the Client has become a customer of Debt Recovery Experts, LLC.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Client Signature** | **Date** |
|
||||
| ________________________ | ________________________ |
|
||||
| **Print Name** | **Title** |
|
||||
| ________________________ | ________________________ |
|
||||
| **Company (if applicable)** | |
|
||||
| ________________________ | |
|
||||
@@ -0,0 +1,52 @@
|
||||
# SCHEDULE A — FEE SCHEDULE
|
||||
## Debt Recovery Experts, LLC
|
||||
|
||||
This Schedule A is attached to and incorporated into the Terms of Service between the Client and Debt Recovery Experts, LLC. **All fees are contingent** — payable only upon actual recovery of funds from the debtor.
|
||||
|
||||
## Contingency Fee by Recovery Tier
|
||||
|
||||
The fee is determined by the tier at which the claim resolves (i.e., the point at which the debtor pays):
|
||||
|
||||
| Tier | Description | DRE Fee |
|
||||
|---|---|---|
|
||||
| **Tier 1** | Soft Touch demand | **20-25%** of amount recovered |
|
||||
| **Tier 2** | Formal Demand | **30%** of amount recovered |
|
||||
| **Tier 2.5** | Lien Threat (construction claims) | **30%** of amount recovered, plus attorney fees only if a lien is actually filed through counsel |
|
||||
| **Tier 3** | Final Notice | **33%** of amount recovered |
|
||||
| **Tier 4** | Legal Action (referral to counsel) | **15%** DRE referral fee **plus 25%** law firm fee (40% combined) |
|
||||
|
||||
## Illustrative Example (Tier 2 resolution, $10,000 claim)
|
||||
|
||||
| Item | Amount |
|
||||
|---|---|
|
||||
| Amount recovered | $10,000.00 |
|
||||
| DRE contingency fee (30%) | -$3,000.00 |
|
||||
| Certified mail + notary costs | -$75.00 |
|
||||
| **Net to Client** | **$6,925.00** |
|
||||
|
||||
## Costs and Expenses (deducted from recovery)
|
||||
|
||||
These are actual, itemized out-of-pocket costs, not DRE profit:
|
||||
|
||||
- Remote online notary: one-time, per LPOA (est. $25)
|
||||
- Certified mail / LetterStream postage and service fees (actual)
|
||||
- Court filing and recording fees (actual, only if lien or litigation pursued, with prior approval)
|
||||
|
||||
## No Recovery, No Fee
|
||||
|
||||
If DRE recovers nothing, the Client owes **no contingency fee**. The Client is responsible only for actual out-of-pocket third-party costs already incurred with the Client's prior approval (e.g., litigation filing fees). DRE will not incur such costs without the Client's written approval.
|
||||
|
||||
## Loyalty Pricing (optional)
|
||||
|
||||
Clients with 3+ prior claims may qualify for reduced Tier 1 pricing (e.g., 25% reduced toward 20% at DRE's discretion).
|
||||
|
||||
---
|
||||
|
||||
**ACKNOWLEDGMENT:** By signing the Terms of Service, the Client acknowledges receipt of this Fee Schedule and agrees to the fee and cost terms set forth above.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Client Signature** | **Date** |
|
||||
| ________________________ | ________________________ |
|
||||
| **Print Name** | |
|
||||
| ________________________ | |
|
||||
@@ -0,0 +1,32 @@
|
||||
# THIRD-PARTY SHARING CONSENT
|
||||
## Debt Recovery Experts, LLC
|
||||
|
||||
The undersigned Client ("you") authorizes Debt Recovery Experts, LLC ("DRE") to share limited information about you and your claim with the following third-party service providers, **solely as necessary** to perform the debt recovery services described in the Terms of Service:
|
||||
|
||||
| Provider | Purpose | Information Shared |
|
||||
|---|---|---|
|
||||
| **OneNotary** (remote online notary) | Notarize your Limited Power of Attorney | Your name, entity name, and the LPOA document |
|
||||
| **LetterStream** (certified mail vendor) | Send certified demand letters and track delivery | Debtor name/address, claim reference |
|
||||
| **Partner law firm** (Tier 4 referral) | Provide legal representation for escalated claims | Claim details, documentation, and correspondence |
|
||||
|
||||
### What DRE does NOT do
|
||||
|
||||
- DRE does **not** sell, rent, or license your information to any third party.
|
||||
- DRE does **not** share your information for marketing purposes.
|
||||
- DRE shares only the minimum information necessary for each provider to perform its function.
|
||||
- DRE does **not** report individual consumer credit information to credit bureaus except as expressly required by law (and only where a valid signed personal guarantee exists).
|
||||
|
||||
### Duration
|
||||
|
||||
This consent remains in effect for the duration of your engagement with DRE for the applicable claim, and may be revoked in writing at any time. Revocation will not affect disclosures lawfully made before receipt of the revocation.
|
||||
|
||||
### Acknowledgment
|
||||
|
||||
By signing, you consent to the disclosures described above.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Client Signature** | **Date** |
|
||||
| ________________________ | ________________________ |
|
||||
| **Print Name** | |
|
||||
| ________________________ | |
|
||||
@@ -0,0 +1,63 @@
|
||||
# DEBTOR INFORMATION SHEET
|
||||
## Debt Recovery Experts, LLC
|
||||
|
||||
Complete one sheet per debtor. The more complete the information, the faster and more effective the recovery. **Legal entity name must match the entity that actually owes the debt** — this determines the correct registered agent, service address, and (critically) whether a personal guarantee applies.
|
||||
|
||||
## Debtor Identification
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Legal entity name** (exact) | {{debtor_legal_name}} |
|
||||
| Entity type (LLC / Corp / Sole Prop / Partnership / Individual) | {{debtor_entity_type}} |
|
||||
| DBA / trade name (if any) | {{debtor_dba}} |
|
||||
| State of formation | {{debtor_state}} |
|
||||
| Registered agent (name) | {{debtor_registered_agent}} |
|
||||
| Registered agent address | {{debtor_registered_agent_address}} |
|
||||
| EIN / Tax ID (if known) | {{debtor_ein}} |
|
||||
| Website | {{debtor_website}} |
|
||||
|
||||
## Contact Information
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Primary contact name | {{debtor_contact_name}} |
|
||||
| Title | {{debtor_contact_title}} |
|
||||
| Direct phone | {{debtor_contact_phone}} |
|
||||
| Email | {{debtor_contact_email}} |
|
||||
| Business address (street) | {{debtor_address}} |
|
||||
| City / State / ZIP | {{debtor_city_state_zip}} |
|
||||
| Alternate address (branch/warehouse) | {{debtor_alt_address}} |
|
||||
|
||||
## Principals / Owners (for personal guarantee determination)
|
||||
|
||||
| Name | Title | Phone | Email |
|
||||
|---|---|---|---|
|
||||
| ________________________ | ______ | ______ | ______ |
|
||||
| ________________________ | ______ | ______ | ______ |
|
||||
|
||||
## Banking / Payment Relationships (if known)
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Bank / financial institution | {{debtor_bank}} |
|
||||
| Any known accounts receivable / lenders | {{debtor_ar_lenders}} |
|
||||
|
||||
## Personal Guarantee
|
||||
|
||||
Does a **signed personal guarantee** exist for this debt?
|
||||
|
||||
- [ ] **Yes** — a signed written personal guarantee exists (attach a copy). This is critical: without it, DRE cannot pursue an individual's personal credit or assets.
|
||||
- [ ] **No** — this is a business-to-business debt only.
|
||||
- [ ] **Unsure**
|
||||
|
||||
If yes, who signed the guarantee? {{personal_guarantee_signer}}
|
||||
|
||||
## Notes
|
||||
|
||||
________________________________________________________________________
|
||||
|
||||
________________________________________________________________________
|
||||
|
||||
---
|
||||
|
||||
*Submit this sheet together with your claim substantiation (statement of account, contracts, invoices, proof of delivery, correspondence, and payment history). Recovery cannot begin until the complete packet is received.*
|
||||
@@ -0,0 +1,34 @@
|
||||
# ACH / DISBURSEMENT AUTHORIZATION
|
||||
## Debt Recovery Experts, LLC
|
||||
|
||||
This form authorizes Debt Recovery Experts, LLC ("DRE") to disburse recovered funds (net of contingency fee and itemized costs) to the Client's bank account identified below.
|
||||
|
||||
## Account Holder Information
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Account holder legal name** (must match W-9) | {{account_holder_name}} |
|
||||
| Entity type (if business) | {{account_holder_entity_type}} |
|
||||
| Bank name | {{bank_name}} |
|
||||
| Account type | [ ] Checking [ ] Savings |
|
||||
| Routing (ABA) number | {{routing_number}} |
|
||||
| Account number | {{account_number}} |
|
||||
|
||||
## Authorization
|
||||
|
||||
The undersigned authorizes DRE to initiate **credit (deposit) entries only** to the account identified above for the purpose of disbursing settlement proceeds. This authorization is for **deposits only** — DRE is **not** authorized to debit this account for any reason.
|
||||
|
||||
This authorization remains in effect until revoked in writing by the undersigned.
|
||||
|
||||
## Tax Reporting
|
||||
|
||||
The undersigned acknowledges that recovered funds may be subject to tax reporting, and that DRE requires a valid IRS Form W-9 on file before any disbursement. DRE will not disburse funds without a completed W-9.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Client Signature** | **Date** |
|
||||
| ________________________ | ________________________ |
|
||||
| **Print Name** | **Title** |
|
||||
| ________________________ | ________________________ |
|
||||
| **Company (if applicable)** | |
|
||||
| ________________________ | |
|
||||
@@ -0,0 +1,86 @@
|
||||
# DRE Customer Portal - Static Frontend
|
||||
|
||||
Dependency-free HTML/CSS/JS frontend for the Debt Recovery Experts (DRE)
|
||||
customer portal. No build step, no frameworks, no npm. Every page loads
|
||||
`css/dre.css` and (where interactive) `js/dre-api.js` via plain `<script>`
|
||||
tags, and calls the backend using relative `/api/*` paths only. This is
|
||||
intended to be dropped behind Caddy, which proxies `/api/*` to the FastAPI
|
||||
backend and serves everything else as static files on the two subdomains
|
||||
below.
|
||||
|
||||
This directory is a DESIGN-ONLY deliverable: no deploy, no Caddy/systemd
|
||||
config, no backend changes were made or are included here.
|
||||
|
||||
## Files created
|
||||
|
||||
```
|
||||
/root/projects/dre/frontend/
|
||||
index.html Public intake form
|
||||
login.html Client login (request magic link)
|
||||
verify.html Magic-link verification landing page
|
||||
dashboard.html Client dashboard (claims list + detail + upload + messages)
|
||||
css/dre.css Shared stylesheet for all four pages
|
||||
js/dre-api.js Shared JS helper: fetch wrapper, session storage, formatting
|
||||
README.md This file
|
||||
```
|
||||
|
||||
## Route / subdomain mapping
|
||||
|
||||
| File | Serves at | Auth required |
|
||||
|------------------|--------------------------------------------------|---------------|
|
||||
| `index.html` | `portal.debtrecoveryexperts.com/` (site root) | No (public) |
|
||||
| `login.html` | `my.debtrecoveryexperts.com/` (site root) | No (public) |
|
||||
| `verify.html` | `my.debtrecoveryexperts.com/verify` | No (public, consumes a one-time token) |
|
||||
| `dashboard.html` | `my.debtrecoveryexperts.com/dashboard` | Yes (redirects to `login.html` if no session) |
|
||||
| `css/dre.css` | `/css/dre.css` on both subdomains | - |
|
||||
| `js/dre-api.js` | `/js/dre-api.js` on both subdomains | - |
|
||||
|
||||
Caddy is expected to:
|
||||
1. Serve `portal.debtrecoveryexperts.com` from this directory with `index.html` as the site index.
|
||||
2. Serve `my.debtrecoveryexperts.com` from this directory with `login.html` as the site index, `verify.html` at `/verify`, and `dashboard.html` at `/dashboard`.
|
||||
3. Reverse-proxy `/api/*` on both subdomains to the FastAPI backend (same-origin so the JS `fetch()` calls need no CORS config and no hardcoded backend host).
|
||||
|
||||
No Caddyfile is included per the design-only constraint; this table is the
|
||||
spec for whoever wires up routing.
|
||||
|
||||
## API contract implemented (verified against backend/main.py, backend/models.py, backend/claims.py, backend/intake.py)
|
||||
|
||||
- `POST /api/intake` - body `{client:{company_name, contact_name, email, phone?}, debtor:{name, business_type, contact_email?, contact_phone?, physical_address?}, claim:{amount_cents, description?, client_reference?, invoice_date?}, tos_accepted:true}`. Response `{claim_number, client_number, status, message}`. Amount is collected as dollars in the UI and converted to integer cents client-side before posting.
|
||||
- `POST /api/auth/request` - body `{email}`. Always returns the anti-enumeration message `{message: "If an account exists..."}`; UI always shows the "check your email" state on a 2xx response regardless of whether the account exists.
|
||||
- `POST /api/auth/verify` - body `{token}`. Response `{session_token, expires_at, client:{client_number, company_name, contact_name}}`. Token is read from `?token=` in the URL, stripped from the address bar immediately (history.replaceState) before the API call, and the session token is stored in `localStorage` under the key `dre_session_token`.
|
||||
- `GET /api/auth/me` - `Authorization: Bearer <token>`. Response `{client:{...}, claim_count}`.
|
||||
- `POST /api/auth/logout` - `Authorization: Bearer <token>`. Clears local storage and redirects to login regardless of response.
|
||||
- `GET /api/claims` - Response `{claims:[{claim_number, status, status_label, tier, amount_cents, amount_display, debtor_name, created_at, date_resolved}]}`.
|
||||
- `GET /api/claims/{claim_number}` - Response includes `status_label`, `tier_step` (used to render the 4-step progress bar), `debtor:{name, business_type}`, `documents:[...]`, `notes:[...]`. Note: this endpoint does not return a claim `created_at`; the UI shows `date_assigned` (or "Not yet assigned") instead of a submission date.
|
||||
- `POST /api/claims/{claim_number}/documents` - multipart `FormData` with field name `file` (matches `UploadFile = File(...)` param name in `claims.py`). Client-side pre-checks: 20 MB max, extensions `.pdf .jpg .jpeg .png .doc .docx` (mirrors `ALLOWED_EXT` in `claims.py`).
|
||||
- `POST /api/claims/{claim_number}/messages` - body `{subject, content}`. `subject` must be one of the fixed `MESSAGE_SUBJECTS` enum from `models.py`; rendered as a `<select>` with those exact values. (The backend's client-facing endpoint for adding case correspondence is `/messages`, not `/notes` - the dashboard's "Message the Team" panel targets this and refreshes the notes list on success, since messages are stored as shared case notes.)
|
||||
|
||||
Error envelope handled uniformly everywhere: `{"error": {"code", "message"}}`.
|
||||
Specific codes handled: `validation_error` (422, including per-field mapping
|
||||
on the intake form for `client.*` / `debtor.*` / `claim.*` / `tos_accepted`
|
||||
locations), `unauthorized` (401, clears session + redirects to login),
|
||||
`not_found` (404), `rate_limited` (429), `payload_too_large` /
|
||||
`unsupported_media_type` / `conflict` (document upload).
|
||||
|
||||
## Compliance / safety notes
|
||||
|
||||
- No field anywhere collects SSN, full bank account, or card numbers. The intake form has an explicit on-page warning, and the backend's PII regex rejection (`validation_error`) is surfaced inline on the matching form field when triggered.
|
||||
- All user-authored or backend-sourced free text (case notes, messages) is rendered via `textContent`, never `innerHTML`, so it cannot execute as markup even though the backend also HTML-escapes it server-side.
|
||||
- Wording throughout intake/login/dashboard is neutral and professional (e.g. "recovery review", "claim", "case notes") - no aggressive or threatening collector language, consistent with FDCPA/TDCPA constraints.
|
||||
- Dollar amounts are always rendered from integer cents (`amount_cents / 100`), formatted as USD via `DRE.formatCentsUSD()`.
|
||||
|
||||
## What was NOT done (out of scope for this seat)
|
||||
|
||||
- No deployment: nothing was copied to `/opt/dre-portal` or any web root.
|
||||
- No Caddy or systemd configuration was created or modified.
|
||||
- No backend files were modified (only read for contract verification).
|
||||
- No database was created, seeded, or touched.
|
||||
|
||||
## Manual smoke test performed
|
||||
|
||||
Served this directory locally with `python3 -m http.server` (throwaway, not
|
||||
part of the deliverable) and confirmed all six files return HTTP 200,
|
||||
all inline `<script>` blocks parse without syntax errors (`node -c`
|
||||
equivalent check), all HTML tags balance, and every `getElementById()`
|
||||
reference in the JS resolves to an element that actually exists in the
|
||||
corresponding HTML file.
|
||||
@@ -0,0 +1,434 @@
|
||||
/* ==========================================================================
|
||||
DRE Customer Portal — shared stylesheet
|
||||
Dependency-free. Used by index.html, login.html, verify.html, dashboard.html
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--dre-navy: #0f2942;
|
||||
--dre-navy-dark: #0a1c2e;
|
||||
--dre-teal: #0f766e;
|
||||
--dre-teal-light: #14b8a6;
|
||||
--dre-ink: #1b2733;
|
||||
--dre-slate: #52606d;
|
||||
--dre-slate-light: #8896a5;
|
||||
--dre-line: #dfe5eb;
|
||||
--dre-bg: #f6f8fa;
|
||||
--dre-white: #ffffff;
|
||||
--dre-ok-bg: #e8f6f1;
|
||||
--dre-ok-text: #0f6b52;
|
||||
--dre-warn-bg: #fdf3e7;
|
||||
--dre-warn-text: #92600a;
|
||||
--dre-err-bg: #fdecec;
|
||||
--dre-err-text: #9b2226;
|
||||
--dre-radius: 10px;
|
||||
--dre-shadow: 0 1px 2px rgba(15, 41, 66, 0.06), 0 4px 16px rgba(15, 41, 66, 0.06);
|
||||
--dre-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--dre-font);
|
||||
color: var(--dre-ink);
|
||||
background: var(--dre-bg);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a { color: var(--dre-teal); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
h1, h2, h3 { color: var(--dre-navy); line-height: 1.25; margin: 0 0 0.5em; }
|
||||
h1 { font-size: 1.75rem; }
|
||||
h2 { font-size: 1.3rem; }
|
||||
h3 { font-size: 1.05rem; }
|
||||
p { margin: 0 0 1em; color: var(--dre-slate); }
|
||||
|
||||
/* ---------------------------------------------------------------- header */
|
||||
.dre-header {
|
||||
background: var(--dre-navy);
|
||||
color: #fff;
|
||||
padding: 0.9rem 1.5rem;
|
||||
}
|
||||
.dre-header-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.dre-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.dre-brand:hover { text-decoration: none; }
|
||||
.dre-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: var(--dre-teal-light);
|
||||
color: var(--dre-navy-dark);
|
||||
font-weight: 800;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.dre-brand-sub {
|
||||
font-weight: 400;
|
||||
font-size: 0.78rem;
|
||||
color: #c6d3de;
|
||||
display: block;
|
||||
}
|
||||
.dre-header-actions { display: flex; align-items: center; gap: 1rem; font-size: 0.9rem; }
|
||||
.dre-header-actions a { color: #dbe7f0; }
|
||||
.dre-header-actions .dre-user { color: #cfe0ea; }
|
||||
|
||||
/* ---------------------------------------------------------------- layout */
|
||||
.dre-main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 3rem;
|
||||
}
|
||||
.dre-main.dre-narrow { max-width: 640px; }
|
||||
|
||||
.dre-footer {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: var(--dre-slate-light);
|
||||
font-size: 0.82rem;
|
||||
border-top: 1px solid var(--dre-line);
|
||||
background: var(--dre-white);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- cards */
|
||||
.dre-card {
|
||||
background: var(--dre-white);
|
||||
border: 1px solid var(--dre-line);
|
||||
border-radius: var(--dre-radius);
|
||||
box-shadow: var(--dre-shadow);
|
||||
padding: 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.dre-card-tight { padding: 1.1rem 1.4rem; }
|
||||
|
||||
.dre-intro { text-align: center; margin-bottom: 2rem; }
|
||||
.dre-intro p { max-width: 560px; margin-left: auto; margin-right: auto; }
|
||||
|
||||
/* ---------------------------------------------------------------- forms */
|
||||
.dre-form-section { margin-bottom: 1.75rem; }
|
||||
.dre-form-section:last-of-type { margin-bottom: 0; }
|
||||
.dre-form-section h3 {
|
||||
border-bottom: 1px solid var(--dre-line);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dre-field { margin-bottom: 1.1rem; }
|
||||
.dre-field label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: var(--dre-navy);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.dre-field .dre-hint {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
color: var(--dre-slate-light);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.dre-required { color: var(--dre-err-text); }
|
||||
|
||||
.dre-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 620px) {
|
||||
.dre-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="tel"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
color: var(--dre-ink);
|
||||
background: #fff;
|
||||
border: 1px solid #c9d3dc;
|
||||
border-radius: 7px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
textarea { resize: vertical; min-height: 90px; }
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--dre-teal);
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
|
||||
}
|
||||
input[aria-invalid="true"], textarea[aria-invalid="true"] {
|
||||
border-color: var(--dre-err-text);
|
||||
}
|
||||
|
||||
.dre-checkbox-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.dre-checkbox-field input[type="checkbox"] {
|
||||
margin-top: 0.2rem;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dre-checkbox-field label { font-weight: 400; color: var(--dre-slate); font-size: 0.9rem; }
|
||||
|
||||
/* ---------------------------------------------------------------- buttons */
|
||||
.dre-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 1.4rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.dre-btn-primary {
|
||||
background: var(--dre-teal);
|
||||
color: #fff;
|
||||
}
|
||||
.dre-btn-primary:hover { background: #0c5e57; }
|
||||
.dre-btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.dre-btn-secondary {
|
||||
background: #fff;
|
||||
color: var(--dre-navy);
|
||||
border-color: #c9d3dc;
|
||||
}
|
||||
.dre-btn-secondary:hover { background: #f2f5f7; }
|
||||
.dre-btn-block { width: 100%; }
|
||||
.dre-btn-sm { padding: 0.4rem 0.9rem; font-size: 0.85rem; }
|
||||
.dre-btn-danger { background: #fff; color: var(--dre-err-text); border-color: #eec5c6; }
|
||||
.dre-btn-danger:hover { background: var(--dre-err-bg); }
|
||||
|
||||
/* ---------------------------------------------------------------- spinner */
|
||||
.dre-spinner {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 2px solid rgba(255,255,255,0.4);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: dre-spin 0.7s linear infinite;
|
||||
}
|
||||
.dre-spinner-dark {
|
||||
border-color: rgba(15,41,66,0.2);
|
||||
border-top-color: var(--dre-navy);
|
||||
}
|
||||
@keyframes dre-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ---------------------------------------------------------------- alerts */
|
||||
.dre-alert {
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
display: none;
|
||||
}
|
||||
.dre-alert.is-visible { display: block; }
|
||||
.dre-alert-error { background: var(--dre-err-bg); color: var(--dre-err-text); border: 1px solid #f3c8c9; }
|
||||
.dre-alert-success { background: var(--dre-ok-bg); color: var(--dre-ok-text); border: 1px solid #b9e3d4; }
|
||||
.dre-alert-info { background: #eaf2fb; color: #1c4d80; border: 1px solid #c9def4; }
|
||||
.dre-field-error {
|
||||
color: var(--dre-err-text);
|
||||
font-size: 0.82rem;
|
||||
margin-top: 0.35rem;
|
||||
display: none;
|
||||
}
|
||||
.dre-field-error.is-visible { display: block; }
|
||||
|
||||
/* ---------------------------------------------------------------- states */
|
||||
.dre-state {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.dre-state-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dre-state-icon.ok { background: var(--dre-ok-bg); color: var(--dre-ok-text); }
|
||||
.dre-state-icon.err { background: var(--dre-err-bg); color: var(--dre-err-text); }
|
||||
.dre-state-icon.info { background: #eaf2fb; color: #1c4d80; }
|
||||
|
||||
.dre-kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4rem 1rem;
|
||||
text-align: left;
|
||||
max-width: 360px;
|
||||
margin: 1.25rem auto 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.dre-kv dt { color: var(--dre-slate); font-weight: 600; }
|
||||
.dre-kv dd { margin: 0; color: var(--dre-ink); }
|
||||
|
||||
/* ---------------------------------------------------------------- claims list */
|
||||
.dre-stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.dre-stats-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
.dre-stat {
|
||||
background: var(--dre-white);
|
||||
border: 1px solid var(--dre-line);
|
||||
border-radius: var(--dre-radius);
|
||||
padding: 1.1rem 1.3rem;
|
||||
box-shadow: var(--dre-shadow);
|
||||
}
|
||||
.dre-stat .dre-stat-label { font-size: 0.8rem; color: var(--dre-slate-light); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.dre-stat .dre-stat-value { font-size: 1.5rem; font-weight: 700; color: var(--dre-navy); margin-top: 0.2rem; }
|
||||
|
||||
.dre-claim-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.dre-claim-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: var(--dre-white);
|
||||
border: 1px solid var(--dre-line);
|
||||
border-radius: var(--dre-radius);
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.dre-claim-row:hover { border-color: var(--dre-teal); box-shadow: var(--dre-shadow); }
|
||||
.dre-claim-main { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.dre-claim-number { font-weight: 700; color: var(--dre-navy); font-size: 0.95rem; }
|
||||
.dre-claim-sub { font-size: 0.85rem; color: var(--dre-slate); }
|
||||
.dre-claim-amount { font-weight: 700; color: var(--dre-navy); font-size: 1.05rem; text-align: right; }
|
||||
.dre-claim-meta { text-align: right; font-size: 0.8rem; color: var(--dre-slate-light); margin-top: 0.15rem; }
|
||||
|
||||
.dre-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
background: #eef1f4;
|
||||
color: var(--dre-slate);
|
||||
}
|
||||
.dre-badge-new, .dre-badge-under_review { background: #eaf2fb; color: #1c4d80; }
|
||||
.dre-badge-active, .dre-badge-negotiation { background: var(--dre-warn-bg); color: var(--dre-warn-text); }
|
||||
.dre-badge-legal { background: #f3e8fd; color: #6b21a8; }
|
||||
.dre-badge-settled, .dre-badge-closed { background: var(--dre-ok-bg); color: var(--dre-ok-text); }
|
||||
.dre-badge-write_off, .dre-badge-rejected { background: var(--dre-err-bg); color: var(--dre-err-text); }
|
||||
|
||||
/* ---------------------------------------------------------------- claim detail */
|
||||
.dre-back-link { display: inline-flex; align-items: center; gap: 0.4rem; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
|
||||
.dre-progress {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin: 1.25rem 0 0.4rem;
|
||||
}
|
||||
.dre-progress-step {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: #e6eaee;
|
||||
}
|
||||
.dre-progress-step.is-done { background: var(--dre-teal); }
|
||||
.dre-progress-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.72rem;
|
||||
color: var(--dre-slate-light);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.dre-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.9rem 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.dre-detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.dre-detail-grid dt { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--dre-slate-light); margin: 0; }
|
||||
.dre-detail-grid dd { margin: 0.15rem 0 0; color: var(--dre-ink); font-size: 0.95rem; }
|
||||
|
||||
.dre-doc-list, .dre-note-list { list-style: none; margin: 0; padding: 0; }
|
||||
.dre-doc-item, .dre-note-item {
|
||||
border: 1px solid var(--dre-line);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.dre-doc-item { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
|
||||
.dre-doc-name { font-weight: 600; color: var(--dre-navy); font-size: 0.9rem; }
|
||||
.dre-doc-meta { font-size: 0.78rem; color: var(--dre-slate-light); }
|
||||
.dre-note-head { display: flex; justify-content: space-between; font-size: 0.8rem; color: var(--dre-slate-light); margin-bottom: 0.35rem; }
|
||||
.dre-note-author { font-weight: 700; color: var(--dre-navy); }
|
||||
.dre-note-body { font-size: 0.92rem; color: var(--dre-ink); white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.dre-dropzone {
|
||||
border: 2px dashed #c9d3dc;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--dre-slate);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.dre-dropzone:hover, .dre-dropzone.is-dragover { border-color: var(--dre-teal); background: #f0faf8; }
|
||||
.dre-dropzone input[type="file"] { display: none; }
|
||||
|
||||
.dre-empty {
|
||||
text-align: center;
|
||||
padding: 2.5rem 1rem;
|
||||
color: var(--dre-slate);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- utility */
|
||||
.dre-hidden { display: none !important; }
|
||||
.dre-mt { margin-top: 1.5rem; }
|
||||
.dre-center { text-align: center; }
|
||||
.dre-flex-between { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.dre-small { font-size: 0.85rem; color: var(--dre-slate-light); }
|
||||
@@ -0,0 +1,540 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard - Debt Recovery Experts Client Portal</title>
|
||||
<link rel="stylesheet" href="css/dre.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="dre-header">
|
||||
<div class="dre-header-inner">
|
||||
<a class="dre-brand" href="dashboard.html">
|
||||
<span class="dre-mark">DRE</span>
|
||||
<span>
|
||||
Debt Recovery Experts
|
||||
<span class="dre-brand-sub">Client Portal</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="dre-header-actions">
|
||||
<span class="dre-user" id="header-user"></span>
|
||||
<a href="#" id="logout-link">Log Out</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dre-main">
|
||||
|
||||
<div id="dash-alert" class="dre-alert dre-alert-error" role="alert"></div>
|
||||
|
||||
<!-- ============================== LOADING STATE ============================== -->
|
||||
<div id="dash-loading" class="dre-state">
|
||||
<div class="dre-spinner dre-spinner-dark" style="width:32px;height:32px;border-width:3px;margin:0 auto 1rem;"></div>
|
||||
<p>Loading your account...</p>
|
||||
</div>
|
||||
|
||||
<!-- ============================== LIST VIEW ============================== -->
|
||||
<div id="view-list" class="dre-hidden">
|
||||
<div class="dre-flex-between" style="margin-bottom:1.25rem;">
|
||||
<h1 style="margin:0;">Your Claims</h1>
|
||||
</div>
|
||||
|
||||
<div class="dre-stats-row">
|
||||
<div class="dre-stat">
|
||||
<div class="dre-stat-label">Total Claims</div>
|
||||
<div class="dre-stat-value" id="stat-total">0</div>
|
||||
</div>
|
||||
<div class="dre-stat">
|
||||
<div class="dre-stat-label">Open</div>
|
||||
<div class="dre-stat-value" id="stat-open">0</div>
|
||||
</div>
|
||||
<div class="dre-stat">
|
||||
<div class="dre-stat-label">Recovered / Resolved</div>
|
||||
<div class="dre-stat-value" id="stat-resolved">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="claim-list" class="dre-claim-list"></div>
|
||||
|
||||
<div id="claim-empty" class="dre-card dre-empty dre-hidden">
|
||||
<h3>No Claims Yet</h3>
|
||||
<p>You have not submitted any claims. Once you submit a claim, it will appear here with
|
||||
live status updates.</p>
|
||||
<a href="https://my.debtrecoveryexperts.com/start" class="dre-btn dre-btn-primary">Submit a Claim</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================== DETAIL VIEW ============================== -->
|
||||
<div id="view-detail" class="dre-hidden">
|
||||
<a href="#" id="back-to-list" class="dre-back-link">← Back to all claims</a>
|
||||
|
||||
<div class="dre-card">
|
||||
<div class="dre-flex-between">
|
||||
<div>
|
||||
<h2 id="detail-claim-number" style="margin-bottom:0.2rem;">--</h2>
|
||||
<span id="detail-badge" class="dre-badge">--</span>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div class="dre-stat-label">Amount</div>
|
||||
<div class="dre-stat-value" id="detail-amount">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dre-progress" id="detail-progress"></div>
|
||||
<div class="dre-progress-labels">
|
||||
<span>Soft Touch</span>
|
||||
<span>Formal Demand</span>
|
||||
<span>Escalation</span>
|
||||
<span>Legal Action</span>
|
||||
</div>
|
||||
|
||||
<dl class="dre-detail-grid">
|
||||
<div>
|
||||
<dt>Debtor</dt>
|
||||
<dd id="detail-debtor-name">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Debtor Type</dt>
|
||||
<dd id="detail-debtor-type">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
<dd id="detail-description">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Your Reference</dt>
|
||||
<dd id="detail-reference">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Invoice Date</dt>
|
||||
<dd id="detail-invoice-date">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Date Assigned</dt>
|
||||
<dd id="detail-created">--</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="dre-card">
|
||||
<h3>Documents</h3>
|
||||
<ul id="doc-list" class="dre-doc-list"></ul>
|
||||
<p id="doc-empty" class="dre-small dre-hidden">No documents uploaded yet.</p>
|
||||
|
||||
<div id="upload-alert" class="dre-alert dre-alert-error" role="alert"></div>
|
||||
<label for="doc-file-input" class="dre-dropzone" id="dropzone">
|
||||
<strong>Click to choose a file</strong> or drag one here<br>
|
||||
<span class="dre-small">PDF, JPG, PNG, DOC, DOCX up to 20 MB</span>
|
||||
<input type="file" id="doc-file-input" accept=".pdf,.jpg,.jpeg,.png,.doc,.docx">
|
||||
</label>
|
||||
<div id="upload-progress" class="dre-small dre-hidden dre-mt">Uploading...</div>
|
||||
</div>
|
||||
|
||||
<div class="dre-card">
|
||||
<h3>Case Notes</h3>
|
||||
<ul id="note-list" class="dre-note-list"></ul>
|
||||
<p id="note-empty" class="dre-small dre-hidden">No notes yet.</p>
|
||||
</div>
|
||||
|
||||
<div class="dre-card">
|
||||
<h3>Message the Team</h3>
|
||||
<div id="message-alert" class="dre-alert dre-alert-error" role="alert"></div>
|
||||
<div id="message-sent" class="dre-alert dre-alert-success" role="status"></div>
|
||||
<form id="message-form">
|
||||
<div class="dre-field">
|
||||
<label for="message-subject">Subject <span class="dre-required">*</span></label>
|
||||
<select id="message-subject" required>
|
||||
<option value="Question about my claim">Question about my claim</option>
|
||||
<option value="New information about the debtor">New information about the debtor</option>
|
||||
<option value="Payment received / want to stop recovery">Payment received / want to stop recovery</option>
|
||||
<option value="Update my contact info">Update my contact info</option>
|
||||
<option value="Complaint or concern">Complaint or concern</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dre-field">
|
||||
<label for="message-content">Message <span class="dre-required">*</span></label>
|
||||
<textarea id="message-content" maxlength="10000" required placeholder="Type your message to the recovery team..."></textarea>
|
||||
</div>
|
||||
<button type="submit" id="message-submit" class="dre-btn dre-btn-primary">Send Message</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="dre-footer">
|
||||
Debt Recovery Experts Client Portal. All account activity is logged for compliance purposes.
|
||||
</footer>
|
||||
|
||||
<script src="js/dre-api.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var token = DRE.requireSessionOrRedirect("login.html");
|
||||
if (!token) return; // redirect already triggered
|
||||
|
||||
var loadingEl = document.getElementById("dash-loading");
|
||||
var listView = document.getElementById("view-list");
|
||||
var detailView = document.getElementById("view-detail");
|
||||
var dashAlert = document.getElementById("dash-alert");
|
||||
|
||||
var claimsCache = [];
|
||||
|
||||
function handleAuthFailure(result) {
|
||||
if (result.status === 401) {
|
||||
DRE.clearSessionToken();
|
||||
window.location.href = "login.html";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
DRE.apiFetch("/api/auth/logout", { method: "POST", auth: true }).then(function () {
|
||||
DRE.clearSessionToken();
|
||||
window.location.href = "login.html";
|
||||
});
|
||||
}
|
||||
document.getElementById("logout-link").addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
logout();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------- init: /me
|
||||
DRE.apiFetch("/api/auth/me", { auth: true }).then(function (result) {
|
||||
if (!result.ok) {
|
||||
if (handleAuthFailure(result)) return;
|
||||
loadingEl.classList.add("dre-hidden");
|
||||
DRE.showAlert(dashAlert, DRE.getErrorMessage(result, "Could not load your account."));
|
||||
return;
|
||||
}
|
||||
var client = result.data.client || {};
|
||||
document.getElementById("header-user").textContent =
|
||||
(client.contact_name || "") + (client.company_name ? " - " + client.company_name : "");
|
||||
loadClaims();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------- claim list
|
||||
function loadClaims() {
|
||||
DRE.apiFetch("/api/claims", { auth: true }).then(function (result) {
|
||||
loadingEl.classList.add("dre-hidden");
|
||||
if (!result.ok) {
|
||||
if (handleAuthFailure(result)) return;
|
||||
DRE.showAlert(dashAlert, DRE.getErrorMessage(result, "Could not load your claims."));
|
||||
listView.classList.remove("dre-hidden");
|
||||
return;
|
||||
}
|
||||
claimsCache = result.data.claims || [];
|
||||
renderClaimList(claimsCache);
|
||||
listView.classList.remove("dre-hidden");
|
||||
routeFromHash();
|
||||
});
|
||||
}
|
||||
|
||||
function renderClaimList(claims) {
|
||||
var listEl = document.getElementById("claim-list");
|
||||
var emptyEl = document.getElementById("claim-empty");
|
||||
listEl.innerHTML = "";
|
||||
|
||||
var openCount = 0, resolvedCount = 0;
|
||||
var openStatuses = { NEW: 1, UNDER_REVIEW: 1, ACTIVE: 1, NEGOTIATION: 1, LEGAL: 1 };
|
||||
var resolvedStatuses = { SETTLED: 1, CLOSED: 1, WRITE_OFF: 1 };
|
||||
|
||||
claims.forEach(function (c) {
|
||||
if (openStatuses[c.status]) openCount++;
|
||||
if (resolvedStatuses[c.status]) resolvedCount++;
|
||||
|
||||
var row = document.createElement("div");
|
||||
row.className = "dre-claim-row";
|
||||
row.setAttribute("role", "button");
|
||||
row.setAttribute("tabindex", "0");
|
||||
|
||||
var main = document.createElement("div");
|
||||
main.className = "dre-claim-main";
|
||||
|
||||
var num = document.createElement("span");
|
||||
num.className = "dre-claim-number";
|
||||
num.textContent = c.claim_number;
|
||||
|
||||
var sub = document.createElement("span");
|
||||
sub.className = "dre-claim-sub";
|
||||
sub.textContent = "Debtor: " + (c.debtor_name || "--");
|
||||
|
||||
var badge = document.createElement("span");
|
||||
badge.className = DRE.badgeClass(c.status);
|
||||
badge.textContent = c.status_label || c.status;
|
||||
badge.style.marginTop = "0.3rem";
|
||||
badge.style.width = "fit-content";
|
||||
|
||||
main.appendChild(num);
|
||||
main.appendChild(sub);
|
||||
main.appendChild(badge);
|
||||
|
||||
var right = document.createElement("div");
|
||||
var amt = document.createElement("div");
|
||||
amt.className = "dre-claim-amount";
|
||||
amt.textContent = c.amount_display || DRE.formatCentsUSD(c.amount_cents);
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "dre-claim-meta";
|
||||
meta.textContent = "Submitted " + DRE.formatDate(c.created_at);
|
||||
right.appendChild(amt);
|
||||
right.appendChild(meta);
|
||||
|
||||
row.appendChild(main);
|
||||
row.appendChild(right);
|
||||
|
||||
row.addEventListener("click", function () {
|
||||
window.location.hash = "claim/" + encodeURIComponent(c.claim_number);
|
||||
});
|
||||
row.addEventListener("keypress", function (e) {
|
||||
if (e.key === "Enter") window.location.hash = "claim/" + encodeURIComponent(c.claim_number);
|
||||
});
|
||||
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
|
||||
document.getElementById("stat-total").textContent = claims.length;
|
||||
document.getElementById("stat-open").textContent = openCount;
|
||||
document.getElementById("stat-resolved").textContent = resolvedCount;
|
||||
|
||||
emptyEl.classList.toggle("dre-hidden", claims.length !== 0);
|
||||
listEl.classList.toggle("dre-hidden", claims.length === 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- claim detail
|
||||
var currentClaimNumber = null;
|
||||
|
||||
function showListView() {
|
||||
detailView.classList.add("dre-hidden");
|
||||
listView.classList.remove("dre-hidden");
|
||||
}
|
||||
|
||||
function showDetailView(claimNumber) {
|
||||
currentClaimNumber = claimNumber;
|
||||
listView.classList.add("dre-hidden");
|
||||
detailView.classList.remove("dre-hidden");
|
||||
loadClaimDetail(claimNumber);
|
||||
}
|
||||
|
||||
document.getElementById("back-to-list").addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
var TIER_LABELS = {
|
||||
TIER_1: "Soft Touch", TIER_2: "Formal Demand", TIER_2_5: "Lien Threat",
|
||||
TIER_3: "Escalation", TIER_4: "Legal Action",
|
||||
};
|
||||
|
||||
function loadClaimDetail(claimNumber) {
|
||||
DRE.hideAlert(dashAlert);
|
||||
DRE.apiFetch("/api/claims/" + encodeURIComponent(claimNumber), { auth: true }).then(function (result) {
|
||||
if (!result.ok) {
|
||||
if (handleAuthFailure(result)) return;
|
||||
if (result.status === 404) {
|
||||
DRE.showAlert(dashAlert, "That claim was not found on your account.");
|
||||
window.location.hash = "";
|
||||
return;
|
||||
}
|
||||
DRE.showAlert(dashAlert, DRE.getErrorMessage(result, "Could not load claim detail."));
|
||||
return;
|
||||
}
|
||||
renderClaimDetail(result.data);
|
||||
});
|
||||
}
|
||||
|
||||
function renderClaimDetail(c) {
|
||||
document.getElementById("detail-claim-number").textContent = c.claim_number;
|
||||
var badge = document.getElementById("detail-badge");
|
||||
badge.className = DRE.badgeClass(c.status);
|
||||
badge.textContent = c.status_label || c.status;
|
||||
document.getElementById("detail-amount").textContent = c.amount_display || DRE.formatCentsUSD(c.amount_cents);
|
||||
document.getElementById("detail-debtor-name").textContent = (c.debtor && c.debtor.name) || "--";
|
||||
document.getElementById("detail-debtor-type").textContent = (c.debtor && c.debtor.business_type) || "--";
|
||||
document.getElementById("detail-description").textContent = c.description || "Not provided";
|
||||
document.getElementById("detail-reference").textContent = c.client_reference || "Not provided";
|
||||
document.getElementById("detail-invoice-date").textContent = c.invoice_date ? DRE.formatDate(c.invoice_date) : "Not provided";
|
||||
document.getElementById("detail-created").textContent = c.date_assigned ? DRE.formatDate(c.date_assigned) : "Not yet assigned";
|
||||
|
||||
// progress bar
|
||||
var progressEl = document.getElementById("detail-progress");
|
||||
progressEl.innerHTML = "";
|
||||
var step = c.tier_step || 1;
|
||||
for (var i = 1; i <= 4; i++) {
|
||||
var seg = document.createElement("div");
|
||||
seg.className = "dre-progress-step" + (i <= step ? " is-done" : "");
|
||||
progressEl.appendChild(seg);
|
||||
}
|
||||
|
||||
// documents
|
||||
var docList = document.getElementById("doc-list");
|
||||
var docEmpty = document.getElementById("doc-empty");
|
||||
docList.innerHTML = "";
|
||||
var docs = c.documents || [];
|
||||
docs.forEach(function (d) {
|
||||
var li = document.createElement("li");
|
||||
li.className = "dre-doc-item";
|
||||
var left = document.createElement("div");
|
||||
var name = document.createElement("div");
|
||||
name.className = "dre-doc-name";
|
||||
name.textContent = d.original_name;
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "dre-doc-meta";
|
||||
meta.textContent = DRE.formatBytes(d.size_bytes) + " - Uploaded " + DRE.formatDateTime(d.created_at);
|
||||
left.appendChild(name);
|
||||
left.appendChild(meta);
|
||||
li.appendChild(left);
|
||||
docList.appendChild(li);
|
||||
});
|
||||
docEmpty.classList.toggle("dre-hidden", docs.length !== 0);
|
||||
|
||||
// notes
|
||||
var noteList = document.getElementById("note-list");
|
||||
var noteEmpty = document.getElementById("note-empty");
|
||||
noteList.innerHTML = "";
|
||||
var notes = c.notes || [];
|
||||
notes.forEach(function (n) {
|
||||
var li = document.createElement("li");
|
||||
li.className = "dre-note-item";
|
||||
var head = document.createElement("div");
|
||||
head.className = "dre-note-head";
|
||||
var author = document.createElement("span");
|
||||
author.className = "dre-note-author";
|
||||
author.textContent = n.author_name + (n.subject ? " - " + n.subject : "");
|
||||
var when = document.createElement("span");
|
||||
when.textContent = DRE.formatDateTime(n.created_at);
|
||||
head.appendChild(author);
|
||||
head.appendChild(when);
|
||||
var body = document.createElement("div");
|
||||
body.className = "dre-note-body";
|
||||
// Backend already HTML-escapes content; we still use textContent so
|
||||
// it renders literally either way (defense in depth, no innerHTML).
|
||||
body.textContent = n.content;
|
||||
li.appendChild(head);
|
||||
li.appendChild(body);
|
||||
noteList.appendChild(li);
|
||||
});
|
||||
noteEmpty.classList.toggle("dre-hidden", notes.length !== 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- document upload
|
||||
var fileInput = document.getElementById("doc-file-input");
|
||||
var dropzone = document.getElementById("dropzone");
|
||||
var uploadAlert = document.getElementById("upload-alert");
|
||||
var uploadProgress = document.getElementById("upload-progress");
|
||||
|
||||
fileInput.addEventListener("change", function () {
|
||||
if (fileInput.files && fileInput.files[0]) {
|
||||
uploadFile(fileInput.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
["dragover", "dragenter"].forEach(function (evt) {
|
||||
dropzone.addEventListener(evt, function (e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("is-dragover");
|
||||
});
|
||||
});
|
||||
["dragleave", "drop"].forEach(function (evt) {
|
||||
dropzone.addEventListener(evt, function (e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("is-dragover");
|
||||
});
|
||||
});
|
||||
dropzone.addEventListener("drop", function (e) {
|
||||
e.preventDefault();
|
||||
var files = e.dataTransfer && e.dataTransfer.files;
|
||||
if (files && files[0]) uploadFile(files[0]);
|
||||
});
|
||||
|
||||
function uploadFile(file) {
|
||||
DRE.hideAlert(uploadAlert);
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
DRE.showAlert(uploadAlert, "File exceeds the 20 MB limit.");
|
||||
fileInput.value = "";
|
||||
return;
|
||||
}
|
||||
var allowedExt = [".pdf", ".jpg", ".jpeg", ".png", ".doc", ".docx"];
|
||||
var lower = file.name.toLowerCase();
|
||||
var ok = allowedExt.some(function (ext) { return lower.endsWith(ext); });
|
||||
if (!ok) {
|
||||
DRE.showAlert(uploadAlert, "File type not allowed. Use PDF, JPG, PNG, DOC, or DOCX.");
|
||||
fileInput.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append("file", file);
|
||||
uploadProgress.classList.remove("dre-hidden");
|
||||
|
||||
DRE.apiFetch("/api/claims/" + encodeURIComponent(currentClaimNumber) + "/documents", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
auth: true,
|
||||
}).then(function (result) {
|
||||
uploadProgress.classList.add("dre-hidden");
|
||||
fileInput.value = "";
|
||||
if (!result.ok) {
|
||||
if (handleAuthFailure(result)) return;
|
||||
DRE.showAlert(uploadAlert, DRE.getErrorMessage(result, "Upload failed. Please try again."));
|
||||
return;
|
||||
}
|
||||
loadClaimDetail(currentClaimNumber);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- message the team
|
||||
var messageForm = document.getElementById("message-form");
|
||||
var messageAlert = document.getElementById("message-alert");
|
||||
var messageSent = document.getElementById("message-sent");
|
||||
var messageSubmit = document.getElementById("message-submit");
|
||||
|
||||
messageForm.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
DRE.hideAlert(messageAlert);
|
||||
DRE.hideAlert(messageSent);
|
||||
|
||||
var subject = document.getElementById("message-subject").value;
|
||||
var content = document.getElementById("message-content").value.trim();
|
||||
if (!content) {
|
||||
DRE.showAlert(messageAlert, "Please enter a message before sending.");
|
||||
return;
|
||||
}
|
||||
|
||||
DRE.setLoading(messageSubmit, true, "Sending...");
|
||||
DRE.apiFetch("/api/claims/" + encodeURIComponent(currentClaimNumber) + "/messages", {
|
||||
method: "POST",
|
||||
body: { subject: subject, content: content },
|
||||
auth: true,
|
||||
}).then(function (result) {
|
||||
DRE.setLoading(messageSubmit, false, null, "Send Message");
|
||||
if (!result.ok) {
|
||||
if (handleAuthFailure(result)) return;
|
||||
DRE.showAlert(messageAlert, DRE.getErrorMessage(result, "Could not send your message. Please try again."));
|
||||
return;
|
||||
}
|
||||
document.getElementById("message-content").value = "";
|
||||
DRE.showAlert(messageSent, "Message sent. Our team will follow up if needed.");
|
||||
loadClaimDetail(currentClaimNumber);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------- hash routing
|
||||
function routeFromHash() {
|
||||
var hash = window.location.hash.replace(/^#/, "");
|
||||
if (hash.indexOf("claim/") === 0) {
|
||||
var claimNumber = decodeURIComponent(hash.slice("claim/".length));
|
||||
showDetailView(claimNumber);
|
||||
} else {
|
||||
showListView();
|
||||
}
|
||||
}
|
||||
window.addEventListener("hashchange", routeFromHash);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,345 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Submit a Claim - Debt Recovery Experts</title>
|
||||
<meta name="description" content="Submit a commercial claim for recovery review. Debt Recovery Experts helps businesses recover what they are owed through professional, compliant collection services.">
|
||||
<link rel="stylesheet" href="css/dre.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="dre-header">
|
||||
<div class="dre-header-inner">
|
||||
<a class="dre-brand" href="index.html">
|
||||
<span class="dre-mark">DRE</span>
|
||||
<span>
|
||||
Debt Recovery Experts
|
||||
<span class="dre-brand-sub">Commercial Claim Recovery</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="dre-header-actions">
|
||||
<a href="login.html">Client Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dre-main dre-narrow">
|
||||
|
||||
<div class="dre-intro">
|
||||
<h1>Submit a Claim</h1>
|
||||
<p>Tell us about the outstanding balance you would like our team to review. Submission
|
||||
takes about three minutes. There is no obligation, and a member of our team will follow
|
||||
up after reviewing your claim.</p>
|
||||
</div>
|
||||
|
||||
<div id="intake-alert" class="dre-alert dre-alert-error" role="alert"></div>
|
||||
|
||||
<!-- ============================== FORM STATE ============================== -->
|
||||
<form id="intake-form" class="dre-card" novalidate>
|
||||
|
||||
<div class="dre-form-section">
|
||||
<h3>Your Information</h3>
|
||||
|
||||
<div class="dre-row">
|
||||
<div class="dre-field">
|
||||
<label for="company_name">Company Name <span class="dre-required">*</span></label>
|
||||
<input type="text" id="company_name" name="company_name" maxlength="200" required autocomplete="organization">
|
||||
<span class="dre-field-error" data-error-for="company_name"></span>
|
||||
</div>
|
||||
<div class="dre-field">
|
||||
<label for="contact_name">Your Full Name <span class="dre-required">*</span></label>
|
||||
<input type="text" id="contact_name" name="contact_name" maxlength="200" required autocomplete="name">
|
||||
<span class="dre-field-error" data-error-for="contact_name"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dre-row">
|
||||
<div class="dre-field">
|
||||
<label for="email">Email Address <span class="dre-required">*</span></label>
|
||||
<input type="email" id="email" name="email" maxlength="254" required autocomplete="email">
|
||||
<span class="dre-hint">We will send your secure client-portal login link here.</span>
|
||||
<span class="dre-field-error" data-error-for="email"></span>
|
||||
</div>
|
||||
<div class="dre-field">
|
||||
<label for="phone">Phone Number</label>
|
||||
<input type="tel" id="phone" name="phone" maxlength="50" autocomplete="tel">
|
||||
<span class="dre-field-error" data-error-for="phone"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dre-form-section">
|
||||
<h3>Debtor Information</h3>
|
||||
<p class="dre-small" style="margin-bottom:1rem;">The business or individual that owes the balance.</p>
|
||||
|
||||
<div class="dre-field">
|
||||
<label for="debtor_name">Debtor Name <span class="dre-required">*</span></label>
|
||||
<input type="text" id="debtor_name" name="debtor_name" maxlength="200" required>
|
||||
<span class="dre-field-error" data-error-for="debtor_name"></span>
|
||||
</div>
|
||||
|
||||
<div class="dre-field">
|
||||
<label for="debtor_business_type">Debtor Entity Type <span class="dre-required">*</span></label>
|
||||
<select id="debtor_business_type" name="debtor_business_type" required>
|
||||
<option value="INDIVIDUAL">Individual</option>
|
||||
<option value="SOLE_PROPRIETORSHIP">Sole Proprietorship</option>
|
||||
<option value="LLC" selected>LLC</option>
|
||||
<option value="CORPORATION">Corporation</option>
|
||||
<option value="PARTNERSHIP">Partnership</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
<span class="dre-field-error" data-error-for="debtor_business_type"></span>
|
||||
</div>
|
||||
|
||||
<div class="dre-row">
|
||||
<div class="dre-field">
|
||||
<label for="debtor_contact_email">Debtor Email</label>
|
||||
<input type="email" id="debtor_contact_email" name="debtor_contact_email" maxlength="254">
|
||||
<span class="dre-field-error" data-error-for="debtor_contact_email"></span>
|
||||
</div>
|
||||
<div class="dre-field">
|
||||
<label for="debtor_contact_phone">Debtor Phone</label>
|
||||
<input type="tel" id="debtor_contact_phone" name="debtor_contact_phone" maxlength="50">
|
||||
<span class="dre-field-error" data-error-for="debtor_contact_phone"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dre-field">
|
||||
<label for="debtor_physical_address">Debtor Address</label>
|
||||
<input type="text" id="debtor_physical_address" name="debtor_physical_address" maxlength="500" placeholder="Street, City, State, ZIP">
|
||||
<span class="dre-field-error" data-error-for="debtor_physical_address"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dre-form-section">
|
||||
<h3>Claim Details</h3>
|
||||
|
||||
<div class="dre-row">
|
||||
<div class="dre-field">
|
||||
<label for="amount">Amount Owed (USD) <span class="dre-required">*</span></label>
|
||||
<input type="number" id="amount" name="amount" min="0.01" max="1000000" step="0.01" required placeholder="15000.00">
|
||||
<span class="dre-hint">Maximum $1,000,000 per claim.</span>
|
||||
<span class="dre-field-error" data-error-for="amount"></span>
|
||||
</div>
|
||||
<div class="dre-field">
|
||||
<label for="invoice_date">Invoice / Debt Date</label>
|
||||
<input type="date" id="invoice_date" name="invoice_date">
|
||||
<span class="dre-field-error" data-error-for="invoice_date"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dre-field">
|
||||
<label for="client_reference">Your Invoice / PO Number</label>
|
||||
<input type="text" id="client_reference" name="client_reference" maxlength="200" placeholder="INV-2048">
|
||||
<span class="dre-field-error" data-error-for="client_reference"></span>
|
||||
</div>
|
||||
|
||||
<div class="dre-field">
|
||||
<label for="description">Description of the Debt</label>
|
||||
<textarea id="description" name="description" maxlength="5000" placeholder="What is the debt for? (service, goods, contract, etc.)"></textarea>
|
||||
<span class="dre-field-error" data-error-for="description"></span>
|
||||
</div>
|
||||
|
||||
<div class="dre-field" style="background:#fdf3e7; border:1px solid #f2ddb8; border-radius:8px; padding:0.85rem 1rem;">
|
||||
<span class="dre-small" style="color:#92600a;">
|
||||
Important: Do not enter Social Security numbers, full bank account numbers, or card
|
||||
numbers anywhere on this form. Submissions containing this information will be rejected.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dre-form-section">
|
||||
<div class="dre-checkbox-field">
|
||||
<input type="checkbox" id="tos_accepted" name="tos_accepted" required>
|
||||
<label for="tos_accepted">
|
||||
I confirm the information provided is accurate and I authorize Debt Recovery Experts
|
||||
to review and pursue recovery of this claim on my behalf. I have read and agree to the
|
||||
Terms of Service. <span class="dre-required">*</span>
|
||||
</label>
|
||||
</div>
|
||||
<span class="dre-field-error" data-error-for="tos_accepted"></span>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="intake-submit" class="dre-btn dre-btn-primary dre-btn-block">
|
||||
Submit Claim
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- ============================== SUCCESS STATE ============================== -->
|
||||
<div id="intake-success" class="dre-card dre-state dre-hidden">
|
||||
<div class="dre-state-icon ok">✓</div>
|
||||
<h2>Claim Received</h2>
|
||||
<p id="intake-success-message">Our team will review and contact you shortly.</p>
|
||||
<dl class="dre-kv">
|
||||
<dt>Claim Number</dt>
|
||||
<dd id="intake-claim-number">--</dd>
|
||||
<dt>Client Number</dt>
|
||||
<dd id="intake-client-number">--</dd>
|
||||
</dl>
|
||||
<p class="dre-mt">
|
||||
<strong>Check your email.</strong> We will send a secure login link to access your client
|
||||
portal, where you can track claim status, upload documents, and message our team.
|
||||
</p>
|
||||
<a href="login.html" class="dre-btn dre-btn-secondary dre-mt">Go to Client Login</a>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="dre-footer">
|
||||
Debt Recovery Experts operates in compliance with the FDCPA and applicable state debt collection
|
||||
laws. This form is for submitting claims for review only.
|
||||
</footer>
|
||||
|
||||
<script src="js/dre-api.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var form = document.getElementById("intake-form");
|
||||
var alertEl = document.getElementById("intake-alert");
|
||||
var submitBtn = document.getElementById("intake-submit");
|
||||
var successEl = document.getElementById("intake-success");
|
||||
|
||||
function clearFieldErrors() {
|
||||
var errs = form.querySelectorAll(".dre-field-error");
|
||||
for (var i = 0; i < errs.length; i++) {
|
||||
errs[i].classList.remove("is-visible");
|
||||
errs[i].textContent = "";
|
||||
}
|
||||
var inputs = form.querySelectorAll("[aria-invalid]");
|
||||
for (var j = 0; j < inputs.length; j++) {
|
||||
inputs[j].removeAttribute("aria-invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function showFieldError(fieldKey, message) {
|
||||
var el = form.querySelector('[data-error-for="' + fieldKey + '"]');
|
||||
if (el) {
|
||||
el.textContent = message;
|
||||
el.classList.add("is-visible");
|
||||
}
|
||||
}
|
||||
|
||||
// Map a backend validation error location like "client.email" or
|
||||
// "debtor.name" or "claim.amount_cents" to a local field key.
|
||||
function mapLocToField(loc) {
|
||||
var map = {
|
||||
"client.company_name": "company_name",
|
||||
"client.contact_name": "contact_name",
|
||||
"client.email": "email",
|
||||
"client.phone": "phone",
|
||||
"debtor.name": "debtor_name",
|
||||
"debtor.business_type": "debtor_business_type",
|
||||
"debtor.contact_email": "debtor_contact_email",
|
||||
"debtor.contact_phone": "debtor_contact_phone",
|
||||
"debtor.physical_address": "debtor_physical_address",
|
||||
"claim.amount_cents": "amount",
|
||||
"claim.description": "description",
|
||||
"claim.client_reference": "client_reference",
|
||||
"claim.invoice_date": "invoice_date",
|
||||
"tos_accepted": "tos_accepted",
|
||||
};
|
||||
return map[loc] || null;
|
||||
}
|
||||
|
||||
function distributeValidationMessage(message) {
|
||||
// Backend joins multiple errors with "; ", each like "loc: msg"
|
||||
var parts = message.split("; ");
|
||||
var matched = false;
|
||||
parts.forEach(function (part) {
|
||||
var idx = part.indexOf(": ");
|
||||
if (idx === -1) return;
|
||||
var loc = part.slice(0, idx);
|
||||
var msg = part.slice(idx + 2);
|
||||
var field = mapLocToField(loc);
|
||||
if (field) {
|
||||
showFieldError(field, msg);
|
||||
var input = form.querySelector('[name="' + field + '"]');
|
||||
if (input) input.setAttribute("aria-invalid", "true");
|
||||
matched = true;
|
||||
}
|
||||
});
|
||||
return matched;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
DRE.hideAlert(alertEl);
|
||||
clearFieldErrors();
|
||||
|
||||
var amountRaw = document.getElementById("amount").value;
|
||||
var amountCents = Math.round(parseFloat(amountRaw || "0") * 100);
|
||||
|
||||
var payload = {
|
||||
client: {
|
||||
company_name: document.getElementById("company_name").value.trim(),
|
||||
contact_name: document.getElementById("contact_name").value.trim(),
|
||||
email: document.getElementById("email").value.trim(),
|
||||
phone: document.getElementById("phone").value.trim() || null,
|
||||
},
|
||||
debtor: {
|
||||
name: document.getElementById("debtor_name").value.trim(),
|
||||
business_type: document.getElementById("debtor_business_type").value,
|
||||
contact_email: document.getElementById("debtor_contact_email").value.trim() || null,
|
||||
contact_phone: document.getElementById("debtor_contact_phone").value.trim() || null,
|
||||
physical_address: document.getElementById("debtor_physical_address").value.trim() || null,
|
||||
},
|
||||
claim: {
|
||||
amount_cents: amountCents,
|
||||
description: document.getElementById("description").value.trim() || null,
|
||||
client_reference: document.getElementById("client_reference").value.trim() || null,
|
||||
invoice_date: document.getElementById("invoice_date").value || null,
|
||||
},
|
||||
tos_accepted: document.getElementById("tos_accepted").checked,
|
||||
};
|
||||
|
||||
if (!payload.tos_accepted) {
|
||||
showFieldError("tos_accepted", "You must accept the Terms of Service to submit a claim.");
|
||||
return;
|
||||
}
|
||||
if (!amountCents || amountCents <= 0) {
|
||||
showFieldError("amount", "Enter a valid amount greater than $0.");
|
||||
return;
|
||||
}
|
||||
|
||||
DRE.setLoading(submitBtn, true, "Submitting...");
|
||||
|
||||
DRE.apiFetch("/api/intake", { method: "POST", body: payload }).then(function (result) {
|
||||
DRE.setLoading(submitBtn, false, null, "Submit Claim");
|
||||
|
||||
if (result.ok) {
|
||||
var data = result.data;
|
||||
document.getElementById("intake-claim-number").textContent = data.claim_number || "--";
|
||||
document.getElementById("intake-client-number").textContent = data.client_number || "--";
|
||||
document.getElementById("intake-success-message").textContent =
|
||||
data.message || "Our team will review and contact you shortly.";
|
||||
form.classList.add("dre-hidden");
|
||||
successEl.classList.remove("dre-hidden");
|
||||
successEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
|
||||
var code = DRE.getErrorCode(result);
|
||||
var message = DRE.getErrorMessage(result, "We could not submit your claim. Please try again.");
|
||||
|
||||
if (code === "validation_error") {
|
||||
var matched = distributeValidationMessage(message);
|
||||
if (!matched) {
|
||||
DRE.showAlert(alertEl, message);
|
||||
} else {
|
||||
DRE.showAlert(alertEl, "Please correct the highlighted fields below.");
|
||||
}
|
||||
} else if (code === "rate_limited") {
|
||||
DRE.showAlert(alertEl, "Too many submissions from this connection. Please try again in a few minutes.");
|
||||
} else {
|
||||
DRE.showAlert(alertEl, message);
|
||||
}
|
||||
alertEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,208 @@
|
||||
/* ==========================================================================
|
||||
DRE Customer Portal — shared JS helpers
|
||||
Dependency-free. Relative /api/* calls only (same-origin; Caddy proxies).
|
||||
========================================================================== */
|
||||
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var SESSION_KEY = "dre_session_token";
|
||||
|
||||
/**
|
||||
* Perform a JSON fetch against the API.
|
||||
* @param {string} path - relative API path, e.g. "/api/claims"
|
||||
* @param {object} opts - { method, body, auth, headers }
|
||||
* Returns a Promise resolving to { ok, status, data } where data is the
|
||||
* parsed JSON body (success payload or {error:{code,message}}).
|
||||
*/
|
||||
function apiFetch(path, opts) {
|
||||
opts = opts || {};
|
||||
var headers = Object.assign({}, opts.headers || {});
|
||||
var fetchOpts = { method: opts.method || "GET", headers: headers };
|
||||
|
||||
if (opts.body !== undefined && !(opts.body instanceof FormData)) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
fetchOpts.body = JSON.stringify(opts.body);
|
||||
} else if (opts.body instanceof FormData) {
|
||||
fetchOpts.body = opts.body; // browser sets multipart boundary
|
||||
}
|
||||
|
||||
if (opts.auth) {
|
||||
var token = getSessionToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(path, fetchOpts)
|
||||
.then(function (res) {
|
||||
return res
|
||||
.json()
|
||||
.catch(function () {
|
||||
return {};
|
||||
})
|
||||
.then(function (data) {
|
||||
return { ok: res.ok, status: res.status, data: data };
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
data: {
|
||||
error: {
|
||||
code: "network_error",
|
||||
message: "Could not reach the server. Check your connection and try again.",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getErrorMessage(result, fallback) {
|
||||
if (result && result.data && result.data.error && result.data.error.message) {
|
||||
return result.data.error.message;
|
||||
}
|
||||
return fallback || "Something went wrong. Please try again.";
|
||||
}
|
||||
|
||||
function getErrorCode(result) {
|
||||
if (result && result.data && result.data.error && result.data.error.code) {
|
||||
return result.data.error.code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionToken() {
|
||||
try {
|
||||
return localStorage.getItem(SESSION_KEY);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setSessionToken(token) {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, token);
|
||||
} catch (e) {
|
||||
/* localStorage unavailable; session will not persist across reload */
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionToken() {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function requireSessionOrRedirect(loginUrl) {
|
||||
var token = getSessionToken();
|
||||
if (!token) {
|
||||
window.location.href = loginUrl || "login.html";
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function formatCentsUSD(cents) {
|
||||
var n = typeof cents === "number" ? cents : parseInt(cents, 10) || 0;
|
||||
return "$" + (n / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "--";
|
||||
try {
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
} catch (e) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso) {
|
||||
if (!iso) return "--";
|
||||
try {
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch (e) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!n && n !== 0) return "";
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
function showAlert(el, message, isVisible) {
|
||||
if (!el) return;
|
||||
el.textContent = message || "";
|
||||
if (isVisible === false) {
|
||||
el.classList.remove("is-visible");
|
||||
} else {
|
||||
el.classList.add("is-visible");
|
||||
}
|
||||
}
|
||||
|
||||
function hideAlert(el) {
|
||||
if (!el) return;
|
||||
el.classList.remove("is-visible");
|
||||
el.textContent = "";
|
||||
}
|
||||
|
||||
function setLoading(button, loading, loadingText, normalText) {
|
||||
if (!button) return;
|
||||
if (loading) {
|
||||
button.disabled = true;
|
||||
button.dataset.originalText = button.dataset.originalText || button.innerHTML;
|
||||
button.innerHTML = '<span class="dre-spinner"></span> ' + (loadingText || "Please wait...");
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.innerHTML = normalText || button.dataset.originalText || button.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
function badgeClass(status) {
|
||||
return "dre-badge dre-badge-" + (status || "").toLowerCase();
|
||||
}
|
||||
|
||||
// Escape helper for any spot where we must build markup with dynamic text.
|
||||
// Prefer textContent everywhere; this exists only as a defensive fallback.
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement("div");
|
||||
div.textContent = str === undefined || str === null ? "" : String(str);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
global.DRE = {
|
||||
SESSION_KEY: SESSION_KEY,
|
||||
apiFetch: apiFetch,
|
||||
getErrorMessage: getErrorMessage,
|
||||
getErrorCode: getErrorCode,
|
||||
getSessionToken: getSessionToken,
|
||||
setSessionToken: setSessionToken,
|
||||
clearSessionToken: clearSessionToken,
|
||||
requireSessionOrRedirect: requireSessionOrRedirect,
|
||||
formatCentsUSD: formatCentsUSD,
|
||||
formatDate: formatDate,
|
||||
formatDateTime: formatDateTime,
|
||||
formatBytes: formatBytes,
|
||||
showAlert: showAlert,
|
||||
hideAlert: hideAlert,
|
||||
setLoading: setLoading,
|
||||
badgeClass: badgeClass,
|
||||
escapeHtml: escapeHtml,
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Client Login - Debt Recovery Experts</title>
|
||||
<meta name="description" content="Log in to your Debt Recovery Experts client portal to track claim status, upload documents, and message the recovery team.">
|
||||
<link rel="stylesheet" href="css/dre.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="dre-header">
|
||||
<div class="dre-header-inner">
|
||||
<a class="dre-brand" href="index.html">
|
||||
<span class="dre-mark">DRE</span>
|
||||
<span>
|
||||
Debt Recovery Experts
|
||||
<span class="dre-brand-sub">Client Portal</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="dre-header-actions">
|
||||
<a href="start.html">Submit a Claim</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dre-main dre-narrow">
|
||||
|
||||
<div class="dre-intro">
|
||||
<h1>Client Login</h1>
|
||||
<p>Enter the email address on file for your account. We will send you a secure, one-time
|
||||
login link. No password required.</p>
|
||||
<p class="dre-small">New here? <a href="start.html">Submit a claim</a> - no account needed.</p>
|
||||
</div>
|
||||
|
||||
<div id="login-alert" class="dre-alert dre-alert-error" role="alert"></div>
|
||||
|
||||
<!-- ============================== REQUEST FORM ============================== -->
|
||||
<form id="login-form" class="dre-card">
|
||||
<div class="dre-field">
|
||||
<label for="email">Email Address <span class="dre-required">*</span></label>
|
||||
<input type="email" id="email" name="email" maxlength="254" required autocomplete="email" autofocus>
|
||||
<span class="dre-field-error" data-error-for="email"></span>
|
||||
</div>
|
||||
<button type="submit" id="login-submit" class="dre-btn dre-btn-primary dre-btn-block">
|
||||
Email Me a Login Link
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- ============================== SENT STATE ============================== -->
|
||||
<div id="login-sent" class="dre-card dre-state dre-hidden">
|
||||
<div class="dre-state-icon info">✉</div>
|
||||
<h2>Check Your Email</h2>
|
||||
<p>If an account exists for that email address, we have sent a secure login link. The link
|
||||
expires in 15 minutes and can only be used once.</p>
|
||||
<p class="dre-small">Did not get an email? Check your spam folder, or
|
||||
<a href="#" id="login-try-again">try a different email address</a>.</p>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="dre-footer">
|
||||
Having trouble accessing your account? Contact your Debt Recovery Experts representative.
|
||||
</footer>
|
||||
|
||||
<script src="js/dre-api.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// If already logged in, skip straight to the dashboard.
|
||||
if (DRE.getSessionToken()) {
|
||||
window.location.href = "dashboard.html";
|
||||
return;
|
||||
}
|
||||
|
||||
var form = document.getElementById("login-form");
|
||||
var alertEl = document.getElementById("login-alert");
|
||||
var submitBtn = document.getElementById("login-submit");
|
||||
var sentEl = document.getElementById("login-sent");
|
||||
var tryAgainLink = document.getElementById("login-try-again");
|
||||
|
||||
tryAgainLink.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
sentEl.classList.add("dre-hidden");
|
||||
form.classList.remove("dre-hidden");
|
||||
document.getElementById("email").focus();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
DRE.hideAlert(alertEl);
|
||||
|
||||
var emailField = document.getElementById("email");
|
||||
var email = emailField.value.trim();
|
||||
var errEl = form.querySelector('[data-error-for="email"]');
|
||||
errEl.classList.remove("is-visible");
|
||||
emailField.removeAttribute("aria-invalid");
|
||||
|
||||
if (!email) {
|
||||
errEl.textContent = "Email address is required.";
|
||||
errEl.classList.add("is-visible");
|
||||
emailField.setAttribute("aria-invalid", "true");
|
||||
return;
|
||||
}
|
||||
|
||||
DRE.setLoading(submitBtn, true, "Sending...");
|
||||
|
||||
DRE.apiFetch("/api/auth/request", { method: "POST", body: { email: email } }).then(function (result) {
|
||||
DRE.setLoading(submitBtn, false, null, "Email Me a Login Link");
|
||||
|
||||
// Backend responds 200 with a generic anti-enumeration message on
|
||||
// success. Only a genuine transport/rate-limit failure should show
|
||||
// an error; otherwise always show the "check your email" state.
|
||||
if (result.ok) {
|
||||
form.classList.add("dre-hidden");
|
||||
sentEl.classList.remove("dre-hidden");
|
||||
sentEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
|
||||
var code = DRE.getErrorCode(result);
|
||||
if (code === "rate_limited") {
|
||||
DRE.showAlert(alertEl, DRE.getErrorMessage(result, "Too many requests. Please try again later."));
|
||||
} else if (code === "validation_error") {
|
||||
errEl.textContent = DRE.getErrorMessage(result, "Enter a valid email address.");
|
||||
errEl.classList.add("is-visible");
|
||||
emailField.setAttribute("aria-invalid", "true");
|
||||
} else {
|
||||
DRE.showAlert(alertEl, DRE.getErrorMessage(result, "We could not send the login link. Please try again."));
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verifying Login - Debt Recovery Experts</title>
|
||||
<link rel="stylesheet" href="css/dre.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="dre-header">
|
||||
<div class="dre-header-inner">
|
||||
<a class="dre-brand" href="index.html">
|
||||
<span class="dre-mark">DRE</span>
|
||||
<span>
|
||||
Debt Recovery Experts
|
||||
<span class="dre-brand-sub">Client Portal</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dre-main dre-narrow">
|
||||
|
||||
<!-- ============================== CHECKING STATE ============================== -->
|
||||
<div id="verify-checking" class="dre-card dre-state">
|
||||
<div class="dre-spinner dre-spinner-dark" style="width:32px;height:32px;border-width:3px;margin:0 auto 1rem;"></div>
|
||||
<h2>Verifying Your Login Link...</h2>
|
||||
<p>Please wait a moment.</p>
|
||||
</div>
|
||||
|
||||
<!-- ============================== ERROR STATE ============================== -->
|
||||
<div id="verify-error" class="dre-card dre-state dre-hidden">
|
||||
<div class="dre-state-icon err">✗</div>
|
||||
<h2>Login Link Invalid or Expired</h2>
|
||||
<p id="verify-error-message">
|
||||
This login link is invalid, has expired, or has already been used. Login links are valid
|
||||
for 15 minutes and can only be used once.
|
||||
</p>
|
||||
<a href="login.html" class="dre-btn dre-btn-primary dre-mt">Request a New Login Link</a>
|
||||
</div>
|
||||
|
||||
<!-- ============================== NO TOKEN STATE ============================== -->
|
||||
<div id="verify-no-token" class="dre-card dre-state dre-hidden">
|
||||
<div class="dre-state-icon err">✗</div>
|
||||
<h2>Missing Login Link</h2>
|
||||
<p>No login token was found in this link. Please use the link from your email, or request a
|
||||
new one below.</p>
|
||||
<a href="login.html" class="dre-btn dre-btn-primary dre-mt">Go to Login</a>
|
||||
</div>
|
||||
|
||||
<!-- ============================== SUCCESS STATE ============================== -->
|
||||
<div id="verify-success" class="dre-card dre-state dre-hidden">
|
||||
<div class="dre-state-icon ok">✓</div>
|
||||
<h2>Login Successful</h2>
|
||||
<p>Redirecting you to your dashboard...</p>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="dre-footer">
|
||||
Debt Recovery Experts Client Portal
|
||||
</footer>
|
||||
|
||||
<script src="js/dre-api.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var checkingEl = document.getElementById("verify-checking");
|
||||
var errorEl = document.getElementById("verify-error");
|
||||
var noTokenEl = document.getElementById("verify-no-token");
|
||||
var successEl = document.getElementById("verify-success");
|
||||
|
||||
function showOnly(el) {
|
||||
[checkingEl, errorEl, noTokenEl, successEl].forEach(function (e) {
|
||||
e.classList.add("dre-hidden");
|
||||
});
|
||||
el.classList.remove("dre-hidden");
|
||||
}
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var token = params.get("token");
|
||||
|
||||
// Strip the token from the URL immediately so it does not linger in
|
||||
// browser history / can't be re-shared accidentally, per spec.
|
||||
if (token && window.history && window.history.replaceState) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
showOnly(noTokenEl);
|
||||
} else {
|
||||
DRE.apiFetch("/api/auth/verify", { method: "POST", body: { token: token } }).then(function (result) {
|
||||
if (result.ok && result.data && result.data.session_token) {
|
||||
DRE.setSessionToken(result.data.session_token);
|
||||
try {
|
||||
if (result.data.client) {
|
||||
localStorage.setItem("dre_client_summary", JSON.stringify(result.data.client));
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
showOnly(successEl);
|
||||
setTimeout(function () {
|
||||
window.location.href = "dashboard.html";
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
|
||||
var message = DRE.getErrorMessage(result, "This login link is invalid, has expired, or has already been used.");
|
||||
document.getElementById("verify-error-message").textContent = message;
|
||||
showOnly(errorEl);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,913 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debt Recovery Experts - Texas Debt Recovery, Handled Properly</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--navy-950: #10192b;
|
||||
--navy-900: #16213a;
|
||||
--navy-800: #1f2d4a;
|
||||
--navy-700: #2b3d5f;
|
||||
--navy-600: #3d5378;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #8593a8;
|
||||
--slate-300: #b3bdcc;
|
||||
--slate-200: #d8dee7;
|
||||
--slate-100: #eceff4;
|
||||
--paper: #faf9f6;
|
||||
--paper-alt: #f2f0ea;
|
||||
--line: #dcdfe6;
|
||||
--gold: #9c7c3f;
|
||||
--white: #ffffff;
|
||||
--radius: 3px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
color: var(--navy-900);
|
||||
background: var(--paper);
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;
|
||||
font-weight: 600;
|
||||
color: var(--navy-950);
|
||||
margin: 0 0 0.5em 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p { margin: 0 0 1em 0; }
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.container {
|
||||
max-width: 1140px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
margin-bottom: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---------- Top compliance strip ---------- */
|
||||
.top-strip {
|
||||
background: var(--navy-950);
|
||||
color: var(--slate-300);
|
||||
font-size: 0.82rem;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.top-strip .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.top-strip a:hover { color: var(--white); }
|
||||
.top-strip .divider { opacity: 0.4; margin: 0 10px; }
|
||||
|
||||
/* ---------- Header / nav ---------- */
|
||||
header.main {
|
||||
background: var(--white);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.nav-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1.5px solid var(--navy-900);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Source Serif 4', serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
color: var(--navy-900);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-family: 'Source Serif 4', serif;
|
||||
font-size: 1.28rem;
|
||||
font-weight: 600;
|
||||
color: var(--navy-950);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-text small {
|
||||
display: block;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--slate-500);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
nav.primary-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
nav.primary-nav a.nav-link {
|
||||
font-size: 0.94rem;
|
||||
font-weight: 500;
|
||||
color: var(--navy-800);
|
||||
padding: 4px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
nav.primary-nav a.nav-link:hover {
|
||||
color: var(--gold);
|
||||
border-bottom-color: var(--gold);
|
||||
}
|
||||
|
||||
.header-ctas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 26px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--navy-950);
|
||||
color: var(--white);
|
||||
border-color: var(--navy-950);
|
||||
}
|
||||
.btn-primary:hover { background: var(--navy-800); border-color: var(--navy-800); }
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--navy-900);
|
||||
border-color: var(--navy-700);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--navy-900); color: var(--white); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--navy-800);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--navy-700); }
|
||||
|
||||
.btn-sm { padding: 9px 18px; font-size: 0.85rem; }
|
||||
.btn-block { display: block; width: 100%; }
|
||||
|
||||
/* mobile nav toggle (css-only) */
|
||||
.nav-toggle { display: none; }
|
||||
.nav-toggle-label {
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
.nav-toggle-label span,
|
||||
.nav-toggle-label span::before,
|
||||
.nav-toggle-label span::after {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--navy-900);
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.nav-toggle-label span::before,
|
||||
.nav-toggle-label span::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
.nav-toggle-label span::before { top: -7px; }
|
||||
.nav-toggle-label span::after { top: 7px; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
background: linear-gradient(180deg, var(--navy-950) 0%, var(--navy-900) 65%, var(--navy-800) 100%);
|
||||
color: var(--white);
|
||||
padding: 96px 0 84px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -10%;
|
||||
top: -20%;
|
||||
width: 560px;
|
||||
height: 560px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 4%;
|
||||
bottom: -30%;
|
||||
width: 380px;
|
||||
height: 380px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.hero .container { position: relative; z-index: 2; }
|
||||
|
||||
.hero-inner {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.hero .eyebrow { color: var(--slate-300); }
|
||||
|
||||
.hero h1 {
|
||||
color: var(--white);
|
||||
font-size: 2.9rem;
|
||||
line-height: 1.18;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.hero .subhead {
|
||||
font-size: 1.15rem;
|
||||
color: var(--slate-200);
|
||||
max-width: 560px;
|
||||
margin-bottom: 38px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hero-ctas {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.hero .btn-primary {
|
||||
background: var(--white);
|
||||
color: var(--navy-950);
|
||||
border-color: var(--white);
|
||||
}
|
||||
.hero .btn-primary:hover { background: var(--slate-200); border-color: var(--slate-200); }
|
||||
|
||||
.hero .btn-secondary {
|
||||
color: var(--white);
|
||||
border-color: rgba(255,255,255,0.4);
|
||||
}
|
||||
.hero .btn-secondary:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.7); }
|
||||
|
||||
.hero-trust-row {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid rgba(255,255,255,0.14);
|
||||
}
|
||||
|
||||
.hero-trust-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--slate-300);
|
||||
}
|
||||
|
||||
.hero-trust-item .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- Section shell ---------- */
|
||||
section { padding: 88px 0; }
|
||||
.section-head {
|
||||
max-width: 660px;
|
||||
margin: 0 auto 56px;
|
||||
text-align: center;
|
||||
}
|
||||
.section-head h2 { font-size: 2.1rem; }
|
||||
.section-head p { color: var(--slate-500); font-size: 1.05rem; }
|
||||
|
||||
.bg-alt { background: var(--paper-alt); }
|
||||
.bg-navy { background: var(--navy-950); color: var(--slate-200); }
|
||||
.bg-navy h2, .bg-navy h3, .bg-navy h4 { color: var(--white); }
|
||||
|
||||
/* ---------- Who we serve ---------- */
|
||||
.audience-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.audience-card {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--line);
|
||||
border-top: 3px solid var(--navy-900);
|
||||
border-radius: var(--radius);
|
||||
padding: 44px 40px;
|
||||
}
|
||||
|
||||
.audience-card.debtor-card { border-top-color: var(--gold); }
|
||||
|
||||
.audience-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 1.5px solid var(--navy-800);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 22px;
|
||||
color: var(--navy-800);
|
||||
}
|
||||
.debtor-card .audience-icon { border-color: var(--gold); color: var(--gold); }
|
||||
|
||||
.audience-card h3 { font-size: 1.4rem; margin-bottom: 12px; }
|
||||
.audience-card .tag {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--slate-500);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.audience-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 28px 0;
|
||||
}
|
||||
.audience-card li {
|
||||
padding: 9px 0 9px 26px;
|
||||
position: relative;
|
||||
color: var(--navy-800);
|
||||
font-size: 0.96rem;
|
||||
border-bottom: 1px solid var(--slate-100);
|
||||
}
|
||||
.audience-card li:last-child { border-bottom: none; }
|
||||
.audience-card li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 17px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1.5px solid var(--navy-700);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.debtor-card li::before { border-color: var(--gold); }
|
||||
|
||||
/* ---------- How it works ---------- */
|
||||
.steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step {
|
||||
padding: 0 26px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step:not(:first-child)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 26px;
|
||||
width: 1px;
|
||||
height: calc(100% - 40px);
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
font-family: 'Source Serif 4', serif;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--slate-300);
|
||||
margin-bottom: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.step h4 { font-size: 1.08rem; margin-bottom: 10px; }
|
||||
.step p { color: var(--slate-500); font-size: 0.93rem; margin-bottom: 0; }
|
||||
|
||||
/* ---------- Compliance section ---------- */
|
||||
.compliance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 64px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.compliance-copy .eyebrow { color: var(--gold); }
|
||||
.compliance-copy h2 { font-size: 2.1rem; margin-bottom: 20px; }
|
||||
.compliance-copy p { color: var(--slate-300); font-size: 1.02rem; }
|
||||
|
||||
.compliance-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.compliance-item {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.compliance-item:first-child { padding-top: 0; }
|
||||
|
||||
.compliance-badge {
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Source Serif 4', serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
color: var(--white);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.compliance-item h4 {
|
||||
font-size: 1.02rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.compliance-item p {
|
||||
color: var(--slate-400);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---------- CTA band ---------- */
|
||||
.cta-band {
|
||||
background: var(--paper-alt);
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.cta-band-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.cta-band-col {
|
||||
padding: 56px 48px;
|
||||
}
|
||||
.cta-band-col:first-child {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.cta-band-col .eyebrow { margin-bottom: 10px; }
|
||||
.cta-band-col h3 { font-size: 1.5rem; margin-bottom: 10px; }
|
||||
.cta-band-col p { color: var(--slate-500); margin-bottom: 22px; font-size: 0.96rem; }
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
footer {
|
||||
background: var(--navy-950);
|
||||
color: var(--slate-400);
|
||||
padding: 64px 0 28px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr 1fr;
|
||||
gap: 40px;
|
||||
padding-bottom: 44px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.footer-brand .brand-text { color: var(--white); }
|
||||
.footer-brand p {
|
||||
color: var(--slate-400);
|
||||
margin-top: 16px;
|
||||
max-width: 320px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.footer-col h5 {
|
||||
color: var(--white);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin-bottom: 18px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.footer-col ul { list-style: none; padding: 0; margin: 0; }
|
||||
.footer-col li { margin-bottom: 12px; }
|
||||
.footer-col a:hover { color: var(--white); }
|
||||
|
||||
.footer-bottom {
|
||||
padding-top: 28px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-legal {
|
||||
font-size: 0.8rem;
|
||||
color: var(--slate-500);
|
||||
max-width: 720px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.footer-legal p { margin-bottom: 10px; }
|
||||
|
||||
.footer-links-inline {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.footer-links-inline a:hover { color: var(--white); }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 900px) {
|
||||
.audience-grid { grid-template-columns: 1fr; }
|
||||
.steps { grid-template-columns: 1fr 1fr; row-gap: 40px; }
|
||||
.step:nth-child(3)::before, .step:nth-child(1)::before { display: none; }
|
||||
.compliance-grid { grid-template-columns: 1fr; gap: 40px; }
|
||||
.cta-band-grid { grid-template-columns: 1fr; }
|
||||
.cta-band-col:first-child { border-right: none; border-bottom: 1px solid var(--line); }
|
||||
.footer-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.top-strip .container { flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||
.nav-toggle-label { display: block; }
|
||||
nav.primary-nav {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--white);
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease;
|
||||
}
|
||||
.nav-toggle:checked ~ nav.primary-nav { max-height: 320px; }
|
||||
nav.primary-nav a.nav-link {
|
||||
width: 100%;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px solid var(--slate-100);
|
||||
border-left: none;
|
||||
}
|
||||
.header-ctas .btn-secondary { display: none; }
|
||||
.hero { padding: 64px 0 56px; }
|
||||
.hero h1 { font-size: 2.1rem; }
|
||||
.hero-trust-row { flex-direction: column; gap: 12px; }
|
||||
section { padding: 60px 0; }
|
||||
.steps { grid-template-columns: 1fr; }
|
||||
.step::before { display: none !important; }
|
||||
.footer-grid { grid-template-columns: 1fr; gap: 32px; }
|
||||
.footer-bottom { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top compliance strip -->
|
||||
<div class="top-strip">
|
||||
<div class="container">
|
||||
<div>
|
||||
Licensed Texas debt recovery firm operating under FDCPA and TDCPA guidelines
|
||||
</div>
|
||||
<div>
|
||||
<a href="tel:+15555555555">[Phone number]</a>
|
||||
<span class="divider">|</span>
|
||||
<a href="https://pay.debtrecoveryexperts.com">Make a payment</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="main">
|
||||
<div class="container">
|
||||
<div class="nav-wrap">
|
||||
<a href="#top" class="brand">
|
||||
<span class="brand-mark">DRE</span>
|
||||
<span class="brand-text">Debt Recovery Experts<small>Texas Debt Recovery Counsel</small></span>
|
||||
</a>
|
||||
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<label for="nav-toggle" class="nav-toggle-label" aria-label="Toggle navigation"><span></span></label>
|
||||
|
||||
<nav class="primary-nav">
|
||||
<a class="nav-link" href="#services">Services</a>
|
||||
<a class="nav-link" href="#how-it-works">How It Works</a>
|
||||
<a class="nav-link" href="#creditors">For Creditors</a>
|
||||
<a class="nav-link" href="#debtors">For Debtors</a>
|
||||
<a class="nav-link" href="#contact">Contact</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-ctas">
|
||||
<a href="https://my.debtrecoveryexperts.com" class="btn btn-secondary btn-sm">Client Login</a>
|
||||
<a href="https://portal.debtrecoveryexperts.com" class="btn btn-primary btn-sm">Submit a Claim</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero" id="top">
|
||||
<div class="container">
|
||||
<div class="hero-inner">
|
||||
<span class="eyebrow">Texas Debt Recovery, Handled Properly</span>
|
||||
<h1>Recover what you are owed, without cutting corners on compliance.</h1>
|
||||
<p class="subhead">
|
||||
Debt Recovery Experts represents Texas businesses in the recovery of commercial and consumer debt,
|
||||
under the same regulatory discipline we would want applied to our own accounts. Deliberate process,
|
||||
documented compliance, and clear communication at every stage.
|
||||
</p>
|
||||
<div class="hero-ctas">
|
||||
<a href="https://portal.debtrecoveryexperts.com" class="btn btn-primary">Submit a Claim</a>
|
||||
<a href="https://pay.debtrecoveryexperts.com" class="btn btn-secondary">Make a Payment</a>
|
||||
</div>
|
||||
<div class="hero-trust-row">
|
||||
<div class="hero-trust-item"><span class="dot"></span> FDCPA compliant procedures</div>
|
||||
<div class="hero-trust-item"><span class="dot"></span> TDCPA compliant, Texas licensed</div>
|
||||
<div class="hero-trust-item"><span class="dot"></span> Secure handling of account data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Who we serve -->
|
||||
<section id="services">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">Who We Serve</span>
|
||||
<h2>Two sides of every account, one standard of conduct</h2>
|
||||
<p>Whether you are owed money or working through a balance you owe, our process is built to be clear,
|
||||
fair, and fully documented.</p>
|
||||
</div>
|
||||
|
||||
<div class="audience-grid">
|
||||
<div class="audience-card" id="creditors">
|
||||
<div class="audience-icon">C</div>
|
||||
<span class="tag">For Creditors</span>
|
||||
<h3>Place an account for recovery</h3>
|
||||
<p>For businesses seeking to recover outstanding commercial or consumer receivables through a
|
||||
structured, compliant process.</p>
|
||||
<ul>
|
||||
<li>Submit claims through a secure intake portal</li>
|
||||
<li>Track case status and correspondence in one place</li>
|
||||
<li>Work with a firm that documents every contact attempt</li>
|
||||
<li>Portfolio placement for single accounts or batches</li>
|
||||
</ul>
|
||||
<a href="https://portal.debtrecoveryexperts.com" class="btn btn-primary btn-block">Submit a Claim</a>
|
||||
</div>
|
||||
|
||||
<div class="audience-card debtor-card" id="debtors">
|
||||
<div class="audience-icon">D</div>
|
||||
<span class="tag">For Debtors</span>
|
||||
<h3>Resolve an outstanding balance</h3>
|
||||
<p>If you have received correspondence from our office regarding an account, you have options and
|
||||
you have rights under federal and state law.</p>
|
||||
<ul>
|
||||
<li>Review your account and verify the balance owed</li>
|
||||
<li>Make a secure payment or set up arrangements</li>
|
||||
<li>Understand your rights under the FDCPA and TDCPA</li>
|
||||
<li>Reach a representative directly with questions</li>
|
||||
</ul>
|
||||
<a href="https://pay.debtrecoveryexperts.com" class="btn btn-secondary btn-block">Make a Payment</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How it works -->
|
||||
<section class="bg-alt" id="how-it-works">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">How It Works</span>
|
||||
<h2>A deliberate, documented process</h2>
|
||||
<p>From claim submission to resolution, each step is tracked and each communication is recorded.</p>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">01</div>
|
||||
<h4>Submit the claim</h4>
|
||||
<p>A creditor submits account details through the secure intake portal, including balance and
|
||||
supporting documentation.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">02</div>
|
||||
<h4>Review and verification</h4>
|
||||
<p>Our team reviews the account for completeness and confirms it meets requirements before
|
||||
placement.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">03</div>
|
||||
<h4>Compliant contact and negotiation</h4>
|
||||
<p>The debtor is contacted in accordance with FDCPA and TDCPA requirements to verify and resolve
|
||||
the balance.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">04</div>
|
||||
<h4>Resolution and reporting</h4>
|
||||
<p>Payments are processed and the creditor receives status updates through their client
|
||||
dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Compliance -->
|
||||
<section class="bg-navy">
|
||||
<div class="container">
|
||||
<div class="compliance-grid">
|
||||
<div class="compliance-copy">
|
||||
<span class="eyebrow">Compliance First</span>
|
||||
<h2>Recovery conducted within the letter of the law</h2>
|
||||
<p>
|
||||
Debt recovery is a regulated activity, and we treat it that way. Our procedures are built around
|
||||
the Fair Debt Collection Practices Act and the Texas Debt Collection Act, with internal controls
|
||||
designed to keep every contact, disclosure, and payment within bounds.
|
||||
</p>
|
||||
<p>
|
||||
This is not a marketing claim, it is how the office is structured to operate day to day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="compliance-list">
|
||||
<div class="compliance-item">
|
||||
<div class="compliance-badge">FDCPA</div>
|
||||
<div>
|
||||
<h4>Federal Fair Debt Collection Practices Act</h4>
|
||||
<p>Governs communication frequency, disclosure requirements, and prohibited practices in
|
||||
consumer debt collection.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compliance-item">
|
||||
<div class="compliance-badge">TDCPA</div>
|
||||
<div>
|
||||
<h4>Texas Debt Collection Act</h4>
|
||||
<p>State-level requirements for licensing, bonding, and conduct for debt collectors operating
|
||||
in Texas.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compliance-item">
|
||||
<div class="compliance-badge">SEC</div>
|
||||
<div>
|
||||
<h4>Secure data handling</h4>
|
||||
<p>Account and payment information is handled through encrypted channels with access limited
|
||||
to authorized personnel.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA band -->
|
||||
<section class="cta-band" style="padding: 0;">
|
||||
<div class="container" style="padding: 0;">
|
||||
<div class="cta-band-grid">
|
||||
<div class="cta-band-col">
|
||||
<span class="eyebrow">New Creditor</span>
|
||||
<h3>Ready to place an account?</h3>
|
||||
<p>Submit claim details through our secure intake portal and a member of our team will confirm
|
||||
receipt.</p>
|
||||
<a href="https://portal.debtrecoveryexperts.com" class="btn btn-primary">Submit a Claim</a>
|
||||
</div>
|
||||
<div class="cta-band-col">
|
||||
<span class="eyebrow">Existing Client</span>
|
||||
<h3>Track your recovery</h3>
|
||||
<p>Log in to your client dashboard to review case status, correspondence, and reporting on
|
||||
placed accounts.</p>
|
||||
<a href="https://my.debtrecoveryexperts.com" class="btn btn-secondary">Client Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer id="contact">
|
||||
<div class="container">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-col footer-brand">
|
||||
<a href="#top" class="brand">
|
||||
<span class="brand-mark" style="border-color: var(--white); color: var(--white);">DRE</span>
|
||||
<span class="brand-text">Debt Recovery Experts</span>
|
||||
</a>
|
||||
<p>A Texas-based debt recovery firm serving creditors and debtors under FDCPA and TDCPA
|
||||
compliance standards.</p>
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h5>Firm</h5>
|
||||
<ul>
|
||||
<li><a href="#services">Services</a></li>
|
||||
<li><a href="#how-it-works">How It Works</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h5>Portals</h5>
|
||||
<ul>
|
||||
<li><a href="https://portal.debtrecoveryexperts.com">Submit a Claim</a></li>
|
||||
<li><a href="https://my.debtrecoveryexperts.com">Client Login</a></li>
|
||||
<li><a href="https://pay.debtrecoveryexperts.com">Make a Payment</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h5>Contact</h5>
|
||||
<ul>
|
||||
<li>[Office address], Texas</li>
|
||||
<li>[Phone number]</li>
|
||||
<li>[Email address]</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<div class="footer-legal">
|
||||
<p>
|
||||
Debt Recovery Experts is a debt collection firm. This communication is an attempt to collect a
|
||||
debt and any information obtained will be used for that purpose, where applicable. You have
|
||||
rights under the federal Fair Debt Collection Practices Act and the Texas Debt Collection Act,
|
||||
including the right to dispute a debt and request verification.
|
||||
</p>
|
||||
<p>[Licensing and bond information placeholder]. [Registered agent / legal entity placeholder].</p>
|
||||
</div>
|
||||
<div class="footer-links-inline">
|
||||
<a href="#">Privacy Policy</a>
|
||||
<a href="#">Terms of Use</a>
|
||||
<a href="#">Consumer Rights Notice</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,737 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debt Recovery Experts - Get Unpaid Debts Recovered</title>
|
||||
<style>
|
||||
:root{
|
||||
--navy:#0b1b33;
|
||||
--navy-2:#11274a;
|
||||
--accent:#ff5a1f;
|
||||
--accent-dark:#d94512;
|
||||
--green:#0f9d58;
|
||||
--gray-bg:#f4f5f7;
|
||||
--gray-line:#dfe3e8;
|
||||
--text:#1b1f27;
|
||||
--text-mid:#4a5261;
|
||||
--text-light:#7a8291;
|
||||
--white:#ffffff;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
html{scroll-behavior:smooth;}
|
||||
body{
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
color:var(--text);
|
||||
background:var(--white);
|
||||
line-height:1.5;
|
||||
}
|
||||
h1,h2,h3,h4{
|
||||
font-weight:800;
|
||||
letter-spacing:-0.02em;
|
||||
line-height:1.1;
|
||||
}
|
||||
a{color:inherit;text-decoration:none;}
|
||||
.container{
|
||||
max-width:1180px;
|
||||
margin:0 auto;
|
||||
padding:0 24px;
|
||||
}
|
||||
.eyebrow{
|
||||
display:inline-block;
|
||||
font-size:12px;
|
||||
font-weight:800;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:0.08em;
|
||||
color:var(--accent);
|
||||
background:rgba(255,90,31,0.1);
|
||||
padding:5px 12px;
|
||||
border-radius:3px;
|
||||
margin-bottom:14px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:8px;
|
||||
font-weight:800;
|
||||
font-size:15px;
|
||||
padding:14px 26px;
|
||||
border-radius:4px;
|
||||
border:2px solid transparent;
|
||||
cursor:pointer;
|
||||
transition:transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.btn-primary{
|
||||
background:var(--accent);
|
||||
color:var(--white);
|
||||
box-shadow:0 4px 0 var(--accent-dark);
|
||||
}
|
||||
.btn-primary:hover{
|
||||
background:var(--accent-dark);
|
||||
transform:translateY(2px);
|
||||
box-shadow:0 2px 0 var(--accent-dark);
|
||||
}
|
||||
.btn-secondary{
|
||||
background:transparent;
|
||||
color:var(--white);
|
||||
border-color:rgba(255,255,255,0.55);
|
||||
}
|
||||
.btn-secondary:hover{
|
||||
background:rgba(255,255,255,0.12);
|
||||
border-color:var(--white);
|
||||
}
|
||||
.btn-dark{
|
||||
background:var(--navy);
|
||||
color:var(--white);
|
||||
box-shadow:0 4px 0 #060f1e;
|
||||
}
|
||||
.btn-dark:hover{
|
||||
background:#060f1e;
|
||||
transform:translateY(2px);
|
||||
box-shadow:0 2px 0 #060f1e;
|
||||
}
|
||||
.btn-outline-navy{
|
||||
background:transparent;
|
||||
color:var(--navy);
|
||||
border-color:var(--navy);
|
||||
}
|
||||
.btn-outline-navy:hover{
|
||||
background:var(--navy);
|
||||
color:var(--white);
|
||||
}
|
||||
.btn-block{width:100%;}
|
||||
.btn-sm{padding:10px 18px;font-size:13px;}
|
||||
|
||||
/* Header */
|
||||
header{
|
||||
position:sticky;
|
||||
top:0;
|
||||
z-index:100;
|
||||
background:var(--white);
|
||||
border-bottom:1px solid var(--gray-line);
|
||||
}
|
||||
.nav-bar{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
height:68px;
|
||||
}
|
||||
.logo{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
font-size:18px;
|
||||
font-weight:800;
|
||||
color:var(--navy);
|
||||
}
|
||||
.logo .mark{
|
||||
background:var(--navy);
|
||||
color:var(--white);
|
||||
font-size:14px;
|
||||
font-weight:800;
|
||||
padding:7px 9px;
|
||||
border-radius:4px;
|
||||
letter-spacing:0.02em;
|
||||
}
|
||||
nav.main-nav{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:28px;
|
||||
}
|
||||
nav.main-nav a{
|
||||
font-size:14px;
|
||||
font-weight:600;
|
||||
color:var(--text-mid);
|
||||
}
|
||||
nav.main-nav a:hover{color:var(--navy);}
|
||||
.nav-cta{display:flex;align-items:center;gap:12px;}
|
||||
.nav-cta .link-login{
|
||||
font-size:14px;
|
||||
font-weight:700;
|
||||
color:var(--navy);
|
||||
}
|
||||
.menu-toggle{
|
||||
display:none;
|
||||
background:none;
|
||||
border:none;
|
||||
font-size:26px;
|
||||
cursor:pointer;
|
||||
color:var(--navy);
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero{
|
||||
background:linear-gradient(135deg,var(--navy) 0%,var(--navy-2) 100%);
|
||||
color:var(--white);
|
||||
padding:64px 0 56px;
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
}
|
||||
.hero::after{
|
||||
content:"";
|
||||
position:absolute;
|
||||
right:-120px;
|
||||
top:-120px;
|
||||
width:420px;
|
||||
height:420px;
|
||||
border-radius:50%;
|
||||
background:radial-gradient(circle,rgba(255,90,31,0.25) 0%,rgba(255,90,31,0) 70%);
|
||||
}
|
||||
.hero-grid{
|
||||
display:grid;
|
||||
grid-template-columns:1.15fr 0.85fr;
|
||||
gap:48px;
|
||||
align-items:center;
|
||||
position:relative;
|
||||
z-index:2;
|
||||
}
|
||||
.hero h1{
|
||||
font-size:44px;
|
||||
margin-bottom:18px;
|
||||
}
|
||||
.hero h1 span{color:var(--accent);}
|
||||
.hero p.sub{
|
||||
font-size:18px;
|
||||
color:#c6cede;
|
||||
max-width:560px;
|
||||
margin-bottom:30px;
|
||||
}
|
||||
.hero-ctas{
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:14px;
|
||||
margin-bottom:22px;
|
||||
}
|
||||
.hero-note{
|
||||
font-size:13px;
|
||||
color:#93a0b8;
|
||||
}
|
||||
.hero-side{
|
||||
background:rgba(255,255,255,0.06);
|
||||
border:1px solid rgba(255,255,255,0.14);
|
||||
border-radius:10px;
|
||||
padding:26px;
|
||||
}
|
||||
.hero-side h3{
|
||||
font-size:15px;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:0.06em;
|
||||
color:#c6cede;
|
||||
margin-bottom:16px;
|
||||
}
|
||||
.hero-checklist{list-style:none;display:flex;flex-direction:column;gap:12px;margin-bottom:20px;}
|
||||
.hero-checklist li{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
gap:10px;
|
||||
font-size:14.5px;
|
||||
color:#e4e8f0;
|
||||
}
|
||||
.hero-checklist li .chk{
|
||||
flex:0 0 18px;
|
||||
width:18px;
|
||||
height:18px;
|
||||
border-radius:50%;
|
||||
background:var(--green);
|
||||
color:var(--white);
|
||||
font-size:11px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
margin-top:2px;
|
||||
}
|
||||
.hero-stats{
|
||||
display:grid;
|
||||
grid-template-columns:1fr 1fr;
|
||||
gap:10px;
|
||||
border-top:1px solid rgba(255,255,255,0.15);
|
||||
padding-top:16px;
|
||||
}
|
||||
.hero-stats .stat-box{
|
||||
background:rgba(255,255,255,0.05);
|
||||
border-radius:6px;
|
||||
padding:12px;
|
||||
text-align:center;
|
||||
}
|
||||
.hero-stats .stat-box .num{
|
||||
font-size:14px;
|
||||
font-weight:800;
|
||||
color:var(--accent);
|
||||
display:block;
|
||||
}
|
||||
.hero-stats .stat-box .label{
|
||||
font-size:11px;
|
||||
color:#a9b3c6;
|
||||
}
|
||||
|
||||
/* Ticker / trust strip */
|
||||
.trust-strip{
|
||||
background:var(--gray-bg);
|
||||
border-bottom:1px solid var(--gray-line);
|
||||
padding:14px 0;
|
||||
}
|
||||
.trust-strip .container{
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
justify-content:center;
|
||||
gap:28px;
|
||||
font-size:13px;
|
||||
font-weight:700;
|
||||
color:var(--text-mid);
|
||||
}
|
||||
.trust-strip span{display:flex;align-items:center;gap:8px;}
|
||||
.trust-strip .dot{width:6px;height:6px;border-radius:50%;background:var(--green);}
|
||||
|
||||
/* Section basics */
|
||||
section{padding:64px 0;}
|
||||
.section-head{
|
||||
max-width:680px;
|
||||
margin-bottom:40px;
|
||||
}
|
||||
.section-head h2{
|
||||
font-size:32px;
|
||||
color:var(--navy);
|
||||
margin-bottom:12px;
|
||||
}
|
||||
.section-head p{
|
||||
font-size:16px;
|
||||
color:var(--text-mid);
|
||||
}
|
||||
.section-head.center{margin-left:auto;margin-right:auto;text-align:center;}
|
||||
|
||||
/* Who we serve */
|
||||
.serve-grid{
|
||||
display:grid;
|
||||
grid-template-columns:1fr 1fr;
|
||||
gap:24px;
|
||||
}
|
||||
.serve-card{
|
||||
border:2px solid var(--gray-line);
|
||||
border-radius:10px;
|
||||
padding:32px;
|
||||
position:relative;
|
||||
}
|
||||
.serve-card.primary{
|
||||
border-color:var(--navy);
|
||||
background:var(--navy);
|
||||
color:var(--white);
|
||||
}
|
||||
.serve-card .tag{
|
||||
display:inline-block;
|
||||
font-size:11px;
|
||||
font-weight:800;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:0.06em;
|
||||
padding:4px 10px;
|
||||
border-radius:3px;
|
||||
margin-bottom:16px;
|
||||
}
|
||||
.serve-card.primary .tag{background:var(--accent);color:var(--white);}
|
||||
.serve-card.secondary .tag{background:var(--gray-bg);color:var(--text-mid);}
|
||||
.serve-card h3{font-size:24px;margin-bottom:10px;}
|
||||
.serve-card.primary h3{color:var(--white);}
|
||||
.serve-card.secondary h3{color:var(--navy);}
|
||||
.serve-card p{font-size:15px;margin-bottom:20px;}
|
||||
.serve-card.primary p{color:#c6cede;}
|
||||
.serve-card.secondary p{color:var(--text-mid);}
|
||||
.serve-list{list-style:none;margin-bottom:24px;display:flex;flex-direction:column;gap:10px;}
|
||||
.serve-list li{
|
||||
display:flex;
|
||||
gap:10px;
|
||||
font-size:14px;
|
||||
align-items:flex-start;
|
||||
}
|
||||
.serve-card.primary .serve-list li{color:#e4e8f0;}
|
||||
.serve-card.secondary .serve-list li{color:var(--text-mid);}
|
||||
.serve-list li::before{
|
||||
content:"\2192";
|
||||
font-weight:800;
|
||||
color:var(--accent);
|
||||
flex:0 0 auto;
|
||||
}
|
||||
|
||||
/* How it works */
|
||||
.steps-wrap{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4,1fr);
|
||||
gap:20px;
|
||||
}
|
||||
.step-card{
|
||||
background:var(--gray-bg);
|
||||
border-radius:10px;
|
||||
padding:26px 22px;
|
||||
position:relative;
|
||||
border-top:4px solid var(--accent);
|
||||
}
|
||||
.step-card .step-num{
|
||||
font-size:34px;
|
||||
font-weight:800;
|
||||
color:var(--navy);
|
||||
opacity:0.15;
|
||||
position:absolute;
|
||||
top:12px;
|
||||
right:16px;
|
||||
}
|
||||
.step-card h4{
|
||||
font-size:17px;
|
||||
color:var(--navy);
|
||||
margin-bottom:8px;
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
.step-card p{
|
||||
font-size:14px;
|
||||
color:var(--text-mid);
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
|
||||
/* Compliance */
|
||||
.compliance{
|
||||
background:var(--navy);
|
||||
color:var(--white);
|
||||
}
|
||||
.compliance .section-head h2,
|
||||
.compliance .section-head p{color:var(--white);}
|
||||
.compliance .section-head p{color:#c6cede;}
|
||||
.compliance-grid{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,1fr);
|
||||
gap:20px;
|
||||
}
|
||||
.compliance-card{
|
||||
background:rgba(255,255,255,0.06);
|
||||
border:1px solid rgba(255,255,255,0.14);
|
||||
border-radius:10px;
|
||||
padding:26px;
|
||||
}
|
||||
.compliance-card .icon{
|
||||
width:42px;
|
||||
height:42px;
|
||||
border-radius:8px;
|
||||
background:var(--accent);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-weight:800;
|
||||
font-size:16px;
|
||||
color:var(--white);
|
||||
margin-bottom:16px;
|
||||
}
|
||||
.compliance-card h4{font-size:17px;margin-bottom:8px;}
|
||||
.compliance-card p{font-size:14px;color:#c6cede;}
|
||||
|
||||
/* CTA band */
|
||||
.cta-band{
|
||||
background:linear-gradient(135deg,var(--accent) 0%,var(--accent-dark) 100%);
|
||||
color:var(--white);
|
||||
padding:56px 0;
|
||||
}
|
||||
.cta-band-grid{
|
||||
display:grid;
|
||||
grid-template-columns:1fr 1fr;
|
||||
gap:24px;
|
||||
}
|
||||
.cta-panel{
|
||||
background:rgba(255,255,255,0.12);
|
||||
border:1px solid rgba(255,255,255,0.3);
|
||||
border-radius:10px;
|
||||
padding:30px;
|
||||
}
|
||||
.cta-panel h3{font-size:22px;margin-bottom:10px;}
|
||||
.cta-panel p{font-size:14.5px;margin-bottom:20px;color:rgba(255,255,255,0.92);}
|
||||
|
||||
/* Footer */
|
||||
footer{
|
||||
background:#060f1e;
|
||||
color:#a9b3c6;
|
||||
padding:48px 0 24px;
|
||||
font-size:13.5px;
|
||||
}
|
||||
.footer-grid{
|
||||
display:grid;
|
||||
grid-template-columns:1.4fr 1fr 1fr 1fr;
|
||||
gap:32px;
|
||||
margin-bottom:32px;
|
||||
}
|
||||
.footer-grid h4{
|
||||
color:var(--white);
|
||||
font-size:14px;
|
||||
margin-bottom:16px;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:0.05em;
|
||||
}
|
||||
.footer-grid ul{list-style:none;display:flex;flex-direction:column;gap:10px;}
|
||||
.footer-grid ul a:hover{color:var(--white);}
|
||||
.footer-brand .logo{color:var(--white);margin-bottom:14px;}
|
||||
.footer-brand p{max-width:280px;color:#7f8ba3;margin-bottom:16px;}
|
||||
.footer-legal{
|
||||
border-top:1px solid rgba(255,255,255,0.1);
|
||||
padding-top:20px;
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
color:#7f8ba3;
|
||||
font-size:12.5px;
|
||||
}
|
||||
.footer-legal a{color:#a9b3c6;}
|
||||
.compliance-notice{
|
||||
background:rgba(255,255,255,0.04);
|
||||
border:1px solid rgba(255,255,255,0.08);
|
||||
border-radius:8px;
|
||||
padding:16px 20px;
|
||||
font-size:12.5px;
|
||||
color:#8a96ac;
|
||||
margin-bottom:24px;
|
||||
line-height:1.6;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width:960px){
|
||||
nav.main-nav{display:none;}
|
||||
.menu-toggle{display:block;}
|
||||
.hero-grid{grid-template-columns:1fr;}
|
||||
.hero h1{font-size:34px;}
|
||||
.serve-grid{grid-template-columns:1fr;}
|
||||
.steps-wrap{grid-template-columns:1fr 1fr;}
|
||||
.compliance-grid{grid-template-columns:1fr;}
|
||||
.cta-band-grid{grid-template-columns:1fr;}
|
||||
.footer-grid{grid-template-columns:1fr 1fr;}
|
||||
}
|
||||
@media (max-width:560px){
|
||||
.steps-wrap{grid-template-columns:1fr;}
|
||||
.hero-stats{grid-template-columns:1fr 1fr;}
|
||||
.footer-grid{grid-template-columns:1fr;}
|
||||
.hero-ctas .btn{width:100%;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="container nav-bar">
|
||||
<a href="#top" class="logo">
|
||||
<span class="mark">DRE</span>
|
||||
Debt Recovery Experts
|
||||
</a>
|
||||
<nav class="main-nav">
|
||||
<a href="#services">Services</a>
|
||||
<a href="#how-it-works">How It Works</a>
|
||||
<a href="#creditors">For Creditors</a>
|
||||
<a href="#debtors">For Debtors</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
<div class="nav-cta">
|
||||
<a class="link-login" href="https://my.debtrecoveryexperts.com">Client Login</a>
|
||||
<a class="btn btn-primary btn-sm" href="https://portal.debtrecoveryexperts.com">Submit a Claim</a>
|
||||
</div>
|
||||
<button class="menu-toggle" aria-label="Open menu">☰</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero" id="top">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-main">
|
||||
<span class="eyebrow">Texas Commercial Debt Recovery</span>
|
||||
<h1>Stop chasing unpaid invoices. <span>Get them recovered.</span></h1>
|
||||
<p class="sub">Submit your claim in minutes and let a licensed Texas recovery team pursue payment on your behalf, so you can get back to running your business.</p>
|
||||
<div class="hero-ctas">
|
||||
<a class="btn btn-primary" href="https://portal.debtrecoveryexperts.com">Submit a Claim Now</a>
|
||||
<a class="btn btn-secondary" href="https://pay.debtrecoveryexperts.com">I Need to Make a Payment</a>
|
||||
</div>
|
||||
<p class="hero-note">No upfront retainer required to start a claim review. FDCPA and TDCPA compliant process, every case.</p>
|
||||
</div>
|
||||
<div class="hero-side">
|
||||
<h3>What happens when you submit a claim</h3>
|
||||
<ul class="hero-checklist">
|
||||
<li><span class="chk">✓</span> Your claim is reviewed by a recovery specialist</li>
|
||||
<li><span class="chk">✓</span> We contact the debtor through compliant channels</li>
|
||||
<li><span class="chk">✓</span> You track progress from your client dashboard</li>
|
||||
<li><span class="chk">✓</span> Recovered funds are remitted to you</li>
|
||||
</ul>
|
||||
<div class="hero-stats">
|
||||
<div class="stat-box">
|
||||
<span class="num">[Recovery rate]</span>
|
||||
<span class="label">Placeholder metric</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="num">[Avg. time to contact]</span>
|
||||
<span class="label">Placeholder metric</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="trust-strip">
|
||||
<div class="container">
|
||||
<span><span class="dot"></span> FDCPA Compliant</span>
|
||||
<span><span class="dot"></span> TDCPA Compliant (Texas)</span>
|
||||
<span><span class="dot"></span> Secure Data Handling</span>
|
||||
<span><span class="dot"></span> Licensed Texas Recovery Firm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="services">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2>Built for one outcome: getting you paid.</h2>
|
||||
<p>DRE handles commercial and consumer debt recovery for Texas businesses that need results without the hassle of DIY collections or expensive litigation.</p>
|
||||
</div>
|
||||
<div class="serve-grid" id="creditors">
|
||||
<div class="serve-card primary">
|
||||
<span class="tag">For Creditors</span>
|
||||
<h3>Owed money by a customer or client?</h3>
|
||||
<p>Submit your outstanding invoice or account and let our team pursue recovery through documented, compliant channels while you stay focused on your business.</p>
|
||||
<ul class="serve-list">
|
||||
<li>Fast intake through a simple online claim form</li>
|
||||
<li>Status updates through your client dashboard</li>
|
||||
<li>Compliant collection practices under FDCPA and TDCPA</li>
|
||||
<li>No claim is too small or too complex to review</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary btn-block" href="https://portal.debtrecoveryexperts.com">Submit a Claim</a>
|
||||
</div>
|
||||
<div class="serve-card secondary" id="debtors">
|
||||
<span class="tag">For Debtors</span>
|
||||
<h3>Received a notice from DRE?</h3>
|
||||
<p>If you owe a debt that has been placed with our office, you have options and rights. Resolve your account quickly and securely online.</p>
|
||||
<ul class="serve-list">
|
||||
<li>Make a secure payment toward your balance</li>
|
||||
<li>Review your account details and balance owed</li>
|
||||
<li>Understand your rights under the FDCPA and TDCPA</li>
|
||||
<li>Contact us to discuss payment arrangements</li>
|
||||
</ul>
|
||||
<a class="btn btn-outline-navy btn-block" href="https://pay.debtrecoveryexperts.com">Make a Payment</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how-it-works" style="background:var(--gray-bg);">
|
||||
<div class="container">
|
||||
<div class="section-head center" style="margin-left:auto;margin-right:auto;">
|
||||
<h2>From unpaid invoice to resolved claim.</h2>
|
||||
<p>A straightforward process designed to move your claim forward quickly and keep you informed at every step.</p>
|
||||
</div>
|
||||
<div class="steps-wrap">
|
||||
<div class="step-card">
|
||||
<span class="step-num">01</span>
|
||||
<h4>Submit your claim</h4>
|
||||
<p>Complete the online intake form with debtor and invoice details. Takes just a few minutes.</p>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<span class="step-num">02</span>
|
||||
<h4>Claim review</h4>
|
||||
<p>A recovery specialist reviews the account and determines the appropriate recovery approach.</p>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<span class="step-num">03</span>
|
||||
<h4>Compliant outreach</h4>
|
||||
<p>We contact the debtor through FDCPA and TDCPA compliant channels to pursue resolution.</p>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<span class="step-num">04</span>
|
||||
<h4>Track and get paid</h4>
|
||||
<p>Monitor progress in your dashboard. Recovered funds are remitted to you per your agreement.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="compliance" id="compliance">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2>Recovery done the right way.</h2>
|
||||
<p>Every account we work is handled under strict federal and state compliance standards, protecting your business and the consumer.</p>
|
||||
</div>
|
||||
<div class="compliance-grid">
|
||||
<div class="compliance-card">
|
||||
<div class="icon">FD</div>
|
||||
<h4>FDCPA Compliant</h4>
|
||||
<p>All collection activity follows the federal Fair Debt Collection Practices Act.</p>
|
||||
</div>
|
||||
<div class="compliance-card">
|
||||
<div class="icon">TX</div>
|
||||
<h4>TDCPA Compliant</h4>
|
||||
<p>Recovery practices also adhere to the Texas Debt Collection Practices Act.</p>
|
||||
</div>
|
||||
<div class="compliance-card">
|
||||
<div class="icon">SEC</div>
|
||||
<h4>Secure Handling</h4>
|
||||
<p>Claim and account data is handled through secure systems built for sensitive financial information.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-band">
|
||||
<div class="container">
|
||||
<div class="cta-band-grid">
|
||||
<div class="cta-panel">
|
||||
<h3>Ready to submit a claim?</h3>
|
||||
<p>Start the recovery process today. Claim submission takes just a few minutes and gets your account into review.</p>
|
||||
<a class="btn btn-dark btn-block" href="https://portal.debtrecoveryexperts.com">Submit a Claim</a>
|
||||
</div>
|
||||
<div class="cta-panel">
|
||||
<h3>Already a DRE client?</h3>
|
||||
<p>Log in to your dashboard to check claim status, view recovered funds, and manage your account.</p>
|
||||
<a class="btn btn-dark btn-block" href="https://my.debtrecoveryexperts.com">Go to Client Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer id="contact">
|
||||
<div class="container">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<a href="#top" class="logo">
|
||||
<span class="mark">DRE</span>
|
||||
Debt Recovery Experts
|
||||
</a>
|
||||
<p>Texas-based commercial debt recovery, built for business owners who need unpaid debts resolved efficiently and compliantly.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Company</h4>
|
||||
<ul>
|
||||
<li><a href="#services">Services</a></li>
|
||||
<li><a href="#how-it-works">How It Works</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>For Creditors</h4>
|
||||
<ul>
|
||||
<li><a href="https://portal.debtrecoveryexperts.com">Submit a Claim</a></li>
|
||||
<li><a href="https://my.debtrecoveryexperts.com">Client Login</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>For Debtors</h4>
|
||||
<ul>
|
||||
<li><a href="https://pay.debtrecoveryexperts.com">Make a Payment</a></li>
|
||||
<li><a href="#debtors">Know Your Rights</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compliance-notice">
|
||||
Debt Recovery Experts is a debt collector. This communication is an attempt to collect a debt, and any information obtained will be used for that purpose, where applicable. Consumers have rights under the federal Fair Debt Collection Practices Act (FDCPA) and the Texas Debt Collection Practices Act (TDCPA). [Company address, phone, and licensing details placeholder.]
|
||||
</div>
|
||||
<div class="footer-legal">
|
||||
<span>© [Year] Debt Recovery Experts. All rights reserved.</span>
|
||||
<span>
|
||||
<a href="#">Privacy Policy</a> |
|
||||
<a href="#">Terms of Use</a> |
|
||||
<a href="#">Debtor Rights Notice</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,948 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debt Recovery Experts - Texas Debt Recovery, Handled Properly</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/favicon-180.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--navy-950: #10192b;
|
||||
--navy-900: #16213a;
|
||||
--navy-800: #1f2d4a;
|
||||
--navy-700: #2b3d5f;
|
||||
--navy-600: #3d5378;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #8593a8;
|
||||
--slate-300: #b3bdcc;
|
||||
--slate-200: #d8dee7;
|
||||
--slate-100: #eceff4;
|
||||
--paper: #faf9f6;
|
||||
--paper-alt: #f2f0ea;
|
||||
--line: #dcdfe6;
|
||||
--gold: #9c7c3f;
|
||||
--white: #ffffff;
|
||||
--radius: 3px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
color: var(--navy-900);
|
||||
background: var(--paper);
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;
|
||||
font-weight: 600;
|
||||
color: var(--navy-950);
|
||||
margin: 0 0 0.5em 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p { margin: 0 0 1em 0; }
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.container {
|
||||
max-width: 1140px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
margin-bottom: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---------- Top compliance strip ---------- */
|
||||
.top-strip {
|
||||
background: var(--navy-950);
|
||||
color: var(--slate-300);
|
||||
font-size: 0.82rem;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.top-strip .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.top-strip a:hover { color: var(--white); }
|
||||
.top-strip .divider { opacity: 0.4; margin: 0 10px; }
|
||||
|
||||
/* ---------- Header / nav ---------- */
|
||||
header.main {
|
||||
background: var(--white);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.nav-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1.5px solid var(--navy-900);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Source Serif 4', serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
color: var(--navy-900);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-family: 'Source Serif 4', serif;
|
||||
font-size: 1.18rem;
|
||||
font-weight: 600;
|
||||
color: var(--navy-950);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-text small {
|
||||
display: block;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--slate-500);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
nav.primary-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
nav.primary-nav a.nav-link {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--navy-800);
|
||||
padding: 4px 0;
|
||||
white-space: nowrap;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
nav.primary-nav a.nav-link:hover {
|
||||
color: var(--gold);
|
||||
border-bottom-color: var(--gold);
|
||||
}
|
||||
|
||||
.header-ctas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 26px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--navy-950);
|
||||
color: var(--white);
|
||||
border-color: var(--navy-950);
|
||||
}
|
||||
.btn-primary:hover { background: var(--navy-800); border-color: var(--navy-800); }
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--navy-700);
|
||||
border-color: var(--slate-300);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--slate-100); border-color: var(--navy-600); color: var(--navy-900); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--navy-800);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--navy-700); }
|
||||
|
||||
.btn-sm { padding: 9px 18px; font-size: 0.85rem; }
|
||||
.btn-block { display: block; width: 100%; }
|
||||
|
||||
/* mobile nav toggle (css-only) */
|
||||
.nav-toggle { display: none; }
|
||||
.nav-toggle-label {
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
.nav-toggle-label span,
|
||||
.nav-toggle-label span::before,
|
||||
.nav-toggle-label span::after {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--navy-900);
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.nav-toggle-label span::before,
|
||||
.nav-toggle-label span::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
.nav-toggle-label span::before { top: -7px; }
|
||||
.nav-toggle-label span::after { top: 7px; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
background: linear-gradient(180deg, var(--navy-950) 0%, var(--navy-900) 65%, var(--navy-800) 100%);
|
||||
color: var(--white);
|
||||
padding: 96px 0 84px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -10%;
|
||||
top: -20%;
|
||||
width: 560px;
|
||||
height: 560px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 4%;
|
||||
bottom: -30%;
|
||||
width: 380px;
|
||||
height: 380px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.hero .container { position: relative; z-index: 2; }
|
||||
|
||||
.hero-inner {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.hero .eyebrow { color: var(--slate-300); }
|
||||
|
||||
.hero h1 {
|
||||
color: var(--white);
|
||||
font-size: 2.9rem;
|
||||
line-height: 1.18;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.hero .subhead {
|
||||
font-size: 1.15rem;
|
||||
color: var(--slate-200);
|
||||
max-width: 560px;
|
||||
margin-bottom: 38px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hero-ctas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.hero .btn-primary {
|
||||
background: var(--white);
|
||||
color: var(--navy-950);
|
||||
border-color: var(--white);
|
||||
}
|
||||
.hero .btn-primary:hover { background: var(--slate-200); border-color: var(--slate-200); }
|
||||
|
||||
.hero .btn-text {
|
||||
display: inline-block;
|
||||
padding: 12px 4px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: var(--slate-200);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.3);
|
||||
border-radius: 0;
|
||||
}
|
||||
.hero .btn-text:hover { color: var(--white); border-bottom-color: var(--white); }
|
||||
|
||||
.hero-trust-row {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid rgba(255,255,255,0.14);
|
||||
}
|
||||
|
||||
.hero-trust-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--slate-300);
|
||||
}
|
||||
|
||||
.hero-trust-item .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- Section shell ---------- */
|
||||
section { padding: 88px 0; }
|
||||
.section-head {
|
||||
max-width: 660px;
|
||||
margin: 0 auto 56px;
|
||||
text-align: center;
|
||||
}
|
||||
.section-head h2 { font-size: 2.1rem; }
|
||||
.section-head p { color: var(--slate-500); font-size: 1.05rem; }
|
||||
|
||||
.bg-alt { background: var(--paper-alt); }
|
||||
.bg-navy { background: var(--navy-950); color: var(--slate-200); }
|
||||
.bg-navy h2, .bg-navy h3, .bg-navy h4 { color: var(--white); }
|
||||
|
||||
/* ---------- Who we serve ---------- */
|
||||
.audience-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.audience-card {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--line);
|
||||
border-top: 3px solid var(--navy-900);
|
||||
border-radius: var(--radius);
|
||||
padding: 44px 40px;
|
||||
}
|
||||
|
||||
.audience-card.debtor-card { border-top-color: var(--gold); }
|
||||
|
||||
.audience-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 1.5px solid var(--navy-800);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 22px;
|
||||
color: var(--navy-800);
|
||||
}
|
||||
.debtor-card .audience-icon { border-color: var(--gold); color: var(--gold); }
|
||||
|
||||
.audience-card h3 { font-size: 1.4rem; margin-bottom: 12px; }
|
||||
.audience-card .tag {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--slate-500);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.audience-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 28px 0;
|
||||
}
|
||||
.audience-card li {
|
||||
padding: 9px 0 9px 26px;
|
||||
position: relative;
|
||||
color: var(--navy-800);
|
||||
font-size: 0.96rem;
|
||||
border-bottom: 1px solid var(--slate-100);
|
||||
}
|
||||
.audience-card li:last-child { border-bottom: none; }
|
||||
.audience-card li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 17px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1.5px solid var(--navy-700);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.debtor-card li::before { border-color: var(--gold); }
|
||||
|
||||
/* ---------- How it works ---------- */
|
||||
.steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step {
|
||||
padding: 0 26px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step:not(:first-child)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 26px;
|
||||
width: 1px;
|
||||
height: calc(100% - 40px);
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
font-family: 'Source Serif 4', serif;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--slate-300);
|
||||
margin-bottom: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.step h4 { font-size: 1.08rem; margin-bottom: 10px; }
|
||||
.step p { color: var(--slate-500); font-size: 0.93rem; margin-bottom: 0; }
|
||||
|
||||
/* ---------- Compliance section ---------- */
|
||||
.compliance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 64px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.compliance-copy .eyebrow { color: var(--gold); }
|
||||
.compliance-copy h2 { font-size: 2.1rem; margin-bottom: 20px; }
|
||||
.compliance-copy p { color: var(--slate-300); font-size: 1.02rem; }
|
||||
|
||||
.compliance-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.compliance-item {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.compliance-item:first-child { padding-top: 0; }
|
||||
|
||||
.compliance-badge {
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Source Serif 4', serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
color: var(--white);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.compliance-item h4 {
|
||||
font-size: 1.02rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.compliance-item p {
|
||||
color: var(--slate-400);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---------- CTA band ---------- */
|
||||
.cta-band {
|
||||
background: var(--paper-alt);
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.cta-band-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.cta-band-col {
|
||||
padding: 56px 48px;
|
||||
}
|
||||
.cta-band-col:first-child {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.cta-band-col .eyebrow { margin-bottom: 10px; }
|
||||
.cta-band-col h3 { font-size: 1.5rem; margin-bottom: 10px; }
|
||||
.cta-band-col p { color: var(--slate-500); margin-bottom: 22px; font-size: 0.96rem; }
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
footer {
|
||||
background: var(--navy-950);
|
||||
color: var(--slate-400);
|
||||
padding: 64px 0 28px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr 1fr;
|
||||
gap: 40px;
|
||||
padding-bottom: 44px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.footer-brand .brand-text { color: var(--white); }
|
||||
.footer-brand p {
|
||||
color: var(--slate-400);
|
||||
margin-top: 16px;
|
||||
max-width: 320px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.footer-col h5 {
|
||||
color: var(--white);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin-bottom: 18px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.footer-col ul { list-style: none; padding: 0; margin: 0; }
|
||||
.footer-col li { margin-bottom: 12px; }
|
||||
.footer-col a:hover { color: var(--white); }
|
||||
|
||||
.footer-bottom {
|
||||
padding-top: 28px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-legal {
|
||||
font-size: 0.8rem;
|
||||
color: var(--slate-500);
|
||||
max-width: 720px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.footer-legal p { margin-bottom: 10px; }
|
||||
|
||||
.footer-links-inline {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.footer-links-inline a:hover { color: var(--white); }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 1100px) {
|
||||
.nav-toggle-label { display: block; }
|
||||
nav.primary-nav {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--white);
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease;
|
||||
}
|
||||
.nav-toggle:checked ~ nav.primary-nav { max-height: 320px; }
|
||||
nav.primary-nav a.nav-link {
|
||||
width: 100%;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px solid var(--slate-100);
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.audience-grid { grid-template-columns: 1fr; }
|
||||
.steps { grid-template-columns: 1fr 1fr; row-gap: 40px; }
|
||||
.step:nth-child(3)::before, .step:nth-child(1)::before { display: none; }
|
||||
.compliance-grid { grid-template-columns: 1fr; gap: 40px; }
|
||||
.cta-band-grid { grid-template-columns: 1fr; }
|
||||
.cta-band-col:first-child { border-right: none; border-bottom: 1px solid var(--line); }
|
||||
.footer-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.top-strip .container { flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||
.nav-toggle-label { display: block; }
|
||||
nav.primary-nav {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--white);
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease;
|
||||
}
|
||||
.nav-toggle:checked ~ nav.primary-nav { max-height: 320px; }
|
||||
nav.primary-nav a.nav-link {
|
||||
width: 100%;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px solid var(--slate-100);
|
||||
border-left: none;
|
||||
}
|
||||
.header-ctas .btn-secondary { display: none; }
|
||||
.hero { padding: 64px 0 56px; }
|
||||
.hero h1 { font-size: 2.1rem; }
|
||||
.hero-trust-row { flex-direction: column; gap: 12px; }
|
||||
section { padding: 60px 0; }
|
||||
.steps { grid-template-columns: 1fr; }
|
||||
.step::before { display: none !important; }
|
||||
.footer-grid { grid-template-columns: 1fr; gap: 32px; }
|
||||
.footer-bottom { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top compliance strip -->
|
||||
<div class="top-strip">
|
||||
<div class="container">
|
||||
<div>
|
||||
Licensed Texas debt recovery firm operating under FDCPA and TDCPA guidelines
|
||||
</div>
|
||||
<div>
|
||||
<a href="#contact">Contact us</a>
|
||||
<span class="divider">|</span>
|
||||
<a href="https://pay.debtrecoveryexperts.com">Make a payment</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="main">
|
||||
<div class="container">
|
||||
<div class="nav-wrap">
|
||||
<a href="#top" class="brand">
|
||||
<span class="brand-mark">DRE</span>
|
||||
<span class="brand-text">Debt Recovery Experts</span>
|
||||
</a>
|
||||
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<label for="nav-toggle" class="nav-toggle-label" aria-label="Toggle navigation"><span></span></label>
|
||||
|
||||
<nav class="primary-nav">
|
||||
<a class="nav-link" href="#services">Services</a>
|
||||
<a class="nav-link" href="#how-it-works">How It Works</a>
|
||||
<a class="nav-link" href="#creditors">For Creditors</a>
|
||||
<a class="nav-link" href="#debtors">For Debtors</a>
|
||||
<a class="nav-link" href="#contact">Contact</a>
|
||||
</nav>
|
||||
|
||||
<div class="header-ctas">
|
||||
<a href="https://my.debtrecoveryexperts.com" class="btn btn-secondary btn-sm">Client Login</a>
|
||||
<a href="https://my.debtrecoveryexperts.com/start" class="btn btn-primary btn-sm">Submit a Claim</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero" id="top">
|
||||
<div class="container">
|
||||
<div class="hero-inner">
|
||||
<span class="eyebrow">Texas Debt Recovery, Handled Properly</span>
|
||||
<h1>Recover what you are owed, without cutting corners on compliance.</h1>
|
||||
<p class="subhead">
|
||||
Debt Recovery Experts represents Texas businesses in the recovery of commercial and consumer debt,
|
||||
under the same regulatory discipline we would want applied to our own accounts. Deliberate process,
|
||||
documented compliance, and clear communication at every stage.
|
||||
</p>
|
||||
<div class="hero-ctas">
|
||||
<a href="https://my.debtrecoveryexperts.com/start" class="btn btn-primary">Submit a Claim</a>
|
||||
<a href="https://pay.debtrecoveryexperts.com" class="btn-text">Make a Payment</a>
|
||||
</div>
|
||||
<div class="hero-trust-row">
|
||||
<div class="hero-trust-item"><span class="dot"></span> FDCPA compliant procedures</div>
|
||||
<div class="hero-trust-item"><span class="dot"></span> TDCPA compliant, Texas licensed</div>
|
||||
<div class="hero-trust-item"><span class="dot"></span> Secure handling of account data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Who we serve -->
|
||||
<section id="services">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">Who We Serve</span>
|
||||
<h2>Two sides of every account, one standard of conduct</h2>
|
||||
<p>Whether you are owed money or working through a balance you owe, our process is built to be clear,
|
||||
fair, and fully documented.</p>
|
||||
</div>
|
||||
|
||||
<div class="audience-grid">
|
||||
<div class="audience-card" id="creditors">
|
||||
<div class="audience-icon">C</div>
|
||||
<span class="tag">For Creditors</span>
|
||||
<h3>Place an account for recovery</h3>
|
||||
<p>For businesses seeking to recover outstanding commercial or consumer receivables through a
|
||||
structured, compliant process.</p>
|
||||
<ul>
|
||||
<li>Submit claims through a secure intake portal</li>
|
||||
<li>Track case status and correspondence in one place</li>
|
||||
<li>Work with a firm that documents every contact attempt</li>
|
||||
<li>Portfolio placement for single accounts or batches</li>
|
||||
</ul>
|
||||
<a href="https://my.debtrecoveryexperts.com/start" class="btn btn-primary btn-block">Submit a Claim</a>
|
||||
</div>
|
||||
|
||||
<div class="audience-card debtor-card" id="debtors">
|
||||
<div class="audience-icon">D</div>
|
||||
<span class="tag">For Debtors</span>
|
||||
<h3>Resolve an outstanding balance</h3>
|
||||
<p>If you have received correspondence from our office regarding an account, you have options and
|
||||
you have rights under federal and state law.</p>
|
||||
<ul>
|
||||
<li>Review your account and verify the balance owed</li>
|
||||
<li>Make a secure payment or set up arrangements</li>
|
||||
<li>Understand your rights under the FDCPA and TDCPA</li>
|
||||
<li>Reach a representative directly with questions</li>
|
||||
</ul>
|
||||
<a href="https://pay.debtrecoveryexperts.com" class="btn btn-secondary btn-block">Make a Payment</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How it works -->
|
||||
<section class="bg-alt" id="how-it-works">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<span class="eyebrow">How It Works</span>
|
||||
<h2>A deliberate, documented process</h2>
|
||||
<p>From claim submission to resolution, each step is tracked and each communication is recorded.</p>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">01</div>
|
||||
<h4>Submit the claim</h4>
|
||||
<p>A creditor submits account details through the secure intake portal, including balance and
|
||||
supporting documentation.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">02</div>
|
||||
<h4>Review and verification</h4>
|
||||
<p>Our team reviews the account for completeness and confirms it meets requirements before
|
||||
placement.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">03</div>
|
||||
<h4>Compliant contact and negotiation</h4>
|
||||
<p>The debtor is contacted in accordance with FDCPA and TDCPA requirements to verify and resolve
|
||||
the balance.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">04</div>
|
||||
<h4>Resolution and reporting</h4>
|
||||
<p>Payments are processed and the creditor receives status updates through their client
|
||||
dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Compliance -->
|
||||
<section class="bg-navy">
|
||||
<div class="container">
|
||||
<div class="compliance-grid">
|
||||
<div class="compliance-copy">
|
||||
<span class="eyebrow">Compliance First</span>
|
||||
<h2>Recovery conducted within the letter of the law</h2>
|
||||
<p>
|
||||
Debt recovery is a regulated activity, and we treat it that way. Our procedures are built around
|
||||
the Fair Debt Collection Practices Act and the Texas Debt Collection Act, with internal controls
|
||||
designed to keep every contact, disclosure, and payment within bounds.
|
||||
</p>
|
||||
<p>
|
||||
This is not a marketing claim, it is how the office is structured to operate day to day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="compliance-list">
|
||||
<div class="compliance-item">
|
||||
<div class="compliance-badge">FDCPA</div>
|
||||
<div>
|
||||
<h4>Federal Fair Debt Collection Practices Act</h4>
|
||||
<p>Governs communication frequency, disclosure requirements, and prohibited practices in
|
||||
consumer debt collection.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compliance-item">
|
||||
<div class="compliance-badge">TDCPA</div>
|
||||
<div>
|
||||
<h4>Texas Debt Collection Act</h4>
|
||||
<p>State-level requirements for licensing, bonding, and conduct for debt collectors operating
|
||||
in Texas.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compliance-item">
|
||||
<div class="compliance-badge">SEC</div>
|
||||
<div>
|
||||
<h4>Secure data handling</h4>
|
||||
<p>Account and payment information is handled through encrypted channels with access limited
|
||||
to authorized personnel.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA band -->
|
||||
<section class="cta-band" style="padding: 0;">
|
||||
<div class="container" style="padding: 0;">
|
||||
<div class="cta-band-grid">
|
||||
<div class="cta-band-col">
|
||||
<span class="eyebrow">New Creditor</span>
|
||||
<h3>Ready to place an account?</h3>
|
||||
<p>Submit claim details through our secure intake portal and a member of our team will confirm
|
||||
receipt.</p>
|
||||
<a href="https://my.debtrecoveryexperts.com/start" class="btn btn-primary">Submit a Claim</a>
|
||||
</div>
|
||||
<div class="cta-band-col">
|
||||
<span class="eyebrow">Existing Client</span>
|
||||
<h3>Track your recovery</h3>
|
||||
<p>Log in to your client dashboard to review case status, correspondence, and reporting on
|
||||
placed accounts.</p>
|
||||
<a href="https://my.debtrecoveryexperts.com" class="btn btn-secondary">Client Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer id="contact">
|
||||
<div class="container">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-col footer-brand">
|
||||
<a href="#top" class="brand">
|
||||
<span class="brand-mark" style="border-color: var(--white); color: var(--white);">DRE</span>
|
||||
<span class="brand-text">Debt Recovery Experts</span>
|
||||
</a>
|
||||
<p>A Texas-based debt recovery firm serving creditors and debtors under FDCPA and TDCPA
|
||||
compliance standards.</p>
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h5>Firm</h5>
|
||||
<ul>
|
||||
<li><a href="#services">Services</a></li>
|
||||
<li><a href="#how-it-works">How It Works</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h5>Portals</h5>
|
||||
<ul>
|
||||
<li><a href="https://my.debtrecoveryexperts.com/start">Submit a Claim</a></li>
|
||||
<li><a href="https://my.debtrecoveryexperts.com">Client Login</a></li>
|
||||
<li><a href="https://pay.debtrecoveryexperts.com">Make a Payment</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h5>Contact</h5>
|
||||
<ul>
|
||||
<li>Serving all of Texas</li>
|
||||
<li>Licensed in the State of Texas</li>
|
||||
<li><a href="https://my.debtrecoveryexperts.com/start">Submit a Claim</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<div class="footer-legal">
|
||||
<p>
|
||||
Debt Recovery Experts is a debt collection firm. This communication is an attempt to collect a
|
||||
debt and any information obtained will be used for that purpose, where applicable. You have
|
||||
rights under the federal Fair Debt Collection Practices Act and the Texas Debt Collection Act,
|
||||
including the right to dispute a debt and request verification.
|
||||
</p>
|
||||
<p>Licensed in the State of Texas. Licensing, bonding, and registration details are available upon request.</p>
|
||||
</div>
|
||||
<div class="footer-links-inline">
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Use</a>
|
||||
<a href="/aup.html">Acceptable Use Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#16213a"/>
|
||||
<rect x="10" y="10" width="492" height="492" rx="102" fill="none" stroke="#9c7c3f" stroke-width="6"/>
|
||||
<text x="256" y="268" font-family="Georgia, 'Times New Roman', serif" font-size="180" font-weight="700" fill="#9c7c3f" text-anchor="middle" dominant-baseline="central" letter-spacing="4">DRE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 458 B |
+328
@@ -0,0 +1,328 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Acceptable Use Policy — Debt Recovery Experts</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/favicon-180.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--navy-950: #10192b;
|
||||
--navy-900: #16213a;
|
||||
--navy-800: #1f2d4a;
|
||||
--navy-700: #2b3d5f;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #8593a8;
|
||||
--slate-200: #d8dee7;
|
||||
--slate-100: #eceff4;
|
||||
--paper: #faf9f6;
|
||||
--line: #dcdfe6;
|
||||
--gold: #9c7c3f;
|
||||
--white: #ffffff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
color: var(--navy-900);
|
||||
background: var(--paper);
|
||||
line-height: 1.7;
|
||||
}
|
||||
a { color: var(--gold); }
|
||||
.topbar {
|
||||
background: var(--navy-900);
|
||||
border-bottom: 3px solid var(--gold);
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.topbar .brand { display: flex; align-items: center; gap: 12px; }
|
||||
.topbar .brand .mark {
|
||||
width: 34px; height: 34px; border: 1px solid var(--gold);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--gold); font-family: 'Source Serif 4', serif; font-weight: 600;
|
||||
font-size: 15px; border-radius: 3px;
|
||||
}
|
||||
.topbar .brand .name { color: var(--white); font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 17px; }
|
||||
.topbar a.back {
|
||||
color: var(--slate-200); text-decoration: none; font-size: 13px;
|
||||
border: 1px solid var(--navy-700); padding: 6px 12px; border-radius: 3px;
|
||||
}
|
||||
.topbar a.back:hover { border-color: var(--gold); color: var(--white); }
|
||||
.wrap { max-width: 820px; margin: 0 auto; padding: 40px 24px 64px; }
|
||||
h1 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 30px;
|
||||
color: var(--navy-900); margin: 0 0 6px; line-height: 1.25;
|
||||
}
|
||||
.effective { color: var(--slate-500); font-size: 13px; margin: 0 0 28px; }
|
||||
.content h2 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 21px;
|
||||
color: var(--navy-900); margin: 34px 0 10px; padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.content h3 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 17px;
|
||||
color: var(--navy-800); margin: 24px 0 8px;
|
||||
}
|
||||
.content h4 { font-size: 15px; color: var(--navy-800); margin: 20px 0 6px; }
|
||||
.content p { margin: 0 0 14px; }
|
||||
.content ul, .content ol { margin: 0 0 16px; padding-left: 24px; }
|
||||
.content li { margin: 0 0 6px; }
|
||||
.content table { border-collapse: collapse; width: 100%; margin: 0 0 18px; font-size: 13.5px; }
|
||||
.content th, .content td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
|
||||
.content th { background: var(--slate-100); color: var(--navy-800); font-weight: 600; }
|
||||
.content hr { border: none; border-top: 1px solid var(--line); margin: 30px 0; }
|
||||
.content strong { color: var(--navy-900); }
|
||||
.footer {
|
||||
background: var(--navy-950); color: var(--slate-400); font-size: 12.5px;
|
||||
padding: 32px 24px; margin-top: 40px;
|
||||
}
|
||||
.footer .inner { max-width: 820px; margin: 0 auto; }
|
||||
.footer .miranda { margin-bottom: 16px; line-height: 1.6; }
|
||||
.footer .links { display: flex; flex-wrap: wrap; gap: 18px; }
|
||||
.footer .links a { color: var(--slate-200); text-decoration: none; }
|
||||
.footer .links a:hover { color: var(--gold); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="mark">DRE</span>
|
||||
<span class="name">Debt Recovery Experts</span>
|
||||
</div>
|
||||
<a class="back" href="https://debtrecoveryexperts.com/">← Back to site</a>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Acceptable Use Policy</h1>
|
||||
<p class="effective">Effective date: August 22, 2026</p>
|
||||
<div class="content">
|
||||
<p><strong>Paste into WordPress page titled "Acceptable Use Policy" | Last Updated: July 25, 2026</strong></p>
|
||||
<hr />
|
||||
<p>This Acceptable Use Policy ("AUP") is incorporated into the DRE Terms of Use. It defines acceptable and prohibited conduct when using the DRE website (https://debtrecoveryexperts.com), client portal, payment portal, and all related services. Capitalized terms not defined here have the meanings given in the Terms of Use.</p>
|
||||
<p>Violations of this AUP are violations of the Terms of Use and may result in account suspension or permanent termination.</p>
|
||||
<hr />
|
||||
<h2>1. Purpose and Scope</h2>
|
||||
<p>This AUP applies to all DRE clients (account holders), anyone who accesses or interacts with the DRE platform, and any use of debtor or creditor data obtained through the platform. It covers claim submission, portal usage, communications with DRE, data handling, and all other interactions with the Services.</p>
|
||||
<p>The rules in this document exist to protect DRE, our clients, and the individuals we interact with during the debt recovery process. By using the Services, you agree to follow them.</p>
|
||||
<hr />
|
||||
<h2>2. Acceptable Use</h2>
|
||||
<h3>2.1 Permitted Uses</h3>
|
||||
<p>You may use the DRE platform to:</p>
|
||||
<ul>
|
||||
<li>Submit legitimate, substantiated commercial debt claims for recovery.</li>
|
||||
<li>Upload supporting documentation, including contracts, invoices, statements, and correspondence.</li>
|
||||
<li>Execute Limited Powers of Attorney through the online notarization workflow.</li>
|
||||
<li>Track claim status and view case details through your client dashboard.</li>
|
||||
<li>Receive disbursements for debts that have been successfully collected.</li>
|
||||
<li>Download closed-case documentation and records.</li>
|
||||
<li>Communicate with DRE staff regarding your active claims.</li>
|
||||
</ul>
|
||||
<h3>2.2 Account Security</h3>
|
||||
<p>You are responsible for maintaining the security of your account. You must:</p>
|
||||
<ul>
|
||||
<li>Keep your login credentials confidential and not share them with unauthorized individuals.</li>
|
||||
<li>Use a strong, unique password for your account and enable multi-factor authentication if offered.</li>
|
||||
<li>Log out after each session on shared or public devices.</li>
|
||||
<li>Report any suspected unauthorized access to DRE immediately.</li>
|
||||
</ul>
|
||||
<h3>2.3 Accurate Information</h3>
|
||||
<p>All information you submit — including claim details, debtor information, documentation, and your own account information — must be truthful, accurate, and complete. You must promptly update your information if circumstances change. Submitting claims under false pretenses or using another person's or entity's identity is strictly prohibited.</p>
|
||||
<hr />
|
||||
<h2>3. Prohibited Conduct</h2>
|
||||
<h3>3.1 Harassment and Abusive Behavior</h3>
|
||||
<p>The following conduct is strictly prohibited:</p>
|
||||
<ul>
|
||||
<li>Harassing, threatening, or intimidating DRE staff, agents, or contractors.</li>
|
||||
<li>Using abusive, profane, or discriminatory language in any communication with DRE.</li>
|
||||
<li>Repeatedly submitting frivolous, bad-faith, or vexatious claims.</li>
|
||||
<li>Using the platform to stalk, dox, or intimidate any person.</li>
|
||||
<li>Any conduct that could reasonably constitute harassment under Texas Penal Code § 42.07 or similar laws.</li>
|
||||
</ul>
|
||||
<h3>3.2 Illegal Collection Tactics</h3>
|
||||
<p>Even if DRE is not directly involved in the communication, you are strictly prohibited from using information obtained through the platform to engage in unlawful collection practices, including:</p>
|
||||
<ul>
|
||||
<li>Contacting debtors in violation of the Fair Debt Collection Practices Act (FDCPA) — for example, calling at unreasonable hours (before 8:00 a.m. or after 9:00 p.m. local time), contacting the debtor at work after being told not to, using false or misleading representations, or threatening legal action that is not actually intended.</li>
|
||||
<li>Violating the Texas Finance Code Chapter 392 — including threatening violence, using obscene or profane language, collecting unauthorized fees, or misrepresenting the character, extent, or amount of a debt.</li>
|
||||
<li>Violating the Telephone Consumer Protection Act (TCPA) — including using auto-dialed calls or pre-recorded messages to mobile phones without prior express consent.</li>
|
||||
<li>Impersonating an attorney, law enforcement officer, government official, or any person you are not.</li>
|
||||
<li>Threatening criminal prosecution, arrest, wage garnishment, or any legal action not authorized by law.</li>
|
||||
<li>Contacting the debtor's employer, family members, or neighbors about the debt, except as specifically permitted by law to locate the debtor.</li>
|
||||
<li>Publishing or threatening to publish information about a debtor or their debt (commonly known as "debt shaming").</li>
|
||||
<li>Using or threatening physical force against any person.</li>
|
||||
</ul>
|
||||
<h3>3.3 Fraudulent and Deceptive Conduct</h3>
|
||||
<p>The following is strictly prohibited:</p>
|
||||
<ul>
|
||||
<li>Submitting fabricated, forged, or altered documentation.</li>
|
||||
<li>Submitting claims for debts that have already been paid, settled, or discharged in bankruptcy.</li>
|
||||
<li>Submitting claims you know or have reason to know are false, inflated, or unsubstantiated.</li>
|
||||
<li>Creating multiple accounts to circumvent claim limits, prior suspensions, or fee structures.</li>
|
||||
<li>Misrepresenting the nature, amount, or legal status of any debt.</li>
|
||||
<li>Impersonating another person, business, or entity.</li>
|
||||
<li>Using stolen or synthetic identities.</li>
|
||||
</ul>
|
||||
<h3>3.4 Misuse of Debtor Data</h3>
|
||||
<p>Debtor information obtained through the DRE platform may be used solely for the specific debt recovery claim for which it was provided. You are strictly prohibited from:</p>
|
||||
<ul>
|
||||
<li>Using debtor information for any purpose unrelated to the specific claim.</li>
|
||||
<li>Selling, renting, trading, or otherwise transferring debtor data to any third party.</li>
|
||||
<li>Using debtor data for marketing, lead generation, competitive intelligence, or any commercial purpose other than the specific recovery claim.</li>
|
||||
<li>Retaining debtor data after the claim has been closed or your account has been terminated.</li>
|
||||
<li>Accessing debtor information for claims you are not authorized to manage.</li>
|
||||
<li>Cross-referencing or aggregating debtor data across unrelated claims.</li>
|
||||
</ul>
|
||||
<h3>3.5 Platform Abuse</h3>
|
||||
<p>You may not:</p>
|
||||
<ul>
|
||||
<li>Use bots, scripts, scrapers, or any automated means to submit claims or interact with the platform.</li>
|
||||
<li>Attempt to bypass rate limits, validation checks, or security controls.</li>
|
||||
<li>Reverse engineer, decompile, or attempt to extract DRE's proprietary AI models, algorithms, or tools.</li>
|
||||
<li>Overload, flood, or conduct denial-of-service attacks against the platform.</li>
|
||||
<li>Probe, scan, or test platform vulnerabilities without DRE's explicit written authorization.</li>
|
||||
<li>Interfere with other clients' access to or use of the Services.</li>
|
||||
<li>Transmit malware, viruses, worms, or any malicious code through the platform.</li>
|
||||
</ul>
|
||||
<h3>3.6 Credit Bureau and FCRA Misuse</h3>
|
||||
<p>DRE's Services are primarily for B2B commercial debt recovery. You are strictly prohibited from:</p>
|
||||
<ul>
|
||||
<li>Using DRE platform data to report commercial debts to consumer credit bureaus (Equifax, Experian, TransUnion) unless you hold a valid, signed personal guarantee from the debtor individual, you are in full compliance with FCRA furnisher duties under 15 U.S.C. § 1681s-2, and you have made pre-reporting contact with the debtor as required by the FDCPA and CFPB regulations.</li>
|
||||
<li>Using DRE skip-tracing or debtor research data to obtain consumer credit reports without a permissible purpose under the FCRA.</li>
|
||||
<li>Reporting debts to credit bureaus without a reasonable basis to believe the information is accurate and complete.</li>
|
||||
<li>Ignoring debtor disputes or failing to conduct a reasonable investigation as required by FCRA § 1681s-2(b).</li>
|
||||
</ul>
|
||||
<h3>3.7 Prohibited Communications</h3>
|
||||
<p>You may not:</p>
|
||||
<ul>
|
||||
<li>Send mass, unsolicited commercial messages (spam) to debtors.</li>
|
||||
<li>Communicate with debtors at times or places you know, or have reason to know, are inconvenient (such as before 8:00 a.m. or after 9:00 p.m. in the debtor's local time zone), unless the debtor has agreed otherwise.</li>
|
||||
<li>Continue contacting a debtor who has requested in writing that communications cease, other than to notify the debtor of specific legally permitted actions (e.g., that collection efforts are terminated, or that a specific remedy will be pursued).</li>
|
||||
<li>Contact a debtor you know to be represented by an attorney regarding the debt — you must direct communications to the attorney instead.</li>
|
||||
<li>Use DRE templates, letterhead, or branding in any communication not authorized or reviewed by DRE.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2>4. Compliance Expectations</h2>
|
||||
<h3>4.1 Regulatory Framework</h3>
|
||||
<p>All users of DRE Services are expected to be familiar with and comply with the following laws and regulations:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Regulation</th>
|
||||
<th>Key Requirements</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>FDCPA (15 U.S.C. § 1692)</td>
|
||||
<td>Prohibits harassment, false statements, and unfair practices in debt collection</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Texas Finance Code Ch. 392</td>
|
||||
<td>Texas state debt collection rules, including criminal penalties for violations</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>TCPA (47 U.S.C. § 227)</td>
|
||||
<td>Requires prior express consent for auto-dialed calls and texts to mobile phones</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>FCRA (15 U.S.C. § 1681)</td>
|
||||
<td>Governs accuracy and dispute handling when reporting to credit bureaus</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Texas Data Privacy Act</td>
|
||||
<td>Governs data protection and breach notification for Texas residents</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>GLBA Safeguards Rule</td>
|
||||
<td>Requires administrative, technical, and physical safeguards for financial data</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>4.2 Best Practices</h3>
|
||||
<p>Although the FDCPA is primarily directed at consumer debt collection, DRE applies FDCPA-level best practices to all claims as a matter of policy. This means:</p>
|
||||
<ul>
|
||||
<li>All communications are professional and respectful.</li>
|
||||
<li>Written debt validation is provided upon debtor request.</li>
|
||||
<li>Communications cease when requested, with legally permitted exceptions.</li>
|
||||
<li>No false, misleading, or deceptive representations are made.</li>
|
||||
<li>Mini-Miranda disclosures are included on all collection communications.</li>
|
||||
</ul>
|
||||
<p>Clients are expected to uphold these same standards in any interaction with debtors.</p>
|
||||
<h3>4.3 Reporting Violations</h3>
|
||||
<p>If you believe a DRE user is violating this AUP, report it to DRE immediately using the contact information in Section 7. If you are a debtor and believe DRE or one of its clients has violated debt collection laws, you may contact DRE directly or file a complaint with the Federal Trade Commission, Consumer Financial Protection Bureau, or Texas Attorney General.</p>
|
||||
<hr />
|
||||
<h2>5. Enforcement</h2>
|
||||
<h3>5.1 Investigation</h3>
|
||||
<p>DRE reserves the right to investigate any suspected violation of this AUP. Investigations may include reviewing account activity, submitted claims, communications, and uploaded documents. DRE may cooperate with law enforcement or regulatory authorities in investigations and may temporarily suspend account access during the investigation period.</p>
|
||||
<h3>5.2 Consequences</h3>
|
||||
<p>Enforcement actions are calibrated to the severity and frequency of the violation:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Level</th>
|
||||
<th>Examples</th>
|
||||
<th>Consequences</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Minor / First Offense</td>
|
||||
<td>Incomplete claim information, minor portal misuse</td>
|
||||
<td>Written warning with a 7-day corrective period</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Moderate</td>
|
||||
<td>Repeat minor violations, unauthorized debtor contact, abrasive communication toward DRE staff</td>
|
||||
<td>30-day account suspension; mandatory compliance review before reinstatement</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Severe</td>
|
||||
<td>Fraud, harassment, illegal collection tactics, debtor data misuse, platform abuse</td>
|
||||
<td>Permanent account termination; forfeiture of pending claims (subject to legal review); possible referral to law enforcement or regulatory authorities</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>5.3 Appeals</h3>
|
||||
<p>If your account is suspended or terminated under this AUP, you may appeal the decision by submitting a written explanation and any mitigating evidence to DRE within 14 calendar days of the enforcement action. DRE will review the appeal and issue a decision within 30 calendar days. The decision on appeal is final. During the appeal period, the suspension or termination remains in effect unless DRE determines otherwise.</p>
|
||||
<h3>5.4 No Waiver</h3>
|
||||
<p>DRE's failure to enforce any provision of this AUP — whether in a specific instance or over time — does not constitute a waiver of our right to enforce it in the future. We may enforce violations retroactively if they are discovered after the fact.</p>
|
||||
<hr />
|
||||
<h2>6. Modifications</h2>
|
||||
<p>DRE may update this AUP at any time to reflect changes in applicable laws, industry best practices, or platform capabilities. Material changes will be communicated via email (if we have your email on file) and/or by a notice on the Site. Your continued use of the Services after changes are posted constitutes your acceptance of the updated AUP.</p>
|
||||
<hr />
|
||||
<h2>7. Contact</h2>
|
||||
<p>To report a violation of this AUP:</p>
|
||||
<ul>
|
||||
<li><strong>Email:</strong> support@debtrecoveryexperts.com</li>
|
||||
<li><strong>Phone:</strong> [Phone]</li>
|
||||
</ul>
|
||||
<p>For general questions about this AUP, contact DRE through the client portal or at the contact information above.</p>
|
||||
<p>If you are a debtor with a complaint about collection conduct, please reference your claim number (if known) when contacting us.</p>
|
||||
<hr />
|
||||
<blockquote>
|
||||
<p><strong>⚠️ ATTORNEY REVIEW REQUIRED:</strong> This document must be reviewed by a licensed Texas attorney before publication. Key review items: FDCPA applicability to B2B commercial debt collections and whether DRE's "best practices" approach creates unintended legal obligations, FCRA furnisher obligations, TCPA SMS consent language alignment, enforcement escalation framework, and cross-reference consistency with the Terms of Use and Privacy Policy.</p>
|
||||
</blockquote>
|
||||
<hr />
|
||||
<p>© 2026 Debt Recovery Experts. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="inner">
|
||||
<p class="miranda">Debt Recovery Experts is a debt collection firm. This communication is an attempt to collect a debt and any information obtained will be used for that purpose, where applicable. You have rights under the federal Fair Debt Collection Practices Act and the Texas Debt Collection Act, including the right to dispute a debt and request verification.</p>
|
||||
<div class="links">
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Use</a>
|
||||
<a href="/aup.html">Acceptable Use Policy</a>
|
||||
<a href="/sms-terms.html">SMS & 10DLC</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,520 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Privacy Policy — Debt Recovery Experts</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/favicon-180.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--navy-950: #10192b;
|
||||
--navy-900: #16213a;
|
||||
--navy-800: #1f2d4a;
|
||||
--navy-700: #2b3d5f;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #8593a8;
|
||||
--slate-200: #d8dee7;
|
||||
--slate-100: #eceff4;
|
||||
--paper: #faf9f6;
|
||||
--line: #dcdfe6;
|
||||
--gold: #9c7c3f;
|
||||
--white: #ffffff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
color: var(--navy-900);
|
||||
background: var(--paper);
|
||||
line-height: 1.7;
|
||||
}
|
||||
a { color: var(--gold); }
|
||||
.topbar {
|
||||
background: var(--navy-900);
|
||||
border-bottom: 3px solid var(--gold);
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.topbar .brand { display: flex; align-items: center; gap: 12px; }
|
||||
.topbar .brand .mark {
|
||||
width: 34px; height: 34px; border: 1px solid var(--gold);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--gold); font-family: 'Source Serif 4', serif; font-weight: 600;
|
||||
font-size: 15px; border-radius: 3px;
|
||||
}
|
||||
.topbar .brand .name { color: var(--white); font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 17px; }
|
||||
.topbar a.back {
|
||||
color: var(--slate-200); text-decoration: none; font-size: 13px;
|
||||
border: 1px solid var(--navy-700); padding: 6px 12px; border-radius: 3px;
|
||||
}
|
||||
.topbar a.back:hover { border-color: var(--gold); color: var(--white); }
|
||||
.wrap { max-width: 820px; margin: 0 auto; padding: 40px 24px 64px; }
|
||||
h1 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 30px;
|
||||
color: var(--navy-900); margin: 0 0 6px; line-height: 1.25;
|
||||
}
|
||||
.effective { color: var(--slate-500); font-size: 13px; margin: 0 0 28px; }
|
||||
.content h2 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 21px;
|
||||
color: var(--navy-900); margin: 34px 0 10px; padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.content h3 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 17px;
|
||||
color: var(--navy-800); margin: 24px 0 8px;
|
||||
}
|
||||
.content h4 { font-size: 15px; color: var(--navy-800); margin: 20px 0 6px; }
|
||||
.content p { margin: 0 0 14px; }
|
||||
.content ul, .content ol { margin: 0 0 16px; padding-left: 24px; }
|
||||
.content li { margin: 0 0 6px; }
|
||||
.content table { border-collapse: collapse; width: 100%; margin: 0 0 18px; font-size: 13.5px; }
|
||||
.content th, .content td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
|
||||
.content th { background: var(--slate-100); color: var(--navy-800); font-weight: 600; }
|
||||
.content hr { border: none; border-top: 1px solid var(--line); margin: 30px 0; }
|
||||
.content strong { color: var(--navy-900); }
|
||||
.footer {
|
||||
background: var(--navy-950); color: var(--slate-400); font-size: 12.5px;
|
||||
padding: 32px 24px; margin-top: 40px;
|
||||
}
|
||||
.footer .inner { max-width: 820px; margin: 0 auto; }
|
||||
.footer .miranda { margin-bottom: 16px; line-height: 1.6; }
|
||||
.footer .links { display: flex; flex-wrap: wrap; gap: 18px; }
|
||||
.footer .links a { color: var(--slate-200); text-decoration: none; }
|
||||
.footer .links a:hover { color: var(--gold); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="mark">DRE</span>
|
||||
<span class="name">Debt Recovery Experts</span>
|
||||
</div>
|
||||
<a class="back" href="https://debtrecoveryexperts.com/">← Back to site</a>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="effective">Effective date: August 22, 2026</p>
|
||||
<div class="content">
|
||||
<p><strong>Paste into WordPress page titled "Privacy Policy" | Last Updated: July 25, 2026</strong></p>
|
||||
<hr />
|
||||
<h2>1. Introduction</h2>
|
||||
<p>Debt Recovery Experts ("DRE," "we," "us," or "our") is committed to protecting the privacy and security of your personal information. This Privacy Policy explains how we collect, use, share, store, and protect information when you:</p>
|
||||
<ul>
|
||||
<li>Visit our website at https://debtrecoveryexperts.com (the "Site")</li>
|
||||
<li>Create an account or submit a debt recovery claim through our secure portal</li>
|
||||
<li>Use our payment portal or client dashboard</li>
|
||||
<li>Receive communications from us, including SMS messages</li>
|
||||
</ul>
|
||||
<p>This Privacy Policy applies to clients (businesses and individuals submitting claims), website visitors, and the individuals whose information we receive in the course of debt recovery (debtors). It is incorporated into the DRE Terms of Use.</p>
|
||||
<p>By using the Site or Services, you acknowledge that you have read and understood this Privacy Policy. If you do not agree with any part of it, please do not use the Site or Services.</p>
|
||||
<hr />
|
||||
<h2>2. Information We Collect</h2>
|
||||
<h3>2.1 Information You Provide Directly</h3>
|
||||
<p><strong>Account and Identity Information:</strong>
|
||||
- Full name (individual or authorized business representative)
|
||||
- Business name, Employer Identification Number (EIN), and entity type (for business clients)
|
||||
- Email address, phone number, and physical mailing address
|
||||
- Social Security Number (SSN) or Individual Taxpayer Identification Number (ITIN), for tax and IRS compliance on disbursements
|
||||
- Username and password (hashed; DRE never stores or views your plaintext password)</p>
|
||||
<p><strong>Claim and Financial Information:</strong>
|
||||
- Debtor details: legal name, business name, physical address, phone number, email address
|
||||
- Debt details: amount owed, date incurred, nature of the debt, and prior collection history
|
||||
- Supporting documentation: contracts, invoices, account statements, correspondence, payment records, and proof of delivery or completion
|
||||
- Bank account and routing numbers, for ACH disbursement of collected funds via Stripe Connect</p>
|
||||
<p><strong>Notarization Information (Remote Online Notarization):</strong>
|
||||
- Government-issued photo identification (driver's license, passport, or state ID)
|
||||
- Video and audio recording of the online notarization session
|
||||
- Digital signature and timestamp records
|
||||
- Identity verification results from the RON platform</p>
|
||||
<p><strong>Communications:</strong>
|
||||
- Emails, support requests, and messages sent through the client portal
|
||||
- SMS opt-in and opt-out records (see Section 3.5)
|
||||
- Phone call logs and notes, where applicable</p>
|
||||
<h3>2.2 Information Collected Automatically</h3>
|
||||
<p>When you visit the Site or use the portal, we automatically collect:</p>
|
||||
<ul>
|
||||
<li><strong>Log Data:</strong> IP address, browser type and version, operating system, referring URL, pages viewed, and timestamps.</li>
|
||||
<li><strong>Device Information:</strong> Device type, screen resolution, and browser settings.</li>
|
||||
<li><strong>Usage Data:</strong> Features accessed, actions taken, session duration, and click patterns.</li>
|
||||
<li><strong>Security Signals:</strong> Data processed by Cloudflare Turnstile for bot detection (IP address, user agent, device fingerprint), handled per Cloudflare's privacy addendum.</li>
|
||||
</ul>
|
||||
<h3>2.3 Information from Third Parties</h3>
|
||||
<p>We may receive information about you from:</p>
|
||||
<ul>
|
||||
<li><strong>Proof.com (or equivalent RON platform):</strong> Identity verification results and notarization confirmations.</li>
|
||||
<li><strong>Stripe Connect:</strong> Transaction confirmations and payment status. DRE does not store full payment card numbers.</li>
|
||||
<li><strong>Public Records and Skip Tracing:</strong> Debtor location information from lawful sources, including court records, Secretary of State business registries, and public databases.</li>
|
||||
<li><strong>Partner Law Firms:</strong> Case status updates for claims referred to litigation (Tier 4).</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2>3. How We Use Information</h2>
|
||||
<h3>3.1 Core Service Delivery</h3>
|
||||
<p>We use your information to:</p>
|
||||
<ul>
|
||||
<li>Process, evaluate, and manage debt recovery claims (including AI-assisted internal review)</li>
|
||||
<li>Communicate with clients: claim status updates, document requests, fee disclosures, disbursement confirmations</li>
|
||||
<li>Execute Limited Powers of Attorney via Remote Online Notarization</li>
|
||||
<li>Conduct debt collection activities on your behalf (demand letters, phone calls, negotiations)</li>
|
||||
<li>Process debtor payments and disburse collected funds to your account via Stripe ACH</li>
|
||||
</ul>
|
||||
<h3>3.2 Legal and Compliance</h3>
|
||||
<p>We process information as necessary to:</p>
|
||||
<ul>
|
||||
<li>Comply with the Fair Debt Collection Practices Act (FDCPA), Texas Finance Code Chapter 392, Telephone Consumer Protection Act (TCPA), Fair Credit Reporting Act (FCRA), and all other applicable laws</li>
|
||||
<li>Respond to lawful government requests, court orders, and subpoenas</li>
|
||||
<li>Establish, exercise, or defend legal claims</li>
|
||||
<li>Detect and prevent fraud, abuse, identity theft, or illegal activity</li>
|
||||
<li>Maintain records per statutory retention requirements</li>
|
||||
</ul>
|
||||
<h3>3.3 Business Operations</h3>
|
||||
<p>We use information for:</p>
|
||||
<ul>
|
||||
<li>Account management, authentication, and customer support</li>
|
||||
<li>Site and Service improvement, including bug fixes, performance optimization, and user experience enhancements</li>
|
||||
<li>Aggregated analytics and reporting (anonymized or de-identified where practicable)</li>
|
||||
<li>Security monitoring, threat detection, and incident response</li>
|
||||
<li>Administrative notices, security alerts, and policy update notifications</li>
|
||||
</ul>
|
||||
<h3>3.4 Communications</h3>
|
||||
<p>We may contact you for:</p>
|
||||
<ul>
|
||||
<li><strong>Service-Related Messages:</strong> Claim status updates, fee disclosures, disbursement confirmations, and account alerts. These are necessary for the performance of the Services.</li>
|
||||
<li><strong>Support Responses:</strong> Answers to your inquiries, requests, and disputes.</li>
|
||||
<li><strong>Marketing (Opt-In Only):</strong> Promotional emails about DRE Services. You may opt out of marketing communications at any time by clicking the unsubscribe link in any marketing email.</li>
|
||||
</ul>
|
||||
<p><strong>We do not sell, rent, or trade your personal information to third parties for their own marketing purposes.</strong></p>
|
||||
<h3>3.5 SMS Communications</h3>
|
||||
<p>DRE may send SMS text messages for claim status updates, document availability notifications, and disbursement alerts. All SMS communications are transactional and informational — we do not send marketing or promotional text messages.</p>
|
||||
<p><strong>Consent:</strong> We obtain your prior express consent before sending SMS messages to your mobile phone. Consent is collected during account registration or claim submission through a clear, standalone checkbox (not pre-ticked) disclosing the types of messages you may receive and the approximate frequency.</p>
|
||||
<p><strong>Opt-Out:</strong> To stop receiving SMS messages at any time, reply STOP to any message. You may also reply UNSUBSCRIBE, CANCEL, or QUIT. SMS opt-out requests are processed immediately upon receipt. You may also opt out through your portal settings, by calling our office, or by emailing our support team.</p>
|
||||
<p><strong>HELP:</strong> Reply HELP to any message for information about the SMS program and DRE contact details.</p>
|
||||
<p><strong>Rates:</strong> Message and data rates may apply. Please check your mobile plan for details.</p>
|
||||
<p><strong>Carrier Liability:</strong> Carriers are not liable for delayed or undelivered messages.</p>
|
||||
<p><strong>No Mobile Information Sharing:</strong> We do not share, sell, rent, or trade your mobile phone number or SMS opt-in consent with any third party for marketing or promotional purposes. Phone numbers are used exclusively to deliver the transactional communications described above.</p>
|
||||
<p><strong>10DLC Compliance:</strong> DRE sends SMS through 10-Digit Long Code (10DLC) messaging in compliance with The Campaign Registry (TCR) standards, the CTIA Messaging Principles and Best Practices, and applicable carrier requirements. All DRE messages are transactional and informational; we do not send marketing or promotional text messages.</p>
|
||||
<p>For full SMS program terms, see our <a href="https://debtrecoveryexperts.com/sms-terms.html">SMS & 10DLC Compliance notice</a>.</p>
|
||||
<hr />
|
||||
<h2>4. How We Share Information</h2>
|
||||
<h3>4.1 Service Providers</h3>
|
||||
<p>We share information with third-party service providers only as necessary to deliver the Services, and only under contractual obligations requiring them to protect your data.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service Provider</th>
|
||||
<th>Information Shared</th>
|
||||
<th>Purpose</th>
|
||||
<th>Safeguards</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Proof.com (RON Platform)</td>
|
||||
<td>Name, photo ID, video/audio of session, signature</td>
|
||||
<td>Execute LPOA under Texas notary law</td>
|
||||
<td>SOC 2 certified; encrypted in transit and at rest</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>LetterStream</td>
|
||||
<td>Debtor name, address, letter content</td>
|
||||
<td>Send certified demand letters</td>
|
||||
<td>Business associate agreement</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stripe Connect</td>
|
||||
<td>Client name, bank account/routing number, disbursement amount</td>
|
||||
<td>ACH disbursement</td>
|
||||
<td>PCI DSS Level 1; DRE does not store full banking details</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Partner Law Firms</td>
|
||||
<td>Claim documents, debtor information, correspondence history</td>
|
||||
<td>Litigation and lien filing (Tiers 2.5/4)</td>
|
||||
<td>Attorney-client privilege where applicable; confidentiality agreements</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cloud Hosting / Infrastructure</td>
|
||||
<td>All stored data</td>
|
||||
<td>Data storage, hosting, backup</td>
|
||||
<td>AES-256 at rest; TLS in transit; access controls</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Analytics Providers</td>
|
||||
<td>Aggregated, anonymized usage data</td>
|
||||
<td>Site performance analytics</td>
|
||||
<td>De-identified; no personally identifiable information</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>4.2 Legal and Regulatory Disclosures</h3>
|
||||
<p>We may disclose information if required to:</p>
|
||||
<ul>
|
||||
<li>Comply with a legal obligation, court order, or governmental request</li>
|
||||
<li>Protect and defend DRE's rights, property, or safety, or that of our clients or others</li>
|
||||
<li>Investigate, prevent, or take action regarding suspected fraud, illegal activity, or Terms of Use violations</li>
|
||||
<li>Enforce the Terms of Use, Acceptable Use Policy, or other agreements</li>
|
||||
</ul>
|
||||
<h3>4.3 Business Transfers</h3>
|
||||
<p>If DRE is involved in a merger, acquisition, asset sale, or bankruptcy, client data may be transferred as part of that transaction. You will be notified of any change in ownership or control affecting your personal information.</p>
|
||||
<h3>4.4 With Your Consent</h3>
|
||||
<p>We may share information with other parties when you give us explicit, informed consent to do so.</p>
|
||||
<h3>4.5 What We Do Not Share</h3>
|
||||
<ul>
|
||||
<li>We do <strong>not</strong> sell, rent, or trade personal information to data brokers, marketers, or third parties for their own purposes.</li>
|
||||
<li>We do <strong>not</strong> use debtor information for unrelated marketing.</li>
|
||||
<li>We do <strong>not</strong> share Social Security Numbers or identification documents beyond what is required for notarization and tax compliance.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2>5. Data Security</h2>
|
||||
<h3>5.1 Technical Safeguards</h3>
|
||||
<p>We implement and maintain industry-standard security measures, including:</p>
|
||||
<ul>
|
||||
<li><strong>Encryption:</strong> TLS 1.3 for all data in transit; AES-256 for data at rest.</li>
|
||||
<li><strong>Access Controls:</strong> Role-based access with the principle of least privilege; all administrative access requires multi-factor authentication (MFA).</li>
|
||||
<li><strong>Network Security:</strong> Firewalls, intrusion detection and prevention systems (IDS/IPS), and regular vulnerability scanning.</li>
|
||||
<li><strong>Bot and Abuse Protection:</strong> Cloudflare Turnstile on all public-facing forms.</li>
|
||||
<li><strong>Regular Testing:</strong> Periodic vulnerability assessments, penetration testing, and code reviews.</li>
|
||||
</ul>
|
||||
<h3>5.2 Administrative Safeguards</h3>
|
||||
<ul>
|
||||
<li>All personnel with access to sensitive data undergo background checks and receive regular training on data privacy, security, and debt collection compliance.</li>
|
||||
<li>We maintain a written information security policy and incident response plan.</li>
|
||||
<li>Third-party vendors are subject to due diligence and security assessments before engagement.</li>
|
||||
</ul>
|
||||
<h3>5.3 Physical Safeguards</h3>
|
||||
<p>Data is hosted in secure facilities with restricted physical access. Any physical documents we receive are stored in locked, access-controlled storage.</p>
|
||||
<h3>5.4 Data Breach Notification</h3>
|
||||
<p>In the event of a security breach that compromises personal information, we will:</p>
|
||||
<ul>
|
||||
<li>Notify affected individuals without unreasonable delay, in accordance with the Texas breach notification law (Texas Business and Commerce Code § 521.053 — within 60 days).</li>
|
||||
<li>Notify the Texas Attorney General if 250 or more Texas residents are affected.</li>
|
||||
<li>Provide the nature of the breach, the types of data involved, the steps we have taken, and recommendations for mitigating potential harm.</li>
|
||||
</ul>
|
||||
<h3>5.5 No Absolute Guarantee</h3>
|
||||
<p>While we implement robust safeguards, no method of electronic storage or transmission is 100% secure. We cannot guarantee absolute security, but we continuously review and improve our defenses.</p>
|
||||
<hr />
|
||||
<h2>6. Data Retention</h2>
|
||||
<h3>6.1 Retention Schedule</h3>
|
||||
<p>We retain personal information only as long as necessary to fulfill the purposes described in this Policy, or as required by law.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data Category</th>
|
||||
<th>Retention Period</th>
|
||||
<th>Basis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Account information</td>
|
||||
<td>Account lifetime + 3 years after closure</td>
|
||||
<td>Business records; dispute resolution</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Claim documentation and evidence</td>
|
||||
<td>5 years after claim closure</td>
|
||||
<td>Four-year Texas statute of limitations for written contracts, plus buffer</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Financial and payment records</td>
|
||||
<td>7 years</td>
|
||||
<td>IRS requirements; tax compliance</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Notarization records (RON)</td>
|
||||
<td>Per Texas notary law (TX Gov't Code § 406)</td>
|
||||
<td>Statutory requirement</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Communications (email, SMS, call logs)</td>
|
||||
<td>3 years</td>
|
||||
<td>Dispute resolution; FDCPA compliance</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Website analytics (anonymized)</td>
|
||||
<td>2 years</td>
|
||||
<td>Business analysis</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Server and security logs</td>
|
||||
<td>12 months</td>
|
||||
<td>Security monitoring; forensic investigation</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>6.2 Deletion</h3>
|
||||
<p>When the applicable retention period expires, data is securely deleted or irreversibly anonymized using cryptographic erasure, secure overwrite, or physical destruction, as appropriate.</p>
|
||||
<h3>6.3 Active Claims Exception</h3>
|
||||
<p>Data for open, active claims is retained until the claim is closed. The retention clock starts upon claim closure.</p>
|
||||
<h3>6.4 Legal Holds</h3>
|
||||
<p>If a legal hold is placed on data — for example, during litigation or a regulatory investigation — the retention schedule is overridden, and the data is preserved until the hold is released.</p>
|
||||
<h3>6.5 Account Closure</h3>
|
||||
<p>If you close your account, your data is retained per the schedule above. After the retention period, it is securely deleted. You may request earlier deletion subject to the exceptions in Section 7.3.</p>
|
||||
<hr />
|
||||
<h2>7. Your Rights and Choices</h2>
|
||||
<h3>7.1 Your Rights</h3>
|
||||
<p>Depending on your jurisdiction, you may have the following rights regarding your personal information:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Right</th>
|
||||
<th>What It Means</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Access</td>
|
||||
<td>You may request a copy of the personal data we hold about you.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Correction</td>
|
||||
<td>You may request correction of inaccurate or incomplete data.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Deletion</td>
|
||||
<td>You may request deletion of your personal data, subject to legal and regulatory exceptions.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Restriction</td>
|
||||
<td>You may request limited processing in certain circumstances.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Portability</td>
|
||||
<td>You may request your data in a structured, machine-readable format.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Objection</td>
|
||||
<td>You may object to processing for direct marketing (this is an absolute right).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Opt-Out of Sale</td>
|
||||
<td>DRE does not sell personal data, but we acknowledge this right.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>SMS Opt-Out</td>
|
||||
<td>Reply STOP to any SMS, or update preferences in your portal settings.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>7.2 How to Exercise Your Rights</h3>
|
||||
<p>To exercise any of these rights, contact us using the information in Section 12. We will verify your identity before processing your request, which may require you to provide additional information. We respond to verified requests within 45 calendar days, in accordance with the Texas Data Privacy and Security Act.</p>
|
||||
<p>The first request in any 12-month period is processed at no charge. For excessive or repetitive requests, we may charge a reasonable fee. If we deny a request, we will explain the reason for the denial and inform you of your right to appeal.</p>
|
||||
<h3>7.3 Exceptions to Deletion</h3>
|
||||
<p>We may deny deletion requests if the data is required for:</p>
|
||||
<ul>
|
||||
<li>Completing an active debt recovery claim.</li>
|
||||
<li>Compliance with legal obligations (FDCPA, Texas Finance Code, IRS retention requirements).</li>
|
||||
<li>Detecting or preventing fraud, security incidents, or illegal activity.</li>
|
||||
<li>Establishing, exercising, or defending legal claims.</li>
|
||||
<li>Internal uses that are reasonably aligned with consumer expectations.</li>
|
||||
</ul>
|
||||
<h3>7.4 Texas Privacy Rights</h3>
|
||||
<p>Under the Texas Data Privacy and Security Act (effective July 1, 2024), Texas residents have the right to opt out of targeted advertising and profiling. DRE does not engage in targeted advertising or profiling that would trigger these rights. Texas residents may file complaints with the Texas Attorney General's Consumer Protection Division.</p>
|
||||
<h3>7.5 California Residents</h3>
|
||||
<p>If you are a California resident, the California Consumer Privacy Act (CCPA) and California Privacy Rights Act (CPRA) may provide you with additional rights, including the right to know what personal information we collect, the right to delete, and the right to opt out of the sale or sharing of personal information. DRE does not sell or share personal information as defined under California law. We will honor verified CCPA requests where applicable. We do not discriminate against anyone for exercising their privacy rights.</p>
|
||||
<hr />
|
||||
<h2>8. Cookies and Tracking Technologies</h2>
|
||||
<h3>8.1 Types of Cookies We Use</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Purpose</th>
|
||||
<th>Duration</th>
|
||||
<th>Examples</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Essential</td>
|
||||
<td>Site functionality: login sessions, security checks, form submissions</td>
|
||||
<td>Session to persistent</td>
|
||||
<td>Authentication tokens, CSRF tokens, Turnstile bot detection</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Functional</td>
|
||||
<td>User preferences and portal settings</td>
|
||||
<td>Up to 1 year</td>
|
||||
<td>Language preferences, dashboard layout</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Analytics</td>
|
||||
<td>Anonymized usage data: page views, load times, errors</td>
|
||||
<td>Up to 2 years</td>
|
||||
<td>Google Analytics (with anonymized IP)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Marketing</td>
|
||||
<td>DRE does not use marketing or advertising cookies</td>
|
||||
<td>N/A</td>
|
||||
<td>N/A</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>8.2 Your Cookie Choices</h3>
|
||||
<p>You can configure your browser to block, delete, or limit cookies. You may opt out of Google Analytics by visiting https://tools.google.com/dlpage/gaoptout. Please note that blocking essential cookies may affect the functionality of the Site and portal.</p>
|
||||
<h3>8.3 Do Not Track</h3>
|
||||
<p>DRE respects Do Not Track (DNT) browser signals where technically feasible. We do not track users across third-party websites for advertising purposes.</p>
|
||||
<hr />
|
||||
<h2>9. Children's Privacy</h2>
|
||||
<p>DRE's Services are not intended for individuals under the age of 18. We do not knowingly collect personal information from children under 18. If we learn that a child's data has been collected, we will promptly delete it. If you are a parent or guardian and believe your child has provided personal information to us, please contact us immediately.</p>
|
||||
<hr />
|
||||
<h2>10. Third-Party Services and Links</h2>
|
||||
<p>Our Site and portal integrate with third-party services — including Proof.com (RON), LetterStream (certified mail), and Stripe Connect (payments). Our Site may also contain links to third-party websites, such as partner law firm sites.</p>
|
||||
<p>This Privacy Policy does not govern the privacy practices of third parties. We encourage you to review the privacy policies of any third-party service before providing your information.</p>
|
||||
<hr />
|
||||
<h2>11. International Data Transfers</h2>
|
||||
<p>DRE is based in the United States and stores all data on servers located in the United States. If you access the Site or Services from outside the United States, your information may be transferred to, stored, and processed in the United States, where data protection laws may differ from those of your jurisdiction. By using the Services, you consent to this transfer.</p>
|
||||
<hr />
|
||||
<h2>12. Updates to This Privacy Policy</h2>
|
||||
<p>We may update this Privacy Policy from time to time to reflect changes in our practices, legal requirements, or the Services. Changes will be posted on this page with an updated "Last Updated" date.</p>
|
||||
<p>For material changes, we will provide notice by email (if we have your email on file) and/or by displaying a prominent notice on the Site. Your continued use of the Services after changes are posted constitutes your acceptance of the updated Privacy Policy. Archived versions of prior policies are available upon request.</p>
|
||||
<hr />
|
||||
<h2>13. Contact and Complaints</h2>
|
||||
<h3>13.1 Contact DRE</h3>
|
||||
<p>For questions about this Privacy Policy, to exercise your privacy rights, or to report a concern:</p>
|
||||
<p><strong>Debt Recovery Experts (DRE)</strong>
|
||||
[Street Address]
|
||||
[City], TX
|
||||
Email: support@debtrecoveryexperts.com
|
||||
Phone: [Phone]
|
||||
Data Protection Contact: Debt Recovery Experts Compliance Team</p>
|
||||
<h3>13.2 File a Complaint</h3>
|
||||
<p>If you believe your privacy rights have been violated, you may file a complaint with:</p>
|
||||
<p><strong>Texas Attorney General — Consumer Protection Division</strong>
|
||||
P.O. Box 12548
|
||||
Austin, TX 78711-2548
|
||||
Phone: (800) 621-0508
|
||||
Website: https://www.texasattorneygeneral.gov</p>
|
||||
<p><strong>Federal Trade Commission (FTC)</strong>
|
||||
Website: https://reportfraud.ftc.gov
|
||||
For FDCPA or FCRA-related privacy complaints.</p>
|
||||
<hr />
|
||||
<blockquote>
|
||||
<p><strong>⚠️ ATTORNEY REVIEW REQUIRED:</strong> This document must be reviewed by a licensed Texas attorney before publication. Key review items: Texas Data Privacy and Security Act applicability and full compliance assessment, CCPA/CPRA applicability determination, data retention periods vs. statutory requirements for debt collectors, SMS consent language for TCPA compliance, and adequacy of international data transfer provisions.</p>
|
||||
</blockquote>
|
||||
<hr />
|
||||
<p>© 2026 Debt Recovery Experts. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="inner">
|
||||
<p class="miranda">Debt Recovery Experts is a debt collection firm. This communication is an attempt to collect a debt and any information obtained will be used for that purpose, where applicable. You have rights under the federal Fair Debt Collection Practices Act and the Texas Debt Collection Act, including the right to dispute a debt and request verification.</p>
|
||||
<div class="links">
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Use</a>
|
||||
<a href="/aup.html">Acceptable Use Policy</a>
|
||||
<a href="/sms-terms.html">SMS & 10DLC</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,283 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SMS & 10DLC Compliance — Debt Recovery Experts</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/favicon-180.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--navy-950: #10192b;
|
||||
--navy-900: #16213a;
|
||||
--navy-800: #1f2d4a;
|
||||
--navy-700: #2b3d5f;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #8593a8;
|
||||
--slate-200: #d8dee7;
|
||||
--slate-100: #eceff4;
|
||||
--paper: #faf9f6;
|
||||
--line: #dcdfe6;
|
||||
--gold: #9c7c3f;
|
||||
--white: #ffffff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
color: var(--navy-900);
|
||||
background: var(--paper);
|
||||
line-height: 1.7;
|
||||
}
|
||||
a { color: var(--gold); }
|
||||
.topbar {
|
||||
background: var(--navy-900);
|
||||
border-bottom: 3px solid var(--gold);
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.topbar .brand { display: flex; align-items: center; gap: 12px; }
|
||||
.topbar .brand .mark {
|
||||
width: 34px; height: 34px; border: 1px solid var(--gold);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--gold); font-family: 'Source Serif 4', serif; font-weight: 600;
|
||||
font-size: 15px; border-radius: 3px;
|
||||
}
|
||||
.topbar .brand .name { color: var(--white); font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 17px; }
|
||||
.topbar a.back {
|
||||
color: var(--slate-200); text-decoration: none; font-size: 13px;
|
||||
border: 1px solid var(--navy-700); padding: 6px 12px; border-radius: 3px;
|
||||
}
|
||||
.topbar a.back:hover { border-color: var(--gold); color: var(--white); }
|
||||
.wrap { max-width: 820px; margin: 0 auto; padding: 40px 24px 64px; }
|
||||
h1 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 30px;
|
||||
color: var(--navy-900); margin: 0 0 6px; line-height: 1.25;
|
||||
}
|
||||
.effective { color: var(--slate-500); font-size: 13px; margin: 0 0 28px; }
|
||||
.content h2 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 21px;
|
||||
color: var(--navy-900); margin: 34px 0 10px; padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.content h3 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 17px;
|
||||
color: var(--navy-800); margin: 24px 0 8px;
|
||||
}
|
||||
.content h4 { font-size: 15px; color: var(--navy-800); margin: 20px 0 6px; }
|
||||
.content p { margin: 0 0 14px; }
|
||||
.content ul, .content ol { margin: 0 0 16px; padding-left: 24px; }
|
||||
.content li { margin: 0 0 6px; }
|
||||
.content table { border-collapse: collapse; width: 100%; margin: 0 0 18px; font-size: 13.5px; }
|
||||
.content th, .content td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
|
||||
.content th { background: var(--slate-100); color: var(--navy-800); font-weight: 600; }
|
||||
.content hr { border: none; border-top: 1px solid var(--line); margin: 30px 0; }
|
||||
.content strong { color: var(--navy-900); }
|
||||
.footer {
|
||||
background: var(--navy-950); color: var(--slate-400); font-size: 12.5px;
|
||||
padding: 32px 24px; margin-top: 40px;
|
||||
}
|
||||
.footer .inner { max-width: 820px; margin: 0 auto; }
|
||||
.footer .miranda { margin-bottom: 16px; line-height: 1.6; }
|
||||
.footer .links { display: flex; flex-wrap: wrap; gap: 18px; }
|
||||
.footer .links a { color: var(--slate-200); text-decoration: none; }
|
||||
.footer .links a:hover { color: var(--gold); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="mark">DRE</span>
|
||||
<span class="name">Debt Recovery Experts</span>
|
||||
</div>
|
||||
<a class="back" href="https://debtrecoveryexperts.com/">← Back to site</a>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>SMS & 10DLC Compliance</h1>
|
||||
<p class="effective">Effective date: August 22, 2026</p>
|
||||
<div class="content">
|
||||
<p><strong>Paste into WordPress page titled "SMS Compliance" or "10DLC Compliance" | Last Updated: July 25, 2026</strong></p>
|
||||
<hr />
|
||||
<h2>What Is 10DLC and Why It Matters</h2>
|
||||
<p>10DLC stands for 10-Digit Long Code — a standard 10-digit phone number that businesses use to send application-to-person (A2P) SMS text messages. Unlike short codes (5- or 6-digit numbers used for mass marketing), 10DLC numbers look like ordinary phone numbers and are designed for higher-quality, lower-volume business messaging.</p>
|
||||
<p>All 10DLC messaging in the United States is regulated through The Campaign Registry (TCR), a centralized system operated by U.S. wireless carriers — including AT&T, T-Mobile, and Verizon. TCR vets every business sender and every messaging campaign before messages can be delivered.</p>
|
||||
<p><strong>As of February 2025, carrier enforcement is mandatory.</strong> Unregistered business SMS traffic is blocked entirely — no throttling, no warning, no delivery. To send SMS messages to clients, a business must register its brand (verifying the company's identity) and each messaging campaign (describing the use case, providing proof of opt-in consent, and submitting sample messages for carrier review).</p>
|
||||
<p>Debt Recovery Experts (DRE) has completed brand and campaign registration through TCR and our messaging provider. Every SMS message we send is routed through a registered, compliant campaign — ensuring our clients receive the information they need, when they need it, with full transparency about who is contacting them and why.</p>
|
||||
<hr />
|
||||
<h2>Our Commitment to Compliant Messaging</h2>
|
||||
<p>DRE is committed to full compliance with all applicable laws, carrier codes of conduct, and industry best practices governing SMS messaging. Specifically, we adhere to:</p>
|
||||
<ul>
|
||||
<li><strong>Telephone Consumer Protection Act (TCPA)</strong> — 47 U.S.C. § 227. We obtain prior express consent before sending any SMS message. We honor opt-out requests immediately. We recognize that TCPA violations carry statutory damages of $500 to $1,500 per violation, and we treat compliance accordingly.</li>
|
||||
<li><strong>CTIA Messaging Principles and Best Practices</strong> — The wireless industry's framework for lawful, consumer-friendly business messaging.</li>
|
||||
<li><strong>T-Mobile Code of Conduct</strong> and <strong>AT&T Code of Conduct</strong> — Carrier-specific requirements for A2P messaging, including prohibited content categories and consent standards.</li>
|
||||
<li><strong>Fair Debt Collection Practices Act (FDCPA)</strong> — 15 U.S.C. § 1692 et seq., where applicable to third-party debt collection communications.</li>
|
||||
<li><strong>Texas Debt Collection Act (TDCA)</strong> — Texas Finance Code Chapter 392, governing debt collection practices within the state.</li>
|
||||
</ul>
|
||||
<p>Our SMS practices are:</p>
|
||||
<ul>
|
||||
<li><strong>Transactional and informational only.</strong> DRE does not send marketing or promotional text messages.</li>
|
||||
<li><strong>Clearly identified.</strong> Every message includes the DRE business name so the recipient knows who is contacting them.</li>
|
||||
<li><strong>Logged and auditable.</strong> All SMS activity is recorded and available for compliance review.</li>
|
||||
<li><strong>Governed by documented policy.</strong> Our SMS practices are part of our Compliance Manual (§9 — TCPA), which governs all DRE communications.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2>Types of Messages You May Receive</h2>
|
||||
<p>DRE sends SMS messages only when there is a meaningful update on your claim or account. We do not send "checking in" messages or marketing content.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Message Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Payment Reminders</td>
|
||||
<td>Notifications about upcoming or past-due payments related to an active claim</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Claim Status Updates</td>
|
||||
<td>Information about where your claim stands in the recovery process</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Demand Letter Notifications</td>
|
||||
<td>Alerts that a demand letter has been issued or is available for review</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Account Notifications</td>
|
||||
<td>Updates related to an outstanding balance or account activity</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Document Availability</td>
|
||||
<td>Notices that new documents — such as settlement offers or status reports — are available in the client portal</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>What we do NOT send:</strong></p>
|
||||
<ul>
|
||||
<li>Promotional or marketing messages</li>
|
||||
<li>Debt consolidation, debt reduction, or debt forgiveness offers</li>
|
||||
<li>Content related to sex, hate, alcohol, firearms, or tobacco (SHAFT content is prohibited on all 10DLC campaigns)</li>
|
||||
</ul>
|
||||
<p>Every DRE SMS message is framed as a neutral business notification — not a collection attempt. A typical message reads: "DRE: Your claim #12345 has a status update. Log in to view details: https://my.debtrecoveryexperts.com Reply STOP to opt out."</p>
|
||||
<hr />
|
||||
<h2>How to Opt In</h2>
|
||||
<p>DRE sends SMS messages only to clients who have expressly consented to receive them. Consent is obtained through:</p>
|
||||
<ol>
|
||||
<li><strong>Claim Intake Form.</strong> When you submit a claim, you are presented with a standalone SMS consent checkbox (not pre-ticked). The checkbox clearly states the types of messages you may receive and the approximate frequency.</li>
|
||||
<li><strong>Client Portal Registration.</strong> During account creation, you may opt in to SMS notifications with a clear, separate consent mechanism.</li>
|
||||
<li><strong>Limited Power of Attorney (LPOA).</strong> The LPOA agreement includes an SMS consent provision.</li>
|
||||
</ol>
|
||||
<p>Consent is specific to SMS communications and is not bundled into general Terms of Use acceptance. DRE maintains a consent log for every recipient — recording the timestamp, method, and scope of consent.</p>
|
||||
<p><strong>We never purchase phone number lists or send SMS to numbers obtained from third parties.</strong></p>
|
||||
<hr />
|
||||
<h2>How to Opt Out</h2>
|
||||
<p>You may stop receiving SMS messages from DRE at any time, through any of the following methods:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>How It Works</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Reply <strong>STOP</strong></td>
|
||||
<td>Send STOP in reply to any DRE text message. Opt-out is processed immediately.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reply <strong>UNSUBSCRIBE, CANCEL, or QUIT</strong></td>
|
||||
<td>Any of these standard keywords will also process your opt-out.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reply <strong>HELP</strong></td>
|
||||
<td>Receive information about the SMS program and DRE contact details.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Call our office</td>
|
||||
<td>Speak with a representative who will process your opt-out.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Email support</td>
|
||||
<td>Send a request to our support email address.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Client Portal</td>
|
||||
<td>Update your notification preferences in your account settings.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Per the FCC's 2024 consent revocation order, you may revoke consent through any reasonable method — not just the STOP keyword. DRE honors all opt-out requests promptly.</p>
|
||||
<p><strong>Opt-out is permanent by default.</strong> Once you opt out, you will not receive further SMS messages from any DRE campaign unless you choose to re-enroll by providing fresh, affirmative consent. DRE maintains suppression lists across voice and SMS channels to ensure compliance.</p>
|
||||
<hr />
|
||||
<h2>Message Frequency</h2>
|
||||
<p>DRE sends SMS messages only when there is a genuine update. We do not send routine or scheduled messages just to "check in."</p>
|
||||
<ul>
|
||||
<li><strong>Payment reminders:</strong> 1–2 per billing cycle</li>
|
||||
<li><strong>Claim status updates:</strong> As events occur — typically 1–3 per month</li>
|
||||
<li><strong>Demand letter notifications:</strong> Once per letter issued</li>
|
||||
</ul>
|
||||
<p><strong>We will not send more than 4 messages per month</strong> to any single recipient across all DRE campaigns. Our TCPA policy (Compliance Manual §9) further caps all communications at 3 contact attempts per 7-day period — and this applies to SMS as well as phone calls.</p>
|
||||
<p>All messages are sent during business hours — 8:00 a.m. to 8:00 p.m. in the recipient's local time zone — in compliance with TCPA quiet-hours restrictions.</p>
|
||||
<hr />
|
||||
<h2>Message and Data Rates</h2>
|
||||
<p>DRE does not charge any fee for sending or receiving SMS messages. However, <strong>message and data rates may apply</strong> depending on your mobile carrier and plan. Please check your plan for details. Standard carrier rates for text messages sent and received will apply based on your individual service agreement with your wireless provider.</p>
|
||||
<hr />
|
||||
<h2>Privacy Protections</h2>
|
||||
<p>Your privacy is core to how we handle SMS communications:</p>
|
||||
<ul>
|
||||
<li><strong>No sale of phone numbers.</strong> DRE never sells, rents, or shares your phone number — or any other personal data — with third parties for their marketing purposes.</li>
|
||||
<li><strong>Encryption.</strong> Phone numbers and message content are encrypted in transit using TLS and at rest using AES-256, consistent with our Privacy Policy.</li>
|
||||
<li><strong>No sensitive content via SMS.</strong> We never send account numbers, Social Security Numbers, payment details, or full claim information through text messages.</li>
|
||||
<li><strong>Data retention.</strong> SMS logs are retained in accordance with DRE's data retention policy.</li>
|
||||
<li><strong>Texas Data Privacy and Security Act.</strong> Texas residents have rights to access, correct, and delete their personal data, including SMS-related information.</li>
|
||||
</ul>
|
||||
<p>For full details, please read our <a href="https://debtrecoveryexperts.com/privacy.html">Privacy Policy</a>.</p>
|
||||
<hr />
|
||||
<h2>Sample Messages (For Carrier Review)</h2>
|
||||
<p>DRE uses the following message templates for all SMS communications. These templates have been approved by The Campaign Registry as compliant with carrier standards:</p>
|
||||
<ul>
|
||||
<li><strong>Claim Status Update:</strong> "DRE: Your claim #12345 has a status update. Log in to view details: https://my.debtrecoveryexperts.com Reply STOP to opt out."</li>
|
||||
<li><strong>Payment Reminder:</strong> "DRE Payment Reminder: A payment of $X.XX is due on [date] for claim #12345. Contact us with questions: [phone] Reply STOP to opt out."</li>
|
||||
<li><strong>Demand Letter Notification:</strong> "DRE: A demand letter has been issued for claim #12345. View in your portal: [URL] Reply STOP to opt out."</li>
|
||||
<li><strong>Document Available:</strong> "DRE: New documents are available for your account. Visit https://my.debtrecoveryexperts.com or call [phone]. Reply STOP to opt out."</li>
|
||||
</ul>
|
||||
<p>All messages include clear sender identification (DRE), the reason for the message, a contact method for questions, and a STOP opt-out instruction.</p>
|
||||
<hr />
|
||||
<h2>Contact for SMS Concerns</h2>
|
||||
<p>If you have questions about our SMS program, wish to report an unwanted message, or need help with opt-in or opt-out:</p>
|
||||
<p><strong>Debt Recovery Experts (DRE)</strong>
|
||||
[Street Address]
|
||||
[City], TX
|
||||
Phone: [Phone]
|
||||
Email: support@debtrecoveryexperts.com</p>
|
||||
<p>DRE takes all SMS-related complaints seriously. We investigate every report and respond within one business day.</p>
|
||||
<hr />
|
||||
<blockquote>
|
||||
<p><strong>⚠️ ATTORNEY REVIEW REQUIRED:</strong> Specific areas of this page — particularly the TCPA consent standard (prior express consent vs. prior express written consent), FDCPA communication restrictions for represented debtors, Mini-Miranda disclosure requirements within SMS character limits, and the scope of the FCC's one-to-one consent rule post-11th Circuit vacatur — should be reviewed by a licensed attorney familiar with TCPA, FDCPA, and 10DLC carrier requirements before publication.</p>
|
||||
</blockquote>
|
||||
<hr />
|
||||
<p>© 2026 Debt Recovery Experts. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="inner">
|
||||
<p class="miranda">Debt Recovery Experts is a debt collection firm. This communication is an attempt to collect a debt and any information obtained will be used for that purpose, where applicable. You have rights under the federal Fair Debt Collection Practices Act and the Texas Debt Collection Act, including the right to dispute a debt and request verification.</p>
|
||||
<div class="links">
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Use</a>
|
||||
<a href="/aup.html">Acceptable Use Policy</a>
|
||||
<a href="/sms-terms.html">SMS & 10DLC</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,472 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Terms of Use — Debt Recovery Experts</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/favicon-180.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--navy-950: #10192b;
|
||||
--navy-900: #16213a;
|
||||
--navy-800: #1f2d4a;
|
||||
--navy-700: #2b3d5f;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #8593a8;
|
||||
--slate-200: #d8dee7;
|
||||
--slate-100: #eceff4;
|
||||
--paper: #faf9f6;
|
||||
--line: #dcdfe6;
|
||||
--gold: #9c7c3f;
|
||||
--white: #ffffff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
color: var(--navy-900);
|
||||
background: var(--paper);
|
||||
line-height: 1.7;
|
||||
}
|
||||
a { color: var(--gold); }
|
||||
.topbar {
|
||||
background: var(--navy-900);
|
||||
border-bottom: 3px solid var(--gold);
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.topbar .brand { display: flex; align-items: center; gap: 12px; }
|
||||
.topbar .brand .mark {
|
||||
width: 34px; height: 34px; border: 1px solid var(--gold);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--gold); font-family: 'Source Serif 4', serif; font-weight: 600;
|
||||
font-size: 15px; border-radius: 3px;
|
||||
}
|
||||
.topbar .brand .name { color: var(--white); font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 17px; }
|
||||
.topbar a.back {
|
||||
color: var(--slate-200); text-decoration: none; font-size: 13px;
|
||||
border: 1px solid var(--navy-700); padding: 6px 12px; border-radius: 3px;
|
||||
}
|
||||
.topbar a.back:hover { border-color: var(--gold); color: var(--white); }
|
||||
.wrap { max-width: 820px; margin: 0 auto; padding: 40px 24px 64px; }
|
||||
h1 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 30px;
|
||||
color: var(--navy-900); margin: 0 0 6px; line-height: 1.25;
|
||||
}
|
||||
.effective { color: var(--slate-500); font-size: 13px; margin: 0 0 28px; }
|
||||
.content h2 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 21px;
|
||||
color: var(--navy-900); margin: 34px 0 10px; padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.content h3 {
|
||||
font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 17px;
|
||||
color: var(--navy-800); margin: 24px 0 8px;
|
||||
}
|
||||
.content h4 { font-size: 15px; color: var(--navy-800); margin: 20px 0 6px; }
|
||||
.content p { margin: 0 0 14px; }
|
||||
.content ul, .content ol { margin: 0 0 16px; padding-left: 24px; }
|
||||
.content li { margin: 0 0 6px; }
|
||||
.content table { border-collapse: collapse; width: 100%; margin: 0 0 18px; font-size: 13.5px; }
|
||||
.content th, .content td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
|
||||
.content th { background: var(--slate-100); color: var(--navy-800); font-weight: 600; }
|
||||
.content hr { border: none; border-top: 1px solid var(--line); margin: 30px 0; }
|
||||
.content strong { color: var(--navy-900); }
|
||||
.footer {
|
||||
background: var(--navy-950); color: var(--slate-400); font-size: 12.5px;
|
||||
padding: 32px 24px; margin-top: 40px;
|
||||
}
|
||||
.footer .inner { max-width: 820px; margin: 0 auto; }
|
||||
.footer .miranda { margin-bottom: 16px; line-height: 1.6; }
|
||||
.footer .links { display: flex; flex-wrap: wrap; gap: 18px; }
|
||||
.footer .links a { color: var(--slate-200); text-decoration: none; }
|
||||
.footer .links a:hover { color: var(--gold); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="mark">DRE</span>
|
||||
<span class="name">Debt Recovery Experts</span>
|
||||
</div>
|
||||
<a class="back" href="https://debtrecoveryexperts.com/">← Back to site</a>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Terms of Use</h1>
|
||||
<p class="effective">Effective date: August 22, 2026</p>
|
||||
<div class="content">
|
||||
<p><strong>Paste into WordPress page titled "Terms of Use" | Last Updated: July 25, 2026</strong></p>
|
||||
<hr />
|
||||
<blockquote>
|
||||
<p><strong>IMPORTANT NOTICE:</strong> These Terms of Use contain a binding arbitration agreement and class action waiver that affect your legal rights. Please read Sections 9 and 10 carefully before using the Services.</p>
|
||||
</blockquote>
|
||||
<hr />
|
||||
<h2>1. Introduction and Acceptance</h2>
|
||||
<p>These Terms of Use ("Terms") are a binding legal agreement between you ("Client," "you," or "your") and Debt Recovery Experts ("DRE," "we," "us," or "our"). They govern your access to and use of the website located at https://debtrecoveryexperts.com (the "Site"), the claim submission portal, the payment portal, and all debt recovery services offered by DRE (collectively, the "Services").</p>
|
||||
<p>By accessing the Site, creating an account, submitting a claim, executing a Limited Power of Attorney, or otherwise using the Services, you acknowledge that you have read, understood, and agree to be bound by these Terms. If you do not agree to all of these Terms, you must not access the Site or use the Services.</p>
|
||||
<p>You represent that you are at least 18 years of age, possess the legal capacity to enter into a binding contract, and have not been previously suspended or removed from the Services.</p>
|
||||
<p>These Terms incorporate by reference the following documents, which are also binding on you:</p>
|
||||
<ul>
|
||||
<li><strong>Privacy Policy</strong> — available at <a href="https://debtrecoveryexperts.com/privacy.html">Privacy Policy</a>. Governs how we collect, use, store, and share your personal information.</li>
|
||||
<li><strong>Acceptable Use Policy (AUP)</strong> — available at <a href="https://debtrecoveryexperts.com/aup.html">Acceptable Use Policy</a>. Defines acceptable and prohibited conduct when using the Site and Services.</li>
|
||||
</ul>
|
||||
<p>Capitalized terms not defined in these Terms have the meanings given to them in the Privacy Policy or AUP. In the event of a conflict between these Terms and a per-claim service agreement, the service agreement controls for that specific claim.</p>
|
||||
<hr />
|
||||
<h2>2. Services Overview</h2>
|
||||
<p>DRE provides commercial debt recovery services to businesses and individuals. Our Services include:</p>
|
||||
<p><strong>Claim Submission.</strong> You submit unpaid commercial debt claims through our secure online portal, along with supporting documentation such as contracts, invoices, statements, and correspondence.</p>
|
||||
<p><strong>AI-Assisted Review.</strong> We use proprietary tools to evaluate claims — scoring viability, researching the debtor, and identifying potential weaknesses in the documentation. This review is internal only and does not constitute legal advice.</p>
|
||||
<p><strong>Limited Power of Attorney (LPOA).</strong> If a claim is accepted, you execute a Limited Power of Attorney authorizing DRE to act on your behalf to collect the specific debt. The LPOA is executed via Remote Online Notarization (RON) in compliance with Texas law.</p>
|
||||
<p><strong>Tiered Recovery.</strong> DRE pursues debts through a structured escalation process:</p>
|
||||
<ul>
|
||||
<li><strong>Tier 1 — Soft Touch:</strong> Email notification with an ACH payment link.</li>
|
||||
<li><strong>Tier 2 — Formal Demand:</strong> Certified mail demand letter sent to the debtor.</li>
|
||||
<li><strong>Tier 2.5 — Lien Threat / Pre-Lien Notice:</strong> For construction claims only; a pre-lien notice sent to the debtor. If a mechanic's lien filing becomes necessary, we refer to a partner law firm.</li>
|
||||
<li><strong>Tier 3 — Escalation:</strong> Final notice, plus phone and electronic contact.</li>
|
||||
<li><strong>Tier 4 — Legal Action:</strong> Referral to a partner law firm for litigation or lien enforcement.</li>
|
||||
</ul>
|
||||
<p><strong>Payment Collection and Disbursement.</strong> Payments from debtors are processed through Stripe ACH. DRE deducts its success fee and any authorized third-party costs, then disburses the remaining balance to your designated account.</p>
|
||||
<p><strong>Case Closure.</strong> When a claim is resolved — whether through collection, settlement, or determination that further action is not viable — DRE produces a closed-case binder with a complete record of the matter.</p>
|
||||
<h3>What DRE Does Not Do</h3>
|
||||
<p>DRE is not a law firm and does not provide legal advice. We do not file lawsuits directly — Tier 4 involves a referral to licensed attorneys who handle litigation independently. We do not file mechanic's liens directly — only pre-lien notices. We do not guarantee recovery; outcomes depend on factors beyond our control, including the debtor's financial condition and willingness to pay.</p>
|
||||
<hr />
|
||||
<h2>3. Eligibility and Account Registration</h2>
|
||||
<h3>3.1 Eligibility Requirements</h3>
|
||||
<p>To use the Services, you must:</p>
|
||||
<ul>
|
||||
<li>Be at least 18 years of age with the legal capacity to enter into a binding contract.</li>
|
||||
<li>Be the lawful owner or authorized agent of the debt claim you submit.</li>
|
||||
<li>Not be located in a jurisdiction where the Services are prohibited by law.</li>
|
||||
<li>Not have been previously terminated or suspended from the Services.</li>
|
||||
</ul>
|
||||
<h3>3.2 Account Registration</h3>
|
||||
<p>You must create an account to submit claims and use the Services. You agree to:</p>
|
||||
<ul>
|
||||
<li>Provide accurate, current, and complete information during registration.</li>
|
||||
<li>Maintain and promptly update your account information as it changes.</li>
|
||||
<li>Protect the confidentiality of your login credentials. You are responsible for all activity that occurs under your account.</li>
|
||||
<li>Notify DRE immediately if you suspect unauthorized access or use of your account.</li>
|
||||
</ul>
|
||||
<p>DRE reserves the right to reject or terminate any account at its sole discretion.</p>
|
||||
<h3>3.3 Business vs. Individual Accounts</h3>
|
||||
<p>If you register as a business, you must provide your Employer Identification Number (EIN), legal business name, and the name of an authorized representative. If you register as an individual, you must provide your Social Security Number (SSN) or Individual Taxpayer Identification Number (ITIN) for disbursement and tax compliance purposes.</p>
|
||||
<hr />
|
||||
<h2>4. Claim Submission Rules</h2>
|
||||
<h3>4.1 Required Information</h3>
|
||||
<p>For each claim you submit, you must provide:</p>
|
||||
<ul>
|
||||
<li>Complete debtor information: legal name, business name (if applicable), physical address, phone number, and email address.</li>
|
||||
<li>Documentation substantiating the debt: the underlying contract or purchase order, unpaid invoices, account statements, correspondence with the debtor, and proof of delivery or completion of work.</li>
|
||||
<li>Debt details: the amount owed, the date the debt was incurred, the nature of the debt, and a summary of any prior collection attempts.</li>
|
||||
<li>Any additional information DRE reasonably requests during the review process.</li>
|
||||
</ul>
|
||||
<h3>4.2 Claim Review and Acceptance</h3>
|
||||
<p>DRE reviews all submitted claims but is under no obligation to accept any claim. Acceptance is at our sole discretion and depends on factors including:</p>
|
||||
<ul>
|
||||
<li>Whether the debt is within the applicable statute of limitations (four years for written contracts in Texas, per Texas Civil Practice and Remedies Code § 16.004).</li>
|
||||
<li>The completeness and sufficiency of the supporting documentation.</li>
|
||||
<li>The likelihood of successful recovery, including the debtor's solvency, location, and asset profile.</li>
|
||||
<li>Compliance with the Fair Debt Collection Practices Act (FDCPA), the Texas Finance Code Chapter 392, and all other applicable laws.</li>
|
||||
</ul>
|
||||
<p>If a claim is rejected, DRE will notify you in writing. DRE is not liable for any loss resulting from a rejected claim. Acceptance of a claim does not constitute a guarantee of recovery.</p>
|
||||
<h3>4.3 Prohibited Claims</h3>
|
||||
<p>You may not submit any claim that:</p>
|
||||
<ul>
|
||||
<li>Is barred by the applicable statute of limitations.</li>
|
||||
<li>Has already been paid, settled, or discharged in bankruptcy.</li>
|
||||
<li>You know or have reason to know is false, fraudulent, or unsubstantiated.</li>
|
||||
<li>Is subject to an active bankruptcy stay on the debtor.</li>
|
||||
<li>Arises from or involves illegal activity or the proceeds of crime.</li>
|
||||
</ul>
|
||||
<h3>4.4 Client Certification</h3>
|
||||
<p>By submitting a claim, you certify that:</p>
|
||||
<ul>
|
||||
<li>The debt is valid, enforceable, and unpaid.</li>
|
||||
<li>All information and documentation you have provided is true, accurate, and complete to the best of your knowledge.</li>
|
||||
<li>You have not assigned, sold, or transferred the debt to any other party.</li>
|
||||
<li>You have not taken, and will not take, any action that would impair DRE's ability to collect the debt.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2>5. Limited Power of Attorney and Notarization</h2>
|
||||
<h3>5.1 LPOA Execution</h3>
|
||||
<p>For every claim DRE accepts, you must execute a Limited Power of Attorney (LPOA) before collection begins. The LPOA grants DRE the authority to:</p>
|
||||
<ul>
|
||||
<li>Communicate with the debtor regarding the specific debt identified in the LPOA.</li>
|
||||
<li>Negotiate, accept, and receive payments on your behalf.</li>
|
||||
<li>Endorse and deposit checks or other instruments made payable to you.</li>
|
||||
<li>Execute settlement agreements and releases.</li>
|
||||
<li>Take any other lawful actions necessary to collect the debt.</li>
|
||||
</ul>
|
||||
<h3>5.2 Scope and Limitations</h3>
|
||||
<p>The LPOA is strictly limited to the specific debt claim identified. It does not grant DRE general authority over your affairs, bank accounts, or other debts. The LPOA terminates upon the earliest of:</p>
|
||||
<ul>
|
||||
<li>Successful collection and disbursement of the debt.</li>
|
||||
<li>Written revocation by you, subject to Section 5.4.</li>
|
||||
<li>Termination of these Terms or the applicable service agreement.</li>
|
||||
</ul>
|
||||
<h3>5.3 Remote Online Notarization (RON)</h3>
|
||||
<p>LPOAs are executed through a Remote Online Notarization platform in compliance with Texas Government Code § 406.101 et seq. and applicable Texas administrative rules. During the notarization session, you must:</p>
|
||||
<ul>
|
||||
<li>Present valid, government-issued photo identification.</li>
|
||||
<li>Appear via live audio-video communication.</li>
|
||||
<li>Acknowledge your signature willingly and without duress.</li>
|
||||
</ul>
|
||||
<p>Any RON fee will be disclosed before the session. The video and audio recording of the session is retained in accordance with Texas notary law.</p>
|
||||
<h3>5.4 Revocation</h3>
|
||||
<p>You may revoke the LPOA at any time by providing written notice to DRE. Revocation does not affect:</p>
|
||||
<ul>
|
||||
<li>Any actions DRE took before receiving your revocation.</li>
|
||||
<li>Fees or costs incurred before revocation.</li>
|
||||
<li>Any partial recoveries already received, which will be remitted to you net of applicable fees.</li>
|
||||
</ul>
|
||||
<p>Upon revocation, DRE will cease collection activity on the affected claim.</p>
|
||||
<hr />
|
||||
<h2>6. Fees and Payment Terms</h2>
|
||||
<h3>6.1 Fee Structure</h3>
|
||||
<p>DRE charges a success fee calculated as a percentage of the amount actually collected from the debtor. No fee is charged if no recovery is made. The fee percentage depends on the tier at which the debt is resolved:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tier</th>
|
||||
<th>DRE Fee</th>
|
||||
<th>Client Keeps</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1 — Soft Touch</td>
|
||||
<td>20–25%</td>
|
||||
<td>75–80%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2 — Formal Demand</td>
|
||||
<td>30%</td>
|
||||
<td>70%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.5 — Lien Threat</td>
|
||||
<td>30% (+ attorney fees if lien filed)</td>
|
||||
<td>70%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3 — Escalation</td>
|
||||
<td>33%</td>
|
||||
<td>67%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>4 — Legal Action</td>
|
||||
<td>10% DRE + 25% law firm</td>
|
||||
<td>65%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>The exact fee percentage applicable to your claim is confirmed in the per-claim service agreement before you execute the LPOA. Fees may vary based on the age, amount, and complexity of the debt.</p>
|
||||
<h3>6.2 Third-Party Costs</h3>
|
||||
<p>Certain out-of-pocket costs may be incurred during the recovery process. These are disclosed to you before they are incurred and may include:</p>
|
||||
<ul>
|
||||
<li>Certified mail and postage fees (LetterStream).</li>
|
||||
<li>Online notarization fees (Proof.com or equivalent).</li>
|
||||
<li>Court filing fees, if litigation is pursued.</li>
|
||||
<li>Attorney fees, for Tiers 2.5 and 4.</li>
|
||||
</ul>
|
||||
<p>Third-party costs are deducted from the collected amount along with the success fee.</p>
|
||||
<h3>6.3 Disbursement</h3>
|
||||
<p>After DRE receives cleared funds from the debtor, we will:</p>
|
||||
<ol>
|
||||
<li>Deduct the agreed success fee.</li>
|
||||
<li>Deduct any authorized third-party costs.</li>
|
||||
<li>Remit the remaining balance to your designated payment method (ACH via Stripe Connect).</li>
|
||||
</ol>
|
||||
<p>Disbursement will be made within 30 calendar days of receipt of cleared funds, unless otherwise stated in your service agreement. A detailed fee statement will accompany each disbursement.</p>
|
||||
<h3>6.4 Direct Payments from Debtor</h3>
|
||||
<p>If you receive a direct payment from the debtor after the LPOA is executed, you must notify DRE immediately. Unless otherwise agreed, DRE's success fee applies to direct payments received during the LPOA period.</p>
|
||||
<hr />
|
||||
<h2>7. Client Representations, Warranties, and Obligations</h2>
|
||||
<h3>7.1 Representations and Warranties</h3>
|
||||
<p>You represent and warrant that:</p>
|
||||
<ul>
|
||||
<li>You are the lawful owner or authorized agent of the owner of the debt claim submitted.</li>
|
||||
<li>The debt is valid, enforceable, and not barred by any applicable statute of limitations.</li>
|
||||
<li>All information and documentation you have provided is true, accurate, and complete to the best of your knowledge.</li>
|
||||
<li>The debt has not been previously assigned, sold, or transferred to another party.</li>
|
||||
<li>There are no pending bankruptcy proceedings affecting the debt.</li>
|
||||
<li>You have not and will not take any action that would impair DRE's ability to collect the debt.</li>
|
||||
</ul>
|
||||
<h3>7.2 Ongoing Obligations</h3>
|
||||
<p>During the recovery process, you agree to:</p>
|
||||
<ul>
|
||||
<li>Cooperate with DRE and provide additional information or documentation as reasonably requested.</li>
|
||||
<li>Refrain from communicating directly with the debtor regarding the debt after the LPOA is executed, unless DRE gives prior written consent.</li>
|
||||
<li>Refrain from settling the debt or accepting payment directly without DRE's prior written consent.</li>
|
||||
<li>Promptly review all documents DRE provides and notify us of any errors or concerns.</li>
|
||||
<li>Notify DRE immediately of any change in your contact information, ownership of the debt, or bankruptcy filing by either party.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2>8. DRE's Rights and Obligations</h2>
|
||||
<h3>8.1 Collection Methods</h3>
|
||||
<p>DRE will use commercially reasonable efforts to collect debts. Authorized collection methods include:</p>
|
||||
<ul>
|
||||
<li>Written demand letters, delivered by email and certified mail.</li>
|
||||
<li>Telephone and electronic communications with the debtor.</li>
|
||||
<li>Negotiation of payment plans, settlements, or structured resolutions.</li>
|
||||
<li>Pre-lien notices for construction claims.</li>
|
||||
<li>Referral to partner law firms for litigation or lien filing at Tiers 2.5 and 4.</li>
|
||||
<li>Any other lawful means consistent with applicable law.</li>
|
||||
</ul>
|
||||
<h3>8.2 Compliance with Law</h3>
|
||||
<p>All DRE collection activities are conducted in compliance with:</p>
|
||||
<ul>
|
||||
<li>The Fair Debt Collection Practices Act (FDCPA), 15 U.S.C. § 1692 et seq.</li>
|
||||
<li>The Texas Finance Code, Chapter 392 (Texas Debt Collection Act).</li>
|
||||
<li>The Telephone Consumer Protection Act (TCPA), 47 U.S.C. § 227.</li>
|
||||
<li>The Fair Credit Reporting Act (FCRA), 15 U.S.C. § 1681 et seq., where applicable.</li>
|
||||
<li>All other applicable federal, state, and local laws.</li>
|
||||
</ul>
|
||||
<h3>8.3 DRE Discretion</h3>
|
||||
<p>DRE retains sole discretion over:</p>
|
||||
<ul>
|
||||
<li>Whether to accept a claim for recovery.</li>
|
||||
<li>The collection strategies and escalation timing used.</li>
|
||||
<li>Settlement amounts and payment terms offered to debtors.</li>
|
||||
<li>Whether to pursue legal action or refer a claim to counsel.</li>
|
||||
</ul>
|
||||
<h3>8.4 No Guarantee of Recovery</h3>
|
||||
<p>DRE makes no guarantee or warranty that any debt will be collected. We use commercially reasonable efforts, but recovery depends on factors beyond our control, including the debtor's financial condition, location, and willingness to pay.</p>
|
||||
<hr />
|
||||
<h2>9. Dispute Resolution</h2>
|
||||
<h3>9.1 Internal Dispute Resolution</h3>
|
||||
<p>If you have a dispute regarding our Services, you must first notify DRE in writing at the contact address provided on the Site or at the end of these Terms. DRE will review your dispute and respond within 30 calendar days. Both parties agree to attempt in good faith to resolve the dispute before initiating formal proceedings.</p>
|
||||
<h3>9.2 Debtor Disputes</h3>
|
||||
<p>If a debtor disputes the validity of a debt during the collection process, DRE will:</p>
|
||||
<ul>
|
||||
<li>Cease collection activities pending verification.</li>
|
||||
<li>Provide the debtor with verification of the debt as required by the FDCPA.</li>
|
||||
<li>Notify you of the dispute and request any additional supporting documentation.</li>
|
||||
</ul>
|
||||
<h3>9.3 Binding Arbitration</h3>
|
||||
<p><strong>All disputes arising out of or relating to these Terms, the Services, or any claim submitted to DRE — except those listed in Section 9.5 — shall be resolved exclusively through binding individual arbitration.</strong> The arbitration will be administered by the American Arbitration Association (AAA) under its Consumer Arbitration Rules or Commercial Arbitration Rules, as applicable.</p>
|
||||
<p>The arbitration will be conducted by a single neutral arbitrator in Travis County, Texas, unless the parties agree otherwise. The arbitrator's decision shall be final and binding and may be entered as a judgment in any court of competent jurisdiction. Each party shall bear its own costs and attorney fees, unless the arbitrator determines that an award of fees is warranted under applicable law.</p>
|
||||
<h3>9.4 Class Action Waiver</h3>
|
||||
<p>All claims must be brought in your individual capacity, and not as a plaintiff or class member in any purported class, collective, or representative proceeding. The arbitrator may not consolidate more than one person's claims and may not otherwise preside over any form of a representative or class proceeding. If this class action waiver is found to be unenforceable, the class claim must proceed in court rather than in arbitration.</p>
|
||||
<h3>9.5 Exceptions to Arbitration</h3>
|
||||
<p>The following claims are not subject to arbitration:</p>
|
||||
<ul>
|
||||
<li>Claims brought in small claims court, if they qualify under applicable jurisdictional limits.</li>
|
||||
<li>Claims for injunctive or equitable relief regarding unauthorized use of the Services or DRE's intellectual property.</li>
|
||||
<li>Claims under the FDCPA or Texas Finance Code that, by law, cannot be compelled to arbitration.</li>
|
||||
</ul>
|
||||
<h3>9.6 Governing Law</h3>
|
||||
<p>These Terms and any disputes arising under them shall be governed by and construed in accordance with the laws of the State of Texas, without regard to its conflict of laws principles. Federal law governs where applicable, including claims arising under the FDCPA, FCRA, and TCPA.</p>
|
||||
<hr />
|
||||
<h2>10. Limitation of Liability and Disclaimers</h2>
|
||||
<h3>10.1 Disclaimers</h3>
|
||||
<p>The Site and Services are provided on an "as is" and "as available" basis, without warranties of any kind, either express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, and non-infringement.</p>
|
||||
<p>DRE makes no warranty that any debt will be collected or that the Services will be uninterrupted, error-free, or completely secure. Content on the Site, AI-assisted claim analysis, and communications from DRE are for informational purposes only and do not constitute legal advice. No attorney-client relationship is created by your use of the Services.</p>
|
||||
<p>DRE is not responsible for the acts or omissions of third-party platforms used in connection with the Services, including Proof.com, LetterStream, Stripe, and partner law firms.</p>
|
||||
<h3>10.2 Limitation of Damages</h3>
|
||||
<p>To the maximum extent permitted by applicable law, DRE, its officers, directors, employees, agents, and affiliates shall not be liable for any indirect, incidental, special, consequential, or punitive damages, including but not limited to loss of profits, loss of business opportunity, loss of data, or loss of goodwill, arising out of or relating to these Terms or the Services, even if DRE has been advised of the possibility of such damages.</p>
|
||||
<h3>10.3 Liability Cap</h3>
|
||||
<p>DRE's total liability to you for any claim arising out of or relating to these Terms or the Services shall not exceed the lesser of:</p>
|
||||
<ul>
|
||||
<li>The amount of fees actually paid by you to DRE for the specific claim giving rise to the liability, or</li>
|
||||
<li>Five hundred dollars ($500.00).</li>
|
||||
</ul>
|
||||
<h3>10.4 Force Majeure</h3>
|
||||
<p>DRE shall not be liable for delays or failures in performance resulting from causes beyond its reasonable control, including but not limited to acts of God, natural disasters, war, civil unrest, pandemics, government actions, or internet or utility outages.</p>
|
||||
<hr />
|
||||
<h2>11. Indemnification</h2>
|
||||
<p>You agree to indemnify, defend, and hold harmless DRE, its officers, directors, employees, agents, and affiliates from and against any and all claims, liabilities, damages, losses, costs, and expenses (including reasonable attorney fees) arising out of or relating to:</p>
|
||||
<ul>
|
||||
<li>Your breach of these Terms or any representation or warranty made herein.</li>
|
||||
<li>Your submission of false, fraudulent, or inaccurate information.</li>
|
||||
<li>Your violation of any applicable law, including the FDCPA and Texas Finance Code.</li>
|
||||
<li>Your direct communication with the debtor after the LPOA is executed without DRE's prior written consent.</li>
|
||||
<li>Any claim that the debt you submitted is invalid, unenforceable, or not owned by you.</li>
|
||||
<li>Any act or omission by you that impairs DRE's ability to collect the debt.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2>12. Intellectual Property</h2>
|
||||
<h3>12.1 DRE Content</h3>
|
||||
<p>All content on the Site — including text, graphics, logos, software, documentation, and AI models and tools — is the property of DRE or its licensors and is protected by copyright, trademark, and other intellectual property laws.</p>
|
||||
<h3>12.2 Limited License</h3>
|
||||
<p>DRE grants you a limited, non-exclusive, non-transferable, revocable license to access and use the Site and Services for their intended purpose.</p>
|
||||
<h3>12.3 Restrictions</h3>
|
||||
<p>You may not:</p>
|
||||
<ul>
|
||||
<li>Copy, modify, distribute, or create derivative works of Site content without DRE's prior written consent.</li>
|
||||
<li>Use any data mining, robots, scrapers, or similar automated data-gathering tools on the Site.</li>
|
||||
<li>Reverse engineer, decompile, or disassemble any aspect of the Site or Services.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2>13. Privacy and Acceptable Use</h2>
|
||||
<h3>13.1 Privacy Policy</h3>
|
||||
<p>DRE's Privacy Policy, available at <a href="https://debtrecoveryexperts.com/privacy.html">Privacy Policy</a>, is incorporated into these Terms by reference. The Privacy Policy describes how DRE collects, uses, stores, shares, and protects your personal information.</p>
|
||||
<h3>13.2 Acceptable Use Policy</h3>
|
||||
<p>DRE's Acceptable Use Policy (AUP), available at <a href="https://debtrecoveryexperts.com/aup.html">Acceptable Use Policy</a>, is incorporated into these Terms by reference. The AUP defines acceptable and prohibited conduct when using the Site and Services. Violations of the AUP are violations of these Terms and may result in account suspension or termination.</p>
|
||||
<hr />
|
||||
<h2>14. Termination</h2>
|
||||
<h3>14.1 Termination by Client</h3>
|
||||
<p>You may terminate your account at any time by providing written notice to DRE. Termination does not affect:</p>
|
||||
<ul>
|
||||
<li>Obligations under any active LPOA.</li>
|
||||
<li>Fees owed for services already rendered.</li>
|
||||
<li>Any provision of these Terms that by its nature survives termination, including Sections 6, 7, 9, 10, and 11.</li>
|
||||
</ul>
|
||||
<h3>14.2 Termination by DRE</h3>
|
||||
<p>DRE may suspend or terminate your access to the Services at any time, with or without cause, including if DRE reasonably believes you have violated these Terms or the AUP. DRE will provide notice of termination where practicable.</p>
|
||||
<h3>14.3 Effect of Termination</h3>
|
||||
<p>Upon termination:</p>
|
||||
<ul>
|
||||
<li>You must cease all use of the Services.</li>
|
||||
<li>Active collection efforts on pending claims will cease, and any active LPOA will be terminated.</li>
|
||||
<li>DRE retains the right to collect its fees for work performed prior to termination.</li>
|
||||
<li>Your data will be handled in accordance with the Privacy Policy's retention schedule.</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h2>15. General Provisions</h2>
|
||||
<p><strong>Entire Agreement.</strong> These Terms, together with the Privacy Policy, Acceptable Use Policy, and any per-claim service agreement, constitute the entire agreement between you and DRE regarding the Services.</p>
|
||||
<p><strong>Amendment.</strong> DRE may modify these Terms at any time. Changes will be effective upon posting to the Site. Material changes will be communicated to you via email (if on file) and/or a Site notice. Your continued use of the Services after changes are posted constitutes acceptance of the modified Terms.</p>
|
||||
<p><strong>Severability.</strong> If any provision of these Terms is found to be invalid or unenforceable, that provision shall be severed, and the remaining provisions shall remain in full force and effect.</p>
|
||||
<p><strong>Waiver.</strong> DRE's failure to enforce any provision of these Terms shall not constitute a waiver of that provision or any other provision.</p>
|
||||
<p><strong>Assignment.</strong> You may not assign your rights or obligations under these Terms without DRE's prior written consent. DRE may assign these Terms without restriction.</p>
|
||||
<p><strong>Notices.</strong> All written notices to DRE must be sent to the contact information provided at the end of these Terms. Notices to you will be sent to the email address associated with your account.</p>
|
||||
<p><strong>Survival.</strong> Sections related to fees, indemnification, limitation of liability, arbitration, governing law, and any other provisions that by their nature should survive, will survive termination of these Terms.</p>
|
||||
<hr />
|
||||
<h2>16. Contact Information</h2>
|
||||
<p>For questions, disputes, or notices under these Terms, contact:</p>
|
||||
<p><strong>Debt Recovery Experts (DRE)</strong>
|
||||
[Street Address]
|
||||
[City], TX
|
||||
Email: support@debtrecoveryexperts.com
|
||||
Phone: [Phone]
|
||||
Website: https://debtrecoveryexperts.com</p>
|
||||
<hr />
|
||||
<blockquote>
|
||||
<p><strong>⚠️ ATTORNEY REVIEW REQUIRED:</strong> This document must be reviewed by a licensed Texas attorney before publication. Key review items: arbitration enforceability under Texas and federal law, liability cap compliance with Texas Finance Code Chapter 392, FDCPA carve-outs and applicability to B2B commercial collections, LPOA scope language per Texas Estates Code, and incorporation-by-reference validity for the Privacy Policy and AUP.</p>
|
||||
</blockquote>
|
||||
<hr />
|
||||
<p>© 2026 Debt Recovery Experts. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="inner">
|
||||
<p class="miranda">Debt Recovery Experts is a debt collection firm. This communication is an attempt to collect a debt and any information obtained will be used for that purpose, where applicable. You have rights under the federal Fair Debt Collection Practices Act and the Texas Debt Collection Act, including the right to dispute a debt and request verification.</p>
|
||||
<div class="links">
|
||||
<a href="/privacy.html">Privacy Policy</a>
|
||||
<a href="/terms.html">Terms of Use</a>
|
||||
<a href="/aup.html">Acceptable Use Policy</a>
|
||||
<a href="/sms-terms.html">SMS & 10DLC</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,133 @@
|
||||
# Feedback Sprint Report: DRE Landing Page (3-variant comparison)
|
||||
|
||||
**Product:** Debt Recovery Experts (DRE) — Texas commercial/consumer debt recovery
|
||||
**URLs:**
|
||||
- A: https://mockups.itpropartner.com/dre/institutional-trust.html
|
||||
- B: https://mockups.itpropartner.com/dre/conversion-results.html
|
||||
- C: https://mockups.itpropartner.com/dre/modern-platform.html
|
||||
**Date:** 2026-08-21
|
||||
**Reviewers:** 3 dispatched, 3 survived moderation (0 rejected)
|
||||
**Model:** claude-sonnet-5 (delegation-pinned), rotated order, blind
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
> **Ready after 3 blocking fixes** — institutional-trust wins the identity vote
|
||||
> (8/6/4 scoreboard, 2 of 3 first-place votes, 2 of 3 identity picks), but it ships
|
||||
> with a legal-liability risk in the logo subtitle, visible placeholder brackets,
|
||||
> and a weak CTA hierarchy. Those three are gating; a fourth item (real proof
|
||||
> points) is a launch-gate, not a mockup fix.
|
||||
|
||||
## Scoreboard (3/2/1 by rank)
|
||||
|
||||
| Candidate | A | B | C | Total |
|
||||
|---|---|---|---|---|
|
||||
| institutional-trust.html | 2 | 3 | 3 | **8** |
|
||||
| conversion-results.html | 3 | 2 | 1 | **6** |
|
||||
| modern-platform.html | 1 | 1 | 2 | **4** |
|
||||
|
||||
**Identity pick:** institutional-trust (B + C). Reviewer A picked conversion-results.
|
||||
Clear winner, not a tie.
|
||||
|
||||
## Blocking fixes (must fix before shipping institutional-trust)
|
||||
|
||||
1. **"TEXAS DEBT RECOVERY COUNSEL" logo subtitle** — implies attorney/legal
|
||||
representation. A debt collector cannot present itself as a law firm (FDCPA /
|
||||
deceptive-practice exposure). Reword to "LICENSED TEXAS RECOVERY FIRM" or drop
|
||||
"Counsel" entirely. Flagged by B (#1 fix) and A.
|
||||
2. **Visible placeholder brackets** — "[Phone number]", "[Office address]",
|
||||
"[Licensing and bond information placeholder]" in the top utility bar and footer.
|
||||
Resolve with real content before launch. Flagged by A + B.
|
||||
3. **CTA hierarchy** — "Submit a Claim" and "Make a Payment" carry near-equal
|
||||
visual weight (solid navy vs outline), and the header "Client Login" adds a
|
||||
third similar navy button. Make "Submit a Claim" the unambiguous primary.
|
||||
Flagged by A + C.
|
||||
|
||||
## Launch-gate (not a mockup fix — requires real data)
|
||||
|
||||
4. **Proof of results** — zero testimonials, case studies, recovery-rate stats, or
|
||||
years-in-business anywhere. Intentional (mockups were built under a
|
||||
no-fabricated-stats rule) and correct to leave blank, but a real prospect
|
||||
evaluating a recovery firm expects it. Supply real numbers before the site goes
|
||||
live. Flagged by all 3 reviewers.
|
||||
|
||||
## Secondary polish (non-blocking)
|
||||
|
||||
- **Hero is a text wall** — no visual in the hero despite a "track it in real time"
|
||||
subhead promise. Add a lightweight dashboard/portal visual. (A #1)
|
||||
- **"Ready to place an account?"** — "place an account" is industry jargon; use
|
||||
"Submit a Claim" plain language. (B #3)
|
||||
- **"SEC" compliance badge** — reads as Securities and Exchange Commission, not
|
||||
"Secure data handling." Relabel (lock icon / "DATA"). (C #3)
|
||||
- **Compliance strip blends into header** — both dark navy; differentiate the top
|
||||
strip so it reads as a distinct trust signal. (C #1)
|
||||
|
||||
## Positioning check (target-user guess)
|
||||
|
||||
| Reviewer | Guessed target user | Match? |
|
||||
|---|---|---|
|
||||
| A | Texas SMB owner/bookkeeper, compliance-conscious, owed money | Yes |
|
||||
| B | Risk-averse Texas business, finance/legal/ops, wary of aggressive collectors | Yes |
|
||||
| C | Texas SMB, finance/legal-adjacent decision-maker, risk-averse | Yes |
|
||||
|
||||
**Reading:** PASS. All three independently named the same user — a Texas business
|
||||
owed money, specifically a risk/compliance-conscious buyer — with no positioning
|
||||
telegraph. This confirms the two-sided (creditor + debtor) structure lands, and
|
||||
that debt recovery is a trust-first purchase. The institutional-trust direction
|
||||
consistently pulled the "risk-averse, compliance-conscious" read that matches the
|
||||
actual target; conversion-results pulled "transactional/urgent," modern-platform
|
||||
pulled "tech-forward SaaS" — i.e. the winning design direction also produces the
|
||||
most accurate target-user read.
|
||||
|
||||
## Consensus pros
|
||||
|
||||
- Dual CTA (Submit a Claim / Make a Payment) cleanly splits creditor vs debtor — all 3.
|
||||
- Explicit FDCPA + TDCPA naming with plain-English explanations = strongest trust
|
||||
signal; institutional-trust does it best (A + B).
|
||||
- "No upfront retainer required" objection handler placed at the decision point (A + C).
|
||||
- Serif + navy/gold register reads as "institutional/law-adjacent," distinct from
|
||||
generic SaaS (all 3, on institutional-trust).
|
||||
|
||||
## Consensus cons
|
||||
|
||||
- Hero placeholder brackets = single most credibility-damaging element
|
||||
(conversion-results "[Recovery rate]", modern-platform "[Account name]") — all 3.
|
||||
- No social proof / testimonials / recovery stats — all 3.
|
||||
- "Counsel" subtitle implies law-firm relationship (liability) — A + B.
|
||||
- No pricing/fee model (contingency vs flat fee) — B + C.
|
||||
|
||||
## Taste calls (single-reviewer)
|
||||
|
||||
- "without cutting corners" / "not a marketing claim" reads defensive — C only
|
||||
(note: A + B both PRAISED the "not a marketing claim" line as a trust win —
|
||||
genuine divergence, treat as a split, not a defect).
|
||||
- Gold accent too subtle / near-monochrome — B + C (borderline consensus).
|
||||
|
||||
## Moderation notes
|
||||
|
||||
- Submissions rejected as slop: 0.
|
||||
- All 3 verified `window.location.href` before each review (no cross-reviewer
|
||||
browser contamination on the shared session).
|
||||
- Reviewer C caught and corrected a vision-model confabulation (false "orange"
|
||||
accent read on modern-platform; actual CSS is green/cyan) against curl'd CSS.
|
||||
|
||||
## Convergence synthesis (per skill)
|
||||
|
||||
Keep **institutional-trust** as the identity (serif + navy/gold + compliance-first
|
||||
is the correct trust register for debt collection). Port the winning elements from
|
||||
the losing directions:
|
||||
|
||||
- **From conversion-results:** benefit-driven headline energy ("Stop chasing...
|
||||
Get them recovered."), the "No upfront retainer required" objection handler, and
|
||||
the solid-vs-outline CTA hierarchy.
|
||||
- **From modern-platform:** a single clean dashboard/portal visual in the hero
|
||||
(fixes the text-wall gap without adopting the SaaS-first framing).
|
||||
|
||||
## What was NOT reviewed
|
||||
|
||||
- Mobile navigation behavior (mockups are static, no JS — hamburger/toggle
|
||||
functionality out of scope for a static-HTML comparison).
|
||||
- Backend / portal / intake flows (separate workstream).
|
||||
- Real legal review of the compliance language (flagged as a required pre-launch
|
||||
step by the reviewers themselves).
|
||||
@@ -0,0 +1,42 @@
|
||||
/* ============================================================
|
||||
DRE shared theme — unifies portal / pay / internal to the
|
||||
debtrecoveryexperts.com homepage palette.
|
||||
Navy #16213a · Gold #9c7c3f · Cream neutrals · Source Serif 4 + Inter
|
||||
============================================================ */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600;8..60,700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif !important;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif !important;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* amber -> DRE gold #9c7c3f */
|
||||
.text-amber-400, .text-amber-500, .text-amber-600 { color: #9c7c3f !important; }
|
||||
.hover\:text-amber-400:hover, .hover\:text-amber-500:hover, .hover\:text-amber-600:hover { color: #9c7c3f !important; }
|
||||
.group:hover .group-hover\:text-amber-400 { color: #9c7c3f !important; }
|
||||
|
||||
.border-amber-200, .border-amber-300, .border-amber-400, .border-amber-500, .border-amber-600 { border-color: #9c7c3f !important; }
|
||||
.border-amber\/20, .border-amber\/30 { border-color: rgba(156, 124, 63, 0.25) !important; }
|
||||
|
||||
.bg-amber-50 { background-color: #faf6ee !important; }
|
||||
.bg-amber-100 { background-color: #f3ead9 !important; }
|
||||
.bg-amber-500, .bg-amber-600 { background-color: #9c7c3f !important; }
|
||||
.bg-amber-500\/5, .bg-amber\/5 { background-color: rgba(156, 124, 63, 0.05) !important; }
|
||||
.bg-amber-500\/10, .bg-amber\/10 { background-color: rgba(156, 124, 63, 0.1) !important; }
|
||||
.bg-amber-500\/20, .bg-amber\/20 { background-color: rgba(156, 124, 63, 0.2) !important; }
|
||||
.hover\:bg-amber-500:hover, .hover\:bg-amber-600:hover, .hover\:bg-amber-700:hover { background-color: #8a6d35 !important; }
|
||||
.hover\:bg-amber\/20:hover { background-color: rgba(156, 124, 63, 0.2) !important; }
|
||||
|
||||
.ring-amber-500, .ring-amber-400 { --tw-ring-color: #9c7c3f !important; }
|
||||
.focus\:ring-amber-500:focus, .focus\:ring-amber-400:focus { --tw-ring-color: #9c7c3f !important; }
|
||||
|
||||
/* slate-900/800 -> DRE navy family */
|
||||
.bg-\[\#0f172a\] { background-color: #16213a !important; }
|
||||
.bg-\[\#1e293b\] { background-color: #2b3d5f !important; }
|
||||
.border-\[\#1e293b\] { border-color: #2b3d5f !important; }
|
||||
|
||||
/* homepage warm neutrals for light sections */
|
||||
.bg-slate-50, .bg-gray-50 { background-color: #faf9f6 !important; }
|
||||
Reference in New Issue
Block a user