web-agent-bridge 3.9.2 → 3.10.1

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.
@@ -1,9 +1,27 @@
1
1
  const express = require('express');
2
+ const crypto = require('crypto');
2
3
  const router = express.Router();
3
- const { registerUser, loginUser, findUserById } = require('../models/db');
4
+ const {
5
+ registerUser, loginUser, findUserById, findUserByEmail,
6
+ createPasswordResetToken, consumePasswordResetToken, updateUserPassword,
7
+ createEmailVerificationToken, consumeEmailVerificationToken, isEmailVerified
8
+ } = require('../models/db');
4
9
  const { generateToken, authenticateToken } = require('../middleware/auth');
5
10
  const { authLimiter, registerLimiter } = require('../middleware/rateLimits');
6
11
  const { validateEmail, sanitizeInput, auditLog, revokeJWT } = require('../services/security');
12
+ const { sendEmail } = require('../services/email');
13
+
14
+ const BASE_URL = process.env.BASE_URL || 'https://webagentbridge.com';
15
+
16
+ function hashToken(token) {
17
+ return crypto.createHash('sha256').update(token).digest('hex');
18
+ }
19
+
20
+ function fireAndForget(promise, label) {
21
+ Promise.resolve(promise).catch((e) => {
22
+ console.error(`[email] ${label} failed (non-fatal):`, e && e.message);
23
+ });
24
+ }
7
25
 
8
26
  router.post('/register', registerLimiter, (req, res) => {
9
27
  const { email, password, name, company } = req.body;
@@ -27,7 +45,25 @@ router.post('/register', registerLimiter, (req, res) => {
27
45
  const user = registerUser({ email: email.toLowerCase().trim(), password, name: cleanName, company: cleanCompany });
28
46
  const token = generateToken(user);
29
47
  auditLog({ actorType: 'user', actorId: String(user.id), action: 'register', ip: req.ip });
30
- res.status(201).json({ user, token });
48
+
49
+ // Generate email-verification token and send it (best-effort, non-blocking)
50
+ try {
51
+ const verifyToken = crypto.randomBytes(32).toString('hex');
52
+ createEmailVerificationToken({ userId: user.id, tokenHash: hashToken(verifyToken) });
53
+ const verifyUrl = `${BASE_URL}/verify-email.html?token=${verifyToken}`;
54
+ fireAndForget(
55
+ sendEmail({ to: user.email, template: 'email_verification', data: { name: user.name, verifyUrl }, userId: user.id }),
56
+ 'verification email'
57
+ );
58
+ fireAndForget(
59
+ sendEmail({ to: user.email, template: 'welcome', data: { name: user.name, dashboardUrl: `${BASE_URL}/dashboard` }, userId: user.id }),
60
+ 'welcome email'
61
+ );
62
+ } catch (e) {
63
+ console.error('[register] verification setup failed:', e.message);
64
+ }
65
+
66
+ res.status(201).json({ user: { ...user, email_verified: 0 }, token });
31
67
  } catch (err) {
32
68
  if (err.message.includes('UNIQUE constraint')) {
33
69
  return res.status(409).json({ error: 'Email already registered' });
@@ -65,7 +101,74 @@ router.post('/logout', authenticateToken, (req, res) => {
65
101
  router.get('/me', authenticateToken, (req, res) => {
66
102
  const user = findUserById.get(req.user.id);
67
103
  if (!user) return res.status(404).json({ error: 'User not found' });
68
- res.json({ user });
104
+ res.json({ user: { ...user, email_verified: isEmailVerified(req.user.id) ? 1 : 0 } });
105
+ });
106
+
107
+ // ─── Password Reset ────────────────────────────────────────────────────
108
+ // Always returns success to avoid leaking which emails exist.
109
+ router.post('/forgot-password', authLimiter, (req, res) => {
110
+ const { email } = req.body || {};
111
+ if (!email || !validateEmail(email)) {
112
+ return res.status(400).json({ error: 'Valid email required' });
113
+ }
114
+ try {
115
+ const user = findUserByEmail.get(email.toLowerCase().trim());
116
+ if (user) {
117
+ const token = crypto.randomBytes(32).toString('hex');
118
+ createPasswordResetToken({ userId: user.id, tokenHash: hashToken(token), ttlMinutes: 60 });
119
+ const resetUrl = `${BASE_URL}/reset-password.html?token=${token}`;
120
+ fireAndForget(
121
+ sendEmail({ to: user.email, template: 'password_reset', data: { name: user.name, resetUrl }, userId: user.id }),
122
+ 'password_reset email'
123
+ );
124
+ auditLog({ actorType: 'user', actorId: String(user.id), action: 'password_reset_requested', ip: req.ip });
125
+ }
126
+ } catch (e) {
127
+ console.error('[forgot-password]', e.message);
128
+ }
129
+ res.json({ success: true, message: 'If that email is registered, a reset link has been sent.' });
130
+ });
131
+
132
+ router.post('/reset-password', authLimiter, (req, res) => {
133
+ const { token, password } = req.body || {};
134
+ if (!token || typeof token !== 'string') return res.status(400).json({ error: 'Token required' });
135
+ if (!password || password.length < 8 || password.length > 128) {
136
+ return res.status(400).json({ error: 'Password must be between 8 and 128 characters' });
137
+ }
138
+ const userId = consumePasswordResetToken(hashToken(token));
139
+ if (!userId) {
140
+ return res.status(400).json({ error: 'Invalid or expired token' });
141
+ }
142
+ updateUserPassword(userId, password);
143
+ auditLog({ actorType: 'user', actorId: String(userId), action: 'password_reset_completed', ip: req.ip });
144
+ res.json({ success: true });
145
+ });
146
+
147
+ // ─── Email Verification ────────────────────────────────────────────────
148
+ router.post('/verify-email', (req, res) => {
149
+ const { token } = req.body || {};
150
+ if (!token || typeof token !== 'string') return res.status(400).json({ error: 'Token required' });
151
+ const userId = consumeEmailVerificationToken(hashToken(token));
152
+ if (!userId) return res.status(400).json({ error: 'Invalid or expired token' });
153
+ auditLog({ actorType: 'user', actorId: String(userId), action: 'email_verified', ip: req.ip });
154
+ res.json({ success: true });
155
+ });
156
+
157
+ router.post('/resend-verification', authenticateToken, (req, res) => {
158
+ const user = findUserById.get(req.user.id);
159
+ if (!user) return res.status(404).json({ error: 'User not found' });
160
+ if (isEmailVerified(req.user.id)) {
161
+ return res.json({ success: true, alreadyVerified: true });
162
+ }
163
+ const token = crypto.randomBytes(32).toString('hex');
164
+ createEmailVerificationToken({ userId: user.id, tokenHash: hashToken(token) });
165
+ const verifyUrl = `${BASE_URL}/verify-email.html?token=${token}`;
166
+ fireAndForget(
167
+ sendEmail({ to: user.email, template: 'email_verification', data: { name: user.name, verifyUrl }, userId: user.id }),
168
+ 'resend verification'
169
+ );
170
+ auditLog({ actorType: 'user', actorId: String(req.user.id), action: 'verification_resent', ip: req.ip });
171
+ res.json({ success: true });
69
172
  });
70
173
 
71
174
  module.exports = router;
@@ -1,6 +1,6 @@
1
1
  const express = require('express');
2
2
  const router = express.Router();
3
- const { authenticateToken } = require('../middleware/auth');
3
+ const { authenticateToken, requireTier } = require('../middleware/auth');
4
4
  const premium = require('../services/premium');
5
5
  const { findSiteById, findSitesByUser } = require('../models/db');
6
6
 
@@ -15,7 +15,7 @@ function requireSiteOwnership(req, res, next) {
15
15
 
16
16
  // ─── Traffic Intelligence ────────────────────────────────────────────────
17
17
 
18
- router.get('/traffic/:siteId/profiles', authenticateToken, requireSiteOwnership, async (req, res) => {
18
+ router.get('/traffic/:siteId/profiles', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
19
19
  try {
20
20
  const { limit, offset, type } = req.query;
21
21
  const profiles = await premium.getAgentProfiles(req.params.siteId, {
@@ -29,7 +29,7 @@ router.get('/traffic/:siteId/profiles', authenticateToken, requireSiteOwnership,
29
29
  }
30
30
  });
31
31
 
32
- router.get('/traffic/:siteId/stats', authenticateToken, requireSiteOwnership, async (req, res) => {
32
+ router.get('/traffic/:siteId/stats', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
33
33
  try {
34
34
  const days = req.query.days ? parseInt(req.query.days) : 30;
35
35
  const stats = await premium.getTrafficStats(req.params.siteId, days);
@@ -39,7 +39,7 @@ router.get('/traffic/:siteId/stats', authenticateToken, requireSiteOwnership, as
39
39
  }
40
40
  });
41
41
 
42
- router.get('/traffic/:siteId/alerts', authenticateToken, requireSiteOwnership, async (req, res) => {
42
+ router.get('/traffic/:siteId/alerts', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
43
43
  try {
44
44
  const { limit, acknowledged } = req.query;
45
45
  const alerts = await premium.getAnomalyAlerts(req.params.siteId, {
@@ -52,7 +52,7 @@ router.get('/traffic/:siteId/alerts', authenticateToken, requireSiteOwnership, a
52
52
  }
53
53
  });
54
54
 
55
- router.post('/traffic/:siteId/alerts/:alertId/acknowledge', authenticateToken, requireSiteOwnership, async (req, res) => {
55
+ router.post('/traffic/:siteId/alerts/:alertId/acknowledge', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
56
56
  try {
57
57
  const ok = await premium.acknowledgeAlert(req.params.alertId, req.params.siteId);
58
58
  if (!ok) return res.status(404).json({ error: 'Alert not found' });
@@ -62,7 +62,7 @@ router.post('/traffic/:siteId/alerts/:alertId/acknowledge', authenticateToken, r
62
62
  }
63
63
  });
64
64
 
65
- router.post('/traffic/:siteId/check-anomalies', authenticateToken, requireSiteOwnership, async (req, res) => {
65
+ router.post('/traffic/:siteId/check-anomalies', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
66
66
  try {
67
67
  const alerts = await premium.checkForAnomalies(req.params.siteId);
68
68
  res.json({ alerts });
@@ -73,7 +73,7 @@ router.post('/traffic/:siteId/check-anomalies', authenticateToken, requireSiteOw
73
73
 
74
74
  // ─── Exploit Shield ──────────────────────────────────────────────────────
75
75
 
76
- router.get('/security/:siteId/events', authenticateToken, requireSiteOwnership, async (req, res) => {
76
+ router.get('/security/:siteId/events', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
77
77
  try {
78
78
  const { limit, severity, since } = req.query;
79
79
  const events = await premium.getSecurityEvents(req.params.siteId, {
@@ -87,7 +87,7 @@ router.get('/security/:siteId/events', authenticateToken, requireSiteOwnership,
87
87
  }
88
88
  });
89
89
 
90
- router.get('/security/:siteId/report', authenticateToken, requireSiteOwnership, async (req, res) => {
90
+ router.get('/security/:siteId/report', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
91
91
  try {
92
92
  const days = req.query.days ? parseInt(req.query.days) : 30;
93
93
  const report = await premium.getSecurityReport(req.params.siteId, days);
@@ -97,7 +97,7 @@ router.get('/security/:siteId/report', authenticateToken, requireSiteOwnership,
97
97
  }
98
98
  });
99
99
 
100
- router.get('/security/:siteId/blocked', authenticateToken, requireSiteOwnership, async (req, res) => {
100
+ router.get('/security/:siteId/blocked', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
101
101
  try {
102
102
  const blocked = await premium.getBlockedAgents(req.params.siteId);
103
103
  res.json({ blocked });
@@ -106,7 +106,7 @@ router.get('/security/:siteId/blocked', authenticateToken, requireSiteOwnership,
106
106
  }
107
107
  });
108
108
 
109
- router.post('/security/:siteId/block', authenticateToken, requireSiteOwnership, async (req, res) => {
109
+ router.post('/security/:siteId/block', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
110
110
  try {
111
111
  const { agentSignature, reason, expiresAt } = req.body;
112
112
  if (!agentSignature) return res.status(400).json({ error: 'agentSignature is required' });
@@ -117,7 +117,7 @@ router.post('/security/:siteId/block', authenticateToken, requireSiteOwnership,
117
117
  }
118
118
  });
119
119
 
120
- router.delete('/security/:siteId/block/:blockId', authenticateToken, requireSiteOwnership, async (req, res) => {
120
+ router.delete('/security/:siteId/block/:blockId', authenticateToken, requireSiteOwnership, requireTier('business'), async (req, res) => {
121
121
  try {
122
122
  const ok = await premium.unblockAgent(req.params.blockId, req.params.siteId);
123
123
  if (!ok) return res.status(404).json({ error: 'Block record not found' });
@@ -193,7 +193,7 @@ router.delete('/actions/:siteId/install/:installId', authenticateToken, requireS
193
193
 
194
194
  // ─── Custom Agents ───────────────────────────────────────────────────────
195
195
 
196
- router.get('/agents/:siteId', authenticateToken, requireSiteOwnership, async (req, res) => {
196
+ router.get('/agents/:siteId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
197
197
  try {
198
198
  const agents = await premium.getAgents(req.user.id, req.params.siteId);
199
199
  res.json({ agents });
@@ -202,7 +202,7 @@ router.get('/agents/:siteId', authenticateToken, requireSiteOwnership, async (re
202
202
  }
203
203
  });
204
204
 
205
- router.post('/agents/:siteId', authenticateToken, requireSiteOwnership, async (req, res) => {
205
+ router.post('/agents/:siteId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
206
206
  try {
207
207
  const { name, description, steps, schedule } = req.body;
208
208
  if (!name || !steps) return res.status(400).json({ error: 'name and steps are required' });
@@ -213,7 +213,7 @@ router.post('/agents/:siteId', authenticateToken, requireSiteOwnership, async (r
213
213
  }
214
214
  });
215
215
 
216
- router.get('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, async (req, res) => {
216
+ router.get('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
217
217
  try {
218
218
  const agent = await premium.getAgent(req.params.agentId, req.user.id);
219
219
  if (!agent) return res.status(404).json({ error: 'Agent not found' });
@@ -223,7 +223,7 @@ router.get('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership,
223
223
  }
224
224
  });
225
225
 
226
- router.put('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, async (req, res) => {
226
+ router.put('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
227
227
  try {
228
228
  const { name, description, steps, schedule } = req.body;
229
229
  const ok = await premium.updateAgent(req.params.agentId, req.user.id, { name, description, steps, schedule });
@@ -234,7 +234,7 @@ router.put('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership,
234
234
  }
235
235
  });
236
236
 
237
- router.delete('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, async (req, res) => {
237
+ router.delete('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
238
238
  try {
239
239
  const ok = await premium.deleteAgent(req.params.agentId, req.user.id);
240
240
  if (!ok) return res.status(404).json({ error: 'Agent not found' });
@@ -244,7 +244,7 @@ router.delete('/agents/:siteId/:agentId', authenticateToken, requireSiteOwnershi
244
244
  }
245
245
  });
246
246
 
247
- router.post('/agents/:siteId/:agentId/run', authenticateToken, requireSiteOwnership, async (req, res) => {
247
+ router.post('/agents/:siteId/:agentId/run', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
248
248
  try {
249
249
  const result = await premium.runAgent(req.params.agentId, req.user.id);
250
250
  res.json({ result });
@@ -253,7 +253,7 @@ router.post('/agents/:siteId/:agentId/run', authenticateToken, requireSiteOwners
253
253
  }
254
254
  });
255
255
 
256
- router.get('/agents/:siteId/:agentId/runs', authenticateToken, requireSiteOwnership, async (req, res) => {
256
+ router.get('/agents/:siteId/:agentId/runs', authenticateToken, requireSiteOwnership, requireTier('pro'), async (req, res) => {
257
257
  try {
258
258
  const { limit } = req.query;
259
259
  const runs = await premium.getAgentRuns(req.params.agentId, {
@@ -245,4 +245,36 @@ router.get('/platform/stats', publicReceiptLimiter, (req, res) => {
245
245
  res.json({ ok: true, data: stats });
246
246
  });
247
247
 
248
+ // ─── Merchant commission endpoints ────────────────────────────────────────
249
+ // Every merchant tx settled through ATP on a paid plan accrues a small
250
+ // platform commission (default 0.10%). Merchants can inspect their own
251
+ // accrual via these endpoints. Public commission rate is exposed for
252
+ // transparency.
253
+ const commissions = require('../services/commissions');
254
+
255
+ router.get('/commissions/rate', (req, res) => {
256
+ res.json({
257
+ ok: true,
258
+ data: {
259
+ rate_bps: commissions.getCommissionBps(),
260
+ rate_percent: commissions.getCommissionBps() / 100,
261
+ min_tier: commissions.getMinTier(),
262
+ applies_to: 'Successful merchant transactions settled through ATP on a paid plan.',
263
+ },
264
+ });
265
+ });
266
+
267
+ router.get('/commissions', authenticateToken, (req, res) => {
268
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
269
+ const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
270
+ const status = req.query.status || null;
271
+ const items = commissions.listCommissionsForMerchant(req.user.id, { limit, offset, status });
272
+ res.json({ ok: true, data: items, limit, offset });
273
+ });
274
+
275
+ router.get('/commissions/stats', authenticateToken, (req, res) => {
276
+ const stats = commissions.getMerchantCommissionStats(req.user.id);
277
+ res.json({ ok: true, data: stats });
278
+ });
279
+
248
280
  module.exports = router;
@@ -0,0 +1,279 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ATP Commission Billing — v3.10.1
5
+ *
6
+ * Converts `pending` rows in atp_commissions into real Stripe invoices.
7
+ *
8
+ * Strategy:
9
+ * - Group pending commissions by merchant_user_id + currency.
10
+ * - One Stripe invoice per (merchant, currency) per cycle.
11
+ * - One Stripe invoice item per commission row, so the merchant sees
12
+ * a line-by-line breakdown.
13
+ * - Mark rows `invoiced` and stamp the stripe invoice id into `notes`
14
+ * inside the same transaction.
15
+ *
16
+ * Idempotency:
17
+ * - Skips rows whose merchant has no Stripe customer yet.
18
+ * - Aborts a merchant's batch on any Stripe error; rows stay `pending`.
19
+ * - dry-run mode just returns the plan without touching Stripe or the DB.
20
+ *
21
+ * Trigger:
22
+ * - Admin endpoint POST /api/admin/commissions/run-billing
23
+ * - Optional periodic timer (env WAB_COMMISSION_BILLING_INTERVAL_HOURS).
24
+ */
25
+
26
+ const { db, getStripeCustomer } = require('../models/db');
27
+
28
+ function getMinAgeDays() {
29
+ const n = parseInt(process.env.WAB_COMMISSION_MIN_AGE_DAYS, 10);
30
+ if (Number.isFinite(n) && n >= 0 && n <= 365) return n;
31
+ return 0; // by default bill anything that's pending
32
+ }
33
+
34
+ function getMinAmountCents() {
35
+ const n = parseInt(process.env.WAB_COMMISSION_MIN_INVOICE_CENTS, 10);
36
+ if (Number.isFinite(n) && n >= 0) return n;
37
+ return 100; // 1 EUR/USD floor — Stripe rejects sub-50c invoices anyway
38
+ }
39
+
40
+ /**
41
+ * Build the per-merchant batches that would be billed in this cycle.
42
+ * Returns an array: [{ merchantUserId, currency, rows[], totalCents, stripeCustomerId|null, skipReason|null }]
43
+ */
44
+ function planBillingCycle() {
45
+ const minAgeDays = getMinAgeDays();
46
+ const ageClause = minAgeDays > 0
47
+ ? `AND datetime(created_at) < datetime('now', '-${minAgeDays} days')`
48
+ : '';
49
+
50
+ const groups = db.prepare(`
51
+ SELECT merchant_user_id, currency,
52
+ COUNT(*) AS n,
53
+ COALESCE(SUM(commission_cents), 0) AS total_cents
54
+ FROM atp_commissions
55
+ WHERE status = 'pending'
56
+ ${ageClause}
57
+ GROUP BY merchant_user_id, currency
58
+ ORDER BY total_cents DESC
59
+ `).all();
60
+
61
+ const minAmount = getMinAmountCents();
62
+
63
+ const batches = [];
64
+ for (const g of groups) {
65
+ if (g.total_cents < minAmount) {
66
+ batches.push({
67
+ merchantUserId: g.merchant_user_id,
68
+ currency: g.currency,
69
+ rows: [],
70
+ totalCents: g.total_cents,
71
+ rowCount: g.n,
72
+ stripeCustomerId: null,
73
+ skipReason: `below_min_invoice (${g.total_cents}c < ${minAmount}c)`,
74
+ });
75
+ continue;
76
+ }
77
+ const cust = getStripeCustomer(g.merchant_user_id);
78
+ if (!cust || !cust.stripe_customer_id) {
79
+ batches.push({
80
+ merchantUserId: g.merchant_user_id,
81
+ currency: g.currency,
82
+ rows: [],
83
+ totalCents: g.total_cents,
84
+ rowCount: g.n,
85
+ stripeCustomerId: null,
86
+ skipReason: 'no_stripe_customer',
87
+ });
88
+ continue;
89
+ }
90
+ const rows = db.prepare(`
91
+ SELECT id, transaction_id, gross_amount_cents, commission_cents, commission_bps, created_at
92
+ FROM atp_commissions
93
+ WHERE merchant_user_id = ? AND currency = ? AND status = 'pending'
94
+ ${ageClause}
95
+ ORDER BY created_at ASC
96
+ `).all(g.merchant_user_id, g.currency);
97
+
98
+ batches.push({
99
+ merchantUserId: g.merchant_user_id,
100
+ currency: g.currency,
101
+ rows,
102
+ totalCents: g.total_cents,
103
+ rowCount: rows.length,
104
+ stripeCustomerId: cust.stripe_customer_id,
105
+ skipReason: null,
106
+ });
107
+ }
108
+ return batches;
109
+ }
110
+
111
+ /**
112
+ * Execute a billing cycle. When dryRun=true, returns the plan without
113
+ * touching Stripe or the DB.
114
+ */
115
+ async function runBillingCycle({ dryRun = false } = {}) {
116
+ const startedAt = new Date().toISOString();
117
+ const batches = planBillingCycle();
118
+ const summary = {
119
+ started_at: startedAt,
120
+ dry_run: !!dryRun,
121
+ batches_total: batches.length,
122
+ batches_billed: 0,
123
+ batches_skipped: 0,
124
+ rows_invoiced: 0,
125
+ total_commission_cents: 0,
126
+ invoices: [],
127
+ errors: [],
128
+ };
129
+
130
+ if (dryRun) {
131
+ summary.batches_skipped = batches.filter((b) => b.skipReason).length;
132
+ summary.batches_billed = batches.length - summary.batches_skipped;
133
+ summary.rows_invoiced = batches.reduce((s, b) => s + (b.skipReason ? 0 : b.rowCount), 0);
134
+ summary.total_commission_cents = batches.reduce(
135
+ (s, b) => s + (b.skipReason ? 0 : b.totalCents),
136
+ 0,
137
+ );
138
+ summary.plan = batches.map((b) => ({
139
+ merchant_user_id: b.merchantUserId,
140
+ currency: b.currency,
141
+ rows: b.rowCount,
142
+ total_cents: b.totalCents,
143
+ stripe_customer_id: b.stripeCustomerId,
144
+ skip_reason: b.skipReason,
145
+ }));
146
+ summary.finished_at = new Date().toISOString();
147
+ return summary;
148
+ }
149
+
150
+ const { getStripe, isStripeConfigured } = require('./stripe');
151
+ if (!isStripeConfigured()) {
152
+ summary.errors.push({ reason: 'stripe_not_configured' });
153
+ summary.finished_at = new Date().toISOString();
154
+ return summary;
155
+ }
156
+ const stripe = getStripe();
157
+
158
+ for (const batch of batches) {
159
+ if (batch.skipReason) {
160
+ summary.batches_skipped++;
161
+ continue;
162
+ }
163
+
164
+ try {
165
+ // 1) One invoice item per commission row → merchant gets a full ledger.
166
+ for (const r of batch.rows) {
167
+ await stripe.invoiceItems.create({
168
+ customer: batch.stripeCustomerId,
169
+ amount: r.commission_cents,
170
+ currency: (batch.currency || 'eur').toLowerCase(),
171
+ description: `WAB ATP commission (${(r.commission_bps / 100).toFixed(2)}%) · tx ${r.transaction_id} · ${r.created_at}`,
172
+ metadata: {
173
+ wab_commission_id: r.id,
174
+ wab_transaction_id: r.transaction_id,
175
+ wab_kind: 'atp_commission',
176
+ },
177
+ });
178
+ }
179
+
180
+ // 2) Finalize a single invoice for all the items above.
181
+ const invoice = await stripe.invoices.create({
182
+ customer: batch.stripeCustomerId,
183
+ collection_method: 'charge_automatically',
184
+ auto_advance: true,
185
+ description: `WAB ATP merchant commission · ${batch.rowCount} transactions`,
186
+ metadata: {
187
+ wab_kind: 'atp_commission_batch',
188
+ wab_merchant_user_id: batch.merchantUserId,
189
+ wab_currency: batch.currency,
190
+ wab_row_count: String(batch.rowCount),
191
+ },
192
+ });
193
+
194
+ // 3) Mark all rows invoiced inside a single DB transaction.
195
+ const ids = batch.rows.map((r) => r.id);
196
+ const stamp = `stripe_invoice:${invoice.id}@${new Date().toISOString()}`;
197
+ const markTx = db.transaction((commIds) => {
198
+ const upd = db.prepare(`
199
+ UPDATE atp_commissions
200
+ SET status = 'invoiced',
201
+ notes = COALESCE(notes || ' | ', '') || ?,
202
+ updated_at = datetime('now')
203
+ WHERE id = ? AND status = 'pending'
204
+ `);
205
+ for (const id of commIds) upd.run(stamp, id);
206
+ });
207
+ markTx(ids);
208
+
209
+ summary.batches_billed++;
210
+ summary.rows_invoiced += batch.rowCount;
211
+ summary.total_commission_cents += batch.totalCents;
212
+ summary.invoices.push({
213
+ merchant_user_id: batch.merchantUserId,
214
+ currency: batch.currency,
215
+ rows: batch.rowCount,
216
+ total_cents: batch.totalCents,
217
+ stripe_invoice_id: invoice.id,
218
+ stripe_invoice_status: invoice.status,
219
+ });
220
+ } catch (e) {
221
+ summary.errors.push({
222
+ merchant_user_id: batch.merchantUserId,
223
+ currency: batch.currency,
224
+ message: e && e.message ? e.message : String(e),
225
+ });
226
+ }
227
+ }
228
+
229
+ summary.finished_at = new Date().toISOString();
230
+ return summary;
231
+ }
232
+
233
+ /**
234
+ * Webhook hook — called from stripe.js when an invoice tied to a wab
235
+ * commission batch is paid or fails. Flips matching atp_commissions rows
236
+ * to 'collected' (paid) or leaves at 'invoiced' (failed → manual).
237
+ */
238
+ function onStripeInvoicePaid(invoice) {
239
+ try {
240
+ const meta = invoice && invoice.metadata;
241
+ if (!meta || meta.wab_kind !== 'atp_commission_batch') return 0;
242
+ const r = db.prepare(`
243
+ UPDATE atp_commissions
244
+ SET status = 'collected',
245
+ notes = COALESCE(notes || ' | ', '') || ?,
246
+ updated_at = datetime('now')
247
+ WHERE status = 'invoiced'
248
+ AND notes LIKE ?
249
+ `).run(`paid:${invoice.id}@${new Date().toISOString()}`, `%stripe_invoice:${invoice.id}%`);
250
+ return r.changes;
251
+ } catch (e) {
252
+ console.error('[commission-billing] onStripeInvoicePaid failed:', e.message);
253
+ return 0;
254
+ }
255
+ }
256
+
257
+ let _timer = null;
258
+ function startPeriodicBilling() {
259
+ const hours = parseFloat(process.env.WAB_COMMISSION_BILLING_INTERVAL_HOURS);
260
+ if (!Number.isFinite(hours) || hours <= 0) return null;
261
+ const ms = Math.max(60_000, hours * 3_600_000);
262
+ if (_timer) clearInterval(_timer);
263
+ _timer = setInterval(() => {
264
+ runBillingCycle({ dryRun: false })
265
+ .then((s) => console.log(
266
+ `[commission-billing] cycle done: billed=${s.batches_billed} rows=${s.rows_invoiced} total_cents=${s.total_commission_cents} errors=${s.errors.length}`,
267
+ ))
268
+ .catch((e) => console.error('[commission-billing] cycle failed:', e.message));
269
+ }, ms);
270
+ if (_timer.unref) _timer.unref();
271
+ return { intervalHours: hours };
272
+ }
273
+
274
+ module.exports = {
275
+ planBillingCycle,
276
+ runBillingCycle,
277
+ onStripeInvoicePaid,
278
+ startPeriodicBilling,
279
+ };