"""Phase 2 leak detectors β behavioral patterns (trends, baselines, spikes).
Eight trend-aware detectors that compare current behaviour against
historical baselines:
ads_low_roas β campaign ROAS below target over trailing 14 days
ads_rising_cpc β CPC up >15% week-over-week with flat conversions
qb_refund_spike β CreditMemo volume 2x the trailing 3-month average
qb_margin_erosion β expense category spike while revenue is flat
crm_funnel_dropoff β stage conversion rate below historical baseline
leads_noshow β past LeadPerfection appointments with no result
five9_missed_followup β voicemail/no-answer calls with no 24h follow-up
qb_partial_stall β partially paid invoices with no payment in 30+ days
All detectors inherit BaseDetector and are exported via ``DETECTORS`` for
registration in ``__init__``.
"""
from __future__ import annotations
import json
import logging
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from .base import BaseDetector, LeakCandidate
from ._common import as_aware, connector_available, days_since, utcnow
logger = logging.getLogger(__name__)
#: Ads connector services that populate ad_campaigns / ad_metrics
ADS_SERVICES = ("google_ads", "facebook_ads")
def _ads_connectors_available(company_id: str) -> List[str]:
"""Connected ads services for a company."""
return [s for s in ADS_SERVICES if connector_available(company_id, s)]
def _parse_dt(value: Any) -> Optional[datetime]:
"""Best-effort parse of a datetime out of raw connector payload data.
Handles ISO 8601 (with/without Z), epoch seconds/milliseconds, and
common US date formats. Returns a UTC-aware datetime or None.
"""
if value is None or value == "":
return None
if isinstance(value, datetime):
return as_aware(value)
if isinstance(value, (int, float)):
try:
ts = float(value)
if ts > 1e12: # epoch millis
ts /= 1000.0
return datetime.fromtimestamp(ts, tz=timezone.utc)
except (ValueError, OSError, OverflowError):
return None
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
# ISO 8601
try:
return as_aware(datetime.fromisoformat(raw.replace("Z", "+00:00")))
except ValueError:
pass
for fmt in (
"%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M", "%m/%d/%Y",
"%Y-%m-%d %H:%M:%S", "%Y-%m-%d",
"%m-%d-%Y",
):
try:
return datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
return None
def _month_start(dt: datetime) -> datetime:
return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
def _shift_month(dt: datetime, months: int) -> datetime:
"""Shift a month-start datetime by N months (negative = back)."""
total = dt.year * 12 + (dt.month - 1) + months
year, month = divmod(total, 12)
return dt.replace(year=year, month=month + 1)
# =============================================================================
# Detector 1: ads_low_roas β Underperforming Campaigns
# =============================================================================
class AdsLowRoasDetector(BaseDetector):
id = "ads_low_roas"
name = "Ads: Underperforming Campaigns"
connector = "google_ads" # primary; also checks facebook_ads
enabled_by_default = True
description = (
"Flags ad campaigns whose ROAS over the trailing 14 days is below "
"the target (2.0 by default), wasting ad spend."
)
default_params = {"target_roas": 2.0, "days": 14}
def check(self, company_id: str) -> List[LeakCandidate]:
services = _ads_connectors_available(company_id)
if not services:
return []
from app.models import AdCampaign, AdMetric
days = int(self.default_params.get("days", 14))
target = float(self.default_params.get("target_roas", 2.0))
cutoff = (utcnow() - timedelta(days=days)).date()
metrics = AdMetric.query.filter(
AdMetric.company_id == company_id,
AdMetric.source_service.in_(services),
AdMetric.metric_date >= cutoff,
).all()
if not metrics:
return []
# Aggregate per campaign: spend + revenue (spend * roas per row)
per_campaign: Dict[str, Dict[str, float]] = {}
total_spend = 0.0
total_revenue = 0.0
for m in metrics:
cid = m.external_campaign_id or ""
if not cid:
continue
spend = m.spend or 0.0
revenue = spend * (m.roas or 0.0)
agg = per_campaign.setdefault(
cid, {"spend": 0.0, "revenue": 0.0, "roas_rows": 0}
)
agg["spend"] += spend
agg["revenue"] += revenue
if m.roas is not None:
agg["roas_rows"] += 1
total_spend += spend
total_revenue += revenue
account_roas = (total_revenue / total_spend) if total_spend > 0 else None
# Campaign name lookup
campaigns = {
c.external_id: c
for c in AdCampaign.query.filter(
AdCampaign.company_id == company_id,
AdCampaign.source_service.in_(services),
).all()
}
candidates: List[LeakCandidate] = []
for cid, agg in per_campaign.items():
spend = agg["spend"]
if spend <= 0 or agg["roas_rows"] == 0:
continue # no meaningful ROAS data
actual = agg["revenue"] / spend
if actual >= target:
continue
severity = "high" if actual < 1.0 else "medium"
loss = round(spend * (1 - actual / target), 2)
campaign = campaigns.get(cid)
name = (campaign.name if campaign else "") or cid
candidates.append(
LeakCandidate(
detector_id=self.id,
source="Ads: Underperforming Campaign",
description=(
f"Campaign '{name}' ROAS is {actual:.2f} vs target "
f"{target:.1f}. Spent ${spend:,.2f} in {days} days"
),
estimated_loss=loss,
severity=severity,
metadata_json={
"dedupe_key": f"{self.id}:{cid}",
"campaign_id": cid,
"campaign_name": name,
"actual_roas": round(actual, 2),
"target_roas": target,
"account_roas": round(account_roas, 2) if account_roas else None,
"spend": round(spend, 2),
"days": days,
},
source_connector=(campaign.source_service if campaign else services[0]),
rule_params={"target_roas": target, "days": days},
)
)
return candidates
# =============================================================================
# Detector 2: ads_rising_cpc β Rising Acquisition Cost
# =============================================================================
class AdsRisingCpcDetector(BaseDetector):
id = "ads_rising_cpc"
name = "Ads: Rising Acquisition Cost"
connector = "google_ads"
enabled_by_default = True
description = (
"Flags campaigns whose cost-per-click rose more than 15% "
"week-over-week while conversions stayed flat or declined."
)
default_params = {"pct_threshold": 15.0}
def check(self, company_id: str) -> List[LeakCandidate]:
services = _ads_connectors_available(company_id)
if not services:
return []
from app.models import AdCampaign, AdMetric
threshold = float(self.default_params.get("pct_threshold", 15.0))
today = utcnow().date()
cur_start = today - timedelta(days=7)
prev_start = today - timedelta(days=14)
metrics = AdMetric.query.filter(
AdMetric.company_id == company_id,
AdMetric.source_service.in_(services),
AdMetric.metric_date >= prev_start,
).all()
if not metrics:
return []
# Per campaign, split into previous week / current week buckets
buckets: Dict[str, Dict[str, Dict[str, float]]] = {}
earliest: Dict[str, date] = {}
for m in metrics:
cid = m.external_campaign_id or ""
if not cid or m.metric_date is None:
continue
week = "current" if m.metric_date >= cur_start else "previous"
camp = buckets.setdefault(cid, {
"current": {"spend": 0.0, "clicks": 0.0, "conversions": 0.0},
"previous": {"spend": 0.0, "clicks": 0.0, "conversions": 0.0},
})
camp[week]["spend"] += m.spend or 0.0
camp[week]["clicks"] += m.clicks or 0
camp[week]["conversions"] += m.conversions or 0.0
if cid not in earliest or m.metric_date < earliest[cid]:
earliest[cid] = m.metric_date
campaigns = {
c.external_id: c
for c in AdCampaign.query.filter(
AdCampaign.company_id == company_id,
AdCampaign.source_service.in_(services),
).all()
}
candidates: List[LeakCandidate] = []
for cid, camp in buckets.items():
cur, prev = camp["current"], camp["previous"]
# Require a full comparison window of data
if prev["clicks"] <= 0 or cur["clicks"] <= 0:
continue
old_cpc = prev["spend"] / prev["clicks"]
new_cpc = cur["spend"] / cur["clicks"]
if old_cpc <= 0:
continue
pct = (new_cpc - old_cpc) / old_cpc * 100.0
if pct <= threshold:
continue
# Conversions must be flat or declining
if cur["conversions"] > prev["conversions"]:
continue
if cur["conversions"] < prev["conversions"]:
conv_trend = "declined"
else:
conv_trend = "stayed flat"
severity = "high" if pct > 30 else "medium"
loss = round(max(new_cpc - old_cpc, 0.0) * cur["clicks"], 2)
campaign = campaigns.get(cid)
name = (campaign.name if campaign else "") or cid
candidates.append(
LeakCandidate(
detector_id=self.id,
source="Ads: Rising Acquisition Cost",
description=(
f"CPC on '{name}' up {pct:.0f}% (was ${old_cpc:.2f}, "
f"now ${new_cpc:.2f}) while conversions {conv_trend}"
),
estimated_loss=loss,
severity=severity,
metadata_json={
"dedupe_key": f"{self.id}:{cid}",
"campaign_id": cid,
"campaign_name": name,
"old_cpc": round(old_cpc, 2),
"new_cpc": round(new_cpc, 2),
"pct_change": round(pct, 1),
"conversion_trend": conv_trend,
},
source_connector=(campaign.source_service if campaign else services[0]),
rule_params={"pct_threshold": threshold},
)
)
return candidates
# =============================================================================
# Detector 3: qb_refund_spike β Refund/Discount Spike
# =============================================================================
class QbRefundSpikeDetector(BaseDetector):
id = "qb_refund_spike"
name = "QuickBooks: Refund/Discount Spike"
connector = "quickbooks"
enabled_by_default = True
description = (
"Flags months where credit memo (refund/discount) volume exceeds "
"2x the trailing 3-month average."
)
default_params = {"multiplier_threshold": 2.0, "trailing_months": 3}
def check(self, company_id: str) -> List[LeakCandidate]:
if not connector_available(company_id, self.connector):
return []
from app.models import QuickbooksTransaction
threshold = float(self.default_params.get("multiplier_threshold", 2.0))
trailing_months = int(self.default_params.get("trailing_months", 3))
now = utcnow()
cur_month_start = _month_start(now)
window_start = _shift_month(cur_month_start, -trailing_months)
txs = QuickbooksTransaction.query.filter(
QuickbooksTransaction.company_id == company_id,
QuickbooksTransaction.tx_type == "CreditMemo",
QuickbooksTransaction.tx_date.isnot(None),
QuickbooksTransaction.tx_date >= window_start.replace(tzinfo=None),
).all()
if not txs:
return []
current_total = 0.0
trailing_total = 0.0
for tx in txs:
tx_date = as_aware(tx.tx_date)
if tx_date is None:
continue
amt = abs(tx.amount or 0.0)
if tx_date >= cur_month_start:
current_total += amt
else:
trailing_total += amt
average = trailing_total / trailing_months if trailing_months else 0.0
if average <= 0 or current_total <= threshold * average:
return []
multiplier = current_total / average
if multiplier > 5:
severity = "critical"
elif multiplier > 3:
severity = "high"
else:
severity = "medium"
month_label = cur_month_start.strftime("%Y-%m")
return [
LeakCandidate(
detector_id=self.id,
source="QuickBooks: Refund/Discount Spike",
description=(
f"Refunds/discounts this month (${current_total:,.2f}) are "
f"{multiplier:.1f}x the {trailing_months}-month average "
f"(${average:,.2f})"
),
estimated_loss=round(current_total - average, 2),
severity=severity,
metadata_json={
"dedupe_key": f"{self.id}:{month_label}",
"current_month_total": round(current_total, 2),
"trailing_average": round(average, 2),
"multiplier": round(multiplier, 2),
"month": month_label,
},
source_connector="quickbooks",
rule_params={
"multiplier_threshold": threshold,
"trailing_months": trailing_months,
},
)
]
# =============================================================================
# Detector 4: qb_margin_erosion β Margin Erosion
# =============================================================================
class QbMarginErosionDetector(BaseDetector):
id = "qb_margin_erosion"
name = "QuickBooks: Margin Erosion"
connector = "quickbooks"
enabled_by_default = True
description = (
"Flags expense categories with a month-over-month increase above "
"20% while revenue stayed flat β compressed margins."
)
default_params = {"expense_pct_threshold": 20.0, "revenue_flat_pct": 5.0}
def check(self, company_id: str) -> List[LeakCandidate]:
if not connector_available(company_id, self.connector):
return []
from app.models import QuickbooksExpense, QuickbooksInvoice
exp_threshold = float(self.default_params.get("expense_pct_threshold", 20.0))
rev_flat_pct = float(self.default_params.get("revenue_flat_pct", 5.0))
now = utcnow()
cur_start = _month_start(now)
prev_start = _shift_month(cur_start, -1)
window_start_naive = prev_start.replace(tzinfo=None)
# --- Revenue MoM (invoices by tx_date) -------------------------------
invoices = QuickbooksInvoice.query.filter(
QuickbooksInvoice.company_id == company_id,
QuickbooksInvoice.tx_date.isnot(None),
QuickbooksInvoice.tx_date >= window_start_naive,
QuickbooksInvoice.status.notin_(["Void"]),
).all()
cur_rev = prev_rev = 0.0
for inv in invoices:
d = as_aware(inv.tx_date)
if d is None:
continue
if d >= cur_start:
cur_rev += inv.total_amount or 0.0
else:
prev_rev += inv.total_amount or 0.0
if prev_rev <= 0:
return [] # no revenue baseline β can't call it "flat"
rev_pct = (cur_rev - prev_rev) / prev_rev * 100.0
if abs(rev_pct) >= rev_flat_pct:
return [] # revenue moved β not a flat-revenue margin squeeze
# --- Expenses by category MoM -----------------------------------------
expenses = QuickbooksExpense.query.filter(
QuickbooksExpense.company_id == company_id,
QuickbooksExpense.tx_date.isnot(None),
QuickbooksExpense.tx_date >= window_start_naive,
).all()
by_cat: Dict[str, Dict[str, float]] = {}
for exp in expenses:
d = as_aware(exp.tx_date)
if d is None:
continue
cat = (exp.category or exp.account_name or "Uncategorized").strip() or "Uncategorized"
agg = by_cat.setdefault(cat, {"current": 0.0, "previous": 0.0})
key = "current" if d >= cur_start else "previous"
agg[key] += abs(exp.amount or 0.0)
month_label = cur_start.strftime("%Y-%m")
candidates: List[LeakCandidate] = []
for cat, agg in by_cat.items():
prev_amt, cur_amt = agg["previous"], agg["current"]
if prev_amt <= 0:
continue # new category β no baseline
pct = (cur_amt - prev_amt) / prev_amt * 100.0
if pct <= exp_threshold:
continue
increase = round(cur_amt - prev_amt, 2)
severity = "high" if pct > 40 else "medium"
candidates.append(
LeakCandidate(
detector_id=self.id,
source="QuickBooks: Margin Erosion",
description=(
f"Expenses in '{cat}' up ${increase:,.2f} ({pct:.0f}%) "
f"while revenue changed {rev_pct:+.1f}% β margin compressed"
),
estimated_loss=increase,
severity=severity,
metadata_json={
"dedupe_key": f"{self.id}:{cat}:{month_label}",
"category": cat,
"expense_increase": increase,
"pct_change": round(pct, 1),
"revenue_pct_change": round(rev_pct, 1),
"month": month_label,
},
source_connector="quickbooks",
rule_params={
"expense_pct_threshold": exp_threshold,
"revenue_flat_pct": rev_flat_pct,
},
)
)
return candidates
# =============================================================================
# Detector 5: crm_funnel_dropoff β Funnel Stage Drop-off
# =============================================================================
class CrmFunnelDropoffDetector(BaseDetector):
id = "crm_funnel_dropoff"
name = "CRM: Funnel Drop-off"
connector = "hubspot"
enabled_by_default = True
description = (
"Flags funnel stage transitions whose recent conversion rate fell "
"below 60% of the 6-month historical baseline."
)
default_params = {
"baseline_months": 6,
"recent_days": 30,
"dropoff_ratio": 0.6,
"min_baseline_deals": 5,
}
#: Known canonical funnel orderings (lowercased) β deals advance leftβright.
_STAGE_ORDER_HINTS = [
"new", "lead", "appointments_set", "appointmentscheduled",
"appointment_set", "qualified", "qualifiedtobuy", "presentation",
"presentationscheduled", "proposal", "quote", "decisionmakerboughtin",
"contractsent", "negotiation", "closedwon", "closed won", "won",
]
def check(self, company_id: str) -> List[LeakCandidate]:
if not connector_available(company_id, self.connector):
return []
from app.models import CrmDeal
params = self.default_params
baseline_months = int(params.get("baseline_months", 6))
recent_days = int(params.get("recent_days", 30))
ratio = float(params.get("dropoff_ratio", 0.6))
min_deals = int(params.get("min_baseline_deals", 5))
now = utcnow()
recent_cutoff = now - timedelta(days=recent_days)
baseline_cutoff = now - timedelta(days=baseline_months * 30)
deals = CrmDeal.query.filter(
CrmDeal.company_id == company_id,
CrmDeal.created_at >= baseline_cutoff.replace(tzinfo=None),
).all()
if not deals:
return []
# Build funnel stage order: hint-based ranking first, then by deal
# count descending (funnels narrow as they progress).
stage_counts: Dict[str, int] = {}
for d in deals:
stage = (d.stage or "").strip()
if stage:
stage_counts[stage] = stage_counts.get(stage, 0) + 1
if len(stage_counts) < 2:
return []
def _rank(stage: str):
low = stage.lower().replace(" ", "_")
for i, hint in enumerate(self._STAGE_ORDER_HINTS):
if hint.replace(" ", "_") == low:
return (0, i)
return (1, -stage_counts[stage]) # unknown: order by count desc
ordered_stages = sorted(stage_counts, key=_rank)
stage_index = {s: i for i, s in enumerate(ordered_stages)}
# Cohorts: baseline = older deals, recent = last N days
def _cohort_rates(cohort: List) -> Dict[int, Dict[str, float]]:
"""For each stage index i: deals at/past i, and conversion iβi+1."""
reached = [0] * len(ordered_stages)
for deal in cohort:
idx = stage_index.get((deal.stage or "").strip())
if idx is None:
continue
for i in range(idx + 1):
reached[i] += 1
rates: Dict[int, Dict[str, float]] = {}
for i in range(len(ordered_stages) - 1):
if reached[i] > 0:
rates[i] = {
"rate": reached[i + 1] / reached[i],
"reached": reached[i],
"advanced": reached[i + 1],
}
return rates
baseline_cohort = [
d for d in deals
if as_aware(d.created_at) and as_aware(d.created_at) < recent_cutoff
]
recent_cohort = [
d for d in deals
if as_aware(d.created_at) and as_aware(d.created_at) >= recent_cutoff
]
if not baseline_cohort or not recent_cohort:
return []
baseline_rates = _cohort_rates(baseline_cohort)
recent_rates = _cohort_rates(recent_cohort)
# Find dropped transitions
dropped = []
for i, base in baseline_rates.items():
if base["reached"] < min_deals or base["rate"] <= 0:
continue
rec = recent_rates.get(i)
if rec is None or rec["reached"] <= 0:
continue
if rec["rate"] < base["rate"] * ratio:
dropped.append((i, base, rec))
if not dropped:
return []
severity = "high" if len(dropped) >= 2 else "medium"
candidates: List[LeakCandidate] = []
for i, base, rec in dropped:
stage_a, stage_b = ordered_stages[i], ordered_stages[i + 1]
stuck_deals = [
d for d in recent_cohort
if (d.stage or "").strip() == stage_a
]
deals_stuck = len(stuck_deals)
amounts = [d.amount or 0.0 for d in stuck_deals]
avg_deal_size = (sum(amounts) / len(amounts)) if amounts else 0.0
est_loss = round(sum(
(d.amount or avg_deal_size or 0.0)
* (d.probability if d.probability is not None else 0.5)
for d in stuck_deals
), 2) or None
cur_pct = rec["rate"] * 100.0
avg_pct = base["rate"] * 100.0
candidates.append(
LeakCandidate(
detector_id=self.id,
source="CRM: Funnel Drop-off",
description=(
f"Conversion from '{stage_a}' β '{stage_b}' dropped to "
f"{cur_pct:.0f}% (avg was {avg_pct:.0f}%). "
f"{deals_stuck} deals stuck."
),
estimated_loss=est_loss,
severity=severity,
metadata_json={
"dedupe_key": f"{self.id}:{stage_a}->{stage_b}",
"stage_from": stage_a,
"stage_to": stage_b,
"current_rate": round(cur_pct, 1),
"avg_rate": round(avg_pct, 1),
"deals_stuck": deals_stuck,
"avg_deal_size": round(avg_deal_size, 2),
},
source_connector="hubspot",
rule_params={
"baseline_months": baseline_months,
"recent_days": recent_days,
"dropoff_ratio": ratio,
},
)
)
return candidates
# =============================================================================
# Detector 6: leads_noshow β No-Show Appointments (LeadPerfection)
# =============================================================================
class LeadsNoshowDetector(BaseDetector):
id = "leads_noshow"
name = "LeadPerfection: No-Show Appointments"
connector = "leadperfection"
enabled_by_default = True
description = (
"Flags past LeadPerfection appointments with no result logged β "
"likely no-shows that never got rescheduled."
)
default_params = {"lookback_days": 90}
def check(self, company_id: str) -> List[LeakCandidate]:
if not connector_available(company_id, self.connector):
return []
from app.models import ExternalSyncRecord
lookback = int(self.default_params.get("lookback_days", 90))
now = utcnow()
lookback_cutoff = now - timedelta(days=lookback)
records = ExternalSyncRecord.query.filter(
ExternalSyncRecord.company_id == company_id,
ExternalSyncRecord.source_service == "leadperfection",
ExternalSyncRecord.entity_type == "lead",
).all()
candidates: List[LeakCandidate] = []
for rec in records:
props = rec.properties_json or {}
appt_dt = _parse_dt(props.get("appt_date"))
if appt_dt is None or appt_dt >= now or appt_dt < lookback_cutoff:
continue
# Result logged? status column carries status/apptresult; also
# check the raw payload for an explicit apptresult.
raw = props.get("raw") or {}
appt_result = str(
raw.get("apptresult") or raw.get("ApptResult") or ""
).strip()
status = (rec.status or "").strip()
if status or appt_result:
continue
name = rec.name or rec.external_id
candidates.append(
LeakCandidate(
detector_id=self.id,
source="LeadPerfection: No-Show Appointment",
description=(
f"Appointment for '{name}' on "
f"{appt_dt.strftime('%Y-%m-%d')} has no result logged "
f"β possible no-show"
),
estimated_loss=None,
severity="medium",
metadata_json={
"dedupe_key": f"{self.id}:{rec.external_id}",
"external_id": rec.external_id,
"appt_date": appt_dt.isoformat(),
"source_service": "leadperfection",
},
source_connector="leadperfection",
rule_params={"lookback_days": lookback},
)
)
return candidates
# =============================================================================
# Detector 7: five9_missed_followup β Missed Call Follow-up
# =============================================================================
class Five9MissedFollowupDetector(BaseDetector):
id = "five9_missed_followup"
name = "Five9: Missed Follow-ups"
connector = "five9"
enabled_by_default = True
description = (
"Flags voicemail/no-answer/busy calls from the last 7 days with no "
"follow-up call to the same contact within 24 hours."
)
default_params = {"lookback_days": 7, "followup_hours": 24}
_MISSED_DISPOSITIONS = {"no-answer", "no answer", "noanswer", "voicemail", "busy"}
def check(self, company_id: str) -> List[LeakCandidate]:
if not connector_available(company_id, self.connector):
return []
from app.models import ExternalSyncRecord
lookback = int(self.default_params.get("lookback_days", 7))
followup_hours = int(self.default_params.get("followup_hours", 24))
now = utcnow()
cutoff = now - timedelta(days=lookback)
followup_window = timedelta(hours=followup_hours)
records = ExternalSyncRecord.query.filter(
ExternalSyncRecord.company_id == company_id,
ExternalSyncRecord.source_service == "five9",
ExternalSyncRecord.entity_type == "call_log",
).all()
# Parse all calls once: (contact_key, started_at, disposition, record)
calls = []
for rec in records:
props = rec.properties_json or {}
started = _parse_dt(props.get("started_at"))
if started is None:
started = as_aware(rec.created_at)
disposition = str(
props.get("disposition") or rec.status or ""
).strip().lower()
# Contact key: explicit contact_id in payload, else the phone
# number (record.name carries ani/dnis).
raw = props.get("raw") or {}
contact_key = str(
raw.get("contactId") or raw.get("contact_id")
or rec.name or ""
).strip()
calls.append((contact_key, started, disposition, rec))
candidates: List[LeakCandidate] = []
for contact_key, started, disposition, rec in calls:
if started is None or started < cutoff or started >= now:
continue
if disposition not in self._MISSED_DISPOSITIONS:
continue
if not contact_key:
continue
# Give calls newer than the follow-up window a chance to be followed up
if now - started < followup_window:
continue
# Any subsequent call to the same contact within the window?
followed_up = any(
other_key == contact_key
and other_started is not None
and started < other_started <= started + followup_window
for other_key, other_started, _d, other_rec in calls
if other_rec.id != rec.id
)
if followed_up:
continue
contact_label = rec.name or contact_key
candidates.append(
LeakCandidate(
detector_id=self.id,
source="Five9: Missed Follow-up",
description=(
f"Call to '{contact_label}' resulted in {disposition} "
f"with no follow-up in {followup_hours} hours"
),
estimated_loss=None,
severity="medium",
metadata_json={
"dedupe_key": f"{self.id}:{rec.external_id}",
"external_id": rec.external_id,
"contact_id": contact_key,
"disposition": disposition,
"call_date": started.isoformat(),
},
source_connector="five9",
rule_params={
"lookback_days": lookback,
"followup_hours": followup_hours,
},
)
)
return candidates
# =============================================================================
# Detector 8: qb_partial_stall β Partial Payment Stall
# =============================================================================
class QbPartialStallDetector(BaseDetector):
id = "qb_partial_stall"
name = "QuickBooks: Partial Payment Stalls"
connector = "quickbooks"
enabled_by_default = True
description = (
"Flags partially paid invoices with no new payment activity in 30+ "
"days β outstanding balances going stale."
)
default_params = {"stall_days": 30}
def check(self, company_id: str) -> List[LeakCandidate]:
if not connector_available(company_id, self.connector):
return []
from app.models import QuickbooksInvoice, QuickbooksTransaction
stall_days = int(self.default_params.get("stall_days", 30))
now = utcnow()
invoices = QuickbooksInvoice.query.filter(
QuickbooksInvoice.company_id == company_id,
QuickbooksInvoice.status == "Partial",
).all()
if not invoices:
return []
# Payments in the stall window β used to prove recent activity.
payment_cutoff = (now - timedelta(days=stall_days)).replace(tzinfo=None)
recent_payments = QuickbooksTransaction.query.filter(
QuickbooksTransaction.company_id == company_id,
QuickbooksTransaction.tx_type == "Payment",
QuickbooksTransaction.tx_date.isnot(None),
QuickbooksTransaction.tx_date >= payment_cutoff,
).all()
def _payment_references_invoice(tx, invoice) -> bool:
"""Heuristic link: raw payment payload mentions the invoice doc id."""
meta = tx.metadata_json or {}
try:
blob = json.dumps(meta, default=str)
except (TypeError, ValueError):
return False
return bool(invoice.qb_doc_id) and invoice.qb_doc_id in blob
candidates: List[LeakCandidate] = []
for inv in invoices:
# Recent payment against this invoice β not stalled
if any(_payment_references_invoice(tx, inv) for tx in recent_payments):
continue
total = inv.total_amount or 0.0
meta = inv.metadata_json or {}
# QBO invoice raw carries Balance = amount still owed
balance = meta.get("Balance")
try:
remaining = float(balance) if balance is not None else None
except (TypeError, ValueError):
remaining = None
if remaining is None or remaining <= 0 or remaining > total:
remaining = total # unknown paid amount β assume full balance
amount_paid = round(max(total - remaining, 0.0), 2)
days_partial = days_since(inv.tx_date or inv.updated_at)
if days_partial < stall_days:
continue
if days_partial > 60:
severity = "critical"
elif days_partial > 30:
severity = "high"
else:
severity = "medium"
candidates.append(
LeakCandidate(
detector_id=self.id,
source="QuickBooks: Partial Payment Stall",
description=(
f"Invoice {inv.invoice_num or inv.qb_doc_id} for "
f"${total:,.2f} is partially paid β "
f"${remaining:,.2f} outstanding for {days_partial} days"
),
estimated_loss=round(remaining, 2),
severity=severity,
metadata_json={
"dedupe_key": f"{self.id}:{inv.qb_doc_id}",
"invoice_id": inv.id,
"invoice_num": inv.invoice_num,
"total_amount": round(total, 2),
"amount_paid": amount_paid,
"remaining": round(remaining, 2),
"days_partial": days_partial,
},
source_connector="quickbooks",
rule_params={"stall_days": stall_days},
)
)
return candidates
#: Phase 2 detector classes β imported and registered in __init__.py
DETECTORS = [
AdsLowRoasDetector,
AdsRisingCpcDetector,
QbRefundSpikeDetector,
QbMarginErosionDetector,
CrmFunnelDropoffDetector,
LeadsNoshowDetector,
Five9MissedFollowupDetector,
QbPartialStallDetector,
]