web-agent-bridge 3.10.0 → 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.
- package/package.json +1 -1
- package/server/index.js +6 -0
- package/server/routes/admin.js +18 -0
- package/server/services/commission-billing.js +279 -0
- package/server/services/stripe.js +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "web-agent-bridge",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.1",
|
|
4
4
|
"description": "Agent Transaction Bridge — the trust + transaction layer for agentic commerce. Signed intent contracts, idempotent transactions, Ed25519-verifiable receipts, explicit compensation. Plus the original WAB stack: sovereign browser, ShieldQR, SSL health, DNS discovery, agent mesh, and unified gateway for safe AI–website interaction.",
|
|
5
5
|
"author": "Web Agent Bridge <dev@webagentbridge.com>",
|
|
6
6
|
"main": "server/index.js",
|
package/server/index.js
CHANGED
|
@@ -790,6 +790,12 @@ if (process.env.NODE_ENV !== 'test') {
|
|
|
790
790
|
// Start the Certificate Transparency Monitor (opt-in via WAB_CT_MONITOR=true).
|
|
791
791
|
try { require('./services/ssl-ct-monitor').start(); } catch (e) { console.warn('[ct-monitor] start failed:', e.message); }
|
|
792
792
|
|
|
793
|
+
// Start the ATP commission billing timer (opt-in via WAB_COMMISSION_BILLING_INTERVAL_HOURS).
|
|
794
|
+
try {
|
|
795
|
+
const r = require('./services/commission-billing').startPeriodicBilling();
|
|
796
|
+
if (r) console.log(`[commission-billing] periodic cycle every ${r.intervalHours}h`);
|
|
797
|
+
} catch (e) { console.warn('[commission-billing] start failed:', e.message); }
|
|
798
|
+
|
|
793
799
|
server.listen(PORT, () => {
|
|
794
800
|
console.log(`\n ╔══════════════════════════════════════════╗`);
|
|
795
801
|
console.log(` ║ Web Agent Bridge v${pkg.version} ║`);
|
package/server/routes/admin.js
CHANGED
|
@@ -658,4 +658,22 @@ router.post('/commissions/:id/status', authenticateAdmin, (req, res) => {
|
|
|
658
658
|
}
|
|
659
659
|
});
|
|
660
660
|
|
|
661
|
+
// Run a billing cycle (turn `pending` rows into Stripe invoices).
|
|
662
|
+
// ?dry_run=1 returns the plan without touching Stripe or the DB.
|
|
663
|
+
router.post('/commissions/run-billing', authenticateAdmin, async (req, res) => {
|
|
664
|
+
const dryRun = req.query.dry_run === '1' || req.body?.dry_run === true;
|
|
665
|
+
try {
|
|
666
|
+
const billing = require('../services/commission-billing');
|
|
667
|
+
const summary = await billing.runBillingCycle({ dryRun });
|
|
668
|
+
auditLog({
|
|
669
|
+
actorType: 'admin', actorId: String(req.admin.id),
|
|
670
|
+
action: 'commission_billing_cycle',
|
|
671
|
+
details: { dry_run: dryRun, batches_billed: summary.batches_billed, rows_invoiced: summary.rows_invoiced, total_cents: summary.total_commission_cents },
|
|
672
|
+
});
|
|
673
|
+
res.json({ ok: true, data: summary });
|
|
674
|
+
} catch (e) {
|
|
675
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
661
679
|
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
|
+
};
|
|
@@ -151,6 +151,18 @@ function handleWebhookEvent(event) {
|
|
|
151
151
|
|
|
152
152
|
case 'invoice.payment_succeeded': {
|
|
153
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
|
+
|
|
154
166
|
if (invoice.subscription) {
|
|
155
167
|
const sub = getStripeSubscriptionBySubId(invoice.subscription);
|
|
156
168
|
if (sub) {
|