#!/usr/bin/env python3 """ Atlas Intelligence Engine Runs on atlas-01. Pulls Odoo invoices, runs Monte Carlo H2 forecast, outputs /opt/atlas/setup/intelligence.json (served at /feeds/intelligence.json). Schedule: cron every hour or on-demand. """ import json, os, random, sys, xmlrpc.client from datetime import datetime, timezone from urllib.request import urlopen, Request from urllib.error import URLError ODOO_URL = 'http://:8069' ODOO_DB = 'atlas' ODOO_USER = 'chaib@atlascorporation.nl' ODOO_PASS = os.environ['ODOO_PASS'] OUT_PATH = '/opt/atlas/setup/intelligence.json' # ── Verified 2025 actuals (from Odoo export + bank reconciliation) ──────────── ACTUALS_2025 = { '2025-01': 237, '2025-02': 225, '2025-03': 0, '2025-04': 255, '2025-05': 1625,'2025-06': 533, '2025-07': 660, '2025-08': -200, '2025-09': 1300,'2025-10': 1400,'2025-11': 280, '2025-12': 200, } ACTUALS_2026_FALLBACK = { '2026-01': 68, '2026-02': 977, '2026-03': 4206, '2026-04': 1479,'2026-05': 1627,'2026-06': 371, } def fetch_odoo(): """Pull posted invoices from Odoo via XMLRPC, return {YYYY-MM: amount} dicts.""" try: common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common') uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {}) if not uid: return None, None models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object') moves = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'account.move', 'search_read', [[['move_type','in',['out_invoice','out_refund']], ['state','=','posted'], ['invoice_date','>=','2025-01-01']]], {'fields':['invoice_date','amount_untaxed','move_type'], 'limit':500} ) if not moves: return None, None by_month_2025, by_month_2026 = {}, {} for m in moves: if not m.get('invoice_date'): continue ym = m['invoice_date'][:7] amt = m['amount_untaxed'] * (-1 if m['move_type'] == 'out_refund' else 1) if ym.startswith('2025'): by_month_2025[ym] = by_month_2025.get(ym, 0) + amt elif ym.startswith('2026'): by_month_2026[ym] = by_month_2026.get(ym, 0) + amt return ( {k: round(v) for k,v in by_month_2025.items()} or None, {k: round(v) for k,v in by_month_2026.items()} or None, ) except Exception as e: print(f'[intelligence] Odoo pull failed: {e}', file=sys.stderr) return None, None def monte_carlo(actuals_2026, n=10000): """Project H2 2026 (Jul–Dec) from recent velocity + POS MRR growth.""" # Base: last 3 completed months (exclude partial current month) months_sorted = sorted(k for k in actuals_2026 if actuals_2026[k] > 0) recent = [actuals_2026[k] for k in months_sorted[-3:]] if months_sorted else [1500] base_mean = sum(recent) / len(recent) base_std = max(base_mean * 0.35, 300) # AtlasPOS: 1 active venue now, conservative 1 new venue/month pos_base = 79 pos_growth_per_mo = 79 # 1 new venue/month results = [] for _ in range(n): total = 0 for mo in range(6): # Jul through Dec consulting = max(0, random.gauss(base_mean, base_std)) new_venues = max(0, int(random.gauss(1, 0.5))) pos_mrr = pos_base + pos_growth_per_mo * mo + new_venues * 79 total += consulting + pos_mrr results.append(total) results.sort() full_yr_actuals = sum(actuals_2026.values()) targets = { 'beat_2025': sum(ACTUALS_2025.values()), 'break_even_monthly': 3000, } return { 'p10': round(results[int(n * .10)]), 'p50': round(results[int(n * .50)]), 'p90': round(results[int(n * .90)]), 'full_year_p50': round(full_yr_actuals + results[int(n * .50)]), 'prob_beat_full_2025_pct': round( sum(1 for r in results if full_yr_actuals + r > targets['beat_2025']) / n * 100 ), 'months': ['Jul','Aug','Sep','Oct','Nov','Dec'], } def ltv(arpu=79, margin=0.90, churn=0.04): return { 'arpu': arpu, 'margin_pct': int(margin*100), 'churn_assumption_pct': int(churn*100), 'ltv': round(arpu * margin / churn), 'lifetime_months': round(1/churn), 'cac_payback_3x_rule': round(arpu * margin / churn / 3), } def main(): a2025, a2026 = fetch_odoo() if not a2025: a2025 = ACTUALS_2025 if not a2026: a2026 = ACTUALS_2026_FALLBACK source = 'odoo' if (a2026 is not ACTUALS_2026_FALLBACK or a2025 is not ACTUALS_2025) else 'hardcoded' ytd26 = sum(a2026.values()) same25 = sum(a2025.get(k.replace('2026','2025'), 0) for k in a2026) yoy = round((ytd26 - same25) / max(abs(same25), 1) * 100) fy25 = sum(a2025.values()) out = { 'generated': datetime.now(timezone.utc).isoformat(), 'data_source': source, 'revenue': { 'actuals_2025': a2025, 'actuals_2026': a2026, 'ytd_2026': ytd26, 'ytd_2025_same_period': same25, 'full_year_2025': fy25, 'remaining_to_beat_2025': max(0, fy25 - ytd26), 'yoy_pct': yoy, }, 'mrr': { 'arpu': 79, 'active_venues': 1, 'current_mrr': 79, 'target_venues': 52, 'mrr_at_target': 52 * 79, }, 'forecast_h2': monte_carlo(a2026), 'ltv': ltv(), 'market': { 'verified_facts': [ {'label':'NL GDP growth','value':'+1.4%','source':'CPB 2025'}, {'label':'Horeca turnover growth','value':'+3.4%','source':'CBS 2024'}, {'label':'Real consumer spending','value':'+1–1.5%','source':'ING 2025'}, {'label':'Contactless payments','value':'94%','source':'DNB end-2024'}, {'label':'Pin transactions 2024','value':'5.77B (+2.9%)','source':'Betaalvereniging NL'}, ], 'tam_tilburg_horeca': '~600 venues', 'ccv_entry_price': '€25–€34.50/mo', 'expansion_verdict': 'Sign 1 AtlasPOS venue/week → 52 venues by Jun 2027 → €4,108/mo MRR', }, } os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True) with open(OUT_PATH, 'w') as f: json.dump(out, f, indent=2) print(f'[intelligence] Written → {OUT_PATH}') print(f'[intelligence] YTD 2026: €{ytd26:,} | YoY: {yoy:+}% | source: {source}') if __name__ == '__main__': main()