aiplang 2.1.0 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -151
- package/aiplang-knowledge.md +172 -129
- package/bin/aiplang.js +8 -8
- package/package.json +1 -1
- package/runtime/aiplang-hydrate.js +3 -3
- package/runtime/aiplang-runtime.js +5 -5
- package/server/server.js +336 -5
- package/bin/flux.js +0 -572
- package/runtime/flux-hydrate.js +0 -473
- package/runtime/flux-runtime.js +0 -1100
package/server/server.js
CHANGED
|
@@ -285,7 +285,7 @@ function migrateModels(models) {
|
|
|
285
285
|
// PARSER
|
|
286
286
|
// ═══════════════════════════════════════════════════════════════════
|
|
287
287
|
function parseApp(src) {
|
|
288
|
-
const app = { env:[], db:null, auth:null, mail:null, middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null }
|
|
288
|
+
const app = { env:[], db:null, auth:null, mail:null, stripe:null, middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null }
|
|
289
289
|
const lines = src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
|
|
290
290
|
let i=0, inModel=false, inAPI=false, curModel=null, curAPI=null, pageLines=[], inPage=false
|
|
291
291
|
|
|
@@ -304,6 +304,8 @@ function parseApp(src) {
|
|
|
304
304
|
if (line.startsWith('~mail ')) { app.mail = parseMailLine(line.slice(6)); i++; continue }
|
|
305
305
|
if (line.startsWith('~middleware ')) { app.middleware = line.slice(12).split('|').map(s=>s.trim()); i++; continue }
|
|
306
306
|
if (line.startsWith('~admin')) { app.admin = parseAdminLine(line); i++; continue }
|
|
307
|
+
if (line.startsWith('~stripe ')) { app.stripe = parseStripeLine(line.slice(8)); i++; continue }
|
|
308
|
+
if (line.startsWith('~plan ')) { app.stripe = app.stripe || {}; app.stripe.plans = app.stripe.plans || {}; parsePlanLine(line.slice(6), app.stripe.plans); i++; continue }
|
|
307
309
|
if (line.startsWith('~job ')) { app.jobs.push(parseJobLine(line.slice(5))); i++; continue }
|
|
308
310
|
if (line.startsWith('~on ')) { app.events.push(parseEventLine(line.slice(4))); i++; continue }
|
|
309
311
|
|
|
@@ -343,6 +345,23 @@ function parseEnvLine(s) { const p=s.split(/\s+/); const ev={name:'',required:fa
|
|
|
343
345
|
function parseDBLine(s) { const p=s.split(/\s+/); return{driver:p[0]||'sqlite',dsn:p[1]||'./app.db'} }
|
|
344
346
|
function parseAuthLine(s) { const p=s.split(/\s+/); const a={provider:'jwt',secret:p[1]||'$JWT_SECRET',expire:'7d'}; for(const x of p){if(x.startsWith('expire='))a.expire=x.slice(7);if(x==='google')a.oauth=['google'];if(x==='github')a.oauth=[...(a.oauth||[]),'google']}; return a }
|
|
345
347
|
function parseMailLine(s) { const parts=s.split(/\s+/); const m={driver:parts[0]||'smtp'}; for(const x of parts.slice(1)){const[k,v]=x.split('='); m[k]=v}; return m }
|
|
348
|
+
function parseStripeLine(s) {
|
|
349
|
+
const parts = s.split(/\s+/)
|
|
350
|
+
const cfg = { key: parts[0] || '$STRIPE_SECRET_KEY', plans: {}, mode: 'subscription' }
|
|
351
|
+
for (const p of parts.slice(1)) {
|
|
352
|
+
if (p.startsWith('webhook=')) cfg.webhookSecret = p.slice(8)
|
|
353
|
+
if (p.startsWith('success=')) cfg.successUrl = p.slice(8)
|
|
354
|
+
if (p.startsWith('cancel=')) cfg.cancelUrl = p.slice(7)
|
|
355
|
+
if (p === 'payment') cfg.mode = 'payment'
|
|
356
|
+
}
|
|
357
|
+
return cfg
|
|
358
|
+
}
|
|
359
|
+
function parsePlanLine(s, plans) {
|
|
360
|
+
// ~plan starter=price_xxx pro=price_yyy enterprise=price_zzz
|
|
361
|
+
s.split(/\s+/).forEach(pair => {
|
|
362
|
+
const eq = pair.indexOf('='); if (eq !== -1) plans[pair.slice(0,eq)] = pair.slice(eq+1)
|
|
363
|
+
})
|
|
364
|
+
}
|
|
346
365
|
function parseAdminLine(s) { const m=s.match(/~admin\s+(\S+)/); return{prefix:m?.[1]||'/admin',guard:'admin'} }
|
|
347
366
|
function parseJobLine(s) { const[name,...rest]=s.split(/\s+/); return{name,action:rest.join(' ')} }
|
|
348
367
|
function parseEventLine(s) { const m=s.match(/^(\S+)\s*=>\s*(.+)$/); return{event:m?.[1],action:m?.[2]} }
|
|
@@ -383,6 +402,13 @@ function compileRoute(route, server) {
|
|
|
383
402
|
for (const guard of route.guards) {
|
|
384
403
|
if (guard === 'auth' && !req.user) { res.error(401, 'Unauthorized'); return }
|
|
385
404
|
if (guard === 'admin' && req.user?.role !== 'admin') { res.error(403, 'Forbidden'); return }
|
|
405
|
+
if (guard === 'subscribed') {
|
|
406
|
+
const activeStatuses = ['active', 'trialing']
|
|
407
|
+
if (!req.user || (!activeStatuses.includes(req.user.subscription_status) && req.user.plan === 'free')) {
|
|
408
|
+
res.error(402, 'Active subscription required')
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
}
|
|
386
412
|
if (guard === 'owner') {
|
|
387
413
|
if (!req.user) { res.error(401, 'Unauthorized'); return }
|
|
388
414
|
// owner check happens in ops
|
|
@@ -768,7 +794,7 @@ function renderHTML(page, allPages) {
|
|
|
768
794
|
const needsJS=page.queries.length>0||page.blocks.some(b=>['table','form','if','btn','select','faq'].includes(b.kind))
|
|
769
795
|
const body=page.blocks.map(b=>renderBlock(b)).join('')
|
|
770
796
|
const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,state:page.state,routes:allPages.map(p=>p.route),queries:page.queries}):''
|
|
771
|
-
const hydrate=needsJS?`<script>window.
|
|
797
|
+
const hydrate=needsJS?`<script>window.__AIPLANG_PAGE__=${config};</script><script src="/aiplang-hydrate.js" defer></script>`:''
|
|
772
798
|
const themeCSS=page.themeVars?genThemeCSS(page.themeVars):''
|
|
773
799
|
const customCSS=page.customTheme?`body{background:${page.customTheme.bg};color:${page.customTheme.text}}.fx-cta,.fx-btn{background:${page.customTheme.accent};color:#fff}` :''
|
|
774
800
|
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${page.id}</title><style>${baseCSS(page.theme)}${customCSS}${themeCSS}</style></head><body>${body}${hydrate}</body></html>`
|
|
@@ -828,8 +854,8 @@ function baseCSS(theme) {
|
|
|
828
854
|
// ═══════════════════════════════════════════════════════════════════
|
|
829
855
|
// MAIN
|
|
830
856
|
// ═══════════════════════════════════════════════════════════════════
|
|
831
|
-
async function startServer(
|
|
832
|
-
const src = fs.readFileSync(
|
|
857
|
+
async function startServer(aipFile, port = 3000) {
|
|
858
|
+
const src = fs.readFileSync(aipFile, 'utf8')
|
|
833
859
|
const app = parseApp(src)
|
|
834
860
|
const srv = new AiplangServer()
|
|
835
861
|
|
|
@@ -866,6 +892,14 @@ async function startServer(fluxFile, port = 3000) {
|
|
|
866
892
|
// Admin panel
|
|
867
893
|
if (app.admin) registerAdminPanel(srv, app.admin, app.models)
|
|
868
894
|
|
|
895
|
+
// Stripe
|
|
896
|
+
if (app.stripe) {
|
|
897
|
+
setupStripe(app.stripe)
|
|
898
|
+
registerStripeRoutes(srv, app.stripe)
|
|
899
|
+
// Add subscription guard to compileRoute
|
|
900
|
+
STRIPE_PLANS = app.stripe.plans || {}
|
|
901
|
+
}
|
|
902
|
+
|
|
869
903
|
// Frontend
|
|
870
904
|
for (const page of app.pages) {
|
|
871
905
|
srv.addRoute('GET', page.route, (req, res) => {
|
|
@@ -877,7 +911,7 @@ async function startServer(fluxFile, port = 3000) {
|
|
|
877
911
|
|
|
878
912
|
// Static assets
|
|
879
913
|
srv.addRoute('GET', '/aiplang-hydrate.js', (req, res) => {
|
|
880
|
-
const p = path.join(__dirname, '..', '
|
|
914
|
+
const p = path.join(__dirname, '..', 'runtime', 'aiplang-hydrate.js')
|
|
881
915
|
if (fs.existsSync(p)) { res.writeHead(200,{'Content-Type':'application/javascript'}); res.end(fs.readFileSync(p)) }
|
|
882
916
|
else { res.writeHead(404); res.end('// not found') }
|
|
883
917
|
})
|
|
@@ -901,3 +935,300 @@ if (require.main === module) {
|
|
|
901
935
|
if (!f) { console.error('Usage: node server.js <app.flux> [port]'); process.exit(1) }
|
|
902
936
|
startServer(f, p).catch(e=>{console.error(e);process.exit(1)})
|
|
903
937
|
}
|
|
938
|
+
|
|
939
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
940
|
+
// STRIPE — Payments, Subscriptions, Webhooks
|
|
941
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
942
|
+
|
|
943
|
+
let STRIPE = null
|
|
944
|
+
let STRIPE_CONFIG = null
|
|
945
|
+
let STRIPE_PLANS = {}
|
|
946
|
+
|
|
947
|
+
function setupStripe(config) {
|
|
948
|
+
STRIPE_CONFIG = config
|
|
949
|
+
const key = resolveEnv(config.key) || ''
|
|
950
|
+
// Use mock if key is placeholder, test/mock value, or SDK unavailable
|
|
951
|
+
const isMock = !key || key.startsWith('$') || key === 'sk_test_mock' || key.includes('mock')
|
|
952
|
+
if (isMock) {
|
|
953
|
+
console.log('[aiplang] Stripe: mock mode (set STRIPE_SECRET_KEY for real payments)')
|
|
954
|
+
STRIPE = null // will use mockStripe()
|
|
955
|
+
return
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const Stripe = require('stripe')
|
|
959
|
+
STRIPE = new Stripe(key, { apiVersion: '2024-06-20' })
|
|
960
|
+
console.log(`[aiplang] Stripe: live mode (${key.startsWith('sk_test') ? 'test key' : 'production key'})`)
|
|
961
|
+
} catch (e) {
|
|
962
|
+
console.log('[aiplang] Stripe: SDK error, using mock')
|
|
963
|
+
STRIPE = null
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function mockStripe() {
|
|
968
|
+
// Mock Stripe for dev/test without real key
|
|
969
|
+
return {
|
|
970
|
+
customers: {
|
|
971
|
+
create: async (opts) => ({ id: 'cus_mock_' + uuid(), email: opts.email }),
|
|
972
|
+
retrieve: async (id) => ({ id, email: 'mock@test.com' })
|
|
973
|
+
},
|
|
974
|
+
checkout: {
|
|
975
|
+
sessions: {
|
|
976
|
+
create: async (opts) => ({
|
|
977
|
+
id: 'cs_mock_' + uuid(),
|
|
978
|
+
url: opts.success_url + '?session_id=mock',
|
|
979
|
+
payment_status: 'unpaid'
|
|
980
|
+
})
|
|
981
|
+
}
|
|
982
|
+
},
|
|
983
|
+
subscriptions: {
|
|
984
|
+
retrieve: async (id) => ({
|
|
985
|
+
id, status: 'active',
|
|
986
|
+
current_period_end: Math.floor(Date.now()/1000) + 86400*30,
|
|
987
|
+
items: { data: [{ price: { id: 'price_mock', nickname: 'Pro' } }] }
|
|
988
|
+
}),
|
|
989
|
+
cancel: async (id) => ({ id, status: 'canceled' })
|
|
990
|
+
},
|
|
991
|
+
billingPortal: {
|
|
992
|
+
sessions: {
|
|
993
|
+
create: async (opts) => ({ url: opts.return_url + '?portal=mock' })
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
webhooks: {
|
|
997
|
+
constructEvent: (body, sig, secret) => {
|
|
998
|
+
try { return JSON.parse(body) } catch { throw new Error('Invalid payload') }
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
prices: {
|
|
1002
|
+
list: async () => ({ data: [] })
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function getStripe() { return STRIPE || mockStripe() }
|
|
1008
|
+
|
|
1009
|
+
function registerStripeRoutes(server, stripeConfig) {
|
|
1010
|
+
const stripe = getStripe()
|
|
1011
|
+
const plans = stripeConfig.plans || {}
|
|
1012
|
+
const webhookSecret = resolveEnv(stripeConfig.webhookSecret || '$STRIPE_WEBHOOK_SECRET')
|
|
1013
|
+
const successUrl = stripeConfig.successUrl || `${process.env.APP_URL || 'http://localhost:3000'}/dashboard?payment=success`
|
|
1014
|
+
const cancelUrl = stripeConfig.cancelUrl || `${process.env.APP_URL || 'http://localhost:3000'}/pricing?payment=cancelled`
|
|
1015
|
+
|
|
1016
|
+
// ── POST /api/stripe/checkout ──────────────────────────────────
|
|
1017
|
+
// Creates a Stripe Checkout session
|
|
1018
|
+
// Body: { plan: 'pro', email: '...' } or uses logged-in user
|
|
1019
|
+
server.addRoute('POST', '/api/stripe/checkout', async (req, res) => {
|
|
1020
|
+
try {
|
|
1021
|
+
const plan = req.body.plan || req.body.price_id || Object.keys(plans)[0]
|
|
1022
|
+
const priceId = plans[plan] || plan // allow passing price_id directly
|
|
1023
|
+
const email = req.body.email || req.user?.email
|
|
1024
|
+
|
|
1025
|
+
if (!priceId) { res.error(400, 'Plan not found. Available: ' + Object.keys(plans).join(', ')); return }
|
|
1026
|
+
|
|
1027
|
+
// Get or create Stripe customer
|
|
1028
|
+
let customerId = null
|
|
1029
|
+
if (req.user?.id) {
|
|
1030
|
+
const userModel = Object.values(server.models).find(m => m.tableName === 'users')
|
|
1031
|
+
if (userModel) {
|
|
1032
|
+
const user = userModel.find(req.user.id)
|
|
1033
|
+
if (user?.stripe_customer_id) {
|
|
1034
|
+
customerId = user.stripe_customer_id
|
|
1035
|
+
} else {
|
|
1036
|
+
const customer = await stripe.customers.create({ email, metadata: { user_id: req.user.id } })
|
|
1037
|
+
customerId = customer.id
|
|
1038
|
+
if (userModel.find(req.user.id)) userModel.update(req.user.id, { stripe_customer_id: customerId })
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const sessionOpts = {
|
|
1044
|
+
mode: stripeConfig.mode === 'payment' ? 'payment' : 'subscription',
|
|
1045
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
1046
|
+
success_url: successUrl + (successUrl.includes('?') ? '&' : '?') + 'session_id={CHECKOUT_SESSION_ID}',
|
|
1047
|
+
cancel_url: cancelUrl,
|
|
1048
|
+
allow_promotion_codes: true,
|
|
1049
|
+
}
|
|
1050
|
+
if (customerId) sessionOpts.customer = customerId
|
|
1051
|
+
else if (email) sessionOpts.customer_email = email
|
|
1052
|
+
if (req.body.trial_days) sessionOpts.subscription_data = { trial_period_days: parseInt(req.body.trial_days) }
|
|
1053
|
+
if (req.user?.id) sessionOpts.metadata = { user_id: req.user.id, plan }
|
|
1054
|
+
|
|
1055
|
+
const session = await stripe.checkout.sessions.create(sessionOpts)
|
|
1056
|
+
res.json(200, { url: session.url, session_id: session.id })
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
console.error('[aiplang:stripe] Checkout error:', e.message)
|
|
1059
|
+
res.error(500, e.message)
|
|
1060
|
+
}
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
// ── POST /api/stripe/portal ────────────────────────────────────
|
|
1064
|
+
// Customer billing portal (manage subscription, invoices, card)
|
|
1065
|
+
server.addRoute('POST', '/api/stripe/portal', async (req, res) => {
|
|
1066
|
+
if (!req.user) { res.error(401, 'Unauthorized'); return }
|
|
1067
|
+
try {
|
|
1068
|
+
const userModel = Object.values(server.models).find(m => m.tableName === 'users')
|
|
1069
|
+
const user = userModel?.find(req.user.id)
|
|
1070
|
+
if (!user?.stripe_customer_id) { res.error(404, 'No billing account found'); return }
|
|
1071
|
+
|
|
1072
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
1073
|
+
customer: user.stripe_customer_id,
|
|
1074
|
+
return_url: cancelUrl
|
|
1075
|
+
})
|
|
1076
|
+
res.json(200, { url: session.url })
|
|
1077
|
+
} catch (e) {
|
|
1078
|
+
res.error(500, e.message)
|
|
1079
|
+
}
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
// ── GET /api/stripe/subscription ──────────────────────────────
|
|
1083
|
+
// Get current user's subscription status
|
|
1084
|
+
server.addRoute('GET', '/api/stripe/subscription', async (req, res) => {
|
|
1085
|
+
if (!req.user) { res.error(401, 'Unauthorized'); return }
|
|
1086
|
+
try {
|
|
1087
|
+
const userModel = Object.values(server.models).find(m => m.tableName === 'users')
|
|
1088
|
+
const user = userModel?.find(req.user.id)
|
|
1089
|
+
res.json(200, {
|
|
1090
|
+
plan: user?.plan || 'free',
|
|
1091
|
+
status: user?.subscription_status || 'inactive',
|
|
1092
|
+
customer_id: user?.stripe_customer_id || null,
|
|
1093
|
+
period_end: user?.subscription_period_end || null
|
|
1094
|
+
})
|
|
1095
|
+
} catch (e) { res.error(500, e.message) }
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
// ── DELETE /api/stripe/subscription ───────────────────────────
|
|
1099
|
+
// Cancel subscription
|
|
1100
|
+
server.addRoute('DELETE', '/api/stripe/subscription', async (req, res) => {
|
|
1101
|
+
if (!req.user) { res.error(401, 'Unauthorized'); return }
|
|
1102
|
+
try {
|
|
1103
|
+
const userModel = Object.values(server.models).find(m => m.tableName === 'users')
|
|
1104
|
+
const user = userModel?.find(req.user.id)
|
|
1105
|
+
if (!user?.subscription_id) { res.error(404, 'No active subscription'); return }
|
|
1106
|
+
await stripe.subscriptions.cancel(user.subscription_id)
|
|
1107
|
+
userModel?.update(req.user.id, { subscription_status: 'canceled', plan: 'free' })
|
|
1108
|
+
res.json(200, { status: 'canceled' })
|
|
1109
|
+
} catch (e) { res.error(500, e.message) }
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
// ── POST /api/stripe/webhook ───────────────────────────────────
|
|
1113
|
+
// Stripe webhook handler — updates user subscription state
|
|
1114
|
+
server.addRoute('POST', '/api/stripe/webhook', async (req, res) => {
|
|
1115
|
+
let event
|
|
1116
|
+
try {
|
|
1117
|
+
const rawBody = await getRawBody(req)
|
|
1118
|
+
const sig = req.headers['stripe-signature']
|
|
1119
|
+
if (webhookSecret && sig) {
|
|
1120
|
+
event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret)
|
|
1121
|
+
} else {
|
|
1122
|
+
event = JSON.parse(rawBody)
|
|
1123
|
+
}
|
|
1124
|
+
} catch (e) {
|
|
1125
|
+
res.error(400, 'Webhook error: ' + e.message); return
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const userModel = Object.values(server.models).find(m => m.tableName === 'users')
|
|
1129
|
+
const data = event.data?.object
|
|
1130
|
+
|
|
1131
|
+
switch (event.type) {
|
|
1132
|
+
case 'checkout.session.completed': {
|
|
1133
|
+
const userId = data.metadata?.user_id
|
|
1134
|
+
if (userId && userModel) {
|
|
1135
|
+
const planName = data.metadata?.plan || 'pro'
|
|
1136
|
+
userModel.update(userId, {
|
|
1137
|
+
plan: planName,
|
|
1138
|
+
subscription_status: 'active',
|
|
1139
|
+
stripe_customer_id: data.customer || undefined,
|
|
1140
|
+
subscription_id: data.subscription || undefined,
|
|
1141
|
+
})
|
|
1142
|
+
emit('stripe.checkout.completed', { userId, plan: planName, session: data.id })
|
|
1143
|
+
}
|
|
1144
|
+
break
|
|
1145
|
+
}
|
|
1146
|
+
case 'customer.subscription.updated': {
|
|
1147
|
+
const custId = data.customer
|
|
1148
|
+
if (custId && userModel) {
|
|
1149
|
+
const user = userModel.findBy('stripe_customer_id', custId)
|
|
1150
|
+
if (user) {
|
|
1151
|
+
const planItem = data.items?.data?.[0]?.price
|
|
1152
|
+
const planName = resolvePlanFromPrice(planItem?.id, plans)
|
|
1153
|
+
userModel.update(user.id, {
|
|
1154
|
+
subscription_status: data.status,
|
|
1155
|
+
plan: planName || user.plan,
|
|
1156
|
+
subscription_period_end: data.current_period_end
|
|
1157
|
+
? new Date(data.current_period_end * 1000).toISOString() : undefined
|
|
1158
|
+
})
|
|
1159
|
+
emit('stripe.subscription.updated', { userId: user.id, status: data.status })
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
break
|
|
1163
|
+
}
|
|
1164
|
+
case 'customer.subscription.deleted': {
|
|
1165
|
+
const custId = data.customer
|
|
1166
|
+
if (custId && userModel) {
|
|
1167
|
+
const user = userModel.findBy('stripe_customer_id', custId)
|
|
1168
|
+
if (user) {
|
|
1169
|
+
userModel.update(user.id, { subscription_status: 'canceled', plan: 'free' })
|
|
1170
|
+
emit('stripe.subscription.canceled', { userId: user.id })
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
break
|
|
1174
|
+
}
|
|
1175
|
+
case 'invoice.payment_failed': {
|
|
1176
|
+
const custId = data.customer
|
|
1177
|
+
if (custId && userModel) {
|
|
1178
|
+
const user = userModel.findBy('stripe_customer_id', custId)
|
|
1179
|
+
if (user) {
|
|
1180
|
+
userModel.update(user.id, { subscription_status: 'past_due' })
|
|
1181
|
+
emit('stripe.payment.failed', { userId: user.id, amount: data.amount_due })
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
break
|
|
1185
|
+
}
|
|
1186
|
+
case 'invoice.payment_succeeded': {
|
|
1187
|
+
const custId = data.customer
|
|
1188
|
+
if (custId && userModel) {
|
|
1189
|
+
const user = userModel.findBy('stripe_customer_id', custId)
|
|
1190
|
+
if (user && user.subscription_status === 'past_due') {
|
|
1191
|
+
userModel.update(user.id, { subscription_status: 'active' })
|
|
1192
|
+
}
|
|
1193
|
+
emit('stripe.payment.succeeded', { userId: user?.id, amount: data.amount_paid })
|
|
1194
|
+
}
|
|
1195
|
+
break
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
res.json(200, { received: true, type: event.type })
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
console.log(`[aiplang] Stripe: /api/stripe/checkout | /api/stripe/portal | /api/stripe/webhook`)
|
|
1203
|
+
console.log(`[aiplang] Stripe: Plans: ${Object.keys(plans).join(', ') || 'none defined'}`)
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ── Guard: ~guard subscription ────────────────────────────────────
|
|
1207
|
+
// Check if user has active subscription
|
|
1208
|
+
function checkSubscription(req, plan = null) {
|
|
1209
|
+
if (!req.user) return false
|
|
1210
|
+
const userModel = Object.values({}).find(m => m.tableName === 'users')
|
|
1211
|
+
// Simplified: check from JWT claims if we embed subscription_status
|
|
1212
|
+
if (req.user.subscription_status === 'active') return true
|
|
1213
|
+
if (req.user.plan && req.user.plan !== 'free') return true
|
|
1214
|
+
return false
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function resolvePlanFromPrice(priceId, plans) {
|
|
1218
|
+
for (const [name, id] of Object.entries(plans)) {
|
|
1219
|
+
if (id === priceId) return name
|
|
1220
|
+
}
|
|
1221
|
+
return null
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function getRawBody(req) {
|
|
1225
|
+
return new Promise((resolve, reject) => {
|
|
1226
|
+
let data = ''
|
|
1227
|
+
req.on('data', chunk => data += chunk)
|
|
1228
|
+
req.on('end', () => resolve(data))
|
|
1229
|
+
req.on('error', reject)
|
|
1230
|
+
})
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
module.exports.setupStripe = setupStripe
|
|
1234
|
+
module.exports.registerStripeRoutes = registerStripeRoutes
|