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.
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ATP Merchant Commission — v3.10.0
5
+ *
6
+ * WAB charges a small platform commission on every successful merchant
7
+ * transaction settled through ATP, when the merchant is on a paid plan
8
+ * and the transaction is real (not a platform self-payment).
9
+ *
10
+ * Defaults:
11
+ * - Rate: 10 bps (0.10%), overridable via env WAB_COMMISSION_BPS or
12
+ * platform_settings('commission_bps').
13
+ * - Minimum tier: 'starter' (free sites exempt), overridable via env
14
+ * WAB_COMMISSION_MIN_TIER.
15
+ * - Platform self-payments (intent.metadata.platform = 1) always exempt.
16
+ *
17
+ * Idempotency: atp_commissions.transaction_id is UNIQUE, so duplicate
18
+ * settle events become a no-op.
19
+ */
20
+
21
+ const crypto = require('crypto');
22
+ const { db, getPlatformSetting, findSiteById } = require('../models/db');
23
+
24
+ const TIER_RANK = { free: 0, starter: 1, pro: 2, business: 3, enterprise: 4 };
25
+
26
+ function ulid(prefix) {
27
+ const t = Date.now().toString(36).padStart(8, '0');
28
+ const r = crypto.randomBytes(10).toString('hex');
29
+ return `${prefix}_${t}${r}`;
30
+ }
31
+
32
+ function getCommissionBps() {
33
+ // Priority: env > platform_setting > default
34
+ const envBps = parseInt(process.env.WAB_COMMISSION_BPS, 10);
35
+ if (Number.isFinite(envBps) && envBps >= 0 && envBps <= 10000) return envBps;
36
+ try {
37
+ const setting = getPlatformSetting('commission_bps');
38
+ if (setting != null) {
39
+ const n = parseInt(setting, 10);
40
+ if (Number.isFinite(n) && n >= 0 && n <= 10000) return n;
41
+ }
42
+ } catch { /* table may not exist in some tests */ }
43
+ return 10; // default 0.10%
44
+ }
45
+
46
+ function getMinTier() {
47
+ const env = (process.env.WAB_COMMISSION_MIN_TIER || '').toLowerCase().trim();
48
+ if (env && TIER_RANK[env] != null) return env;
49
+ return 'starter';
50
+ }
51
+
52
+ function calcCommissionCents(amountCents, bps) {
53
+ if (!Number.isFinite(amountCents) || amountCents <= 0) return 0;
54
+ // round half-up to nearest cent
55
+ return Math.floor((amountCents * bps + 5000) / 10000);
56
+ }
57
+
58
+ function safeJson(s, fallback) {
59
+ if (s == null) return fallback;
60
+ if (typeof s === 'object') return s;
61
+ try { return JSON.parse(s); } catch { return fallback; }
62
+ }
63
+
64
+ /**
65
+ * Record a commission row for a settled merchant transaction.
66
+ * Idempotent: safe to call multiple times for the same tx.
67
+ * Returns the commission row (or null when not applicable).
68
+ */
69
+ function recordCommissionForTransaction(tx) {
70
+ if (!tx || !tx.id || !tx.intent_id) return null;
71
+ if (!Number.isFinite(tx.amount_cents) || tx.amount_cents <= 0) return null;
72
+
73
+ // Already recorded?
74
+ const existing = db.prepare('SELECT * FROM atp_commissions WHERE transaction_id=?').get(tx.id);
75
+ if (existing) return existing;
76
+
77
+ const intent = db.prepare('SELECT user_id, site_id, metadata FROM atp_intents WHERE id=?').get(tx.intent_id);
78
+ if (!intent) return null;
79
+
80
+ // Platform self-payment? Skip.
81
+ const meta = safeJson(intent.metadata, {});
82
+ if (meta && meta.platform) return null;
83
+
84
+ // Need a merchant site to bill against.
85
+ if (!intent.site_id) return null;
86
+
87
+ let site;
88
+ try { site = findSiteById.get(intent.site_id); } catch { site = null; }
89
+ if (!site) return null;
90
+
91
+ const tier = (site.tier || 'free').toLowerCase();
92
+ const minTier = getMinTier();
93
+ if ((TIER_RANK[tier] ?? 0) < (TIER_RANK[minTier] ?? 1)) return null;
94
+
95
+ const bps = getCommissionBps();
96
+ if (bps <= 0) return null;
97
+
98
+ const commissionCents = calcCommissionCents(tx.amount_cents, bps);
99
+ if (commissionCents <= 0) return null;
100
+
101
+ const id = ulid('atp_com');
102
+ const externalRef =
103
+ (tx.metadata && safeJson(tx.metadata, {}).external_ref) ||
104
+ tx.idempotency_key || null;
105
+
106
+ db.prepare(`
107
+ INSERT INTO atp_commissions
108
+ (id, transaction_id, intent_id, merchant_user_id, merchant_site_id,
109
+ merchant_tier, gross_amount_cents, currency, commission_bps, commission_cents,
110
+ status, external_ref)
111
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)
112
+ `).run(
113
+ id, tx.id, tx.intent_id, site.user_id, site.id,
114
+ tier, tx.amount_cents, tx.currency || 'EUR', bps, commissionCents,
115
+ externalRef
116
+ );
117
+
118
+ return db.prepare('SELECT * FROM atp_commissions WHERE id=?').get(id);
119
+ }
120
+
121
+ /**
122
+ * Flip a commission to 'refunded' when the underlying tx is compensated.
123
+ */
124
+ function markCommissionRefunded(txId, reason = null) {
125
+ const row = db.prepare('SELECT * FROM atp_commissions WHERE transaction_id=?').get(txId);
126
+ if (!row) return null;
127
+ if (row.status === 'refunded' || row.status === 'waived') return row;
128
+ db.prepare(`
129
+ UPDATE atp_commissions
130
+ SET status='refunded',
131
+ notes = COALESCE(notes || ' | ', '') || ?,
132
+ updated_at = datetime('now')
133
+ WHERE transaction_id=?
134
+ `).run(`refund: ${reason || 'tx_compensated'}`, txId);
135
+ return db.prepare('SELECT * FROM atp_commissions WHERE transaction_id=?').get(txId);
136
+ }
137
+
138
+ function listCommissionsForMerchant(userId, { limit = 50, offset = 0, status = null } = {}) {
139
+ const lim = Math.max(1, Math.min(200, Number(limit) || 50));
140
+ const off = Math.max(0, Number(offset) || 0);
141
+ if (status) {
142
+ return db.prepare(`
143
+ SELECT * FROM atp_commissions
144
+ WHERE merchant_user_id=? AND status=?
145
+ ORDER BY created_at DESC LIMIT ? OFFSET ?
146
+ `).all(userId, status, lim, off);
147
+ }
148
+ return db.prepare(`
149
+ SELECT * FROM atp_commissions
150
+ WHERE merchant_user_id=?
151
+ ORDER BY created_at DESC LIMIT ? OFFSET ?
152
+ `).all(userId, lim, off);
153
+ }
154
+
155
+ function getMerchantCommissionStats(userId) {
156
+ const overall = db.prepare(`
157
+ SELECT COUNT(*) AS count_total,
158
+ COALESCE(SUM(commission_cents), 0) AS commission_total_cents,
159
+ COALESCE(SUM(gross_amount_cents), 0) AS gross_total_cents
160
+ FROM atp_commissions
161
+ WHERE merchant_user_id = ?
162
+ AND status IN ('pending','invoiced','collected')
163
+ `).get(userId);
164
+ const byStatus = db.prepare(`
165
+ SELECT status,
166
+ COUNT(*) AS n,
167
+ COALESCE(SUM(commission_cents), 0) AS commission_cents
168
+ FROM atp_commissions
169
+ WHERE merchant_user_id = ?
170
+ GROUP BY status
171
+ `).all(userId);
172
+ return { ...overall, by_status: byStatus, rate_bps: getCommissionBps() };
173
+ }
174
+
175
+ function getPlatformCommissionStats() {
176
+ const row = db.prepare(`
177
+ SELECT COUNT(*) AS count_total,
178
+ COALESCE(SUM(commission_cents), 0) AS commission_total_cents,
179
+ COALESCE(SUM(gross_amount_cents), 0) AS gross_total_cents,
180
+ MIN(created_at) AS first_at,
181
+ MAX(created_at) AS last_at
182
+ FROM atp_commissions
183
+ WHERE status IN ('pending','invoiced','collected')
184
+ `).get();
185
+ const byStatus = db.prepare(`
186
+ SELECT status, COUNT(*) AS n, COALESCE(SUM(commission_cents),0) AS commission_cents
187
+ FROM atp_commissions GROUP BY status
188
+ `).all();
189
+ const byTier = db.prepare(`
190
+ SELECT merchant_tier AS tier, COUNT(*) AS n,
191
+ COALESCE(SUM(commission_cents),0) AS commission_cents
192
+ FROM atp_commissions
193
+ WHERE status IN ('pending','invoiced','collected')
194
+ GROUP BY merchant_tier
195
+ ORDER BY commission_cents DESC
196
+ `).all();
197
+ return { ...row, by_status: byStatus, by_tier: byTier, rate_bps: getCommissionBps() };
198
+ }
199
+
200
+ module.exports = {
201
+ recordCommissionForTransaction,
202
+ markCommissionRefunded,
203
+ listCommissionsForMerchant,
204
+ getMerchantCommissionStats,
205
+ getPlatformCommissionStats,
206
+ getCommissionBps,
207
+ getMinTier,
208
+ _calcCommissionCents: calcCommissionCents,
209
+ };
@@ -123,6 +123,59 @@ const templates = {
123
123
  `
124
124
  }),
125
125
 
126
+ email_verification: (data) => ({
127
+ subject: 'Verify your email — Web Agent Bridge',
128
+ html: `
129
+ <div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;background:#0a0e1a;color:#f0f4ff;padding:40px;border-radius:12px;">
130
+ <div style="text-align:center;margin-bottom:30px;">
131
+ <div style="font-size:40px;">✉️</div>
132
+ <h1 style="color:#3b82f6;margin:10px 0;">Verify your email</h1>
133
+ </div>
134
+ <p style="color:#94a3b8;">Hello ${escapeHtml(data.name)},</p>
135
+ <p style="color:#94a3b8;line-height:1.8;">
136
+ Thanks for signing up for Web Agent Bridge. Please confirm your email by clicking the button below.
137
+ </p>
138
+ <div style="text-align:center;margin:30px 0;">
139
+ <a href="${data.verifyUrl}" style="background:linear-gradient(135deg,#3b82f6,#8b5cf6);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;">Verify Email</a>
140
+ </div>
141
+ <p style="color:#64748b;font-size:13px;">
142
+ This link expires in 7 days. If you didn't create an account, ignore this email.
143
+ </p>
144
+ <p style="color:#64748b;font-size:12px;text-align:center;margin-top:30px;">
145
+ &copy; ${new Date().getFullYear()} Web Agent Bridge
146
+ </p>
147
+ </div>
148
+ `
149
+ }),
150
+
151
+ license_delivery: (data) => ({
152
+ subject: `Your ${sanitizeSubjectPart(data.tier || 'WAB')} license is active — Web Agent Bridge`,
153
+ html: `
154
+ <div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;background:#0a0e1a;color:#f0f4ff;padding:40px;border-radius:12px;">
155
+ <div style="text-align:center;margin-bottom:30px;">
156
+ <div style="font-size:40px;">🎟️</div>
157
+ <h1 style="color:#10b981;margin:10px 0;">Payment received</h1>
158
+ </div>
159
+ <p style="color:#94a3b8;">Hello ${escapeHtml(data.name || '')},</p>
160
+ <p style="color:#94a3b8;line-height:1.8;">
161
+ Your subscription to <strong style="color:#10b981;">${escapeHtml(String(data.tier || '').toUpperCase())}</strong> is now active.
162
+ Below are your license details — keep them safe.
163
+ </p>
164
+ <div style="background:#1a2236;border-radius:8px;padding:20px;margin:20px 0;">
165
+ ${data.siteDomain ? `<p style="color:#94a3b8;"><strong style="color:#f0f4ff;">Site:</strong> ${escapeHtml(data.siteDomain)}</p>` : ''}
166
+ ${data.licenseKey ? `<p style="color:#94a3b8;"><strong style="color:#f0f4ff;">License key:</strong> <code style="color:#f0f4ff;">${escapeHtml(data.licenseKey)}</code></p>` : ''}
167
+ ${data.amount ? `<p style="color:#94a3b8;"><strong style="color:#f0f4ff;">Amount:</strong> ${escapeHtml(data.amount)}</p>` : ''}
168
+ </div>
169
+ <div style="text-align:center;margin-top:30px;">
170
+ <a href="${data.dashboardUrl || 'https://webagentbridge.com/dashboard'}" style="background:linear-gradient(135deg,#10b981,#3b82f6);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;">Open Dashboard</a>
171
+ </div>
172
+ <p style="color:#64748b;font-size:12px;text-align:center;margin-top:30px;">
173
+ &copy; ${new Date().getFullYear()} Web Agent Bridge
174
+ </p>
175
+ </div>
176
+ `
177
+ }),
178
+
126
179
  contact: (data) => ({
127
180
  subject: `New Contact Message: ${sanitizeSubjectPart(data.subject || 'No Subject')}`,
128
181
  html: `
@@ -116,12 +116,53 @@ function handleWebhookEvent(event) {
116
116
  periodEnd: null
117
117
  });
118
118
  updateSiteTier.run(tier || 'starter', wab_site_id, wab_user_id);
119
+
120
+ // ── License delivery email (best-effort, non-blocking) ──
121
+ try {
122
+ const { findUserById, findSiteById: getSite } = require('../models/db');
123
+ const user = findUserById.get(wab_user_id);
124
+ const site = getSite.get(wab_site_id);
125
+ if (user && site) {
126
+ const { sendEmail } = require('./email');
127
+ const baseUrl = process.env.BASE_URL || 'https://webagentbridge.com';
128
+ const amount = session.amount_total != null
129
+ ? `${(session.amount_total / 100).toFixed(2)} ${(session.currency || 'usd').toUpperCase()}`
130
+ : null;
131
+ Promise.resolve(sendEmail({
132
+ to: user.email,
133
+ template: 'license_delivery',
134
+ data: {
135
+ name: user.name,
136
+ tier: tier || 'starter',
137
+ siteDomain: site.domain,
138
+ licenseKey: site.license_key,
139
+ amount,
140
+ dashboardUrl: `${baseUrl}/dashboard`
141
+ },
142
+ userId: user.id
143
+ })).catch((e) => console.error('[stripe] license_delivery email failed:', e.message));
144
+ }
145
+ } catch (e) {
146
+ console.error('[stripe] license_delivery setup failed:', e.message);
147
+ }
119
148
  }
120
149
  break;
121
150
  }
122
151
 
123
152
  case 'invoice.payment_succeeded': {
124
153
  const invoice = event.data.object;
154
+
155
+ // ── ATP merchant commission invoice paid? Flip rows to 'collected'. ──
156
+ try {
157
+ if (invoice.metadata && invoice.metadata.wab_kind === 'atp_commission_batch') {
158
+ const billing = require('./commission-billing');
159
+ const changed = billing.onStripeInvoicePaid(invoice);
160
+ console.log(`[atp] commission invoice ${invoice.id} paid; ${changed} rows → collected`);
161
+ }
162
+ } catch (e) {
163
+ console.error('[atp] commission invoice paid handler failed (non-fatal):', e.message);
164
+ }
165
+
125
166
  if (invoice.subscription) {
126
167
  const sub = getStripeSubscriptionBySubId(invoice.subscription);
127
168
  if (sub) {
@@ -187,6 +228,85 @@ function handleWebhookEvent(event) {
187
228
  }
188
229
  break;
189
230
  }
231
+
232
+ case 'charge.refunded': {
233
+ const charge = event.data.object;
234
+ try {
235
+ const userId =
236
+ (charge.metadata && charge.metadata.wab_user_id) ||
237
+ (charge.invoice && (() => {
238
+ // best-effort: look up subscription via the invoice's subscription id
239
+ try {
240
+ const inv = charge.invoice;
241
+ if (typeof inv === 'string') return null;
242
+ if (inv && inv.subscription) {
243
+ const sub = getStripeSubscriptionBySubId(inv.subscription);
244
+ return sub ? sub.user_id : null;
245
+ }
246
+ } catch { return null; }
247
+ return null;
248
+ })()) || null;
249
+
250
+ if (userId) {
251
+ savePayment({
252
+ userId,
253
+ stripePaymentId: `refund_${charge.id}`,
254
+ amount: -(charge.amount_refunded || charge.amount || 0),
255
+ currency: charge.currency || 'usd',
256
+ status: 'refunded',
257
+ description: `Refund: ${charge.id}`
258
+ });
259
+ }
260
+
261
+ // Downgrade any subscription tied to this charge (best-effort)
262
+ if (charge.invoice) {
263
+ try {
264
+ const invId = typeof charge.invoice === 'string' ? null : charge.invoice;
265
+ // Subscription id can be on the expanded invoice; we mark the
266
+ // user's subscriptions as refunded so admin tools can review.
267
+ if (invId && invId.subscription) {
268
+ updateStripeSubscription(invId.subscription, { status: 'cancelled' });
269
+ const sub = getStripeSubscriptionBySubId(invId.subscription);
270
+ if (sub) updateSiteTier.run('free', sub.site_id, sub.user_id);
271
+ }
272
+ } catch (e) {
273
+ console.error('[stripe] charge.refunded subscription update failed:', e.message);
274
+ }
275
+ }
276
+ console.warn(`[stripe] charge.refunded processed: charge=${charge.id} amount=${charge.amount_refunded}`);
277
+ } catch (e) {
278
+ console.error('[stripe] charge.refunded handler error:', e.message);
279
+ }
280
+ break;
281
+ }
282
+
283
+ case 'charge.dispute.created': {
284
+ const dispute = event.data.object;
285
+ try {
286
+ const userId = (dispute.metadata && dispute.metadata.wab_user_id) || null;
287
+ if (userId) {
288
+ savePayment({
289
+ userId,
290
+ stripePaymentId: `dispute_${dispute.id}`,
291
+ amount: -(dispute.amount || 0),
292
+ currency: dispute.currency || 'usd',
293
+ status: 'disputed',
294
+ description: `Chargeback/dispute: ${dispute.id} reason=${dispute.reason || 'unknown'}`
295
+ });
296
+ }
297
+ // Suspend any subscription on the disputed charge
298
+ try {
299
+ if (dispute.charge) {
300
+ // Without expanding the charge we cannot reliably find the sub,
301
+ // but admins are alerted via the audit log + payments row above.
302
+ }
303
+ } catch { /* noop */ }
304
+ console.warn(`[stripe] charge.dispute.created: dispute=${dispute.id} reason=${dispute.reason}`);
305
+ } catch (e) {
306
+ console.error('[stripe] charge.dispute.created handler error:', e.message);
307
+ }
308
+ break;
309
+ }
190
310
  }
191
311
  }
192
312
 
@@ -292,6 +292,14 @@ function transitionTransaction(txId, toStatus, patch = {}) {
292
292
  db.prepare("UPDATE atp_intents SET status='consumed', updated_at=? WHERE id=? AND status='authorized'")
293
293
  .run(nowIso(), tx.intent_id);
294
294
  }
295
+
296
+ // Platform commission (best-effort, never blocks the tx).
297
+ try {
298
+ const commissions = require('./commissions');
299
+ commissions.recordCommissionForTransaction(getTransaction(txId));
300
+ } catch (e) {
301
+ console.error('[atp] commission record failed (non-fatal):', e.message);
302
+ }
295
303
  }
296
304
  if (toStatus === 'compensated' && tx.status === 'settled') {
297
305
  db.prepare(`
@@ -300,6 +308,13 @@ function transitionTransaction(txId, toStatus, patch = {}) {
300
308
  updated_at = ?
301
309
  WHERE id = ?
302
310
  `).run(tx.amount_cents, nowIso(), tx.intent_id);
311
+
312
+ try {
313
+ const commissions = require('./commissions');
314
+ commissions.markCommissionRefunded(txId, 'tx_compensated');
315
+ } catch (e) {
316
+ console.error('[atp] commission refund mark failed (non-fatal):', e.message);
317
+ }
303
318
  }
304
319
 
305
320
  return getTransaction(txId);