"""Covenant Score — one formula-driven grade for any company, computed from the official record across every spectrum we track. PUBLISHED METHODOLOGY, never editable, never adjusted for money (same firewall as rankings + seal). Plus the $19 Covenant Report: every spectrum pulled into one cited document — a Carfax for companies.""" import io import os import uuid import asyncio import re import logging from datetime import datetime, timezone, timedelta from urllib.parse import quote import httpx from fastapi import APIRouter, HTTPException, Response from pydantic import BaseModel from deps import db logger = logging.getLogger("covenantscore") router = APIRouter(prefix="/api/score") REPORT_PRICE = 19.0 UA = {"User-Agent": "AlphaCovenantScore/1.0 (support@alphacovenantholdings.com)"} METHODOLOGY = { "start": "Every company starts at 100 points.", "components": [ {"name": "Federal complaint trend vs market (CFPB)", "rule": "The company's 12-month change in complaints minus the change in ALL CFPB complaints over the same window. " "≤ −10 points below market +3 · within ±10 of market 0 · +10 to +25 above −5 · +25 to +50 above −10 · more than +50 above −15. " "Scored only at ≥20 complaints/yr. Raw counts are NOT scored because they scale with company size; measuring against the market removes industry-wide surges."}, {"name": "Relief rate vs market (CFPB)", "rule": "Share of the company's closed complaints that ended with monetary or non-monetary relief, minus the same share for all CFPB complaints. " "≥ +10 points above market +5 · within −5 to +10 0 · −5 to −15 below −5 · more than −15 below −10. A rate, so it does not scale with size."}, {"name": "Missed 15-day response window (CFPB)", "rule": "Share of complaints the company answered after the CFPB's 15-day window. ≥ 5% late −5 · ≥ 3× the market rate (min 1%) −2 · otherwise 0."}, {"name": "Product safety recalls (FDA food + CPSC, last 24 months)", "rule": "−5 per matched recall, capped at −15"}, {"name": "Federal court records", "rule": "Shown as context, NEVER scored — litigation volume scales with size and cases are not verdicts"}, {"name": "FDIC insurance (banks)", "rule": "Active FDIC-insured +2"}, ], "inputs_rule": "Only official government records are scored. Private or vendor checks (website security, reviews, news) are never inputs.", "grades": "A ≥ 90 · B ≥ 80 · C ≥ 70 · D ≥ 60 · F < 60", "governance": "The formula is changed only by a dated, published version. Every version is listed below with what changed and why; all companies are re-scored on the new version at once. No version is ever applied to one company and not another, and no change is made for or at the request of any company.", "versions": [ {"version": "2026-09-14c", "date": "2026-09-14", "change": "Added two size-neutral rate components: relief rate vs market and missed 15-day response window.", "why": "Trend alone, once market-adjusted, did not separate companies that resolve complaints from those that only explain them."}, {"version": "2026-09-14b", "date": "2026-09-14", "change": "Complaint trend is now measured against the change in ALL CFPB complaints over the same window, with graded penalties, instead of a flat −15 for any increase.", "why": "A flat penalty treated a company rising with an industry-wide surge the same as one rising alone; the penalty was flat while the signal was not."}, {"version": "2026-09-14", "date": "2026-09-14", "change": "Removed the passive website-security check as a score input.", "why": "It was the only input not drawn from a government record."}, {"version": "2026-06", "date": "2026-06-01", "change": "Original formula: CFPB trend (flat ±), recalls, security posture, FDIC, courts as context.", "why": "Launch."}, ], "firewall": "The formula is published, machine-computed from official records, and is never " "altered, delayed, or withheld for any client, prospect, or payment.", "caveats": ["Complaints, cases and recalls are official filings, not adjudicated wrongdoing.", "The score reflects public-record signals only — it is not financial, legal or safety advice."], } async def _recall_matches(client: httpx.AsyncClient, company: str) -> list[str]: hits = [] d0 = (datetime.now(timezone.utc) - timedelta(days=730)) try: r = await client.get("https://api.fda.gov/food/enforcement.json", params={"search": f'recalling_firm:"{company}" AND report_date:' f'[{d0.strftime("%Y%m%d")} TO {datetime.now(timezone.utc).strftime("%Y%m%d")}]', "limit": 5}, timeout=20) if r.status_code == 200: hits += [f"FDA: {x.get('product_description', '')[:90]}" for x in r.json().get("results", [])] except Exception: pass try: r = await client.get("https://www.saferproducts.gov/RestWebServices/Recall", params={"format": "json", "RecallDateStart": d0.strftime("%Y-%m-%d"), "Manufacturer": company}, timeout=25) if r.status_code == 200: hits += [f"CPSC: {x.get('Title', '')[:90]}" for x in r.json()[:5]] except Exception: pass return hits[:6] async def _courts_total(client: httpx.AsyncClient, company: str) -> tuple[int, list[dict]]: try: r = await client.get("https://www.courtlistener.com/api/rest/v4/search/", params={"q": f'"{company}"', "type": "r", "page_size": 5}, headers=UA, timeout=30) r.raise_for_status() d = r.json() rows = [{"case": x.get("caseName"), "court": x.get("court"), "date_filed": x.get("dateFiled")} for x in d.get("results", [])] return d.get("count", 0), rows except Exception: return -1, [] async def _fdic_active(client: httpx.AsyncClient, company: str) -> bool | None: try: r = await client.get("https://api.fdic.gov/banks/institutions", params={"search": f"NAME:{company}", "limit": 1, "fields": "NAME,ACTIVE"}, headers=UA, timeout=20) rows = r.json().get("data", []) if not rows: return None return rows[0].get("data", {}).get("ACTIVE") == 1 except Exception: return None def _u(base: str, params) -> str: from urllib.parse import urlencode return f"{base}?{urlencode(params, doseq=True)}" def _qkey(company: str, domain: str = "") -> str: return f"{(company or '').strip().lower()}|{(domain or '').strip().lower()}" async def resolve_key(company: str, domain: str = "") -> str: """Every spelling of a company resolves to ONE cache key — the CFPB canonical name — via score_aliases.""" key = _qkey(company, domain) alias = await db.score_aliases.find_one({"query_key": key}, {"_id": 0, "key": 1}) return alias["key"] if alias else key async def dedupe_scores() -> dict: """Idempotent: one covenant_scores doc per (canonical company, domain). Freshest copy kept; other spellings become aliases. Runs at boot; result published.""" docs = await db.covenant_scores.find({}, {"_id": 0, "key": 1, "company": 1, "domain": 1, "cached_at": 1}).to_list(5000) groups: dict[str, list] = {} for d in docs: groups.setdefault(_qkey(d.get("company") or d["key"].split("|")[0], d.get("domain") or d["key"].split("|")[-1]), []).append(d) removed, renamed = 0, 0 for ckey, ds in groups.items(): ds.sort(key=lambda d: d.get("cached_at", ""), reverse=True) keep = ds[0] for d in ds[1:]: await db.covenant_scores.delete_one({"key": d["key"]}) await db.score_aliases.update_one({"query_key": d["key"]}, {"$set": {"key": ckey}}, upsert=True) removed += 1 if keep["key"] != ckey: await db.covenant_scores.update_one({"key": keep["key"]}, {"$set": {"key": ckey}}) await db.score_aliases.update_one({"query_key": keep["key"]}, {"$set": {"key": ckey}}, upsert=True) renamed += 1 out = {"companies": len(groups), "duplicates_removed": removed, "keys_canonicalised": renamed, "at": datetime.now(timezone.utc).isoformat(), "rule": "One published copy per company as filed with the CFPB; other spellings are aliases to it. The freshest copy is kept; nothing is recomputed by this step."} if removed or renamed: await db.score_dedupe_runs.insert_one(dict(out)) return out async def compute_score(company: str, domain: str = "") -> dict: """The published formula, machine-applied. Cached 24h. One cache doc per canonical (CFPB-filed) company name.""" qkey = _qkey(company, domain) key = await resolve_key(company, domain) cached = await db.covenant_scores.find_one({"key": key}, {"_id": 0}) if cached and cached.get("formula_version") == "2026-09-14c" and cached.get("reproduce") and cached.get("cached_at", "") > (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat(): return cached import watchdog score = 100 components = [] snap = await watchdog.company_snapshot(db, company) canonical_key = _qkey(snap.get("company", company.title()), domain) if canonical_key != key: if cached: await db.covenant_scores.delete_one({"key": key}) cached = await db.covenant_scores.find_one({"key": canonical_key}, {"_id": 0}) key = canonical_key if qkey != key: await db.score_aliases.update_one({"query_key": qkey}, {"$set": {"key": key}}, upsert=True) n12 = snap.get("last_12mo", 0) n0 = snap.get("prior_12mo", 0) trend = snap.get("trend", "flat") market = await watchdog.market_change(db) co_chg = round((n12 - n0) / n0 * 100, 1) if n0 else None excess = round(co_chg - market["change_pct"], 1) if co_chg is not None and market.get("change_pct") is not None else None from datetime import date from watchdog import CFPB today = date.today(); y1 = today.replace(year=today.year - 1); y2 = y1.replace(year=y1.year - 1) canonical = snap.get("filed_as") or company d365 = (datetime.now(timezone.utc) - timedelta(days=365)).strftime("%Y-%m-%d") steps = [ {"n": 1, "what": "Company complaints, last 12 months (hits.total.value)", "value": n12, "url": _u(CFPB, {"company": canonical, "date_received_min": y1.isoformat(), "date_received_max": today.isoformat(), "size": 0})}, {"n": 2, "what": "Company complaints, prior 12 months", "value": n0, "url": _u(CFPB, {"company": canonical, "date_received_min": y2.isoformat(), "date_received_max": y1.isoformat(), "size": 0})}, {"n": 3, "what": "Company change % = (step1 − step2) ÷ step2 × 100", "value": co_chg, "url": None}, {"n": 4, "what": "ALL CFPB complaints, last 12 months", "value": market.get("last_12mo"), "url": _u(CFPB, {"date_received_min": y1.isoformat(), "date_received_max": today.isoformat(), "size": 0})}, {"n": 5, "what": "ALL CFPB complaints, prior 12 months", "value": market.get("prior_12mo"), "url": _u(CFPB, {"date_received_min": y2.isoformat(), "date_received_max": (y1 - timedelta(days=1)).isoformat(), "size": 0})}, {"n": 6, "what": "Market change % = (step4 − step5) ÷ step5 × 100", "value": market.get("change_pct"), "url": None}, {"n": 7, "what": "Excess = step3 − step6 (points vs market)", "value": excess, "url": None}, ] if n12 >= 20 and excess is not None: pts = 3 if excess <= -10 else 0 if excess < 10 else -5 if excess < 25 else -10 if excess < 50 else -15 detail = (f"{n12:,} complaints last 12mo, {'+' if co_chg > 0 else ''}{co_chg}% vs prior year; all CFPB complaints moved " f"{'+' if market['change_pct'] > 0 else ''}{market['change_pct']}% in the same window → {'+' if excess > 0 else ''}{excess} points vs the market") else: pts = 0 detail = f"{n12:,} complaints last 12mo, trend {trend}" + ("" if n12 >= 20 else " (below scoring threshold)") score += pts steps.append({"n": 8, "what": "Trend points: needs ≥20 complaints; excess ≤ −10 → +3 · < +10 → 0 · < +25 → −5 · < +50 → −10 · else −15", "value": pts, "url": None}) components.append({"name": "Federal complaint trend vs market (CFPB)", "points": pts, "detail": detail, "source": "CFPB Consumer Complaint Database"}) if n12 >= 20: import rootcause rr = await rootcause.response_record(company) y = rr.get("last_12mo") or {} closed = (y.get("closed_with_relief") or 0) + (y.get("closed_with_explanation_only") or 0) relief_pct = round((y.get("closed_with_relief") or 0) / closed * 100, 1) if closed else None mk_relief = market.get("relief_pct_of_closed") steps += [ {"n": 9, "what": "Company closed with monetary relief, last 365 days", "value": None, "url": _u(CFPB, [("company", canonical), ("size", 0), ("company_response", "Closed with monetary relief"), ("date_received_min", d365)])}, {"n": 10, "what": "Company closed with non-monetary relief, last 365 days (step9 + step10 = relief)", "value": y.get("closed_with_relief"), "url": _u(CFPB, [("company", canonical), ("size", 0), ("company_response", "Closed with non-monetary relief"), ("date_received_min", d365)])}, {"n": 11, "what": "Company closed with explanation, last 365 days", "value": y.get("closed_with_explanation_only"), "url": _u(CFPB, [("company", canonical), ("size", 0), ("company_response", "Closed with explanation"), ("date_received_min", d365)])}, {"n": 12, "what": "Relief % = relief ÷ (relief + explanation) × 100", "value": relief_pct, "url": None}, {"n": 13, "what": "Market relief % of closed (company_response buckets in step 4's aggregations)", "value": mk_relief, "url": None}, ] if relief_pct is not None and mk_relief is not None: gap = round(relief_pct - mk_relief, 1) pts = 5 if gap >= 10 else 0 if gap > -5 else -5 if gap > -15 else -10 score += pts steps.append({"n": 14, "what": "Relief points: gap = step12 − step13; ≥ +10 → +5 · > −5 → 0 · > −15 → −5 · else −10", "value": pts, "url": None}) components.append({"name": "Relief rate vs market (CFPB)", "points": pts, "detail": f"{relief_pct}% of closed complaints ended with relief; market {mk_relief}% → {'+' if gap > 0 else ''}{gap} points vs the market", "source": "CFPB Consumer Complaint Database — company_response"}) untimely_pct = y.get("missed_15day_pct") mk_unt = market.get("untimely_pct") steps += [ {"n": 15, "what": "Company complaints answered late (timely=No), last 365 days", "value": None, "url": _u(CFPB, [("company", canonical), ("size", 0), ("timely", "No"), ("date_received_min", d365)])}, {"n": 16, "what": "Company complaints total, last 365 days; late % = step15 ÷ step16 × 100", "value": untimely_pct, "url": _u(CFPB, [("company", canonical), ("size", 0), ("date_received_min", d365)])}, {"n": 17, "what": "Market late % (timely buckets in step 4's aggregations)", "value": mk_unt, "url": None}, ] if untimely_pct is not None and mk_unt is not None: pts = -5 if untimely_pct >= 5 else -2 if untimely_pct >= max(1.0, mk_unt * 3) else 0 score += pts steps.append({"n": 18, "what": "Late points: ≥ 5% → −5 · ≥ 3× market (min 1%) → −2 · else 0", "value": pts, "url": None}) components.append({"name": "Missed 15-day response window (CFPB)", "points": pts, "detail": f"{untimely_pct}% of complaints answered late; market {mk_unt}%", "source": "CFPB Consumer Complaint Database — timely"}) async with httpx.AsyncClient() as client: recalls, (courts_n, court_rows), fdic = await asyncio.gather( _recall_matches(client, snap.get("company", company)), _courts_total(client, snap.get("company", company)), _fdic_active(client, company)) pts = -min(len(recalls) * 5, 15) score += pts d730 = datetime.now(timezone.utc) - timedelta(days=730) steps += [ {"n": 19, "what": "FDA enforcement recalls naming the firm, 24 months", "value": len([h for h in recalls if h.startswith("FDA")]), "url": _u("https://api.fda.gov/food/enforcement.json", {"search": f'recalling_firm:"{snap.get("company", company)}" AND report_date:[{d730.strftime("%Y%m%d")} TO {datetime.now(timezone.utc).strftime("%Y%m%d")}]', "limit": 5})}, {"n": 20, "what": "CPSC recalls with this manufacturer, 24 months", "value": len([h for h in recalls if h.startswith("CPSC")]), "url": _u("https://www.saferproducts.gov/RestWebServices/Recall", {"format": "json", "RecallDateStart": d730.strftime("%Y-%m-%d"), "Manufacturer": snap.get("company", company)})}, {"n": 21, "what": "Recall points = −5 per matched recall, floor −15", "value": pts, "url": None}, {"n": 22, "what": "Federal court records (shown, never scored)", "value": courts_n, "url": _u("https://www.courtlistener.com/api/rest/v4/search/", {"q": f'"{snap.get("company", company)}"', "type": "r", "page_size": 5})}, {"n": 23, "what": "FDIC BankFind ACTIVE flag (+2 if an active insured institution; otherwise no component)", "value": fdic, "url": _u("https://api.fdic.gov/banks/institutions", {"search": f"NAME:{company}", "limit": 1, "fields": "NAME,ACTIVE"})}, ] components.append({"name": "Product safety recalls (24 months)", "points": pts, "detail": f"{len(recalls)} matched recall(s)" + (": " + "; ".join(recalls[:2]) if recalls else ""), "source": "FDA + CPSC official recall records"}) sec_grade = None components.append({"name": "Federal court records", "points": 0, "detail": (f"{courts_n:,} matching records — shown, never scored: litigation volume scales with company size and a case is not a verdict" if courts_n >= 0 else "lookup unavailable"), "source": "PACER via CourtListener/RECAP"}) if fdic is not None: pts = 2 if fdic else 0 score += pts components.append({"name": "FDIC insurance", "points": pts, "detail": "active FDIC-insured institution" if fdic else "matched but not active", "source": "FDIC BankFind"}) score = max(0, min(100, score)) steps.append({"n": 24, "what": "Score = 100 + all points, clamped 0–100; grade A ≥ 90 · B ≥ 80 · C ≥ 70 · D ≥ 60 · else F", "value": score, "url": None}) grade = "A" if score >= 90 else "B" if score >= 80 else "C" if score >= 70 else "D" if score >= 60 else "F" result = {"key": key, "company": snap.get("company", company.title()), "domain": domain.strip().lower(), "score": score, "grade": grade, "components": components, "cfpb": {"last_12mo": n12, "trend": trend, "top_issues": snap.get("top_issues", [])}, "courts": {"total": courts_n, "recent": court_rows}, "recalls": recalls, "security_grade": sec_grade, "formula_version": "2026-09-14c", "reproduce": {"note": "Every input below is a public API call anyone can open. Counts move as the CFPB adds complaints, so re-running later gives the numbers as of that day; the arithmetic is fixed by the formula version.", "canonical_company": canonical, "steps": steps}, "sources_pulled_at": {"cfpb": snap.get("cached_at")}, "methodology_note": METHODOLOGY["firewall"], "disclaimer": "Official public-record signals only — filings are not adjudicated " "wrongdoing. Not financial, legal or safety advice.", "cached_at": datetime.now(timezone.utc).isoformat()} if cached and cached.get("score") is not None and cached.get("score") != score: old_steps = {st["n"]: st for st in (cached.get("reproduce") or {}).get("steps", []) if st.get("url")} moved = [{"n": st["n"], "input": st["what"], "old": old_steps[st["n"]].get("value"), "new": st.get("value"), "url": st.get("url")} for st in steps if st.get("url") and st["n"] in old_steps and old_steps[st["n"]].get("value") != st.get("value")] await db.score_changes.insert_one({"key": key, "company": result["company"], "at": result["cached_at"], "old": cached["score"], "new": score, "old_grade": cached.get("grade"), "new_grade": grade, "old_formula": cached.get("formula_version"), "new_formula": "2026-09-14c", "moved_inputs": moved, "cause": "formula version changed" if cached.get("formula_version") != "2026-09-14c" else ("source data changed" if moved else "source data changed (inputs not itemised for the previous pull)")}) await db.covenant_scores.update_one({"key": key}, {"$set": result}, upsert=True) return result @router.get("/changes") async def score_changes(company: str, limit: int = 50): """Public log of every move in a company's score: old → new, which public inputs moved. Scores move only when a source or the published formula moves.""" key_prefix = f"{company.strip().lower()}|" ckey = await resolve_key(company) rows = await db.score_changes.find({"$or": [{"key": {"$regex": "^" + re.escape(key_prefix)}}, {"key": ckey}]}, {"_id": 0}).sort("at", -1).to_list(min(limit, 200)) return {"company": company, "changes": rows, "note": "A score changes for one of two reasons only: a public input moved at the source, or the published formula version changed (then it changes for every company at once). No other path exists."} @router.get("/methodology") async def methodology(): return METHODOLOGY @router.get("") async def get_score(company: str = "", domain: str = ""): company = (company or "").strip() if len(company) < 2: raise HTTPException(status_code=400, detail="Enter a company name.") result = await compute_score(company, domain) result.pop("_id", None) import cite as cite_mod src, fld = "CFPB Consumer Complaint Database", "complaints, last 12 months" result["cite"] = f"/api/cite/{cite_mod.figure_id(src, result['company'], fld)}?source={quote(src)}&subject={quote(result['company'])}&field={quote(fld)}" await db.lookup_counts.update_one({"day": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "kind": "score"}, {"$inc": {"count": 1}}, upsert=True) return result # ---------- $19 Covenant Report ---------- class ReportCheckoutReq(BaseModel): company: str domain: str = "" email: str @router.post("/report/checkout", status_code=201) async def report_checkout(req: ReportCheckoutReq): email = (req.email or "").strip().lower() if "@" not in email or len(email) < 5: raise HTTPException(status_code=400, detail="Please enter a valid email.") if len((req.company or "").strip()) < 2: raise HTTPException(status_code=400, detail="Enter a company name.") key = os.environ.get("STRIPE_API_KEY") if not key: raise HTTPException(status_code=503, detail="Checkout is temporarily unavailable.") import holyday obs = holyday.observance() if obs["resting"]: raise HTTPException(status_code=423, detail=obs["message"]) from emergentintegrations.payments.stripe.checkout import StripeCheckout, CheckoutSessionRequest base = (os.environ.get("PUBLIC_BASE_URL") or "").rstrip("/") metadata = {"source": "covreport", "email": email, "company": req.company.strip()[:120], "domain": (req.domain or "").strip().lower()[:100]} checkout = StripeCheckout(api_key=key, webhook_url=f"{base}/api/webhook/stripe") session = await checkout.create_checkout_session(CheckoutSessionRequest( amount=REPORT_PRICE, currency="usd", success_url=f"{base}/covenant-score?report_session={{CHECKOUT_SESSION_ID}}", cancel_url=f"{base}/covenant-score?cancelled=1", metadata=metadata)) await db.payment_transactions.insert_one({ "id": str(uuid.uuid4()), "user_id": None, "session_id": session.session_id, "amount": REPORT_PRICE, "currency": "usd", "metadata": metadata, "payment_status": "initiated", "status": "open", "created_at": datetime.now(timezone.utc).isoformat()}) return {"checkout_url": session.url, "session_id": session.session_id} async def _gather_report(company: str, domain: str) -> dict: """Everything, one document: score + all spectrums, all cited.""" score = await compute_score(company, domain) canonical = score["company"] async with httpx.AsyncClient() as client: async def sec_filings(): try: r = await client.get("https://efts.sec.gov/LATEST/search-index", params={"q": f'"{canonical}"'}, headers=UA, timeout=25) h = r.json().get("hits", {}) return {"total": (h.get("total") or {}).get("value", 0), "recent": [{"form": x["_source"].get("root_form", ""), "date": x["_source"].get("file_date", "")} for x in h.get("hits", [])[:6]]} except Exception: return {"total": -1, "recent": []} async def contracts(): try: end = datetime.now(timezone.utc).date() r = await client.post("https://api.usaspending.gov/api/v2/search/spending_by_award/", json={"filters": {"recipient_search_text": [canonical], "award_type_codes": ["A", "B", "C", "D"], "time_period": [{"start_date": (end - timedelta(days=730)).isoformat(), "end_date": end.isoformat()}]}, "fields": ["Recipient Name", "Award Amount", "Awarding Agency"], "order": "desc", "sort": "Award Amount", "limit": 5}, timeout=30) return [{"recipient": x.get("Recipient Name"), "amount": x.get("Award Amount"), "agency": x.get("Awarding Agency")} for x in r.json().get("results", [])] except Exception: return [] sec, awards = await asyncio.gather(sec_filings(), contracts()) patterns = None try: import rootcause patterns = await rootcause.mine_company(canonical, 3) patterns.pop("_id", None) except Exception as e: logger.warning(f"report rootcause failed {canonical}: {e}") return {"score": score, "sec": sec, "contracts": awards, "patterns": patterns, "generated_at": datetime.now(timezone.utc).isoformat()} async def fulfill(txn: dict, session_id: str, amount: float): meta = (txn or {}).get("metadata", {}) or {} email, company, domain = meta.get("email", ""), meta.get("company", ""), meta.get("domain", "") existing = await db.covenant_reports.find_one({"session_id": session_id}) if existing: return try: data = await _gather_report(company, domain) except Exception as e: logger.warning(f"report gather failed {company}: {e}") data = {"score": {"company": company, "error": "partial"}, "generated_at": datetime.now(timezone.utc).isoformat()} token = uuid.uuid4().hex await db.covenant_reports.insert_one({ "id": uuid.uuid4().hex[:10], "token": token, "session_id": session_id, "email": email, "company": company, "domain": domain, "data": data, "created_at": datetime.now(timezone.utc).isoformat()}) try: import emailer base = (os.environ.get("PUBLIC_BASE_URL") or "").rstrip("/") await emailer.send_alert( email, f"Your Covenant Report: {company}", f"Your full official-record report on {company} is ready.\n\n" f"View online: {base}/covenant-score?report={token}\n" f"Download PDF: {base}/api/score/report/{token}/pdf\n\n" "Every figure cites its official government source. Filings are not adjudicated " "wrongdoing.\n\n— Alpha Covenant Holdings") except Exception as e: logger.warning(f"report email failed: {e}") @router.get("/report/status/{session_id}") async def report_status(session_id: str): txn = await db.payment_transactions.find_one( {"session_id": session_id, "metadata.source": "covreport"}, {"_id": 0}) if not txn: raise HTTPException(status_code=404, detail="Checkout session not found") rep = await db.covenant_reports.find_one({"session_id": session_id}, {"_id": 0, "data": 0}) if rep: return {"payment_status": "paid", "token": rep["token"], "company": rep["company"]} key = os.environ.get("STRIPE_API_KEY") from emergentintegrations.payments.stripe.checkout import StripeCheckout base = (os.environ.get("PUBLIC_BASE_URL") or "").rstrip("/") sc_client = StripeCheckout(api_key=key, webhook_url=f"{base}/api/webhook/stripe") try: status = await sc_client.get_checkout_status(session_id) except Exception: raise HTTPException(status_code=404, detail="Checkout session not found") if status.payment_status == "paid": res = await db.payment_transactions.update_one( {"session_id": session_id, "payment_status": {"$ne": "paid"}}, {"$set": {"payment_status": "paid", "status": "complete", "updated_at": datetime.now(timezone.utc).isoformat()}}) if res.modified_count == 1: await fulfill(txn, session_id, float(txn.get("amount") or 0)) rep = await db.covenant_reports.find_one({"session_id": session_id}, {"_id": 0, "data": 0}) if rep: return {"payment_status": "paid", "token": rep["token"], "company": rep["company"]} return {"payment_status": status.payment_status} @router.get("/report/{token}") async def report_view(token: str): rep = await db.covenant_reports.find_one({"token": token}, {"_id": 0, "email": 0, "session_id": 0}) if not rep: raise HTTPException(status_code=404, detail="Report not found") return rep def _build_report_pdf(rep: dict) -> bytes: from reportlab.lib.pagesizes import LETTER from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer styles = getSampleStyleSheet() h1 = ParagraphStyle("H1r", parent=styles["Title"], fontSize=20, spaceAfter=4) h2 = ParagraphStyle("H2r", parent=styles["Heading2"], fontSize=13, spaceBefore=12, spaceAfter=3) body = ParagraphStyle("Bodyr", parent=styles["BodyText"], fontSize=10, leading=14) small = ParagraphStyle("Smallr", parent=styles["BodyText"], fontSize=7.5, leading=10, textColor="#555555") d = rep.get("data", {}) s = d.get("score", {}) buf = io.BytesIO() docx = SimpleDocTemplate(buf, pagesize=LETTER, leftMargin=0.9 * inch, rightMargin=0.9 * inch, topMargin=0.8 * inch, bottomMargin=0.8 * inch) story = [Paragraph(f"Covenant Report — {rep['company']}", h1), Paragraph(f"Alpha Covenant Holdings · {rep['created_at'][:10]} · every figure verifiable in official records", small), Spacer(1, 10), Paragraph(f"Covenant Score: {s.get('score', 'n/a')} / 100 — Grade {s.get('grade', 'n/a')}", h2)] for c in s.get("components", []): story.append(Paragraph(f"{c['name']}: {'+' if c['points'] > 0 else ''}{c['points']} pts — {c['detail']} " f"(Source: {c['source']})", body)) cf = s.get("cfpb", {}) if cf: story.append(Paragraph("Federal complaint record (CFPB)", h2)) story.append(Paragraph(f"{cf.get('last_12mo', 0):,} complaints last 12 months, trend {cf.get('trend', 'n/a')}. " + ("Top issues: " + "; ".join(f"{i['issue']} ({i['count']:,})" for i in cf.get("top_issues", [])[:4]) + "." if cf.get("top_issues") else ""), body)) ct = s.get("courts", {}) if ct: story.append(Paragraph("Federal court records (PACER via RECAP)", h2)) story.append(Paragraph(f"{ct.get('total', 0):,} matching records. Recent: " + ("; ".join(f"{r['case']} ({r['court']}, {r.get('date_filed') or 'n/a'})" for r in ct.get("recent", [])[:4]) if ct.get("recent") else "none listed") + ".", body)) if s.get("recalls"): story.append(Paragraph("Product safety recalls (24 months)", h2)) for x in s["recalls"]: story.append(Paragraph(x, body)) pat = d.get("patterns") if pat and pat.get("failure_modes"): story.append(Paragraph("Behavior patterns — what consumers describe, in their own words", h2)) story.append(Paragraph(f"Mined from {pat['narratives_sampled']} recent consumer narratives " f"({pat['window_years']}-year window). Relief history: " f"{pat['outcomes']['relief_rate_pct']}% of complaints ended with relief.", body)) for m in pat["failure_modes"][:6]: story.append(Paragraph(f"• {m['mode']} — {m['count']} matched narratives" + (f'. Example (consumer\'s words): "{m["sample"][:200]}…"' if m.get("sample") else ""), body)) story.append(Paragraph(pat.get("disclaimer", ""), small)) sec = d.get("sec", {}) if sec and sec.get("total", -1) >= 0: story.append(Paragraph("SEC filings (EDGAR full-text)", h2)) story.append(Paragraph(f"{sec['total']:,} filings mention this name. Recent: " + ("; ".join(f"{x['form']} ({x['date']})" for x in sec.get("recent", [])[:5]) or "n/a") + ".", body)) if d.get("contracts"): story.append(Paragraph("Federal contracts (USAspending, 24 months)", h2)) for a in d["contracts"]: story.append(Paragraph(f"{a['recipient']}: ${(a['amount'] or 0):,.0f} — {a['agency']}", body)) story.append(Spacer(1, 14)) story.append(Paragraph("Methodology is published and formula-driven; scores are never altered for any " "client or payment. Filings, complaints, cases and recalls are official records, " "not adjudicated wrongdoing. Not financial, legal or safety advice.", small)) docx.build(story) return buf.getvalue() @router.get("/report/{token}/pdf") async def report_pdf(token: str): rep = await db.covenant_reports.find_one({"token": token}, {"_id": 0}) if not rep: raise HTTPException(status_code=404, detail="Report not found") data = _build_report_pdf(rep) return Response(content=data, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="covenant-report-{rep["company"][:30]}.pdf"'})