# Atlas Academy — Mollie Subscription + Enrollment Service # Routes: # GET /betalen?plan=starter|pro → checkout form # POST /betalen → create Mollie customer + first payment → redirect # POST /mollie-enroll → Mollie webhook: first paid → create subscription + enroll Odoo # recurring paid → verify enrollment # failed/cancelled/expired → remove enrollment # GET /health → {"ok": true} # Systemd: atlas-mollie-enroll, port 7794, EnvironmentFile=/etc/atlas/mollie.env + /etc/atlas/academy-relay.env import os, json, logging, sqlite3, xmlrpc.client, urllib.request, urllib.parse from http.server import HTTPServer, BaseHTTPRequestHandler from pathlib import Path logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') log = logging.getLogger('mollie') MOLLIE_API_KEY = os.environ.get('MOLLIE_API_KEY', '') ODOO_URL = os.environ.get('ODOO_URL', 'http://localhost:8069') ODOO_DB = os.environ.get('ODOO_DB', 'atlas') ODOO_LOGIN = os.environ.get('ODOO_RELAY_LOGIN', '') ODOO_PASS = os.environ.get('ODOO_RELAY_PASS', '') PORT = int(os.environ.get('MOLLIE_ENROLL_PORT', 7794)) BASE_URL = 'https://atlasacademy.nl' DB_PATH = '/opt/atlas/academy/subscriptions.db' PLANS = { 'starter': {'name': 'Atlas Academy Starter', 'amount': '20.00', 'cents': 2000, 'interval': '1 month'}, 'pro': {'name': 'Atlas Academy Pro', 'amount': '49.00', 'cents': 4900, 'interval': '1 month'}, } # Odoo slide.channel IDs to enroll subscribers in (all except the free course 6) SUBSCRIPTION_CHANNELS = [1, 2, 3, 4, 5, 7, 8, 9, 10] # ── Database ─────────────────────────────────────────────────────────────────── def db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute('''CREATE TABLE IF NOT EXISTS subscribers ( email TEXT PRIMARY KEY, mollie_customer_id TEXT, mollie_subscription_id TEXT, plan TEXT, status TEXT, odoo_partner_id INTEGER, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) )''') conn.execute('''CREATE TABLE IF NOT EXISTS payments ( payment_id TEXT PRIMARY KEY, email TEXT, plan TEXT, status TEXT, sequence_type TEXT, created_at TEXT DEFAULT (datetime('now')) )''') conn.commit() return conn # ── Mollie API helpers ───────────────────────────────────────────────────────── def mollie(method: str, path: str, body: dict = None) -> dict: url = f'https://api.mollie.com/v2{path}' data = json.dumps(body).encode() if body else None req = urllib.request.Request(url, data=data, method=method, headers={ 'Authorization': f'Bearer {MOLLIE_API_KEY}', 'Content-Type': 'application/json', }) try: with urllib.request.urlopen(req, timeout=10) as r: return json.loads(r.read()) except urllib.error.HTTPError as e: err = json.loads(e.read()) raise RuntimeError(f"Mollie {method} {path}: {err.get('detail', err)}") def create_customer(email: str, name: str) -> str: resp = mollie('POST', '/customers', {'name': name, 'email': email}) return resp['id'] def create_first_payment(customer_id: str, plan: str, email: str) -> dict: p = PLANS[plan] resp = mollie('POST', '/payments', { 'amount': {'currency': 'EUR', 'value': p['amount']}, 'description': p['name'], 'sequenceType': 'first', 'customerId': customer_id, 'redirectUrl': f'{BASE_URL}/betaald?plan={plan}', 'webhookUrl': f'{BASE_URL}/mollie-enroll', 'metadata': {'email': email, 'plan': plan}, }) return {'payment_id': resp['id'], 'checkout_url': resp['_links']['checkout']['href']} def create_subscription(customer_id: str, mandate_id: str, plan: str, email: str) -> str: p = PLANS[plan] resp = mollie('POST', f'/customers/{customer_id}/subscriptions', { 'amount': {'currency': 'EUR', 'value': p['amount']}, 'interval': p['interval'], 'description': p['name'], 'mandateId': mandate_id, 'webhookUrl': f'{BASE_URL}/mollie-enroll', 'metadata': {'email': email, 'plan': plan}, }) return resp['id'] def cancel_subscription(customer_id: str, subscription_id: str): try: mollie('DELETE', f'/customers/{customer_id}/subscriptions/{subscription_id}') except Exception as e: log.warning(f'Cancel subscription error (may already be cancelled): {e}') def get_payment(payment_id: str) -> dict: return mollie('GET', f'/payments/{payment_id}') # ── Odoo helpers ─────────────────────────────────────────────────────────────── def _odoo_models(): common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common') uid = common.authenticate(ODOO_DB, ODOO_LOGIN, ODOO_PASS, {}) if not uid: raise RuntimeError('Odoo auth failed') return uid, xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object') def odoo_enroll_all(email: str) -> int: uid, m = _odoo_models() kw = {'ODOO_DB': ODOO_DB, 'uid': uid, 'pass': ODOO_PASS} partner_ids = m.execute_kw(ODOO_DB, uid, ODOO_PASS, 'res.partner', 'search', [[('email', '=', email)]]) if not partner_ids: partner_id = m.execute_kw(ODOO_DB, uid, ODOO_PASS, 'res.partner', 'create', [{'name': email.split('@')[0], 'email': email}]) else: partner_id = partner_ids[0] user_ids = m.execute_kw(ODOO_DB, uid, ODOO_PASS, 'res.users', 'search', [[('login', '=', email)]]) if not user_ids: m.execute_kw(ODOO_DB, uid, ODOO_PASS, 'res.users', 'create', [{'name': email.split('@')[0], 'login': email, 'email': email, 'partner_id': partner_id}]) for channel_id in SUBSCRIPTION_CHANNELS: existing = m.execute_kw(ODOO_DB, uid, ODOO_PASS, 'slide.channel.partner', 'search', [[('channel_id', '=', channel_id), ('partner_id', '=', partner_id)]]) if not existing: m.execute_kw(ODOO_DB, uid, ODOO_PASS, 'slide.channel.partner', 'create', [{'channel_id': channel_id, 'partner_id': partner_id}]) log.info(f'Enrolled {email} (partner {partner_id}) in channels {SUBSCRIPTION_CHANNELS}') return partner_id def odoo_remove_all(partner_id: int): uid, m = _odoo_models() for channel_id in SUBSCRIPTION_CHANNELS: ids = m.execute_kw(ODOO_DB, uid, ODOO_PASS, 'slide.channel.partner', 'search', [[('channel_id', '=', channel_id), ('partner_id', '=', partner_id)]]) if ids: m.execute_kw(ODOO_DB, uid, ODOO_PASS, 'slide.channel.partner', 'unlink', [ids]) log.info(f'Removed partner {partner_id} from channels {SUBSCRIPTION_CHANNELS}') # ── Checkout HTML ────────────────────────────────────────────────────────────── def checkout_html(plan: str = 'starter') -> str: starter_sel = ' selected' if plan == 'starter' else '' pro_sel = ' selected' if plan == 'pro' else '' return f""" Abonneren · Atlas Academy

Start jouw abonnement

Vul je e-mailadres in, kies je plan en betaal via iDEAL of creditcard. Maandelijks opzegbaar.

🔒 Veilige betaling via Mollie · SEPA iDEAL · creditcard

← Terug naar prijsoverzicht
""" # ── HTTP handler ─────────────────────────────────────────────────────────────── class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): log.info(fmt % args) def do_GET(self): p = urllib.parse.urlparse(self.path) q = urllib.parse.parse_qs(p.query) if p.path in ('/betalen', '/betalen/'): plan = q.get('plan', ['starter'])[0] if plan not in PLANS: plan = 'starter' self._html(200, checkout_html(plan)) elif p.path == '/health': self._json(200, {'ok': True, 'service': 'mollie-enroll', 'key': bool(MOLLIE_API_KEY)}) else: self._json(404, {'error': 'not found'}) def do_POST(self): length = int(self.headers.get('Content-Length', 0)) body = self.rfile.read(length) p = urllib.parse.urlparse(self.path) if p.path in ('/betalen', '/betalen/'): self._handle_checkout(body) elif p.path == '/mollie-enroll': self._handle_webhook(body) elif p.path == '/health': self._json(200, {'ok': True}) else: self._json(404, {'error': 'not found'}) def _handle_checkout(self, body: bytes): if not MOLLIE_API_KEY: self._html(503, '

Betalingen tijdelijk niet beschikbaar

') return params = urllib.parse.parse_qs(body.decode()) email = (params.get('email', [''])[0]).strip().lower() plan = (params.get('plan', ['starter'])[0]).strip() if not email or '@' not in email or '.' not in email.split('@')[-1]: self._html(400, checkout_html(plan)) return if plan not in PLANS: plan = 'starter' try: with db() as conn: row = conn.execute('SELECT * FROM subscribers WHERE email=?', (email,)).fetchone() if row and row['status'] == 'active': # already subscribed — send to login self.send_response(303) self.send_header('Location', f'{BASE_URL}/web/login?redirect=%2Fslides') self.end_headers() return if row and row['mollie_customer_id']: customer_id = row['mollie_customer_id'] else: customer_id = create_customer(email, email.split('@')[0]) conn.execute('''INSERT OR REPLACE INTO subscribers (email, mollie_customer_id, plan, status) VALUES (?, ?, ?, 'pending')''', (email, customer_id, plan)) conn.commit() result = create_first_payment(customer_id, plan, email) with db() as conn: conn.execute('''INSERT OR REPLACE INTO payments (payment_id, email, plan, status, sequence_type) VALUES (?, ?, ?, 'open', 'first')''', (result['payment_id'], email, plan)) conn.commit() self.send_response(303) self.send_header('Location', result['checkout_url']) self.end_headers() except Exception as e: log.error(f'Checkout error: {e}') self._html(500, f'

Fout bij aanmaken betaling.
Mail info@atlascorporation.nl

') def _handle_webhook(self, body: bytes): try: params = urllib.parse.parse_qs(body.decode()) payment_id = (params.get('id', [None]) or [None])[0] if not payment_id: self._json(400, {'error': 'missing id'}) return payment = get_payment(payment_id) status = payment.get('status') seq = payment.get('sequenceType', '') meta = payment.get('metadata', {}) or {} email = meta.get('email', '') plan = meta.get('plan', 'starter') log.info(f'Webhook {payment_id}: status={status} seq={seq} email={email} plan={plan}') if status != 'paid': if status in ('failed', 'expired', 'canceled') and email: self._deactivate(email, reason=status) self._json(200, {'ok': True, 'status': status}) return if seq == 'first': # First payment paid — set up subscription customer_id = payment.get('customerId', '') mandates_resp = mollie('GET', f'/customers/{customer_id}/mandates') mandates = mandates_resp.get('_embedded', {}).get('mandates', []) valid = [m for m in mandates if m.get('status') == 'valid'] if not valid: log.warning(f'No valid mandate for {customer_id} after first payment') sub_id = None else: sub_id = create_subscription(customer_id, valid[0]['id'], plan, email) partner_id = odoo_enroll_all(email) with db() as conn: conn.execute('''INSERT OR REPLACE INTO subscribers (email, mollie_customer_id, mollie_subscription_id, plan, status, odoo_partner_id, updated_at) VALUES (?, ?, ?, ?, 'active', ?, datetime('now'))''', (email, customer_id, sub_id, plan, partner_id)) conn.execute('''UPDATE payments SET status='paid' WHERE payment_id=?''', (payment_id,)) conn.commit() log.info(f'Subscribed {email} ({plan}), subscription={sub_id}, partner={partner_id}') elif seq == 'recurring': # Recurring charge paid — ensure still enrolled with db() as conn: row = conn.execute('SELECT * FROM subscribers WHERE email=?', (email,)).fetchone() if row and row['status'] == 'active': if row['odoo_partner_id']: odoo_enroll_all(email) conn2 = db() conn2.execute("UPDATE subscribers SET updated_at=datetime('now') WHERE email=?", (email,)) conn2.commit() conn2.close() log.info(f'Recurring payment confirmed for {email}') else: log.warning(f'Recurring for unknown/inactive {email}, enrolling') partner_id = odoo_enroll_all(email) with db() as conn: conn.execute('''UPDATE subscribers SET status='active', odoo_partner_id=?, updated_at=datetime('now') WHERE email=?''', (partner_id, email)) conn.commit() self._json(200, {'ok': True}) except Exception as e: log.error(f'Webhook error: {e}', exc_info=True) self._json(200, {'ok': True}) def _deactivate(self, email: str, reason: str = 'cancelled'): try: with db() as conn: row = conn.execute('SELECT * FROM subscribers WHERE email=?', (email,)).fetchone() if not row: return if row['odoo_partner_id']: odoo_remove_all(row['odoo_partner_id']) if row['mollie_customer_id'] and row['mollie_subscription_id']: cancel_subscription(row['mollie_customer_id'], row['mollie_subscription_id']) with db() as conn: conn.execute("UPDATE subscribers SET status=?, updated_at=datetime('now') WHERE email=?", (reason, email)) conn.commit() log.info(f'Deactivated {email}: {reason}') except Exception as e: log.error(f'Deactivate error for {email}: {e}') def _json(self, code, data): body = json.dumps(data).encode() self.send_response(code) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', len(body)) self.end_headers() self.wfile.write(body) def _html(self, code, html): body = html.encode() self.send_response(code) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', len(body)) self.end_headers() self.wfile.write(body) if __name__ == '__main__': if not MOLLIE_API_KEY: log.warning('MOLLIE_API_KEY not set') Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) with db(): pass log.info(f'Starting on 127.0.0.1:{PORT}') HTTPServer(('127.0.0.1', PORT), Handler).serve_forever()