From b1fed1989abc588592946a04afa85a26c0b2129e Mon Sep 17 00:00:00 2001 From: Atlas Developer Date: Tue, 23 Jun 2026 01:43:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20subscription=20model=20=E2=80=94=20Moll?= =?UTF-8?q?ie=20recurring=20+=20course=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mollie_enroll.py rewritten: Mollie Customer → sequenceType=first payment → Subscription API → recurring webhook → Odoo enrollment / removal - SQLite subscriber + payments tracking (/opt/atlas/academy/subscriptions.db) - Plans: Starter €20/mo, Pro €49/mo with per-plan Odoo channel enrollment - Courses 1-5,7-10 set to invite-only; course 6 (IT-Basis) free sample - index.html: subscription CTAs, removed coming-soon waitlist language - pricing.html: CTAs → /betalen?plan= (live Mollie), Free → /web/signup - betaald.html: subscription success messaging with login CTA - Mollie live key active, Odoo provider ID=11 enabled --- backend/mollie_enroll.py | 415 +++++++++++++++++++++++++++++++++++++++ static/betaald.html | 43 ++++ static/index.html | 46 ++--- static/pricing.html | 12 +- 4 files changed, 479 insertions(+), 37 deletions(-) create mode 100644 backend/mollie_enroll.py create mode 100644 static/betaald.html 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.

+
+ + + + +
+ +

🔒 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() 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.

+
@@ -170,9 +175,9 @@ h1{font-size:clamp(36px,6vw,56px);font-weight:900;letter-spacing:-.03em;line-hei
- Al een account? Inloggen → + Al een account? Inloggen →   |   - Nieuw account + Bekijk prijzen
\ No newline at end of file diff --git a/static/pricing.html b/static/pricing.html index 9010d82..78e7b95 100644 --- a/static/pricing.html +++ b/static/pricing.html @@ -691,7 +691,7 @@ footer {
  • Over
  • Inloggen
  • - Start gratis → + Start gratis → @@ -749,7 +749,7 @@ footer {
  • 1-op-1 sessie
  • - + Start gratis @@ -783,7 +783,7 @@ footer {
  • Persoonlijk traject
  • - + Word Starter → @@ -813,7 +813,7 @@ footer {
  • Early access nieuwe cursussen
  • - + Word Pro → @@ -924,7 +924,7 @@ footer {