diff --git a/backend/mollie_enroll.py b/backend/mollie_enroll.py
new file mode 100644
index 0000000..55ca3ac
--- /dev/null
+++ b/backend/mollie_enroll.py
@@ -0,0 +1,415 @@
+# 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.
+
+
← 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()
diff --git a/static/betaald.html b/static/betaald.html
new file mode 100644
index 0000000..daa470b
--- /dev/null
+++ b/static/betaald.html
@@ -0,0 +1,43 @@
+
+
+
+
+Welkom bij Atlas Academy
+
+
+
+
+
+
⏳
+
Betaling verwerken...
+
We verwerken je betaling. Dit duurt normaal minder dan een minuut.
+
+
+
+
diff --git a/static/index.html b/static/index.html
index 32bb808..8c9a73a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -4,15 +4,15 @@
-
+
-
+
-Atlas Academy — Online leerplatform
+Atlas Academy — Praktijkcursussen voor ondernemers
@@ -154,14 +154,19 @@ h1{font-size:clamp(36px,6vw,56px);font-weight:900;letter-spacing:-.03em;line-hei
Atlas Academy
- Praktijkcursussen voor ondernemers die bouwen.
Theorie én kennischecks — direct toegang tot 9 cursussen.
+ 9 cursussen · BiSL examentrainer · groepscoaching
Direct toegang — maandelijks opzegbaar.
-
Begin vandaag
-
Maak gratis een account aan en start meteen, of bekijk eerst het cursusaanbod.
+
Start jouw abonnement
+
Alle 9 cursussen, BiSL examentrainer en groepscoaching — alles in één plan.
+
+ - ✓ Lean, Sales, AI, Strategie, BiSL & meer
+ - ✓ Kennischecks na elke les
+ - ✓ Groepscoaching 1×/maand via Zoom
+
@@ -170,9 +175,9 @@ h1{font-size:clamp(36px,6vw,56px);font-weight:900;letter-spacing:-.03em;line-hei