"""Credit risk scoring for store credit ("fiado") — A9. Pure rule-based / heuristic scoring: no ML dependencies, works with very little history and stays conservative when evidence is scarce. Every point of the score is traceable to an explicit rule listed in ``reasons``. Public API: score_customer(tenant_conn, customer_id) -> dict is_enforcement_enabled(tenant_conn) -> bool The score is 0..100 where **higher is safer**. It is built from up to four independent signals, each 0..100, then combined with fixed weights over the signals that actually have data (missing signals are not invented): tenure 0.15 months since customers.created_at purchase 0.25 frequency + recency + value from sales payment 0.35 punctuality on credit sales + open overdue aging utilization 0.25 credit_balance / credit_limit Credit sales are assumed to be due 30 days after the sale, matching the receivables aging convention in ``blueprints/accounting_bp.py``. """ import logging from datetime import datetime, timedelta, timezone logger = logging.getLogger('pos') # Credit sale terms used across the app for receivables aging. CREDIT_TERMS_DAYS = 30 # Weight of each signal in the combined score. _WEIGHTS = { 'tenure': 0.15, 'purchase': 0.25, 'payment': 0.35, 'utilization': 0.25, } _TIER_ORDER = {'risk': 0, 'medium': 1, 'good': 2} # Enforcement flag key in tenant_config. Off by default. ENFORCE_FLAG = 'credit_scoring_enforce' def _now(): return datetime.now(timezone.utc) def _aware(dt): """Normalize a possibly-naive datetime to UTC-aware.""" if dt is None: return None if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt def _clamp(value, low=0, high=100): return max(low, min(high, value)) def is_enforcement_enabled(tenant_conn): """Return True when the tenant opted into blocking credit sales for 'risk'. Defaults to False on any error or missing flag so the sale flow never breaks because of scoring. """ try: cur = tenant_conn.cursor() cur.execute("SELECT value FROM tenant_config WHERE key = %s", (ENFORCE_FLAG,)) row = cur.fetchone() cur.close() if not row: return False return str(row[0]).strip().lower() in ('true', '1', 'yes', 'on') except Exception as exc: # pragma: no cover - defensive logger.warning("credit_scoring: could not read enforce flag: %s", exc) return False def _collect_evidence(cur, customer_id): """Read every raw signal in a few queries. Returns a dict or None if the customer does not exist.""" cur.execute(""" SELECT id, name, created_at, credit_limit, credit_balance, price_tier, is_active FROM customers WHERE id = %s """, (customer_id,)) row = cur.fetchone() if not row: return None ev = { 'customer_id': row[0], 'name': row[1], 'created_at': _aware(row[2]), 'credit_limit': float(row[3] or 0), 'credit_balance': float(row[4] or 0), 'price_tier': row[5], 'is_active': row[6], } # Completed sales aggregate (frequency / value / recency). cur.execute(""" SELECT COUNT(*), COALESCE(SUM(total), 0), MIN(created_at), MAX(created_at), COALESCE(AVG(total), 0), COUNT(*) FILTER (WHERE sale_type = 'credit') FROM sales WHERE customer_id = %s AND status = 'completed' """, (customer_id,)) total_orders, ltv, first_purchase, last_purchase, aov, credit_sales = cur.fetchone() ev.update({ 'total_orders': int(total_orders or 0), 'ltv': float(ltv or 0), 'first_purchase': _aware(first_purchase), 'last_purchase': _aware(last_purchase), 'avg_order_value': float(aov or 0), 'credit_sales': int(credit_sales or 0), }) # Open (unpaid) credit sales: balance and due date for aging. cur.execute(""" SELECT s.id, s.total, s.created_at, s.total - COALESCE(SUM(sp.amount), 0) AS balance FROM sales s LEFT JOIN sale_payments sp ON sp.sale_id = s.id WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed' GROUP BY s.id, s.total, s.created_at HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0.005 ORDER BY s.created_at """, (customer_id,)) now = _now() open_sales = [] for sale_id, total, created_at, balance in cur.fetchall(): created_at = _aware(created_at) due = created_at + timedelta(days=CREDIT_TERMS_DAYS) if created_at else None overdue = max(0, (now - due).days) if due else 0 open_sales.append({ 'sale_id': sale_id, 'total': float(total or 0), 'created_at': created_at, 'balance': float(balance or 0), 'due_date': due, 'days_overdue': overdue, }) ev['open_sales'] = open_sales ev['open_balance'] = round(sum(s['balance'] for s in open_sales), 2) ev['max_overdue_days'] = max((s['days_overdue'] for s in open_sales), default=0) ev['overdue_balance'] = round( sum(s['balance'] for s in open_sales if s['days_overdue'] > 0), 2 ) # Aging buckets (by days overdue). buckets = {'current': 0.0, 'd1_30': 0.0, 'd31_60': 0.0, 'd61_90': 0.0, 'd90_plus': 0.0} for s in open_sales: d = s['days_overdue'] if d == 0: buckets['current'] += s['balance'] elif d <= 30: buckets['d1_30'] += s['balance'] elif d <= 60: buckets['d31_60'] += s['balance'] elif d <= 90: buckets['d61_90'] += s['balance'] else: buckets['d90_plus'] += s['balance'] ev['aging'] = {k: round(v, 2) for k, v in buckets.items()} # Settled credit sales: was the final payment within terms? cur.execute(""" SELECT s.created_at, MAX(sp.created_at) AS last_payment FROM sales s JOIN sale_payments sp ON sp.sale_id = s.id WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed' GROUP BY s.id, s.total, s.created_at HAVING SUM(sp.amount) >= s.total - 0.005 """, (customer_id,)) settled = [] for created_at, last_payment in cur.fetchall(): created_at = _aware(created_at) last_payment = _aware(last_payment) if not created_at or not last_payment: continue due = created_at + timedelta(days=CREDIT_TERMS_DAYS) settled.append({'days_late': max(0, (last_payment - due).days)}) ev['settled_credit_sales'] = settled # Direct customer payments (abonos) — extra evidence of engagement. cur.execute(""" SELECT COUNT(*), COALESCE(SUM(amount), 0), MAX(created_at) FROM customer_payments WHERE customer_id = %s """, (customer_id,)) cp_count, cp_total, cp_last = cur.fetchone() ev.update({ 'customer_payments_count': int(cp_count or 0), 'customer_payments_total': float(cp_total or 0), 'customer_payments_last': _aware(cp_last), }) return ev # ─── Signal scoring ───────────────────────────────────── def _tenure_signal(ev, reasons): created_at = ev['created_at'] if not created_at: return None days = max(0, (_now() - created_at).days) months = days / 30.44 if months < 3: score = 45 elif months < 6: score = 60 elif months < 12: score = 75 elif months < 24: score = 85 else: score = 95 reasons.append(f"Cliente con {int(months)} meses de antigüedad ({days} días).") if ev.get('is_active') is False: score = min(score, 40) reasons.append("Cliente marcado como inactivo.") return score def _purchase_signal(ev, reasons): total_orders = ev['total_orders'] if total_orders <= 0: reasons.append("Sin historial de compras registrado.") return None if total_orders == 1: freq = 50 elif total_orders <= 3: freq = 65 elif total_orders <= 9: freq = 80 elif total_orders <= 24: freq = 90 else: freq = 95 last = ev['last_purchase'] days_since = (_now() - last).days if last else None if days_since is None: recency = 60 elif days_since <= 30: recency = 100 elif days_since <= 90: recency = 80 elif days_since <= 180: recency = 60 elif days_since <= 365: recency = 40 else: recency = 25 score = round(0.6 * freq + 0.4 * recency) reasons.append( f"{total_orders} compras por ${ev['ltv']:.2f} " f"(ticket promedio ${ev['avg_order_value']:.2f}), " f"última hace {days_since} días." if days_since is not None else f"{total_orders} compras por ${ev['ltv']:.2f}." ) return _clamp(score) def _payment_signal(ev, reasons): has_history = ( ev['credit_sales'] > 0 or ev['open_balance'] > 0 or ev['customer_payments_count'] > 0 ) if not has_history: reasons.append("Sin historial de crédito ni abonos.") return None settled = ev['settled_credit_sales'] if settled: on_time = sum(1 for s in settled if s['days_late'] <= 0) late_ratio = (len(settled) - on_time) / len(settled) base = 90 - 60 * late_ratio reasons.append( f"{on_time}/{len(settled)} créditos liquidados dentro del plazo de " f"{CREDIT_TERMS_DAYS} días." ) else: base = 70 if ev['credit_sales'] > 0 or ev['open_balance'] > 0 else 75 reasons.append("Sin créditos liquidados aún; sin historial de puntualidad.") max_overdue = ev['max_overdue_days'] if max_overdue > 0: penalty = min(55.0, 15.0 + max_overdue * 0.5) base -= penalty reasons.append( f"Mora abierta: ${ev['overdue_balance']:.2f} con hasta {max_overdue} " f"días de atraso." ) if ev['credit_limit'] > 0: util = ev['credit_balance'] / ev['credit_limit'] if util > 1.0: base -= 15 reasons.append(f"Crédito excedido ({util * 100:.0f}% del límite).") elif util > 0.9: base -= 8 reasons.append(f"Crédito casi al límite ({util * 100:.0f}%).") return _clamp(round(base)) def _utilization_signal(ev, reasons): limit = ev['credit_limit'] balance = ev['credit_balance'] if limit <= 0: if balance > 0: reasons.append(f"Saldo de ${balance:.2f} sin límite de crédito definido.") return 45 return None util = balance / limit if util <= 0.001: score = 100 elif util <= 0.3: score = 95 elif util <= 0.6: score = 80 elif util <= 0.9: score = 60 elif util <= 1.0: score = 40 else: score = 15 reasons.append(f"Utilización de crédito {util * 100:.0f}% (${balance:.2f} / ${limit:.2f}).") return score def _tier_from_score(score, data_points, max_overdue_days, behavioral_evidence): if max_overdue_days > 60: return 'risk' if score >= 75 and data_points >= 2 and behavioral_evidence: tier = 'good' elif score >= 50: tier = 'medium' else: tier = 'risk' # Conservative when mora is building or evidence is thin. if max_overdue_days > 30 and _TIER_ORDER[tier] > _TIER_ORDER['medium']: tier = 'medium' if not behavioral_evidence and _TIER_ORDER[tier] > _TIER_ORDER['medium']: tier = 'medium' if data_points <= 1 and _TIER_ORDER[tier] > _TIER_ORDER['medium']: tier = 'medium' return tier def _recommended_limit(tier, ev): if ev['total_orders'] <= 0: return 0.0 aov = ev['avg_order_value'] or (ev['ltv'] / ev['total_orders']) if tier == 'good': factor = 4.0 elif tier == 'medium': factor = 2.0 else: return 0.0 # risk: do not recommend extending new credit suggested = aov * factor suggested = round(suggested / 100.0) * 100.0 return float(min(suggested, 200000.0)) def score_customer(tenant_conn, customer_id): """Score a customer's credit risk. Returns a dict:: { 'score': 0..100, # higher = safer 'tier': 'good'|'medium'|'risk', 'recommended_limit': float, 'reasons': [str, ...], 'data_points': int, # number of signals with real data (0..4) 'components': {...}, # per-signal sub-scores (None when absent) 'metrics': {...}, # raw evidence for auditing 'computed_at': iso8601 str, } Raises ValueError if the customer does not exist. """ cur = tenant_conn.cursor() try: ev = _collect_evidence(cur, customer_id) finally: cur.close() if ev is None: raise ValueError(f"Customer {customer_id} not found") reasons = [] components = { 'tenure': _tenure_signal(ev, reasons), 'purchase': _purchase_signal(ev, reasons), 'payment': _payment_signal(ev, reasons), 'utilization': _utilization_signal(ev, reasons), } available = {k: v for k, v in components.items() if v is not None} data_points = len(available) if available: weight_sum = sum(_WEIGHTS[k] for k in available) score = sum(available[k] * _WEIGHTS[k] for k in available) / weight_sum else: score = 50.0 score = int(round(_clamp(score))) behavioral = components['purchase'] is not None or components['payment'] is not None # Hard cap: heavy mora can never yield a high score. if ev['max_overdue_days'] > 60: score = min(score, 39) elif ev['max_overdue_days'] > 30: score = min(score, 60) tier = _tier_from_score(score, data_points, ev['max_overdue_days'], behavioral) recommended = _recommended_limit(tier, ev) if data_points <= 1: reasons.append( f"Pocos datos ({data_points} señal{'es' if data_points != 1 else ''}); " "evaluación conservadora." ) return { 'customer_id': customer_id, 'score': score, 'tier': tier, 'recommended_limit': recommended, 'reasons': reasons, 'data_points': data_points, 'components': components, 'metrics': { 'months_active': round((_now() - ev['created_at']).days / 30.44, 1) if ev['created_at'] else None, 'total_orders': ev['total_orders'], 'ltv': round(ev['ltv'], 2), 'avg_order_value': round(ev['avg_order_value'], 2), 'days_since_last_purchase': (_now() - ev['last_purchase']).days if ev['last_purchase'] else None, 'credit_limit': ev['credit_limit'], 'credit_balance': ev['credit_balance'], 'open_credit_balance': ev['open_balance'], 'max_overdue_days': ev['max_overdue_days'], 'overdue_balance': ev['overdue_balance'], 'aging': ev['aging'], 'credit_sales': ev['credit_sales'], 'settled_credit_sales': len(ev['settled_credit_sales']), }, 'computed_at': _now().isoformat(), }