|
1
|
(function () { |
|
2
|
const DEFAULT_API_BASE = 'https://api.procurenet.io'; |
|
3
|
const DEFAULT_SESSION_ID = 'sess_demo_001'; |
|
4
|
// Platform default fee recipient (Architect / ProcureNet) — hardcoded DEFAULT |
|
5
|
const DEFAULT_FEE_RECIPIENT = '0xD0Fb43F2e9b4Dcd4C9FB70DA89385f77086cb778'; |
|
6
|
const DEFAULT_FEE_BPS = 100; // 1% |
|
7
|
|
|
8
|
const LS = { |
|
9
|
apiBase: 'pn.checkoutFunding.apiBase', |
|
10
|
jwt: 'pn.checkoutFunding.jwt', |
|
11
|
sessionId: 'pn.checkoutFunding.sessionId', |
|
12
|
previewOnly: 'pn.checkoutFunding.previewOnly', |
|
13
|
feeRecipient: 'pn.checkoutFunding.feeRecipient', |
|
14
|
feeAsset: 'pn.checkoutFunding.feeAsset', |
|
15
|
}; |
|
16
|
|
|
17
|
const methodInputs = document.querySelectorAll('input[name="method"]'); |
|
18
|
const panels = document.querySelectorAll('[data-panel]'); |
|
19
|
const formError = document.getElementById('formError'); |
|
20
|
const payloadOut = document.getElementById('payloadOut'); |
|
21
|
const payloadBadge = document.getElementById('payloadBadge'); |
|
22
|
const endpointPath = document.getElementById('endpointPath'); |
|
23
|
const sessionIdDisplay = document.getElementById('sessionIdDisplay'); |
|
24
|
const submitBtn = document.getElementById('submitBtn'); |
|
25
|
const resetBtn = document.getElementById('resetBtn'); |
|
26
|
const apiBaseInput = document.getElementById('apiBase'); |
|
27
|
const bearerJwtInput = document.getElementById('bearerJwt'); |
|
28
|
const sessionIdInput = document.getElementById('sessionId'); |
|
29
|
const previewOnlyInput = document.getElementById('previewOnly'); |
|
30
|
const feeRecipientInput = document.getElementById('feeRecipient'); |
|
31
|
const feeAssetInput = document.getElementById('feeAsset'); |
|
32
|
const amountInput = document.getElementById('amount'); |
|
33
|
const feeLineText = document.getElementById('feeLineText'); |
|
34
|
const feeEstimate = document.getElementById('feeEstimate'); |
|
35
|
const responseBadge = document.getElementById('responseBadge'); |
|
36
|
const responseMeta = document.getElementById('responseMeta'); |
|
37
|
const responseOut = document.getElementById('responseOut'); |
|
38
|
const requestHint = document.getElementById('requestHint'); |
|
39
|
|
|
40
|
function truncateAddress(addr) { |
|
41
|
if (!addr || addr.length < 10) return addr || ''; |
|
42
|
return addr.slice(0, 6) + '…' + addr.slice(-4); |
|
43
|
} |
|
44
|
|
|
45
|
function loadConfig() { |
|
46
|
apiBaseInput.value = localStorage.getItem(LS.apiBase) || DEFAULT_API_BASE; |
|
47
|
bearerJwtInput.value = localStorage.getItem(LS.jwt) || ''; |
|
48
|
sessionIdInput.value = localStorage.getItem(LS.sessionId) || DEFAULT_SESSION_ID; |
|
49
|
previewOnlyInput.checked = localStorage.getItem(LS.previewOnly) === '1'; |
|
50
|
feeRecipientInput.value = localStorage.getItem(LS.feeRecipient) || DEFAULT_FEE_RECIPIENT; |
|
51
|
feeAssetInput.value = localStorage.getItem(LS.feeAsset) || 'usdc'; |
|
52
|
syncEndpoint(); |
|
53
|
syncSubmitLabel(); |
|
54
|
syncFeeLine(); |
|
55
|
} |
|
56
|
|
|
57
|
function persistConfig() { |
|
58
|
localStorage.setItem(LS.apiBase, apiBaseInput.value.trim() || DEFAULT_API_BASE); |
|
59
|
localStorage.setItem(LS.jwt, bearerJwtInput.value.trim()); |
|
60
|
localStorage.setItem(LS.sessionId, sessionIdInput.value.trim() || DEFAULT_SESSION_ID); |
|
61
|
localStorage.setItem(LS.previewOnly, previewOnlyInput.checked ? '1' : '0'); |
|
62
|
const fr = feeRecipientInput.value.trim() || DEFAULT_FEE_RECIPIENT; |
|
63
|
localStorage.setItem(LS.feeRecipient, fr); |
|
64
|
localStorage.setItem(LS.feeAsset, feeAssetInput.value === 'eth' ? 'eth' : 'usdc'); |
|
65
|
} |
|
66
|
|
|
67
|
function getConfig() { |
|
68
|
const apiBase = (apiBaseInput.value.trim() || DEFAULT_API_BASE).replace(/\/+$/, ''); |
|
69
|
const jwt = bearerJwtInput.value.trim(); |
|
70
|
const sessionId = sessionIdInput.value.trim() || DEFAULT_SESSION_ID; |
|
71
|
const previewOnly = previewOnlyInput.checked; |
|
72
|
const feeRecipient = feeRecipientInput.value.trim() || DEFAULT_FEE_RECIPIENT; |
|
73
|
const feeAsset = feeAssetInput.value === 'eth' ? 'eth' : 'usdc'; |
|
74
|
const amountRaw = amountInput.value.trim(); |
|
75
|
return { apiBase, jwt, sessionId, previewOnly, feeRecipient, feeAsset, amountRaw }; |
|
76
|
} |
|
77
|
|
|
78
|
function syncFeeLine() { |
|
79
|
const { feeRecipient, amountRaw } = getConfig(); |
|
80
|
feeLineText.textContent = '1% → ' + truncateAddress(feeRecipient); |
|
81
|
feeLineText.title = feeRecipient; |
|
82
|
const amount = parseFloat(amountRaw); |
|
83
|
if (amountRaw && Number.isFinite(amount) && amount >= 0) { |
|
84
|
const fee = (amount * DEFAULT_FEE_BPS) / 10000; |
|
85
|
feeEstimate.hidden = false; |
|
86
|
feeEstimate.textContent = 'Est. fee: ' + fee.toFixed(4) + ' (1% of ' + amount + ')'; |
|
87
|
} else { |
|
88
|
feeEstimate.hidden = true; |
|
89
|
feeEstimate.textContent = ''; |
|
90
|
} |
|
91
|
} |
|
92
|
|
|
93
|
function syncEndpoint() { |
|
94
|
const { sessionId } = getConfig(); |
|
95
|
const path = `/api/v7/sessions/${sessionId}/funding-method`; |
|
96
|
endpointPath.textContent = path; |
|
97
|
sessionIdDisplay.textContent = sessionId; |
|
98
|
} |
|
99
|
|
|
100
|
function syncSubmitLabel() { |
|
101
|
submitBtn.textContent = previewOnlyInput.checked |
|
102
|
? 'Preview funding-method request' |
|
103
|
: 'Submit funding method'; |
|
104
|
requestHint.textContent = previewOnlyInput.checked |
|
105
|
? 'Preview only is on — JSON is built locally; no network call.' |
|
106
|
: 'Live POST uses Authorization: Bearer and Content-Type: application/json. Enable Preview only to skip the network call.'; |
|
107
|
} |
|
108
|
|
|
109
|
function selectedMethod() { |
|
110
|
const checked = document.querySelector('input[name="method"]:checked'); |
|
111
|
return checked ? checked.value : 'usdc'; |
|
112
|
} |
|
113
|
|
|
114
|
function showPanel(method) { |
|
115
|
panels.forEach((panel) => { |
|
116
|
panel.hidden = panel.dataset.panel !== method; |
|
117
|
}); |
|
118
|
} |
|
119
|
|
|
120
|
function setError(message) { |
|
121
|
if (!message) { |
|
122
|
formError.hidden = true; |
|
123
|
formError.textContent = ''; |
|
124
|
return; |
|
125
|
} |
|
126
|
formError.hidden = false; |
|
127
|
formError.textContent = message; |
|
128
|
} |
|
129
|
|
|
130
|
|
|
131
|
function isValidEthAddress(addr) { |
|
132
|
if (!/^0x[a-fA-F0-9]{40}$/.test(addr)) return false; |
|
133
|
// Reject all-zero address |
|
134
|
if (/^0x0{40}$/i.test(addr)) return false; |
|
135
|
return true; |
|
136
|
} |
|
137
|
|
|
138
|
function isValidEnsName(name) { |
|
139
|
if (!name) return true; |
|
140
|
if (name.length < 3 || name.length > 255) return false; |
|
141
|
// Basic ENS-like: labels separated by dots, optional trailing .eth |
|
142
|
return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/i.test(name); |
|
143
|
} |
|
144
|
|
|
145
|
function abaCheckDigitValid(routing) { |
|
146
|
if (!/^\d{9}$/.test(routing)) return false; |
|
147
|
const d = routing.split('').map(Number); |
|
148
|
const sum = |
|
149
|
3 * (d[0] + d[3] + d[6]) + |
|
150
|
7 * (d[1] + d[4] + d[7]) + |
|
151
|
1 * (d[2] + d[5] + d[8]); |
|
152
|
return sum % 10 === 0; |
|
153
|
} |
|
154
|
|
|
155
|
function attachPlatformFee(body) { |
|
156
|
const { feeRecipient, feeAsset } = getConfig(); |
|
157
|
if (!isValidEthAddress(feeRecipient)) { |
|
158
|
return { |
|
159
|
ok: false, |
|
160
|
error: 'fee_recipient must be 0x followed by exactly 40 hex characters (non-zero).', |
|
161
|
}; |
|
162
|
} |
|
163
|
body.fee_bps = DEFAULT_FEE_BPS; |
|
164
|
body.fee_recipient = feeRecipient; |
|
165
|
body.fee_asset = feeAsset; |
|
166
|
return { ok: true, body }; |
|
167
|
} |
|
168
|
|
|
169
|
function buildBody() { |
|
170
|
const method = selectedMethod(); |
|
171
|
const body = { method }; |
|
172
|
|
|
173
|
if (method === 'usdc') { |
|
174
|
body.network = document.getElementById('network').value; |
|
175
|
return attachPlatformFee(body); |
|
176
|
} |
|
177
|
|
|
178
|
if (method === 'card') { |
|
179
|
return attachPlatformFee(body); |
|
180
|
} |
|
181
|
|
|
182
|
if (method === 'eth_wallet') { |
|
183
|
const wallet_address = document.getElementById('wallet_address').value.trim(); |
|
184
|
const ens_name = document.getElementById('ens_name').value.trim(); |
|
185
|
const network = document.getElementById('eth_network').value; |
|
186
|
|
|
187
|
if (!isValidEthAddress(wallet_address)) { |
|
188
|
return { |
|
189
|
ok: false, |
|
190
|
error: 'wallet_address must be 0x followed by exactly 40 hex characters (non-zero).', |
|
191
|
}; |
|
192
|
} |
|
193
|
if (ens_name && !isValidEnsName(ens_name)) { |
|
194
|
return { |
|
195
|
ok: false, |
|
196
|
error: 'ens_name must be a valid ENS-like name (3–255 chars, labels and dots).', |
|
197
|
}; |
|
198
|
} |
|
199
|
const networks = ['base', 'arbitrum', 'polygon', 'ethereum']; |
|
200
|
if (!networks.includes(network)) { |
|
201
|
return { ok: false, error: 'network must be base, arbitrum, polygon, or ethereum.' }; |
|
202
|
} |
|
203
|
|
|
204
|
body.wallet_address = wallet_address; |
|
205
|
body.network = network; |
|
206
|
if (ens_name) body.ens_name = ens_name; |
|
207
|
return attachPlatformFee(body); |
|
208
|
} |
|
209
|
|
|
210
|
const routing_number = document.getElementById('routing_number').value.trim(); |
|
211
|
const account_number = document.getElementById('account_number').value.trim(); |
|
212
|
const account_type = document.getElementById('account_type').value; |
|
213
|
const account_holder_name = document |
|
214
|
.getElementById('account_holder_name') |
|
215
|
.value.trim(); |
|
216
|
|
|
217
|
if (!/^\d{9}$/.test(routing_number)) { |
|
218
|
return { ok: false, error: 'routing_number must be exactly 9 digits (ABA).' }; |
|
219
|
} |
|
220
|
if (!abaCheckDigitValid(routing_number)) { |
|
221
|
return { |
|
222
|
ok: false, |
|
223
|
error: 'routing_number failed the ABA check-digit validation.', |
|
224
|
}; |
|
225
|
} |
|
226
|
if (!/^\d{4,17}$/.test(account_number)) { |
|
227
|
return { ok: false, error: 'account_number must be 4–17 digits.' }; |
|
228
|
} |
|
229
|
if (account_type !== 'checking' && account_type !== 'savings') { |
|
230
|
return { ok: false, error: 'account_type must be checking or savings.' }; |
|
231
|
} |
|
232
|
if (account_holder_name.length < 2 || account_holder_name.length > 100) { |
|
233
|
return { |
|
234
|
ok: false, |
|
235
|
error: 'account_holder_name must be between 2 and 100 characters.', |
|
236
|
}; |
|
237
|
} |
|
238
|
|
|
239
|
body.routing_number = routing_number; |
|
240
|
body.account_number = account_number; |
|
241
|
body.account_type = account_type; |
|
242
|
body.account_holder_name = account_holder_name; |
|
243
|
return attachPlatformFee(body); |
|
244
|
} |
|
245
|
|
|
246
|
function resetResponse() { |
|
247
|
responseBadge.textContent = '—'; |
|
248
|
responseBadge.className = 'badge'; |
|
249
|
responseMeta.textContent = 'No response yet.'; |
|
250
|
responseOut.textContent = '{ /* submit to see status and body */ }'; |
|
251
|
} |
|
252
|
|
|
253
|
function renderRequest(body) { |
|
254
|
payloadOut.textContent = JSON.stringify(body, null, 2); |
|
255
|
} |
|
256
|
|
|
257
|
function formatResponseBody(text) { |
|
258
|
const trimmed = (text || '').trim(); |
|
259
|
if (!trimmed) return '(empty body)'; |
|
260
|
try { |
|
261
|
return JSON.stringify(JSON.parse(trimmed), null, 2); |
|
262
|
} catch { |
|
263
|
return trimmed; |
|
264
|
} |
|
265
|
} |
|
266
|
|
|
267
|
async function submitLive(body, config) { |
|
268
|
const url = `${config.apiBase}/api/v7/sessions/${encodeURIComponent(config.sessionId)}/funding-method`; |
|
269
|
payloadBadge.textContent = 'Sending'; |
|
270
|
payloadBadge.className = 'badge'; |
|
271
|
responseBadge.textContent = '…'; |
|
272
|
responseBadge.className = 'badge'; |
|
273
|
responseMeta.textContent = `POST ${url}`; |
|
274
|
responseOut.textContent = '{ /* waiting */ }'; |
|
275
|
submitBtn.disabled = true; |
|
276
|
|
|
277
|
try { |
|
278
|
const res = await fetch(url, { |
|
279
|
method: 'POST', |
|
280
|
headers: { |
|
281
|
Authorization: `Bearer ${config.jwt}`, |
|
282
|
'Content-Type': 'application/json', |
|
283
|
Accept: 'application/json', |
|
284
|
}, |
|
285
|
body: JSON.stringify(body), |
|
286
|
}); |
|
287
|
|
|
288
|
const raw = await res.text(); |
|
289
|
const pretty = formatResponseBody(raw); |
|
290
|
responseMeta.textContent = `HTTP ${res.status} ${res.statusText || ''} · ${url}`.trim(); |
|
291
|
responseOut.textContent = pretty; |
|
292
|
responseBadge.textContent = String(res.status); |
|
293
|
responseBadge.className = res.ok ? 'badge ok' : 'badge err'; |
|
294
|
payloadBadge.textContent = res.ok ? 'Sent' : 'Sent (error)'; |
|
295
|
payloadBadge.className = res.ok ? 'badge ok' : 'badge err'; |
|
296
|
} catch (err) { |
|
297
|
const message = err && err.message ? err.message : String(err); |
|
298
|
responseBadge.textContent = 'Network'; |
|
299
|
responseBadge.className = 'badge err'; |
|
300
|
responseMeta.textContent = `Network error · ${url}`; |
|
301
|
responseOut.textContent = message; |
|
302
|
payloadBadge.textContent = 'Failed'; |
|
303
|
payloadBadge.className = 'badge err'; |
|
304
|
setError(`Network error: ${message}`); |
|
305
|
} finally { |
|
306
|
submitBtn.disabled = false; |
|
307
|
} |
|
308
|
} |
|
309
|
|
|
310
|
async function onSubmit() { |
|
311
|
persistConfig(); |
|
312
|
syncEndpoint(); |
|
313
|
const config = getConfig(); |
|
314
|
const result = buildBody(); |
|
315
|
|
|
316
|
if (!result.ok) { |
|
317
|
setError(result.error); |
|
318
|
payloadBadge.textContent = 'Invalid'; |
|
319
|
payloadBadge.className = 'badge err'; |
|
320
|
payloadOut.textContent = '{\n /* fix validation errors, then submit again */\n}'; |
|
321
|
resetResponse(); |
|
322
|
return; |
|
323
|
} |
|
324
|
|
|
325
|
setError(''); |
|
326
|
renderRequest(result.body); |
|
327
|
|
|
328
|
if (config.previewOnly) { |
|
329
|
payloadBadge.textContent = 'Preview'; |
|
330
|
payloadBadge.className = 'badge ok'; |
|
331
|
responseBadge.textContent = 'Skipped'; |
|
332
|
responseBadge.className = 'badge'; |
|
333
|
responseMeta.textContent = 'Preview only — no network call.'; |
|
334
|
responseOut.textContent = '{ /* preview mode */ }'; |
|
335
|
console.info('[checkout-funding-picker] preview POST', { |
|
336
|
url: `${config.apiBase}/api/v7/sessions/${config.sessionId}/funding-method`, |
|
337
|
body: result.body, |
|
338
|
}); |
|
339
|
return; |
|
340
|
} |
|
341
|
|
|
342
|
if (!config.jwt) { |
|
343
|
setError('Bearer JWT is required for live POST. Paste a token, or enable Preview only.'); |
|
344
|
payloadBadge.textContent = 'Blocked'; |
|
345
|
payloadBadge.className = 'badge err'; |
|
346
|
resetResponse(); |
|
347
|
responseMeta.textContent = 'Blocked — empty JWT.'; |
|
348
|
responseOut.textContent = '{ /* set Bearer JWT or enable Preview only */ }'; |
|
349
|
return; |
|
350
|
} |
|
351
|
|
|
352
|
if (!config.apiBase) { |
|
353
|
setError('API base URL is required.'); |
|
354
|
payloadBadge.textContent = 'Blocked'; |
|
355
|
payloadBadge.className = 'badge err'; |
|
356
|
return; |
|
357
|
} |
|
358
|
|
|
359
|
await submitLive(result.body, config); |
|
360
|
} |
|
361
|
|
|
362
|
methodInputs.forEach((input) => { |
|
363
|
input.addEventListener('change', () => { |
|
364
|
showPanel(selectedMethod()); |
|
365
|
setError(''); |
|
366
|
}); |
|
367
|
}); |
|
368
|
|
|
369
|
[apiBaseInput, bearerJwtInput, sessionIdInput, feeRecipientInput, feeAssetInput].forEach((el) => { |
|
370
|
el.addEventListener('change', () => { |
|
371
|
persistConfig(); |
|
372
|
syncEndpoint(); |
|
373
|
syncFeeLine(); |
|
374
|
}); |
|
375
|
el.addEventListener('input', () => { |
|
376
|
if (el === sessionIdInput || el === apiBaseInput) syncEndpoint(); |
|
377
|
if (el === feeRecipientInput) syncFeeLine(); |
|
378
|
}); |
|
379
|
}); |
|
380
|
|
|
381
|
amountInput.addEventListener('input', () => { |
|
382
|
syncFeeLine(); |
|
383
|
}); |
|
384
|
|
|
385
|
previewOnlyInput.addEventListener('change', () => { |
|
386
|
persistConfig(); |
|
387
|
syncSubmitLabel(); |
|
388
|
}); |
|
389
|
|
|
390
|
submitBtn.addEventListener('click', () => { |
|
391
|
onSubmit(); |
|
392
|
}); |
|
393
|
|
|
394
|
resetBtn.addEventListener('click', () => { |
|
395
|
document.querySelector('input[name="method"][value="usdc"]').checked = true; |
|
396
|
document.getElementById('network').value = 'base'; |
|
397
|
document.getElementById('routing_number').value = ''; |
|
398
|
document.getElementById('account_number').value = ''; |
|
399
|
document.getElementById('account_type').value = 'checking'; |
|
400
|
document.getElementById('account_holder_name').value = ''; |
|
401
|
document.getElementById('wallet_address').value = ''; |
|
402
|
document.getElementById('ens_name').value = ''; |
|
403
|
document.getElementById('eth_network').value = 'ethereum'; |
|
404
|
amountInput.value = ''; |
|
405
|
// Keep feeRecipient / feeAsset from config (persisted); re-sync display |
|
406
|
showPanel('usdc'); |
|
407
|
setError(''); |
|
408
|
payloadBadge.textContent = 'Idle'; |
|
409
|
payloadBadge.className = 'badge'; |
|
410
|
payloadOut.textContent = '{ /* choose a method and submit */ }'; |
|
411
|
resetResponse(); |
|
412
|
syncFeeLine(); |
|
413
|
}); |
|
414
|
|
|
415
|
loadConfig(); |
|
416
|
showPanel(selectedMethod()); |
|
417
|
})(); |