neoagent 3.0.1-beta.2 → 3.0.1-beta.21

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.
Files changed (94) hide show
  1. package/.env.example +19 -0
  2. package/README.md +20 -3
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/getting-started.md +10 -8
  7. package/docs/index.md +1 -0
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +4 -1
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +316 -0
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1594 -224
  14. package/flutter_app/lib/main_controller.dart +263 -26
  15. package/flutter_app/lib/main_devices.dart +1 -1
  16. package/flutter_app/lib/main_install.dart +1147 -0
  17. package/flutter_app/lib/main_navigation.dart +12 -0
  18. package/flutter_app/lib/main_operations.dart +134 -9
  19. package/flutter_app/lib/main_settings.dart +148 -52
  20. package/flutter_app/lib/main_shared.dart +1 -0
  21. package/flutter_app/lib/src/backend_client.dart +86 -1
  22. package/landing/index.html +2 -2
  23. package/lib/manager.js +184 -66
  24. package/lib/schema_migrations.js +84 -0
  25. package/package.json +10 -4
  26. package/server/admin/access.js +12 -7
  27. package/server/admin/admin.css +78 -0
  28. package/server/admin/admin.js +511 -23
  29. package/server/admin/billing.js +415 -0
  30. package/server/admin/index.html +120 -13
  31. package/server/admin/users.js +15 -15
  32. package/server/db/database.js +13 -21
  33. package/server/db/sessions_db.js +8 -0
  34. package/server/http/middleware.js +4 -4
  35. package/server/http/routes.js +9 -0
  36. package/server/http/static.js +4 -2
  37. package/server/middleware/requireBilling.js +12 -0
  38. package/server/public/.last_build_id +1 -1
  39. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  40. package/server/public/flutter_bootstrap.js +1 -1
  41. package/server/public/main.dart.js +89347 -85449
  42. package/server/routes/account.js +53 -0
  43. package/server/routes/admin.js +515 -63
  44. package/server/routes/agents.js +203 -21
  45. package/server/routes/billing.js +127 -0
  46. package/server/routes/billing_webhook.js +43 -0
  47. package/server/routes/memory.js +1 -4
  48. package/server/routes/settings.js +1 -2
  49. package/server/routes/skills.js +1 -1
  50. package/server/routes/triggers.js +2 -2
  51. package/server/services/account/erasure.js +263 -0
  52. package/server/services/account/sessions.js +1 -9
  53. package/server/services/ai/loop/agent_engine_core.js +42 -0
  54. package/server/services/ai/loop/blank_recovery.js +36 -0
  55. package/server/services/ai/loop/completion_judge.js +42 -0
  56. package/server/services/ai/loop/conversation_loop.js +248 -34
  57. package/server/services/ai/loop/progress_monitor.js +6 -6
  58. package/server/services/ai/loopPolicy.js +37 -44
  59. package/server/services/ai/messagingFallback.js +25 -1
  60. package/server/services/ai/models.js +18 -0
  61. package/server/services/ai/preModelCompaction.js +25 -5
  62. package/server/services/ai/rate_limits.js +28 -4
  63. package/server/services/ai/systemPrompt.js +23 -5
  64. package/server/services/ai/taskAnalysis.js +2 -0
  65. package/server/services/ai/tools.js +231 -20
  66. package/server/services/android/controller.js +6 -2
  67. package/server/services/billing/billing_email.js +106 -0
  68. package/server/services/billing/config.js +17 -0
  69. package/server/services/billing/plans.js +121 -0
  70. package/server/services/billing/stripe_client.js +21 -0
  71. package/server/services/billing/subscriptions.js +462 -0
  72. package/server/services/billing/trial_guard.js +111 -0
  73. package/server/services/browser/contentExtractor.js +629 -0
  74. package/server/services/browser/controller.js +4 -8
  75. package/server/services/desktop/gateway.js +7 -4
  76. package/server/services/integrations/google/calendar.js +30 -2
  77. package/server/services/integrations/secrets.js +7 -4
  78. package/server/services/manager.js +11 -0
  79. package/server/services/messaging/automation.js +1 -1
  80. package/server/services/messaging/telnyx.js +12 -11
  81. package/server/services/messaging/whatsapp.js +1 -1
  82. package/server/services/recordings/manager.js +13 -7
  83. package/server/services/runtime/backends/local-vm.js +40 -22
  84. package/server/services/runtime/docker-vm-manager.js +157 -266
  85. package/server/services/runtime/guest_bootstrap.js +17 -5
  86. package/server/services/runtime/guest_image.js +182 -0
  87. package/server/services/runtime/manager.js +0 -1
  88. package/server/services/runtime/validation.js +3 -8
  89. package/server/services/tasks/runtime.js +103 -15
  90. package/server/services/tasks/schedule_utils.js +5 -5
  91. package/server/services/voice/runtimeManager.js +19 -10
  92. package/server/services/wearable/gateway.js +8 -5
  93. package/server/services/websocket.js +26 -8
  94. package/server/services/workspace/manager.js +37 -3
@@ -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
+ };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const db = require('../../db/database');
5
+
6
+ const HIGH_VOLUME_DOMAINS = new Set([
7
+ 'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com',
8
+ 'live.com', 'icloud.com', 'me.com', 'mac.com', 'yahoo.com',
9
+ 'yahoo.co.uk', 'proton.me', 'protonmail.com',
10
+ ]);
11
+
12
+ const MAX_TRIALS_PER_IP = 2;
13
+ const MAX_TRIALS_PER_DOMAIN = 3;
14
+ const WINDOW_DAYS = 30;
15
+ const MIN_ACCOUNT_AGE_DAYS = 1;
16
+
17
+ function sha256(value) {
18
+ return crypto.createHash('sha256').update(String(value)).digest('hex');
19
+ }
20
+
21
+ function windowStart() {
22
+ const d = new Date();
23
+ d.setDate(d.getDate() - WINDOW_DAYS);
24
+ return d.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
25
+ }
26
+
27
+ function countFingerprints(hash, type) {
28
+ const row = db.prepare(
29
+ `SELECT COUNT(*) AS n FROM trial_fingerprints
30
+ WHERE fingerprint_hash = ? AND fingerprint_type = ? AND used_at > ?`,
31
+ ).get(hash, type, windowStart());
32
+ return row?.n ?? 0;
33
+ }
34
+
35
+ function checkIpThrottle(ip) {
36
+ if (!ip) return;
37
+ const count = countFingerprints(sha256(ip), 'ip');
38
+ if (count >= MAX_TRIALS_PER_IP) {
39
+ const err = new Error('Too many trial registrations from this IP address.');
40
+ err.statusCode = 429;
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ function checkEmailDomainThrottle(email) {
46
+ if (!email) return;
47
+ const domain = email.split('@')[1]?.toLowerCase();
48
+ if (!domain || HIGH_VOLUME_DOMAINS.has(domain)) return;
49
+ const count = countFingerprints(sha256(domain), 'email_domain');
50
+ if (count >= MAX_TRIALS_PER_DOMAIN) {
51
+ const err = new Error('Too many trial registrations from this email domain.');
52
+ err.statusCode = 429;
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ function checkAccountAge(userId) {
58
+ const user = db.prepare('SELECT created_at FROM users WHERE id = ?').get(userId);
59
+ if (!user) return;
60
+ const createdMs = new Date(user.created_at.replace(' ', 'T') + 'Z').getTime();
61
+ const ageDays = (Date.now() - createdMs) / 86400000;
62
+ if (ageDays < MIN_ACCOUNT_AGE_DAYS) {
63
+ const err = new Error('Your account is too new to start a trial. Please try again tomorrow.');
64
+ err.statusCode = 403;
65
+ throw err;
66
+ }
67
+ }
68
+
69
+ const DEVICE_WINDOW_DAYS = 90;
70
+
71
+ function checkDeviceFingerprint(deviceFp) {
72
+ if (!deviceFp) return;
73
+ const hash = sha256(deviceFp);
74
+ const cutoff = new Date();
75
+ cutoff.setDate(cutoff.getDate() - DEVICE_WINDOW_DAYS);
76
+ const cutoffStr = cutoff.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
77
+ const row = db.prepare(
78
+ 'SELECT id FROM trial_fingerprints WHERE fingerprint_hash = ? AND fingerprint_type = ? AND used_at > ? LIMIT 1',
79
+ ).get(hash, 'device', cutoffStr);
80
+ if (row) {
81
+ const err = new Error('A trial has already been used from this device.');
82
+ err.statusCode = 429;
83
+ throw err;
84
+ }
85
+ }
86
+
87
+ function recordFingerprints(userId, { ip, email, deviceFp } = {}) {
88
+ const now = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
89
+ const insert = db.prepare(
90
+ 'INSERT INTO trial_fingerprints (fingerprint_hash, fingerprint_type, user_id, used_at) VALUES (?, ?, ?, ?)',
91
+ );
92
+ db.transaction(() => {
93
+ if (ip) insert.run(sha256(ip), 'ip', userId, now);
94
+ if (email) {
95
+ const domain = email.split('@')[1]?.toLowerCase();
96
+ if (domain && !HIGH_VOLUME_DOMAINS.has(domain)) {
97
+ insert.run(sha256(domain), 'email_domain', userId, now);
98
+ }
99
+ }
100
+ if (deviceFp) insert.run(sha256(deviceFp), 'device', userId, now);
101
+ })();
102
+ }
103
+
104
+ function runChecks(userId, userEmail, { ip, deviceFp } = {}) {
105
+ checkIpThrottle(ip);
106
+ checkEmailDomainThrottle(userEmail);
107
+ checkAccountAge(userId);
108
+ checkDeviceFingerprint(deviceFp);
109
+ }
110
+
111
+ module.exports = { runChecks, recordFingerprints };