web-agent-bridge 3.10.0 → 3.12.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/package.json +1 -1
- package/public/revocations.html +151 -0
- package/sdk/index.d.ts +13 -0
- package/sdk/index.js +11 -0
- package/sdk/system-prompt.js +91 -0
- package/server/index.js +18 -0
- package/server/middleware/rateLimits.js +23 -0
- package/server/migrations/024_site_revocations.sql +69 -0
- package/server/routes/admin.js +18 -0
- package/server/routes/agent-prompt.js +27 -0
- package/server/routes/discovery.js +32 -2
- package/server/routes/revocations.js +183 -0
- package/server/routes/transactions.js +3 -2
- package/server/services/canonical-json.js +103 -0
- package/server/services/commission-billing.js +279 -0
- package/server/services/revocations.js +378 -0
- package/server/services/stripe.js +12 -0
- package/server/services/transactions.js +5 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Revocations API (v3.11.0)
|
|
3
|
+
*
|
|
4
|
+
* • Public — anyone can read the transparency log + check a domain's status.
|
|
5
|
+
* • Owner — site owners can self-disable, reinstate their own disable,
|
|
6
|
+
* and submit appeals against suspensions / revocations.
|
|
7
|
+
* • Admin — open suspensions/revocations, decide appeals, manual reinstate.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const express = require('express');
|
|
13
|
+
const router = express.Router();
|
|
14
|
+
|
|
15
|
+
const { authenticateToken } = require('../middleware/auth');
|
|
16
|
+
const { authenticateAdmin } = require('../middleware/adminAuth');
|
|
17
|
+
const { db } = require('../models/db');
|
|
18
|
+
const rev = require('../services/revocations');
|
|
19
|
+
|
|
20
|
+
function _handle(res, fn) {
|
|
21
|
+
try {
|
|
22
|
+
const out = fn();
|
|
23
|
+
res.json({ ok: true, data: out });
|
|
24
|
+
} catch (e) {
|
|
25
|
+
const status = e.statusCode || 500;
|
|
26
|
+
res.status(status).json({ ok: false, error: e.code || 'internal_error', message: e.message });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── PUBLIC ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** GET /api/revocations/transparency — public log */
|
|
33
|
+
router.get('/transparency', (req, res) => {
|
|
34
|
+
_handle(res, () => rev.listPublic({
|
|
35
|
+
limit: parseInt(req.query.limit, 10) || 50,
|
|
36
|
+
offset: parseInt(req.query.offset, 10) || 0,
|
|
37
|
+
}));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** GET /api/revocations/status?domain=example.com — public per-domain status */
|
|
41
|
+
router.get('/status', (req, res) => {
|
|
42
|
+
const domain = String(req.query.domain || '').trim().toLowerCase();
|
|
43
|
+
if (!domain) return res.status(400).json({ ok: false, error: 'domain required' });
|
|
44
|
+
const r = rev.getActiveByDomain(domain);
|
|
45
|
+
res.json({
|
|
46
|
+
ok: true,
|
|
47
|
+
data: {
|
|
48
|
+
domain,
|
|
49
|
+
revoked: !!r,
|
|
50
|
+
revocation: r ? {
|
|
51
|
+
id: r.id,
|
|
52
|
+
type: r.type,
|
|
53
|
+
reason_code: r.reason_code,
|
|
54
|
+
reason_text: r.reason_text,
|
|
55
|
+
evidence_url: r.evidence_url,
|
|
56
|
+
decided_at: r.decided_at,
|
|
57
|
+
appeal_deadline: r.appeal_deadline,
|
|
58
|
+
status: r.status,
|
|
59
|
+
} : null,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ─── OWNER (authenticated user) ──────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** POST /api/revocations/sites/:siteId/disable — owner self-disable */
|
|
67
|
+
router.post('/sites/:siteId/disable', authenticateToken, express.json({ limit: '8kb' }), (req, res) => {
|
|
68
|
+
_handle(res, () => {
|
|
69
|
+
const site = db.prepare(`SELECT * FROM sites WHERE id = ?`).get(req.params.siteId);
|
|
70
|
+
if (!site) { const e = new Error('site not found'); e.statusCode = 404; e.code = 'not_found'; throw e; }
|
|
71
|
+
if (site.user_id !== req.user.id) {
|
|
72
|
+
const e = new Error('forbidden'); e.statusCode = 403; e.code = 'forbidden'; throw e;
|
|
73
|
+
}
|
|
74
|
+
return rev.openRevocation({
|
|
75
|
+
siteId: site.id,
|
|
76
|
+
type: 'owner_disable',
|
|
77
|
+
reasonCode: 'owner_request',
|
|
78
|
+
reasonText: (req.body.reason_text && String(req.body.reason_text).trim())
|
|
79
|
+
|| 'Owner requested self-disable.',
|
|
80
|
+
decidedBy: `owner:${req.user.id}`,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** POST /api/revocations/:id/appeal — owner appeal */
|
|
86
|
+
router.post('/:id/appeal', authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
|
|
87
|
+
_handle(res, () => rev.submitAppeal({
|
|
88
|
+
revocationId: req.params.id,
|
|
89
|
+
ownerUserId: req.user.id,
|
|
90
|
+
statement: String(req.body.statement || ''),
|
|
91
|
+
remediationProof: req.body.remediation_proof || null,
|
|
92
|
+
}));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** POST /api/revocations/:id/reinstate — owner re-enables their own disable */
|
|
96
|
+
router.post('/:id/reinstate', authenticateToken, express.json({ limit: '4kb' }), (req, res) => {
|
|
97
|
+
_handle(res, () => {
|
|
98
|
+
const r = rev.getById(req.params.id);
|
|
99
|
+
if (!r) { const e = new Error('not found'); e.statusCode = 404; e.code = 'not_found'; throw e; }
|
|
100
|
+
if (r.type !== 'owner_disable') {
|
|
101
|
+
const e = new Error('only owner_disable can be self-reinstated'); e.statusCode = 403; e.code = 'forbidden'; throw e;
|
|
102
|
+
}
|
|
103
|
+
const site = db.prepare(`SELECT * FROM sites WHERE id = ?`).get(r.site_id);
|
|
104
|
+
if (!site || site.user_id !== req.user.id) {
|
|
105
|
+
const e = new Error('forbidden'); e.statusCode = 403; e.code = 'forbidden'; throw e;
|
|
106
|
+
}
|
|
107
|
+
return rev.reinstate({
|
|
108
|
+
revocationId: r.id, actorId: req.user.id, actorType: 'user',
|
|
109
|
+
reason: req.body.reason || 'owner_reinstated',
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** GET /api/revocations/:id — owner/admin can view */
|
|
115
|
+
router.get('/:id', authenticateToken, (req, res) => {
|
|
116
|
+
const r = rev.getById(req.params.id);
|
|
117
|
+
if (!r) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
118
|
+
const site = db.prepare(`SELECT user_id FROM sites WHERE id = ?`).get(r.site_id);
|
|
119
|
+
if (!site || site.user_id !== req.user.id) {
|
|
120
|
+
return res.status(403).json({ ok: false, error: 'forbidden' });
|
|
121
|
+
}
|
|
122
|
+
res.json({ ok: true, data: { ...r, appeal: rev.getAppeal(r.id) } });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ─── ADMIN ───────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/** GET /api/revocations/admin/list */
|
|
128
|
+
router.get('/admin/list', authenticateAdmin, (req, res) => {
|
|
129
|
+
_handle(res, () => rev.listAdmin({
|
|
130
|
+
status: req.query.status || undefined,
|
|
131
|
+
type: req.query.type || undefined,
|
|
132
|
+
limit: parseInt(req.query.limit, 10) || 100,
|
|
133
|
+
offset: parseInt(req.query.offset, 10) || 0,
|
|
134
|
+
}));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/** POST /api/revocations/admin/open */
|
|
138
|
+
router.post('/admin/open', authenticateAdmin, express.json({ limit: '16kb' }), (req, res) => {
|
|
139
|
+
_handle(res, () => {
|
|
140
|
+
const b = req.body || {};
|
|
141
|
+
let siteId = b.site_id;
|
|
142
|
+
if (!siteId && b.domain) {
|
|
143
|
+
const site = db.prepare(`SELECT id FROM sites WHERE domain = ? LIMIT 1`).get(String(b.domain).toLowerCase());
|
|
144
|
+
siteId = site ? site.id : null;
|
|
145
|
+
}
|
|
146
|
+
if (!siteId) { const e = new Error('site_id or domain required'); e.statusCode = 400; e.code = 'bad_request'; throw e; }
|
|
147
|
+
return rev.openRevocation({
|
|
148
|
+
siteId,
|
|
149
|
+
type: b.type || 'suspended',
|
|
150
|
+
reasonCode: b.reason_code,
|
|
151
|
+
reasonText: b.reason_text,
|
|
152
|
+
evidenceUrl: b.evidence_url || null,
|
|
153
|
+
decidedBy: `admin:${req.admin.id}`,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/** POST /api/revocations/admin/:id/decide — decide an appeal */
|
|
159
|
+
router.post('/admin/:id/decide', authenticateAdmin, express.json({ limit: '8kb' }), (req, res) => {
|
|
160
|
+
_handle(res, () => rev.decideAppeal({
|
|
161
|
+
revocationId: req.params.id,
|
|
162
|
+
decision: req.body.decision,
|
|
163
|
+
decisionReason: req.body.decision_reason || null,
|
|
164
|
+
adminId: req.admin.id,
|
|
165
|
+
}));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/** POST /api/revocations/admin/:id/reinstate — manual reinstate */
|
|
169
|
+
router.post('/admin/:id/reinstate', authenticateAdmin, express.json({ limit: '4kb' }), (req, res) => {
|
|
170
|
+
_handle(res, () => rev.reinstate({
|
|
171
|
+
revocationId: req.params.id,
|
|
172
|
+
actorId: req.admin.id,
|
|
173
|
+
actorType: 'admin',
|
|
174
|
+
reason: req.body.reason || null,
|
|
175
|
+
}));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** POST /api/revocations/admin/sweep — manually trigger expired-appeal sweep */
|
|
179
|
+
router.post('/admin/sweep', authenticateAdmin, (req, res) => {
|
|
180
|
+
_handle(res, () => ({ swept: rev.sweepExpired() }));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
module.exports = router;
|
|
@@ -25,6 +25,7 @@ const express = require('express');
|
|
|
25
25
|
const router = express.Router();
|
|
26
26
|
|
|
27
27
|
const { authenticateToken } = require('../middleware/auth');
|
|
28
|
+
const { atpStrictLimiter } = require('../middleware/rateLimits');
|
|
28
29
|
const transactions = require('../services/transactions');
|
|
29
30
|
const { db } = require('../models/db');
|
|
30
31
|
|
|
@@ -63,7 +64,7 @@ function send(res, fn) {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
// ─── Intents ─────────────────────────────────────────────────────────────────
|
|
66
|
-
router.post('/intents', authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
|
|
67
|
+
router.post('/intents', atpStrictLimiter, authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
|
|
67
68
|
const q = checkDailyIntentQuota(req.user.id);
|
|
68
69
|
if (!q.ok) {
|
|
69
70
|
return res.status(429).json({
|
|
@@ -125,7 +126,7 @@ function loadTxOwned(txId, userId) {
|
|
|
125
126
|
return { tx, intent };
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
router.post('/transactions', authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
|
|
129
|
+
router.post('/transactions', atpStrictLimiter, authenticateToken, express.json({ limit: '32kb' }), (req, res) => {
|
|
129
130
|
send(res, () => {
|
|
130
131
|
const intent = loadIntentAuthorized(req.body.intent_id, req.user.id);
|
|
131
132
|
const idem = req.headers['idempotency-key'] || req.body.idempotency_key;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 8785 — JSON Canonicalization Scheme (JCS)
|
|
3
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Produces a deterministic byte sequence for any JSON-serialisable value,
|
|
5
|
+
* suitable for hashing or signing. Implements:
|
|
6
|
+
*
|
|
7
|
+
* • Object keys sorted lexicographically by UTF-16 code units (per RFC 8785 §3.2.3).
|
|
8
|
+
* • Numbers serialised per ES2017 ECMAScript ToString (RFC 8785 §3.2.2.2),
|
|
9
|
+
* with finite-only checks (Infinity / NaN are rejected — RFC 8259 §6).
|
|
10
|
+
* • Strings escaped with the minimal RFC 8259 §7 form (control chars + \" + \\).
|
|
11
|
+
* • Booleans / null encoded as `true` / `false` / `null`.
|
|
12
|
+
* • Arrays preserve element order.
|
|
13
|
+
*
|
|
14
|
+
* This is intentionally dependency-free so it can be used by signing paths,
|
|
15
|
+
* audit-log HMAC chains, and ATP receipt verification without pulling extra
|
|
16
|
+
* packages. Performance is O(n log n) over object keys.
|
|
17
|
+
*
|
|
18
|
+
* Anti-features (deliberate):
|
|
19
|
+
* • Does NOT support `undefined`, functions, symbols, BigInt — throws.
|
|
20
|
+
* • Does NOT escape non-ASCII; output is valid UTF-8 by construction.
|
|
21
|
+
* • Does NOT pretty-print.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const HEX = '0123456789abcdef';
|
|
27
|
+
|
|
28
|
+
function _escapeString(s) {
|
|
29
|
+
// RFC 8785 §3.2.2.1 → RFC 8259 §7: minimal escaping.
|
|
30
|
+
let out = '"';
|
|
31
|
+
for (let i = 0; i < s.length; i++) {
|
|
32
|
+
const c = s.charCodeAt(i);
|
|
33
|
+
if (c === 0x22) out += '\\"';
|
|
34
|
+
else if (c === 0x5c) out += '\\\\';
|
|
35
|
+
else if (c === 0x08) out += '\\b';
|
|
36
|
+
else if (c === 0x09) out += '\\t';
|
|
37
|
+
else if (c === 0x0a) out += '\\n';
|
|
38
|
+
else if (c === 0x0c) out += '\\f';
|
|
39
|
+
else if (c === 0x0d) out += '\\r';
|
|
40
|
+
else if (c < 0x20) {
|
|
41
|
+
out += '\\u00' + HEX[(c >> 4) & 0xf] + HEX[c & 0xf];
|
|
42
|
+
} else {
|
|
43
|
+
out += s[i];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out + '"';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _serializeNumber(n) {
|
|
50
|
+
// RFC 8785 §3.2.2.2: ECMAScript ToString. Reject non-finite per RFC 8259.
|
|
51
|
+
if (!Number.isFinite(n)) {
|
|
52
|
+
throw new TypeError(`canonical-json: non-finite number not allowed (${n})`);
|
|
53
|
+
}
|
|
54
|
+
if (n === 0) return '0'; // collapses -0 → "0"
|
|
55
|
+
return String(n);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function canonicalize(value) {
|
|
59
|
+
if (value === null) return 'null';
|
|
60
|
+
|
|
61
|
+
const t = typeof value;
|
|
62
|
+
|
|
63
|
+
if (t === 'boolean') return value ? 'true' : 'false';
|
|
64
|
+
if (t === 'number') return _serializeNumber(value);
|
|
65
|
+
if (t === 'string') return _escapeString(value);
|
|
66
|
+
|
|
67
|
+
if (t === 'bigint' || t === 'function' || t === 'symbol' || t === 'undefined') {
|
|
68
|
+
throw new TypeError(`canonical-json: unsupported type ${t}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
let out = '[';
|
|
73
|
+
for (let i = 0; i < value.length; i++) {
|
|
74
|
+
if (i > 0) out += ',';
|
|
75
|
+
const v = value[i];
|
|
76
|
+
out += v === undefined ? 'null' : canonicalize(v); // align with JSON.stringify
|
|
77
|
+
}
|
|
78
|
+
return out + ']';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Plain object — sort keys by UTF-16 code units (default String sort).
|
|
82
|
+
if (t === 'object') {
|
|
83
|
+
// Strip undefined values (per JSON spec) before sorting.
|
|
84
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
|
|
85
|
+
let out = '{';
|
|
86
|
+
for (let i = 0; i < keys.length; i++) {
|
|
87
|
+
if (i > 0) out += ',';
|
|
88
|
+
const k = keys[i];
|
|
89
|
+
out += _escapeString(k) + ':' + canonicalize(value[k]);
|
|
90
|
+
}
|
|
91
|
+
return out + '}';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new TypeError(`canonical-json: unsupported value ${value}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Convenience: return a SHA-256 hex digest over the canonical form. */
|
|
98
|
+
function canonicalDigest(value, algo = 'sha256') {
|
|
99
|
+
const crypto = require('crypto');
|
|
100
|
+
return crypto.createHash(algo).update(canonicalize(value), 'utf8').digest('hex');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { canonicalize, canonicalDigest };
|
|
@@ -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
|
+
};
|