Raw
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 })();