neoagent 3.0.1-beta.7 → 3.0.1-beta.8
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/.env.example +19 -0
- package/README.md +1 -0
- package/docs/billing.md +203 -0
- package/docs/configuration.md +13 -0
- package/docs/index.md +1 -0
- package/lib/schema_migrations.js +84 -0
- package/package.json +2 -1
- package/server/admin/admin.js +2 -1
- package/server/admin/billing.js +292 -0
- package/server/admin/index.html +37 -0
- package/server/db/database.js +1 -0
- package/server/http/routes.js +9 -0
- package/server/middleware/requireBilling.js +12 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/admin.js +110 -0
- package/server/routes/billing.js +122 -0
- package/server/routes/billing_webhook.js +43 -0
- package/server/routes/memory.js +1 -4
- package/server/routes/settings.js +1 -2
- package/server/routes/skills.js +1 -1
- package/server/services/ai/loop/agent_engine_core.js +1 -1
- package/server/services/ai/loop/completion_judge.js +2 -9
- package/server/services/ai/loop/conversation_loop.js +12 -26
- package/server/services/ai/loopPolicy.js +2 -39
- package/server/services/ai/models.js +18 -0
- package/server/services/ai/systemPrompt.js +17 -0
- package/server/services/ai/tools.js +1 -11
- package/server/services/billing/billing_email.js +106 -0
- package/server/services/billing/config.js +17 -0
- package/server/services/billing/plans.js +120 -0
- package/server/services/billing/stripe_client.js +21 -0
- package/server/services/billing/subscriptions.js +462 -0
- package/server/services/billing/trial_guard.js +111 -0
- package/server/services/manager.js +11 -0
- package/server/services/tasks/runtime.js +52 -12
- package/server/services/workspace/manager.js +23 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require('crypto');
|
|
4
|
+
const db = require('../../db/database');
|
|
5
|
+
|
|
6
|
+
function listPlans({ includeInactive = false } = {}) {
|
|
7
|
+
const sql = includeInactive
|
|
8
|
+
? 'SELECT * FROM billing_plans ORDER BY sort_order ASC, created_at ASC'
|
|
9
|
+
: 'SELECT * FROM billing_plans WHERE is_active = 1 ORDER BY sort_order ASC, created_at ASC';
|
|
10
|
+
return db.prepare(sql).all().map(parsePlan);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getPlan(planId) {
|
|
14
|
+
const row = db.prepare('SELECT * FROM billing_plans WHERE id = ?').get(planId);
|
|
15
|
+
return row ? parsePlan(row) : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getFreePlan() {
|
|
19
|
+
const row = db.prepare(
|
|
20
|
+
'SELECT * FROM billing_plans WHERE price_cents = 0 AND is_active = 1 ORDER BY sort_order ASC LIMIT 1',
|
|
21
|
+
).get();
|
|
22
|
+
return row ? parsePlan(row) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validatePlanData(data, requireName = true) {
|
|
26
|
+
if (requireName && !data.name?.trim()) {
|
|
27
|
+
throw Object.assign(new Error('Plan name is required.'), { statusCode: 400 });
|
|
28
|
+
}
|
|
29
|
+
if (data.price_cents !== undefined && (!Number.isInteger(data.price_cents) || data.price_cents < 0)) {
|
|
30
|
+
throw Object.assign(new Error('price_cents must be a non-negative integer.'), { statusCode: 400 });
|
|
31
|
+
}
|
|
32
|
+
if (data.currency !== undefined && !/^[a-z]{3}$/i.test(data.currency)) {
|
|
33
|
+
throw Object.assign(new Error('currency must be a 3-letter ISO code.'), { statusCode: 400 });
|
|
34
|
+
}
|
|
35
|
+
if (data.interval !== undefined && data.interval !== null && !['month', 'year'].includes(data.interval)) {
|
|
36
|
+
throw Object.assign(new Error('interval must be "month", "year", or null.'), { statusCode: 400 });
|
|
37
|
+
}
|
|
38
|
+
if (data.id !== undefined && !/^[a-zA-Z0-9_-]+$/.test(data.id)) {
|
|
39
|
+
throw Object.assign(new Error('Plan ID may only contain letters, numbers, underscores, and hyphens.'), { statusCode: 400 });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createPlan(data) {
|
|
44
|
+
validatePlanData(data, true);
|
|
45
|
+
const id = data.id || `plan_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
|
46
|
+
const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
47
|
+
db.prepare(`
|
|
48
|
+
INSERT INTO billing_plans
|
|
49
|
+
(id, name, description, price_cents, currency, interval, stripe_price_id,
|
|
50
|
+
token_limit_4h, token_limit_weekly, allowed_models_json, features_json,
|
|
51
|
+
is_active, sort_order, created_at, updated_at)
|
|
52
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
53
|
+
`).run(
|
|
54
|
+
id,
|
|
55
|
+
data.name,
|
|
56
|
+
data.description || '',
|
|
57
|
+
data.price_cents ?? 0,
|
|
58
|
+
data.currency || 'usd',
|
|
59
|
+
data.interval ?? 'month',
|
|
60
|
+
data.stripe_price_id ?? null,
|
|
61
|
+
data.token_limit_4h ?? null,
|
|
62
|
+
data.token_limit_weekly ?? null,
|
|
63
|
+
JSON.stringify(data.allowed_models ?? []),
|
|
64
|
+
JSON.stringify(data.features ?? []),
|
|
65
|
+
data.is_active !== false ? 1 : 0,
|
|
66
|
+
data.sort_order ?? 0,
|
|
67
|
+
now,
|
|
68
|
+
now,
|
|
69
|
+
);
|
|
70
|
+
return getPlan(id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function updatePlan(planId, data) {
|
|
74
|
+
validatePlanData(data, false);
|
|
75
|
+
const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
76
|
+
const fields = [];
|
|
77
|
+
const values = [];
|
|
78
|
+
|
|
79
|
+
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
|
|
80
|
+
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description); }
|
|
81
|
+
if (data.price_cents !== undefined) { fields.push('price_cents = ?'); values.push(data.price_cents); }
|
|
82
|
+
if (data.currency !== undefined) { fields.push('currency = ?'); values.push(data.currency); }
|
|
83
|
+
if (data.interval !== undefined) { fields.push('interval = ?'); values.push(data.interval); }
|
|
84
|
+
if (data.stripe_price_id !== undefined) { fields.push('stripe_price_id = ?'); values.push(data.stripe_price_id); }
|
|
85
|
+
if (data.token_limit_4h !== undefined) { fields.push('token_limit_4h = ?'); values.push(data.token_limit_4h); }
|
|
86
|
+
if (data.token_limit_weekly !== undefined) { fields.push('token_limit_weekly = ?'); values.push(data.token_limit_weekly); }
|
|
87
|
+
if (data.allowed_models !== undefined) { fields.push('allowed_models_json = ?'); values.push(JSON.stringify(data.allowed_models)); }
|
|
88
|
+
if (data.features !== undefined) { fields.push('features_json = ?'); values.push(JSON.stringify(data.features)); }
|
|
89
|
+
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active ? 1 : 0); }
|
|
90
|
+
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
|
|
91
|
+
|
|
92
|
+
if (!fields.length) return getPlan(planId);
|
|
93
|
+
|
|
94
|
+
fields.push('updated_at = ?');
|
|
95
|
+
values.push(now);
|
|
96
|
+
values.push(planId);
|
|
97
|
+
|
|
98
|
+
db.prepare(`UPDATE billing_plans SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
|
99
|
+
return getPlan(planId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function deletePlan(planId) {
|
|
103
|
+
const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
104
|
+
db.prepare('UPDATE billing_plans SET is_active = 0, updated_at = ? WHERE id = ?').run(now, planId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parsePlan(row) {
|
|
108
|
+
return {
|
|
109
|
+
...row,
|
|
110
|
+
allowed_models: tryParseJson(row.allowed_models_json, []),
|
|
111
|
+
features: tryParseJson(row.features_json, []),
|
|
112
|
+
is_active: Boolean(row.is_active),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function tryParseJson(str, fallback) {
|
|
117
|
+
try { return JSON.parse(str); } catch { return fallback; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { listPlans, getPlan, getFreePlan, createPlan, updatePlan, deletePlan };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isBillingEnabled, getStripeConfig } = require('./config');
|
|
4
|
+
|
|
5
|
+
let _client = null;
|
|
6
|
+
|
|
7
|
+
function getStripeClient() {
|
|
8
|
+
if (!isBillingEnabled()) {
|
|
9
|
+
throw new Error('Billing is not enabled.');
|
|
10
|
+
}
|
|
11
|
+
if (_client) return _client;
|
|
12
|
+
const { secretKey } = getStripeConfig();
|
|
13
|
+
if (!secretKey) {
|
|
14
|
+
throw new Error('STRIPE_SECRET_KEY is not configured.');
|
|
15
|
+
}
|
|
16
|
+
const Stripe = require('stripe');
|
|
17
|
+
_client = new Stripe(secretKey);
|
|
18
|
+
return _client;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { getStripeClient };
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require('crypto');
|
|
4
|
+
const db = require('../../db/database');
|
|
5
|
+
const { getStripeClient } = require('./stripe_client');
|
|
6
|
+
const { getPlan, getFreePlan } = require('./plans');
|
|
7
|
+
const trialGuard = require('./trial_guard');
|
|
8
|
+
const billingEmail = require('./billing_email');
|
|
9
|
+
|
|
10
|
+
function isoNow() {
|
|
11
|
+
return new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getUserById(userId) {
|
|
15
|
+
return db.prepare('SELECT id, email, display_name, username FROM users WHERE id = ?').get(userId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripeTs(ts) {
|
|
19
|
+
if (!ts) return null;
|
|
20
|
+
return new Date(ts * 1000).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Returns the user's active subscription joined with plan data.
|
|
24
|
+
// If admin set billing_override_plan_id, that plan is used regardless of user_subscriptions.
|
|
25
|
+
function getActiveSubscription(userId) {
|
|
26
|
+
const user = db.prepare('SELECT billing_override_plan_id FROM users WHERE id = ?').get(userId);
|
|
27
|
+
if (user?.billing_override_plan_id) {
|
|
28
|
+
const plan = getPlan(user.billing_override_plan_id);
|
|
29
|
+
if (plan) {
|
|
30
|
+
return {
|
|
31
|
+
id: null,
|
|
32
|
+
user_id: userId,
|
|
33
|
+
plan_id: plan.id,
|
|
34
|
+
plan,
|
|
35
|
+
status: 'active',
|
|
36
|
+
trial_ends_at: null,
|
|
37
|
+
cancel_at_period_end: false,
|
|
38
|
+
stripe_subscription_id: null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const row = db.prepare(`
|
|
44
|
+
SELECT s.*, p.name AS plan_name, p.description AS plan_description,
|
|
45
|
+
p.price_cents, p.currency, p.interval, p.stripe_price_id,
|
|
46
|
+
p.token_limit_4h, p.token_limit_weekly,
|
|
47
|
+
p.allowed_models_json, p.features_json
|
|
48
|
+
FROM user_subscriptions s
|
|
49
|
+
JOIN billing_plans p ON p.id = s.plan_id
|
|
50
|
+
WHERE s.user_id = ?
|
|
51
|
+
ORDER BY
|
|
52
|
+
CASE s.status WHEN 'active' THEN 0 WHEN 'trialing' THEN 1 WHEN 'past_due' THEN 2 ELSE 3 END,
|
|
53
|
+
s.updated_at DESC
|
|
54
|
+
LIMIT 1
|
|
55
|
+
`).get(userId);
|
|
56
|
+
|
|
57
|
+
if (!row) return null;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
...row,
|
|
61
|
+
cancel_at_period_end: Boolean(row.cancel_at_period_end),
|
|
62
|
+
plan: {
|
|
63
|
+
id: row.plan_id,
|
|
64
|
+
name: row.plan_name,
|
|
65
|
+
description: row.plan_description,
|
|
66
|
+
price_cents: row.price_cents,
|
|
67
|
+
currency: row.currency,
|
|
68
|
+
interval: row.interval,
|
|
69
|
+
stripe_price_id: row.stripe_price_id,
|
|
70
|
+
token_limit_4h: row.token_limit_4h,
|
|
71
|
+
token_limit_weekly: row.token_limit_weekly,
|
|
72
|
+
allowed_models: tryParseJson(row.allowed_models_json, []),
|
|
73
|
+
features: tryParseJson(row.features_json, []),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Ensure a Stripe customer exists for this user; create if not.
|
|
79
|
+
async function getOrCreateStripeCustomer(userId) {
|
|
80
|
+
const user = getUserById(userId);
|
|
81
|
+
if (!user) throw new Error('User not found.');
|
|
82
|
+
|
|
83
|
+
const existing = db.prepare('SELECT stripe_customer_id FROM billing_customers WHERE user_id = ?').get(userId);
|
|
84
|
+
if (existing?.stripe_customer_id) return existing.stripe_customer_id;
|
|
85
|
+
|
|
86
|
+
const stripe = getStripeClient();
|
|
87
|
+
const customer = await stripe.customers.create({
|
|
88
|
+
email: user.email || undefined,
|
|
89
|
+
name: user.display_name || user.username || undefined,
|
|
90
|
+
metadata: { neoagent_user_id: String(userId) },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const now = isoNow();
|
|
94
|
+
db.prepare(`
|
|
95
|
+
INSERT INTO billing_customers (user_id, stripe_customer_id, created_at, updated_at)
|
|
96
|
+
VALUES (?, ?, ?, ?)
|
|
97
|
+
ON CONFLICT(user_id) DO UPDATE SET stripe_customer_id = excluded.stripe_customer_id, updated_at = excluded.updated_at
|
|
98
|
+
`).run(userId, customer.id, now, now);
|
|
99
|
+
|
|
100
|
+
return customer.id;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createFreeSubscription(userId) {
|
|
104
|
+
const freePlan = getFreePlan();
|
|
105
|
+
if (!freePlan) return null;
|
|
106
|
+
|
|
107
|
+
const id = randomUUID();
|
|
108
|
+
const now = isoNow();
|
|
109
|
+
// INSERT OR IGNORE makes this idempotent under concurrent calls — the unique
|
|
110
|
+
// constraint on (user_id, active status) is enforced by the application layer,
|
|
111
|
+
// but concurrent requests could both reach this point; ignore the second attempt.
|
|
112
|
+
const info = db.prepare(`
|
|
113
|
+
INSERT OR IGNORE INTO user_subscriptions
|
|
114
|
+
(id, user_id, plan_id, stripe_subscription_id, status, created_at, updated_at)
|
|
115
|
+
SELECT ?, ?, ?, NULL, 'active', ?, ?
|
|
116
|
+
WHERE NOT EXISTS (
|
|
117
|
+
SELECT 1 FROM user_subscriptions WHERE user_id = ? AND status IN ('active', 'trialing')
|
|
118
|
+
)
|
|
119
|
+
`).run(id, userId, freePlan.id, now, now, userId);
|
|
120
|
+
|
|
121
|
+
if (info.changes > 0) {
|
|
122
|
+
applyRateLimitsFromSubscription(userId);
|
|
123
|
+
}
|
|
124
|
+
return getActiveSubscription(userId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function startTrial(userId, planId, { ip, deviceFp } = {}) {
|
|
128
|
+
const plan = getPlan(planId);
|
|
129
|
+
if (!plan) throw Object.assign(new Error('Plan not found.'), { statusCode: 404 });
|
|
130
|
+
if (plan.price_cents === 0) throw Object.assign(new Error('Free plans do not have trials.'), { statusCode: 400 });
|
|
131
|
+
|
|
132
|
+
const user = getUserById(userId);
|
|
133
|
+
trialGuard.runChecks(userId, user?.email, { ip, deviceFp });
|
|
134
|
+
|
|
135
|
+
const stripe = getStripeClient();
|
|
136
|
+
const { trialDays } = require('./config').getStripeConfig();
|
|
137
|
+
const customerId = await getOrCreateStripeCustomer(userId);
|
|
138
|
+
|
|
139
|
+
const stripeSub = await stripe.subscriptions.create({
|
|
140
|
+
customer: customerId,
|
|
141
|
+
items: [{ price: plan.stripe_price_id }],
|
|
142
|
+
trial_period_days: trialDays,
|
|
143
|
+
payment_behavior: 'default_incomplete',
|
|
144
|
+
expand: ['latest_invoice.payment_intent'],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const id = randomUUID();
|
|
148
|
+
const now = isoNow();
|
|
149
|
+
try {
|
|
150
|
+
db.prepare(`
|
|
151
|
+
INSERT INTO user_subscriptions
|
|
152
|
+
(id, user_id, plan_id, stripe_subscription_id, status, trial_ends_at,
|
|
153
|
+
current_period_start, current_period_end, created_at, updated_at)
|
|
154
|
+
VALUES (?, ?, ?, ?, 'trialing', ?, ?, ?, ?, ?)
|
|
155
|
+
`).run(
|
|
156
|
+
id, userId, planId, stripeSub.id,
|
|
157
|
+
stripeTs(stripeSub.trial_end),
|
|
158
|
+
stripeTs(stripeSub.current_period_start),
|
|
159
|
+
stripeTs(stripeSub.current_period_end),
|
|
160
|
+
now, now,
|
|
161
|
+
);
|
|
162
|
+
} catch (localErr) {
|
|
163
|
+
// Local insert failed — cancel the Stripe subscription to avoid an orphan.
|
|
164
|
+
try { await stripe.subscriptions.cancel(stripeSub.id); } catch { /* best effort */ }
|
|
165
|
+
throw localErr;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
recordEvent(userId, id, 'trial_started', null, { plan_id: planId });
|
|
169
|
+
trialGuard.recordFingerprints(userId, { ip, email: user?.email, deviceFp });
|
|
170
|
+
applyRateLimitsFromSubscription(userId);
|
|
171
|
+
|
|
172
|
+
return { subscription: getActiveSubscription(userId), stripeSubscription: stripeSub };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function createCheckoutSession(userId, planId, successUrl, cancelUrl) {
|
|
176
|
+
const plan = getPlan(planId);
|
|
177
|
+
if (!plan) throw Object.assign(new Error('Plan not found.'), { statusCode: 404 });
|
|
178
|
+
if (!plan.stripe_price_id) throw Object.assign(new Error('Plan has no Stripe price configured.'), { statusCode: 400 });
|
|
179
|
+
|
|
180
|
+
const stripe = getStripeClient();
|
|
181
|
+
const customerId = await getOrCreateStripeCustomer(userId);
|
|
182
|
+
|
|
183
|
+
const session = await stripe.checkout.sessions.create({
|
|
184
|
+
customer: customerId,
|
|
185
|
+
mode: 'subscription',
|
|
186
|
+
line_items: [{ price: plan.stripe_price_id, quantity: 1 }],
|
|
187
|
+
success_url: successUrl,
|
|
188
|
+
cancel_url: cancelUrl,
|
|
189
|
+
metadata: { neoagent_user_id: String(userId), plan_id: planId },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return session.url;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function createCustomerPortalSession(userId, returnUrl) {
|
|
196
|
+
const stripe = getStripeClient();
|
|
197
|
+
const customerId = await getOrCreateStripeCustomer(userId);
|
|
198
|
+
|
|
199
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
200
|
+
customer: customerId,
|
|
201
|
+
return_url: returnUrl,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return session.url;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function cancelSubscription(userId) {
|
|
208
|
+
const sub = getActiveSubscription(userId);
|
|
209
|
+
if (!sub?.stripe_subscription_id) {
|
|
210
|
+
throw Object.assign(new Error('No active Stripe subscription to cancel.'), { statusCode: 400 });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const stripe = getStripeClient();
|
|
214
|
+
await stripe.subscriptions.update(sub.stripe_subscription_id, { cancel_at_period_end: true });
|
|
215
|
+
|
|
216
|
+
const now = isoNow();
|
|
217
|
+
db.prepare(
|
|
218
|
+
'UPDATE user_subscriptions SET cancel_at_period_end = 1, updated_at = ? WHERE id = ?',
|
|
219
|
+
).run(now, sub.id);
|
|
220
|
+
|
|
221
|
+
recordEvent(userId, sub.id, 'subscription_cancel_requested', null, {});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Called from webhook handlers to sync the user's rate limits from their current plan.
|
|
225
|
+
function applyRateLimitsFromSubscription(userId) {
|
|
226
|
+
const sub = getActiveSubscription(userId);
|
|
227
|
+
if (!sub) return;
|
|
228
|
+
const { token_limit_4h, token_limit_weekly } = sub.plan;
|
|
229
|
+
db.prepare(
|
|
230
|
+
'UPDATE users SET rate_limit_4h = ?, rate_limit_weekly = ? WHERE id = ?',
|
|
231
|
+
).run(token_limit_4h ?? null, token_limit_weekly ?? null, userId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Find a user by their Stripe customer ID.
|
|
235
|
+
function findUserByStripeCustomer(stripeCustomerId) {
|
|
236
|
+
const row = db.prepare('SELECT user_id FROM billing_customers WHERE stripe_customer_id = ?').get(stripeCustomerId);
|
|
237
|
+
return row?.user_id ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Upsert a subscription row from a Stripe subscription object.
|
|
241
|
+
function upsertFromStripeSubscription(userId, stripeSub, overridePlanId = null) {
|
|
242
|
+
const planId = overridePlanId || stripeSub.metadata?.plan_id;
|
|
243
|
+
|
|
244
|
+
const existing = db.prepare(
|
|
245
|
+
'SELECT id FROM user_subscriptions WHERE stripe_subscription_id = ?',
|
|
246
|
+
).get(stripeSub.id);
|
|
247
|
+
|
|
248
|
+
const now = isoNow();
|
|
249
|
+
const status = stripeSub.status;
|
|
250
|
+
const trialEnd = stripeTs(stripeSub.trial_end);
|
|
251
|
+
const periodStart = stripeTs(stripeSub.current_period_start);
|
|
252
|
+
const periodEnd = stripeTs(stripeSub.current_period_end);
|
|
253
|
+
const cancelAtPeriodEnd = stripeSub.cancel_at_period_end ? 1 : 0;
|
|
254
|
+
const canceledAt = stripeSub.canceled_at ? stripeTs(stripeSub.canceled_at) : null;
|
|
255
|
+
|
|
256
|
+
if (existing) {
|
|
257
|
+
db.prepare(`
|
|
258
|
+
UPDATE user_subscriptions
|
|
259
|
+
SET status = ?, trial_ends_at = ?, current_period_start = ?, current_period_end = ?,
|
|
260
|
+
cancel_at_period_end = ?, canceled_at = ?, updated_at = ?
|
|
261
|
+
${planId ? ', plan_id = ?' : ''}
|
|
262
|
+
WHERE id = ?
|
|
263
|
+
`).run(
|
|
264
|
+
...(planId
|
|
265
|
+
? [status, trialEnd, periodStart, periodEnd, cancelAtPeriodEnd, canceledAt, now, planId, existing.id]
|
|
266
|
+
: [status, trialEnd, periodStart, periodEnd, cancelAtPeriodEnd, canceledAt, now, existing.id]),
|
|
267
|
+
);
|
|
268
|
+
return existing.id;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// planId is required to satisfy the FK on billing_plans(id).
|
|
272
|
+
// Webhooks from subscriptions created outside NeoAgent (no metadata.plan_id)
|
|
273
|
+
// cannot be inserted without a valid plan — skip rather than violate the constraint.
|
|
274
|
+
if (!planId) return null;
|
|
275
|
+
|
|
276
|
+
const id = randomUUID();
|
|
277
|
+
db.prepare(`
|
|
278
|
+
INSERT INTO user_subscriptions
|
|
279
|
+
(id, user_id, plan_id, stripe_subscription_id, status, trial_ends_at,
|
|
280
|
+
current_period_start, current_period_end, cancel_at_period_end, canceled_at, created_at, updated_at)
|
|
281
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
282
|
+
`).run(id, userId, planId, stripeSub.id, status, trialEnd, periodStart, periodEnd, cancelAtPeriodEnd, canceledAt, now, now);
|
|
283
|
+
return id;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function recordEvent(userId, subscriptionId, eventType, stripeEventId, payload) {
|
|
287
|
+
try {
|
|
288
|
+
db.prepare(`
|
|
289
|
+
INSERT OR IGNORE INTO subscription_events
|
|
290
|
+
(user_id, subscription_id, event_type, stripe_event_id, payload_json, created_at)
|
|
291
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
292
|
+
`).run(
|
|
293
|
+
userId,
|
|
294
|
+
subscriptionId,
|
|
295
|
+
eventType,
|
|
296
|
+
stripeEventId,
|
|
297
|
+
JSON.stringify(payload || {}),
|
|
298
|
+
isoNow(),
|
|
299
|
+
);
|
|
300
|
+
} catch {
|
|
301
|
+
// Best-effort; never fail the caller.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function handleWebhookEvent(stripeEvent) {
|
|
306
|
+
const obj = stripeEvent.data.object;
|
|
307
|
+
|
|
308
|
+
switch (stripeEvent.type) {
|
|
309
|
+
case 'customer.subscription.created':
|
|
310
|
+
case 'customer.subscription.updated': {
|
|
311
|
+
const userId = findUserByStripeCustomer(obj.customer);
|
|
312
|
+
if (!userId) return;
|
|
313
|
+
|
|
314
|
+
const oldStatus = stripeEvent.type === 'customer.subscription.updated'
|
|
315
|
+
? stripeEvent.data.previous_attributes?.status
|
|
316
|
+
: null;
|
|
317
|
+
|
|
318
|
+
const subId = upsertFromStripeSubscription(userId, obj);
|
|
319
|
+
applyRateLimitsFromSubscription(userId);
|
|
320
|
+
|
|
321
|
+
const newStatus = obj.status;
|
|
322
|
+
const user = getUserById(userId);
|
|
323
|
+
const sub = getActiveSubscription(userId);
|
|
324
|
+
|
|
325
|
+
if (oldStatus === 'trialing' && newStatus === 'active') {
|
|
326
|
+
recordEvent(userId, subId, 'subscription_started', stripeEvent.id, { plan_id: sub?.plan_id });
|
|
327
|
+
await billingEmail.sendSubscriptionStarted(user, sub?.plan).catch(() => {});
|
|
328
|
+
} else if (newStatus === 'trialing' && !oldStatus) {
|
|
329
|
+
recordEvent(userId, subId, 'trial_started', stripeEvent.id, { plan_id: sub?.plan_id });
|
|
330
|
+
await billingEmail.sendTrialStarted(user, sub?.plan, obj.trial_end).catch(() => {});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (stripeEvent.data.previous_attributes?.plan && newStatus === 'active') {
|
|
334
|
+
recordEvent(userId, subId, 'plan_changed', stripeEvent.id, {});
|
|
335
|
+
await billingEmail.sendPlanChanged(user, null, sub?.plan).catch(() => {});
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case 'customer.subscription.deleted': {
|
|
341
|
+
const userId = findUserByStripeCustomer(obj.customer);
|
|
342
|
+
if (!userId) return;
|
|
343
|
+
upsertFromStripeSubscription(userId, obj);
|
|
344
|
+
applyRateLimitsFromSubscription(userId);
|
|
345
|
+
recordEvent(userId, null, 'subscription_canceled', stripeEvent.id, {});
|
|
346
|
+
const user = getUserById(userId);
|
|
347
|
+
const sub = getActiveSubscription(userId);
|
|
348
|
+
await billingEmail.sendSubscriptionCanceled(user, sub?.plan).catch(() => {});
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case 'invoice.payment_succeeded': {
|
|
353
|
+
const userId = findUserByStripeCustomer(obj.customer);
|
|
354
|
+
if (!userId) return;
|
|
355
|
+
recordEvent(userId, null, 'payment_succeeded', stripeEvent.id, { amount: obj.amount_paid });
|
|
356
|
+
if (obj.billing_reason === 'subscription_cycle') {
|
|
357
|
+
const user = getUserById(userId);
|
|
358
|
+
const sub = getActiveSubscription(userId);
|
|
359
|
+
await billingEmail.sendSubscriptionRenewed(user, sub?.plan).catch(() => {});
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
case 'invoice.payment_failed': {
|
|
365
|
+
const userId = findUserByStripeCustomer(obj.customer);
|
|
366
|
+
if (!userId) return;
|
|
367
|
+
recordEvent(userId, null, 'payment_failed', stripeEvent.id, { amount: obj.amount_due });
|
|
368
|
+
const user = getUserById(userId);
|
|
369
|
+
const sub = getActiveSubscription(userId);
|
|
370
|
+
const nextRetry = obj.next_payment_attempt
|
|
371
|
+
? new Date(obj.next_payment_attempt * 1000).toISOString()
|
|
372
|
+
: null;
|
|
373
|
+
await billingEmail.sendPaymentFailed(user, sub?.plan, nextRetry).catch(() => {});
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case 'customer.subscription.trial_will_end': {
|
|
378
|
+
const userId = findUserByStripeCustomer(obj.customer);
|
|
379
|
+
if (!userId) return;
|
|
380
|
+
recordEvent(userId, null, 'trial_ending', stripeEvent.id, { trial_end: obj.trial_end });
|
|
381
|
+
const user = getUserById(userId);
|
|
382
|
+
const sub = getActiveSubscription(userId);
|
|
383
|
+
await billingEmail.sendTrialEnding(user, sub?.plan, obj.trial_end).catch(() => {});
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
default:
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const VALID_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing', 'past_due', 'canceled', 'paused']);
|
|
393
|
+
|
|
394
|
+
// Admin override: assign a plan directly without Stripe.
|
|
395
|
+
function adminSetSubscription(userId, planId, status = 'active') {
|
|
396
|
+
if (!VALID_SUBSCRIPTION_STATUSES.has(status)) {
|
|
397
|
+
throw Object.assign(new Error(`Invalid status "${status}".`), { statusCode: 400 });
|
|
398
|
+
}
|
|
399
|
+
const plan = getPlan(planId);
|
|
400
|
+
if (!plan) throw Object.assign(new Error('Plan not found.'), { statusCode: 404 });
|
|
401
|
+
|
|
402
|
+
const existing = db.prepare(
|
|
403
|
+
'SELECT id FROM user_subscriptions WHERE user_id = ? AND stripe_subscription_id IS NULL ORDER BY created_at DESC LIMIT 1',
|
|
404
|
+
).get(userId);
|
|
405
|
+
|
|
406
|
+
const now = isoNow();
|
|
407
|
+
if (existing) {
|
|
408
|
+
db.prepare('UPDATE user_subscriptions SET plan_id = ?, status = ?, updated_at = ? WHERE id = ?')
|
|
409
|
+
.run(planId, status, now, existing.id);
|
|
410
|
+
} else {
|
|
411
|
+
const id = randomUUID();
|
|
412
|
+
db.prepare(`
|
|
413
|
+
INSERT INTO user_subscriptions
|
|
414
|
+
(id, user_id, plan_id, stripe_subscription_id, status, created_at, updated_at)
|
|
415
|
+
VALUES (?, ?, ?, NULL, ?, ?, ?)
|
|
416
|
+
`).run(id, userId, planId, status, now, now);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
applyRateLimitsFromSubscription(userId);
|
|
420
|
+
return getActiveSubscription(userId);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Revoke admin override and/or cancel manual subscription immediately.
|
|
424
|
+
function adminCancelSubscription(userId) {
|
|
425
|
+
const now = isoNow();
|
|
426
|
+
db.prepare("UPDATE users SET billing_override_plan_id = NULL WHERE id = ?").run(userId);
|
|
427
|
+
db.prepare(
|
|
428
|
+
"UPDATE user_subscriptions SET status = 'canceled', canceled_at = ?, updated_at = ? WHERE user_id = ? AND stripe_subscription_id IS NULL",
|
|
429
|
+
).run(now, now, userId);
|
|
430
|
+
applyRateLimitsFromSubscription(userId);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Run at startup to re-sync rate limits for all active subscribers (in case webhooks were missed).
|
|
434
|
+
function syncAllSubscriberRateLimits() {
|
|
435
|
+
try {
|
|
436
|
+
const rows = db.prepare(
|
|
437
|
+
"SELECT DISTINCT user_id FROM user_subscriptions WHERE status IN ('active', 'trialing')",
|
|
438
|
+
).all();
|
|
439
|
+
for (const { user_id } of rows) {
|
|
440
|
+
try { applyRateLimitsFromSubscription(user_id); } catch { /* best effort */ }
|
|
441
|
+
}
|
|
442
|
+
} catch { /* best effort */ }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function tryParseJson(str, fallback) {
|
|
446
|
+
try { return JSON.parse(str); } catch { return fallback; }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
module.exports = {
|
|
450
|
+
getActiveSubscription,
|
|
451
|
+
createFreeSubscription,
|
|
452
|
+
startTrial,
|
|
453
|
+
createCheckoutSession,
|
|
454
|
+
createCustomerPortalSession,
|
|
455
|
+
cancelSubscription,
|
|
456
|
+
applyRateLimitsFromSubscription,
|
|
457
|
+
handleWebhookEvent,
|
|
458
|
+
adminSetSubscription,
|
|
459
|
+
adminCancelSubscription,
|
|
460
|
+
syncAllSubscriberRateLimits,
|
|
461
|
+
findUserByStripeCustomer,
|
|
462
|
+
};
|