web-agent-bridge 3.9.1 → 3.10.0
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/bin/wab.js +54 -0
- package/package.json +93 -93
- package/public/forgot-password.html +68 -0
- package/public/login.html +3 -2
- package/public/reset-password.html +84 -0
- package/public/verify-email.html +76 -0
- package/server/index.js +9 -0
- package/server/middleware/auth.js +42 -1
- package/server/migrations/021_visitor_analytics.sql +31 -0
- package/server/migrations/022_auth_recovery_verification.sql +27 -0
- package/server/migrations/023_atp_merchant_commission.sql +43 -0
- package/server/models/db.js +76 -1
- package/server/routes/admin.js +111 -0
- package/server/routes/auth.js +106 -3
- package/server/routes/premium.js +18 -18
- package/server/routes/transactions.js +32 -0
- package/server/services/commissions.js +209 -0
- package/server/services/email.js +53 -0
- package/server/services/stripe.js +108 -0
- package/server/services/transactions.js +15 -0
- package/server/services/visitor-tracker.js +250 -0
|
@@ -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
|
+
};
|
package/server/services/email.js
CHANGED
|
@@ -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
|
+
© ${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
|
+
© ${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,6 +116,35 @@ 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
|
}
|
|
@@ -187,6 +216,85 @@ function handleWebhookEvent(event) {
|
|
|
187
216
|
}
|
|
188
217
|
break;
|
|
189
218
|
}
|
|
219
|
+
|
|
220
|
+
case 'charge.refunded': {
|
|
221
|
+
const charge = event.data.object;
|
|
222
|
+
try {
|
|
223
|
+
const userId =
|
|
224
|
+
(charge.metadata && charge.metadata.wab_user_id) ||
|
|
225
|
+
(charge.invoice && (() => {
|
|
226
|
+
// best-effort: look up subscription via the invoice's subscription id
|
|
227
|
+
try {
|
|
228
|
+
const inv = charge.invoice;
|
|
229
|
+
if (typeof inv === 'string') return null;
|
|
230
|
+
if (inv && inv.subscription) {
|
|
231
|
+
const sub = getStripeSubscriptionBySubId(inv.subscription);
|
|
232
|
+
return sub ? sub.user_id : null;
|
|
233
|
+
}
|
|
234
|
+
} catch { return null; }
|
|
235
|
+
return null;
|
|
236
|
+
})()) || null;
|
|
237
|
+
|
|
238
|
+
if (userId) {
|
|
239
|
+
savePayment({
|
|
240
|
+
userId,
|
|
241
|
+
stripePaymentId: `refund_${charge.id}`,
|
|
242
|
+
amount: -(charge.amount_refunded || charge.amount || 0),
|
|
243
|
+
currency: charge.currency || 'usd',
|
|
244
|
+
status: 'refunded',
|
|
245
|
+
description: `Refund: ${charge.id}`
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Downgrade any subscription tied to this charge (best-effort)
|
|
250
|
+
if (charge.invoice) {
|
|
251
|
+
try {
|
|
252
|
+
const invId = typeof charge.invoice === 'string' ? null : charge.invoice;
|
|
253
|
+
// Subscription id can be on the expanded invoice; we mark the
|
|
254
|
+
// user's subscriptions as refunded so admin tools can review.
|
|
255
|
+
if (invId && invId.subscription) {
|
|
256
|
+
updateStripeSubscription(invId.subscription, { status: 'cancelled' });
|
|
257
|
+
const sub = getStripeSubscriptionBySubId(invId.subscription);
|
|
258
|
+
if (sub) updateSiteTier.run('free', sub.site_id, sub.user_id);
|
|
259
|
+
}
|
|
260
|
+
} catch (e) {
|
|
261
|
+
console.error('[stripe] charge.refunded subscription update failed:', e.message);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
console.warn(`[stripe] charge.refunded processed: charge=${charge.id} amount=${charge.amount_refunded}`);
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error('[stripe] charge.refunded handler error:', e.message);
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case 'charge.dispute.created': {
|
|
272
|
+
const dispute = event.data.object;
|
|
273
|
+
try {
|
|
274
|
+
const userId = (dispute.metadata && dispute.metadata.wab_user_id) || null;
|
|
275
|
+
if (userId) {
|
|
276
|
+
savePayment({
|
|
277
|
+
userId,
|
|
278
|
+
stripePaymentId: `dispute_${dispute.id}`,
|
|
279
|
+
amount: -(dispute.amount || 0),
|
|
280
|
+
currency: dispute.currency || 'usd',
|
|
281
|
+
status: 'disputed',
|
|
282
|
+
description: `Chargeback/dispute: ${dispute.id} reason=${dispute.reason || 'unknown'}`
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// Suspend any subscription on the disputed charge
|
|
286
|
+
try {
|
|
287
|
+
if (dispute.charge) {
|
|
288
|
+
// Without expanding the charge we cannot reliably find the sub,
|
|
289
|
+
// but admins are alerted via the audit log + payments row above.
|
|
290
|
+
}
|
|
291
|
+
} catch { /* noop */ }
|
|
292
|
+
console.warn(`[stripe] charge.dispute.created: dispute=${dispute.id} reason=${dispute.reason}`);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
console.error('[stripe] charge.dispute.created handler error:', e.message);
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
190
298
|
}
|
|
191
299
|
}
|
|
192
300
|
|
|
@@ -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);
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visitor tracking — records every public HTML page hit into `page_visits`.
|
|
3
|
+
* Anonymous-friendly: IPs are hashed (sha256 + IP_HASH_SALT) so we never store raw IPs.
|
|
4
|
+
*
|
|
5
|
+
* Exposes:
|
|
6
|
+
* - middleware() — Express middleware to mount before express.static
|
|
7
|
+
* - getVisitorAnalytics(days) — totals + timeline + breakdowns for /api/admin/analytics/visits
|
|
8
|
+
* - getRecentVisits(limit) — latest individual page hits for the admin live feed
|
|
9
|
+
* - getQuickCounts() — visits_24h / visitors_24h / visits_30d for /api/admin/stats
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const { db } = require('../models/db');
|
|
14
|
+
|
|
15
|
+
const IP_SALT = process.env.IP_HASH_SALT || process.env.JWT_SECRET || 'wab-visitor-salt-v1';
|
|
16
|
+
|
|
17
|
+
// ── Bot detection ────────────────────────────────────────────────────
|
|
18
|
+
const BOT_RE = /(bot|crawler|spider|crawl|slurp|bingpreview|facebookexternalhit|preview|monitor|uptime|curl|wget|axios|node-fetch|python-requests|java\/|ahrefs|semrush|petalbot|yandex|baiduspider|duckduckbot|googlebot|applebot|gpt|claude|anthropic|openai|perplexity)/i;
|
|
19
|
+
|
|
20
|
+
// ── Path filter ──────────────────────────────────────────────────────
|
|
21
|
+
// We track real page requests, not asset/API noise.
|
|
22
|
+
const SKIP_PREFIX = ['/api/', '/css/', '/js/', '/assets/', '/script/', '/v3/', '/v2/', '/v1/', '/latest/', '/.well-known/', '/admin/', '/socket.io', '/favicon', '/sitemap', '/robots.txt', '/feed.xml', '/downloads/'];
|
|
23
|
+
const SKIP_EXT = /\.(?:js|mjs|css|map|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|pdf|xml|txt|wasm)$/i;
|
|
24
|
+
|
|
25
|
+
function shouldTrack(req) {
|
|
26
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
|
27
|
+
const p = req.path || '/';
|
|
28
|
+
if (SKIP_EXT.test(p)) return false;
|
|
29
|
+
for (const pre of SKIP_PREFIX) if (p.startsWith(pre)) return false;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hashIp(ip) {
|
|
34
|
+
if (!ip) return null;
|
|
35
|
+
return crypto.createHash('sha256').update(IP_SALT + ':' + ip).digest('hex').slice(0, 32);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function detectDevice(ua) {
|
|
39
|
+
if (!ua) return 'unknown';
|
|
40
|
+
if (BOT_RE.test(ua)) return 'bot';
|
|
41
|
+
if (/iPad|Tablet|PlayBook|Silk/i.test(ua)) return 'tablet';
|
|
42
|
+
if (/Mobi|Android|iPhone|iPod|Opera Mini|IEMobile/i.test(ua)) return 'mobile';
|
|
43
|
+
return 'desktop';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractUserId(req) {
|
|
47
|
+
// Best-effort: req.user (set by upstream auth middleware) wins; otherwise null.
|
|
48
|
+
if (req.user && req.user.id) return String(req.user.id);
|
|
49
|
+
if (req.session && req.session.userId) return String(req.session.userId);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Lazily prepared so the require() of this module can run before migrations
|
|
54
|
+
// have created the page_visits table (e.g. in tests or during cold boot).
|
|
55
|
+
let _insertVisit = null;
|
|
56
|
+
function getInsertStmt() {
|
|
57
|
+
if (_insertVisit) return _insertVisit;
|
|
58
|
+
_insertVisit = db.prepare(`
|
|
59
|
+
INSERT INTO page_visits
|
|
60
|
+
(path, query_string, referrer, host, user_agent, ip_hash, country, device, is_bot, session_id, user_id, status_code, duration_ms)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
62
|
+
`);
|
|
63
|
+
return _insertVisit;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function middleware() {
|
|
67
|
+
return function visitTracker(req, res, next) {
|
|
68
|
+
if (!shouldTrack(req)) return next();
|
|
69
|
+
const start = Date.now();
|
|
70
|
+
res.on('finish', () => {
|
|
71
|
+
try {
|
|
72
|
+
const ua = req.get('user-agent') || null;
|
|
73
|
+
const ref = req.get('referer') || req.get('referrer') || null;
|
|
74
|
+
const ipHash = hashIp(req.ip);
|
|
75
|
+
const device = detectDevice(ua);
|
|
76
|
+
const isBot = device === 'bot' ? 1 : 0;
|
|
77
|
+
const country = req.get('cf-ipcountry') || req.get('x-vercel-ip-country') || null;
|
|
78
|
+
const session = ipHash && ua
|
|
79
|
+
? crypto.createHash('sha256').update(ipHash + ':' + ua).digest('hex').slice(0, 24)
|
|
80
|
+
: null;
|
|
81
|
+
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1, req.url.indexOf('?') + 257) : null;
|
|
82
|
+
getInsertStmt().run(
|
|
83
|
+
req.path.slice(0, 512),
|
|
84
|
+
qs,
|
|
85
|
+
ref ? ref.slice(0, 512) : null,
|
|
86
|
+
(req.get('host') || '').slice(0, 255),
|
|
87
|
+
ua ? ua.slice(0, 512) : null,
|
|
88
|
+
ipHash,
|
|
89
|
+
country ? country.slice(0, 4) : null,
|
|
90
|
+
device,
|
|
91
|
+
isBot,
|
|
92
|
+
session,
|
|
93
|
+
extractUserId(req),
|
|
94
|
+
res.statusCode,
|
|
95
|
+
Date.now() - start
|
|
96
|
+
);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
99
|
+
console.warn('[visit-tracker] insert failed:', e.message);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
next();
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Read queries ────────────────────────────────────────────────────
|
|
108
|
+
function getVisitorAnalytics(days) {
|
|
109
|
+
const n = Math.max(1, Math.min(365, parseInt(days, 10) || 30));
|
|
110
|
+
const since = new Date(Date.now() - n * 86400000).toISOString();
|
|
111
|
+
|
|
112
|
+
const totalsRow = db.prepare(`
|
|
113
|
+
SELECT
|
|
114
|
+
COUNT(*) AS pageviews,
|
|
115
|
+
COUNT(DISTINCT session_id) AS visitors,
|
|
116
|
+
COALESCE(SUM(CASE WHEN is_bot=1 THEN 1 ELSE 0 END), 0) AS bot_hits,
|
|
117
|
+
COALESCE(SUM(CASE WHEN user_id IS NOT NULL THEN 1 ELSE 0 END), 0) AS authenticated_hits,
|
|
118
|
+
COUNT(DISTINCT user_id) AS authenticated_users
|
|
119
|
+
FROM page_visits WHERE created_at >= ?
|
|
120
|
+
`).get(since);
|
|
121
|
+
|
|
122
|
+
const last24Row = db.prepare(`
|
|
123
|
+
SELECT COUNT(*) AS pageviews, COUNT(DISTINCT session_id) AS visitors
|
|
124
|
+
FROM page_visits WHERE created_at >= datetime('now','-1 day')
|
|
125
|
+
`).get();
|
|
126
|
+
|
|
127
|
+
const todayRow = db.prepare(`
|
|
128
|
+
SELECT COUNT(*) AS pageviews, COUNT(DISTINCT session_id) AS visitors
|
|
129
|
+
FROM page_visits WHERE date(created_at) = date('now')
|
|
130
|
+
`).get();
|
|
131
|
+
|
|
132
|
+
const timeline = db.prepare(`
|
|
133
|
+
SELECT date(created_at) AS day,
|
|
134
|
+
COUNT(*) AS pageviews,
|
|
135
|
+
COUNT(DISTINCT session_id) AS visitors,
|
|
136
|
+
SUM(CASE WHEN is_bot=1 THEN 1 ELSE 0 END) AS bots
|
|
137
|
+
FROM page_visits WHERE created_at >= ?
|
|
138
|
+
GROUP BY day ORDER BY day
|
|
139
|
+
`).all(since);
|
|
140
|
+
|
|
141
|
+
const topPaths = db.prepare(`
|
|
142
|
+
SELECT path, COUNT(*) AS pageviews, COUNT(DISTINCT session_id) AS visitors
|
|
143
|
+
FROM page_visits WHERE created_at >= ? AND is_bot = 0
|
|
144
|
+
GROUP BY path ORDER BY pageviews DESC LIMIT 25
|
|
145
|
+
`).all(since);
|
|
146
|
+
|
|
147
|
+
const topReferrers = db.prepare(`
|
|
148
|
+
SELECT
|
|
149
|
+
COALESCE(NULLIF(substr(referrer, 1, instr(substr(referrer, 9), '/') + 7), ''), 'Direct') AS source,
|
|
150
|
+
COUNT(*) AS hits
|
|
151
|
+
FROM page_visits WHERE created_at >= ? AND is_bot = 0
|
|
152
|
+
GROUP BY source ORDER BY hits DESC LIMIT 15
|
|
153
|
+
`).all(since);
|
|
154
|
+
|
|
155
|
+
const devices = db.prepare(`
|
|
156
|
+
SELECT device, COUNT(*) AS hits
|
|
157
|
+
FROM page_visits WHERE created_at >= ?
|
|
158
|
+
GROUP BY device ORDER BY hits DESC
|
|
159
|
+
`).all(since);
|
|
160
|
+
|
|
161
|
+
const countries = db.prepare(`
|
|
162
|
+
SELECT COALESCE(country, 'Unknown') AS country, COUNT(*) AS hits
|
|
163
|
+
FROM page_visits WHERE created_at >= ? AND is_bot = 0
|
|
164
|
+
GROUP BY country ORDER BY hits DESC LIMIT 20
|
|
165
|
+
`).all(since);
|
|
166
|
+
|
|
167
|
+
const topBots = db.prepare(`
|
|
168
|
+
SELECT
|
|
169
|
+
CASE
|
|
170
|
+
WHEN user_agent LIKE '%Googlebot%' THEN 'Googlebot'
|
|
171
|
+
WHEN user_agent LIKE '%bingbot%' OR user_agent LIKE '%Bingbot%' THEN 'Bingbot'
|
|
172
|
+
WHEN user_agent LIKE '%DuckDuckBot%' THEN 'DuckDuckBot'
|
|
173
|
+
WHEN user_agent LIKE '%AhrefsBot%' THEN 'AhrefsBot'
|
|
174
|
+
WHEN user_agent LIKE '%SemrushBot%' THEN 'SemrushBot'
|
|
175
|
+
WHEN user_agent LIKE '%YandexBot%' THEN 'YandexBot'
|
|
176
|
+
WHEN user_agent LIKE '%Baiduspider%' THEN 'Baiduspider'
|
|
177
|
+
WHEN user_agent LIKE '%facebookexternalhit%' THEN 'Facebook'
|
|
178
|
+
WHEN user_agent LIKE '%GPTBot%' OR user_agent LIKE '%ChatGPT%' THEN 'GPTBot'
|
|
179
|
+
WHEN user_agent LIKE '%anthropic%' OR user_agent LIKE '%Claude%' THEN 'Claude / Anthropic'
|
|
180
|
+
WHEN user_agent LIKE '%PerplexityBot%' THEN 'Perplexity'
|
|
181
|
+
WHEN user_agent LIKE '%Applebot%' THEN 'Applebot'
|
|
182
|
+
ELSE 'Other bot'
|
|
183
|
+
END AS bot,
|
|
184
|
+
COUNT(*) AS hits
|
|
185
|
+
FROM page_visits WHERE created_at >= ? AND is_bot = 1
|
|
186
|
+
GROUP BY bot ORDER BY hits DESC LIMIT 15
|
|
187
|
+
`).all(since);
|
|
188
|
+
|
|
189
|
+
const signups = db.prepare(`
|
|
190
|
+
SELECT date(created_at) AS day, COUNT(*) AS count
|
|
191
|
+
FROM users WHERE created_at >= ? GROUP BY day ORDER BY day
|
|
192
|
+
`).all(since);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
period_days: n,
|
|
196
|
+
totals: {
|
|
197
|
+
pageviews: totalsRow.pageviews || 0,
|
|
198
|
+
visitors: totalsRow.visitors || 0,
|
|
199
|
+
bot_hits: totalsRow.bot_hits || 0,
|
|
200
|
+
human_hits: (totalsRow.pageviews || 0) - (totalsRow.bot_hits || 0),
|
|
201
|
+
authenticated_hits: totalsRow.authenticated_hits || 0,
|
|
202
|
+
authenticated_users: totalsRow.authenticated_users || 0,
|
|
203
|
+
pageviews_24h: last24Row.pageviews || 0,
|
|
204
|
+
visitors_24h: last24Row.visitors || 0,
|
|
205
|
+
pageviews_today: todayRow.pageviews || 0,
|
|
206
|
+
visitors_today: todayRow.visitors || 0,
|
|
207
|
+
},
|
|
208
|
+
timeline,
|
|
209
|
+
topPaths,
|
|
210
|
+
topReferrers,
|
|
211
|
+
devices,
|
|
212
|
+
countries,
|
|
213
|
+
topBots,
|
|
214
|
+
signups,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getRecentVisits(limit) {
|
|
219
|
+
const n = Math.max(1, Math.min(500, parseInt(limit, 10) || 50));
|
|
220
|
+
return db.prepare(`
|
|
221
|
+
SELECT id, path, referrer, host, user_agent, country, device, is_bot, session_id, user_id, status_code, duration_ms, created_at
|
|
222
|
+
FROM page_visits ORDER BY id DESC LIMIT ?
|
|
223
|
+
`).all(n);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function getQuickCounts() {
|
|
227
|
+
const row24 = db.prepare(`
|
|
228
|
+
SELECT COUNT(*) AS pageviews, COUNT(DISTINCT session_id) AS visitors
|
|
229
|
+
FROM page_visits WHERE created_at >= datetime('now','-1 day')
|
|
230
|
+
`).get();
|
|
231
|
+
const row30 = db.prepare(`
|
|
232
|
+
SELECT COUNT(*) AS pageviews, COUNT(DISTINCT session_id) AS visitors
|
|
233
|
+
FROM page_visits WHERE created_at >= datetime('now','-30 days')
|
|
234
|
+
`).get();
|
|
235
|
+
const total = db.prepare(`SELECT COUNT(*) AS c FROM page_visits`).get();
|
|
236
|
+
return {
|
|
237
|
+
pageviews_24h: row24.pageviews || 0,
|
|
238
|
+
visitors_24h: row24.visitors || 0,
|
|
239
|
+
pageviews_30d: row30.pageviews || 0,
|
|
240
|
+
visitors_30d: row30.visitors || 0,
|
|
241
|
+
pageviews_total: total.c || 0,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
middleware,
|
|
247
|
+
getVisitorAnalytics,
|
|
248
|
+
getRecentVisits,
|
|
249
|
+
getQuickCounts,
|
|
250
|
+
};
|