feat: subscription model — Mollie recurring + course gating
- 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
This commit is contained in:
parent
03829a38b2
commit
b1fed1989a
4 changed files with 479 additions and 37 deletions
415
backend/mollie_enroll.py
Normal file
415
backend/mollie_enroll.py
Normal file
|
|
@ -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"""<!DOCTYPE html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Abonneren · Atlas Academy</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<style>
|
||||||
|
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||||
|
body{{font-family:'Segoe UI',sans-serif;background:#040810;color:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center}}
|
||||||
|
.wrap{{max-width:440px;width:100%;margin:2rem;padding:2.5rem;background:#0a1628;border:1px solid rgba(59,130,246,.2);border-radius:20px}}
|
||||||
|
h1{{font-size:1.5rem;font-weight:800;margin-bottom:.4rem}}
|
||||||
|
.sub{{color:#94a3b8;font-size:.95rem;margin-bottom:2rem;line-height:1.55}}
|
||||||
|
label{{font-size:.8rem;color:#94a3b8;display:block;margin-bottom:.3rem}}
|
||||||
|
input,select{{width:100%;padding:.75rem 1rem;background:#040810;border:1px solid rgba(59,130,246,.2);border-radius:10px;color:#f1f5f9;font-size:1rem;margin-bottom:1.1rem;outline:none}}
|
||||||
|
input:focus,select:focus{{border-color:rgba(59,130,246,.6)}}
|
||||||
|
.price-tag{{font-size:.85rem;color:#60a5fa;margin-top:-.75rem;margin-bottom:1.1rem;min-height:1.2rem}}
|
||||||
|
.btn{{width:100%;padding:.9rem;background:linear-gradient(135deg,#3b82f6,#8b5cf6);border:none;border-radius:10px;color:#fff;font-size:1rem;font-weight:700;cursor:pointer;letter-spacing:.01em}}
|
||||||
|
.btn:hover{{opacity:.9}}
|
||||||
|
.lock{{text-align:center;font-size:.72rem;color:#475569;margin-top:.85rem}}
|
||||||
|
.back{{display:block;text-align:center;color:#60a5fa;text-decoration:none;margin-top:1.5rem;font-size:.875rem}}
|
||||||
|
.back:hover{{color:#93c5fd}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Start jouw abonnement</h1>
|
||||||
|
<p class="sub">Vul je e-mailadres in, kies je plan en betaal via iDEAL of creditcard. Maandelijks opzegbaar.</p>
|
||||||
|
<form method="POST" action="/betalen">
|
||||||
|
<label>E-mailadres</label>
|
||||||
|
<input type="email" name="email" required placeholder="jouw@email.nl" autocomplete="email">
|
||||||
|
<label>Plan</label>
|
||||||
|
<select name="plan" id="pl" onchange="upd()">
|
||||||
|
<option value="starter"{starter_sel}>Starter — alle cursussen</option>
|
||||||
|
<option value="pro"{pro_sel}>Pro — cursussen + 1-op-1 coaching</option>
|
||||||
|
</select>
|
||||||
|
<div class="price-tag" id="pt"></div>
|
||||||
|
<button type="submit" class="btn">Betalen via Mollie →</button>
|
||||||
|
<p class="lock">🔒 Veilige betaling via Mollie · SEPA iDEAL · creditcard</p>
|
||||||
|
</form>
|
||||||
|
<a class="back" href="/pricing.html">← Terug naar prijsoverzicht</a>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var prices={{"starter":"€ 20,— / maand","pro":"€ 49,— / maand"}};
|
||||||
|
function upd(){{var v=document.getElementById("pl").value;document.getElementById("pt").textContent=prices[v]||"";}}
|
||||||
|
upd();
|
||||||
|
</script>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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, '<h1 style="font-family:sans-serif;color:#fff;background:#040810;padding:2rem">Betalingen tijdelijk niet beschikbaar</h1>')
|
||||||
|
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'<h1 style="font-family:sans-serif;color:#fff;background:#040810;padding:2rem">Fout bij aanmaken betaling.<br>Mail info@atlascorporation.nl</h1>')
|
||||||
|
|
||||||
|
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()
|
||||||
43
static/betaald.html
Normal file
43
static/betaald.html
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Welkom bij Atlas Academy</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:'Segoe UI',sans-serif;background:#040810;color:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center}
|
||||||
|
.wrap{max-width:500px;padding:3rem 2.5rem;background:#0a1628;border:1px solid rgba(59,130,246,.2);border-radius:20px;margin:2rem}
|
||||||
|
.icon{font-size:3.5rem;margin-bottom:1.2rem}
|
||||||
|
h1{font-size:1.6rem;font-weight:800;margin-bottom:.75rem}
|
||||||
|
p{color:#94a3b8;line-height:1.65;font-size:.95rem}
|
||||||
|
.btn{display:inline-block;margin-top:2rem;padding:.9rem 2.2rem;background:linear-gradient(135deg,#3b82f6,#8b5cf6);color:#fff;text-decoration:none;border-radius:10px;font-weight:700;font-size:1rem}
|
||||||
|
.btn:hover{opacity:.9}
|
||||||
|
.sub{font-size:.8rem;color:#475569;margin-top:1rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap" id="c">
|
||||||
|
<div class="icon" id="ic">⏳</div>
|
||||||
|
<h1 id="ti">Betaling verwerken...</h1>
|
||||||
|
<p id="ms">We verwerken je betaling. Dit duurt normaal minder dan een minuut.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var p=new URLSearchParams(location.search),plan=p.get('plan')||'starter',ic=document.getElementById('ic'),ti=document.getElementById('ti'),ms=document.getElementById('ms'),c=document.getElementById('c');
|
||||||
|
function btn(href,txt){var a=document.createElement('a');a.href=href;a.className='btn';a.textContent=txt;c.appendChild(a);}
|
||||||
|
var paid=location.search.includes('plan=');
|
||||||
|
if(paid){
|
||||||
|
ic.textContent='✅';
|
||||||
|
ti.textContent='Welkom bij Atlas Academy!';
|
||||||
|
ms.innerHTML='Je abonnement is actief. We hebben je toegang ingesteld — je kunt nu direct starten met alle cursussen.<br><br><strong>Check ook je inbox</strong> voor je inloggegevens.';
|
||||||
|
btn('/web/login?redirect=%2Fslides','Ga naar cursussen →');
|
||||||
|
var s=document.createElement('p');s.className='sub';s.textContent='Maandelijks opzegbaar — geen jaarcontract.';c.appendChild(s);
|
||||||
|
}else{
|
||||||
|
ic.textContent='⌛';
|
||||||
|
ti.textContent='Betaling in behandeling';
|
||||||
|
ms.textContent='We wachten op bevestiging. Controleer je inbox voor verdere informatie.';
|
||||||
|
setTimeout(function(){location.reload()},10000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -4,15 +4,15 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="theme-color" content="#080c18">
|
<meta name="theme-color" content="#080c18">
|
||||||
<meta name="description" content="Atlas Academy — binnenkort online. Praktijkkennis voor MKB-ondernemers. Laat je e-mail achter voor vroege toegang.">
|
<meta name="description" content="Atlas Academy — 9 praktijkcursussen voor ondernemers. Lean, Sales, AI, BiSL, Strategie en meer. Vanaf €20/maand, maandelijks opzegbaar.">
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
<meta property="og:title" content="Atlas Academy — Online leerplatform">
|
<meta property="og:title" content="Atlas Academy — Online leerplatform">
|
||||||
<meta property="og:description" content="Praktijkkennis voor MKB-ondernemers. Schrijf je in voor de wachtlijst.">
|
<meta property="og:description" content="9 cursussen voor ondernemers die bouwen. Lean, Sales, Strategisch Denken, AI, BiSL en meer. Vanaf €20/maand.">
|
||||||
<meta property="og:image" content="https://atlasacademy.nl/og-card.png">
|
<meta property="og:image" content="https://atlasacademy.nl/og-card.png">
|
||||||
<meta property="og:url" content="https://atlasacademy.nl">
|
<meta property="og:url" content="https://atlasacademy.nl">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<title>Atlas Academy — Online leerplatform</title>
|
<title>Atlas Academy — Praktijkcursussen voor ondernemers</title>
|
||||||
<link rel="icon" type="image/png" href="/atlas-icon.png">
|
<link rel="icon" type="image/png" href="/atlas-icon.png">
|
||||||
<!-- GA4 -->
|
<!-- GA4 -->
|
||||||
<!-- Google Tag (GT-MR2TH3RQ rollup G-2S7SWHG9TH) -->
|
<!-- Google Tag (GT-MR2TH3RQ rollup G-2S7SWHG9TH) -->
|
||||||
|
|
@ -154,14 +154,19 @@ h1{font-size:clamp(36px,6vw,56px);font-weight:900;letter-spacing:-.03em;line-hei
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="gradient">Atlas Academy</h1>
|
<h1 class="gradient">Atlas Academy</h1>
|
||||||
<p class="sub">Praktijkcursussen voor ondernemers die bouwen.<br>Theorie én kennischecks — direct toegang tot 9 cursussen.</p>
|
<p class="sub">9 cursussen · BiSL examentrainer · groepscoaching<br>Direct toegang — maandelijks opzegbaar.</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Begin vandaag</h2>
|
<h2>Start jouw abonnement</h2>
|
||||||
<p>Maak gratis een account aan en start meteen, of bekijk eerst het cursusaanbod.</p>
|
<p style="margin-bottom:16px">Alle 9 cursussen, BiSL examentrainer en groepscoaching — alles in één plan.</p>
|
||||||
|
<ul style="list-style:none;margin-bottom:20px;display:flex;flex-direction:column;gap:6px;font-size:14px;color:var(--text-2)">
|
||||||
|
<li>✓ Lean, Sales, AI, Strategie, BiSL & meer</li>
|
||||||
|
<li>✓ Kennischecks na elke les</li>
|
||||||
|
<li>✓ Groepscoaching 1×/maand via Zoom</li>
|
||||||
|
</ul>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<a href="/slides" class="btn btn-primary" style="text-decoration:none">Bekijk de cursussen →</a>
|
<a href="/betalen?plan=starter" class="btn btn-primary" style="text-decoration:none">Start voor €20/maand →</a>
|
||||||
<a href="/register" class="btn" style="text-decoration:none;background:rgba(255,255,255,.06);color:var(--text);border:1px solid rgba(78,160,255,.25)">Gratis account aanmaken</a>
|
<a href="/web/signup?redirect=%2Fslides" class="btn" style="text-decoration:none;background:rgba(255,255,255,.06);color:var(--text);border:1px solid rgba(78,160,255,.25)">Gratis proberen (1 cursus)</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -170,9 +175,9 @@ h1{font-size:clamp(36px,6vw,56px);font-weight:900;letter-spacing:-.03em;line-hei
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="position:relative;z-index:1;text-align:center;margin-top:20px;font-size:13px;color:#7a8aab">
|
<div style="position:relative;z-index:1;text-align:center;margin-top:20px;font-size:13px;color:#7a8aab">
|
||||||
Al een account? <a href="/login.html" style="color:#2E80FF;font-weight:700">Inloggen →</a>
|
Al een account? <a href="/web/login?redirect=%2Fslides" style="color:#2E80FF;font-weight:700">Inloggen →</a>
|
||||||
|
|
|
|
||||||
<a href="/register.html" style="color:#2E80FF;font-weight:600">Nieuw account</a>
|
<a href="/pricing.html" style="color:#2E80FF;font-weight:600">Bekijk prijzen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -219,27 +224,6 @@ h1{font-size:clamp(36px,6vw,56px);font-weight:900;letter-spacing:-.03em;line-hei
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/* ── Form ── */
|
|
||||||
async function handleSubmit(e){
|
|
||||||
e.preventDefault();
|
|
||||||
var btn=document.getElementById('btn');
|
|
||||||
var name=document.getElementById('name').value.trim();
|
|
||||||
var email=document.getElementById('email').value.trim();
|
|
||||||
if(!name||!email)return;
|
|
||||||
btn.textContent='Even wachten...';btn.disabled=true;
|
|
||||||
try{
|
|
||||||
var resp=await fetch('/academy-lead',{
|
|
||||||
method:'POST',
|
|
||||||
headers:{'Content-Type':'application/json'},
|
|
||||||
body:JSON.stringify({name:name,email:email,interest:'waitlist',source:'coming-soon'})
|
|
||||||
});
|
|
||||||
if(resp.ok){
|
|
||||||
document.getElementById('success').classList.add('visible');
|
|
||||||
document.getElementById('form').querySelector('.form-group').style.display='none';
|
|
||||||
btn.style.display='none';
|
|
||||||
}else{btn.textContent='Probeer opnieuw';btn.disabled=false}
|
|
||||||
}catch(err){btn.textContent='Probeer opnieuw';btn.disabled=false}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -691,7 +691,7 @@ footer {
|
||||||
<li><a href="over.html">Over</a></li>
|
<li><a href="over.html">Over</a></li>
|
||||||
<li><a href="/web/login?redirect=%2Fslides">Inloggen</a></li>
|
<li><a href="/web/login?redirect=%2Fslides">Inloggen</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<button class="lang-toggle" onclick="toggleLang()" id="langBtn">EN</button><a class="btn btn-primary nav-cta" href="mailto:info@atlascorporation.nl?subject=Atlas+Academy">Start gratis →</a>
|
<button class="lang-toggle" onclick="toggleLang()" id="langBtn">EN</button><a class="btn btn-primary nav-cta" href="/web/signup?redirect=%2Fslides">Start gratis →</a>
|
||||||
<button class="nav-mobile-toggle" onclick="toggleMenu()" aria-label="Menu openen">☰</button>
|
<button class="nav-mobile-toggle" onclick="toggleMenu()" aria-label="Menu openen">☰</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-mobile-menu" id="mobileMenu">
|
<div class="nav-mobile-menu" id="mobileMenu">
|
||||||
|
|
@ -702,7 +702,7 @@ footer {
|
||||||
<a href="shop.html">Shop</a>
|
<a href="shop.html">Shop</a>
|
||||||
<a href="over.html">Over</a>
|
<a href="over.html">Over</a>
|
||||||
<a href="/web/login?redirect=%2Fslides">Inloggen</a>
|
<a href="/web/login?redirect=%2Fslides">Inloggen</a>
|
||||||
<a href="mailto:info@atlascorporation.nl?subject=Atlas+Academy">Start gratis</a>
|
<a href="/web/signup?redirect=%2Fslides">Start gratis</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -749,7 +749,7 @@ footer {
|
||||||
<li><span class="ico ico--no">✕</span> 1-op-1 sessie</li>
|
<li><span class="ico ico--no">✕</span> 1-op-1 sessie</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<a href="mailto:info@atlascorporation.nl?subject=Atlas+Academy+Free" class="pricing-cta pricing-cta--ghost">
|
<a href="/web/signup?redirect=%2Fslides" class="pricing-cta pricing-cta--ghost">
|
||||||
Start gratis
|
Start gratis
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -783,7 +783,7 @@ footer {
|
||||||
<li><span class="ico ico--no">✕</span> Persoonlijk traject</li>
|
<li><span class="ico ico--no">✕</span> Persoonlijk traject</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<a href="mailto:info@atlascorporation.nl?subject=Atlas+Academy+Starter" class="pricing-cta pricing-cta--primary">
|
<a href="/betalen?plan=starter" class="pricing-cta pricing-cta--primary">
|
||||||
Word Starter →
|
Word Starter →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -813,7 +813,7 @@ footer {
|
||||||
<li><span class="ico ico--yes">✓</span> Early access nieuwe cursussen</li>
|
<li><span class="ico ico--yes">✓</span> Early access nieuwe cursussen</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<a href="mailto:info@atlascorporation.nl?subject=Atlas+Academy+Pro" class="pricing-cta pricing-cta--amber">
|
<a href="/betalen?plan=pro" class="pricing-cta pricing-cta--amber">
|
||||||
Word Pro →
|
Word Pro →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -924,7 +924,7 @@ footer {
|
||||||
<ul class="footer-links">
|
<ul class="footer-links">
|
||||||
<li><a href="mailto:info@atlascorporation.nl">info@atlascorporation.nl</a></li>
|
<li><a href="mailto:info@atlascorporation.nl">info@atlascorporation.nl</a></li>
|
||||||
<li><a href="https://atlascorporation.nl" target="_blank" rel="noopener">atlascorporation.nl</a></li>
|
<li><a href="https://atlascorporation.nl" target="_blank" rel="noopener">atlascorporation.nl</a></li>
|
||||||
<li><a href="mailto:info@atlascorporation.nl?subject=Atlas+Academy">Aanmelden</a></li>
|
<li><a href="/web/signup?redirect=%2Fslides">Aanmelden</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue