@siglume/direct-request-payment 0.4.22 → 0.4.24

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/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.24 - 2026-06-20
4
+
5
+ - Split CLI checks into `preflight` for pre-mount setup checks and `verify` for
6
+ full Hosted Checkout plus signed webhook delivery verification, so the
7
+ 10-minute guide no longer asks users to verify a webhook route before it
8
+ exists.
9
+ - Made merchant account status fail-closed in readiness checks; only `active`
10
+ and `ready` pass.
11
+ - Reworked Express and FastAPI checkout attempts to support attempt generations,
12
+ expiry/failure recovery, and one active checkout attempt per order enforced by
13
+ a database unique key.
14
+ - Added Express E2E coverage for 50 concurrent checkout starts creating exactly
15
+ one Hosted Checkout session, new attempt creation after expiry, and stale
16
+ non-transactional webhook `processing` recovery.
17
+ - Added FastAPI SQLAlchemy expiry retry coverage and made the adapter configurable
18
+ for existing product order table/column names.
19
+ - Added a FastAPI `AsyncSession` SQLAlchemy adapter and E2E coverage for async
20
+ checkout concurrency, webhook idempotency, and expired-session retry.
21
+ - Marked readiness probe webhooks so generated routes ignore them instead of
22
+ writing manual-review records.
23
+ - Updated sandbox checkout to follow the returned success redirect after
24
+ confirmation and expose metered summary responses with seller-borne Micro /
25
+ Nano accounting fields.
26
+
27
+ ## 0.4.23 - 2026-06-20
28
+
29
+ - Made the local SDRP sandbox reject invalid checkout input early, including
30
+ non-positive `amount_minor`, unsupported currencies, and unsafe return URLs.
31
+ - Made sandbox checkout confirmation idempotent so repeated confirmation calls
32
+ return the original event and do not send duplicate webhooks.
33
+ - Made the Express SQL order-store adapter recoverable for custom SQL executors
34
+ without a transaction hook by marking failed webhook handling as retryable
35
+ instead of permanently treating it as a duplicate.
36
+ - Added E2E coverage for sandbox idempotency, invalid sandbox input, and
37
+ non-transactional webhook retry recovery.
38
+
3
39
  ## 0.4.22 - 2026-06-20
4
40
 
5
41
  - Fixed clean-checkout TypeScript resolution for template imports so CI and npm
package/README.md CHANGED
@@ -86,10 +86,11 @@ CLI-first:
86
86
 
87
87
  ```bash
88
88
  npm install @siglume/direct-request-payment
89
- npx siglume-sdrp sandbox --webhook-url http://localhost:3000/payments/webhooks/siglume
90
- npx siglume-check readiness --sandbox
91
- npx siglume-check readiness
92
89
  npx siglume-sdrp init express --target src/siglume
90
+ # mount the routes, start your app, then:
91
+ npx siglume-sdrp sandbox --webhook-url http://localhost:3000/payments/webhooks/siglume
92
+ npx siglume-check verify --sandbox
93
+ npx siglume-check verify
93
94
  ```
94
95
 
95
96
  or:
@@ -97,14 +98,22 @@ or:
97
98
  ```bash
98
99
  pip install siglume-direct-request-payment
99
100
  siglume-sdrp init fastapi --target app/siglume
101
+ # mount the routes, start your app, then use the npm sandbox for local checkout:
102
+ npx siglume-sdrp sandbox --webhook-url http://localhost:3000/payments/webhooks/siglume
103
+ siglume-check verify --sandbox
100
104
  ```
101
105
 
102
106
  The sandbox command starts a local Siglume-compatible API that creates fake
103
107
  checkout sessions and sends signed webhooks to your product. It never charges a
104
- wallet; see [SDRP Sandbox](./docs/sandbox.md). The readiness command checks
105
- account, billing, origin, webhook, and Hosted Checkout availability before you
106
- write checkout code. It also confirms the webhook subscription and signed test
107
- delivery when API probes are enabled.
108
+ wallet; see [SDRP Sandbox](./docs/sandbox.md). `siglume-check preflight`
109
+ checks account, billing, origin, webhook subscription metadata, and Hosted
110
+ Checkout availability before route mounting. `siglume-check verify` additionally
111
+ requires a signed webhook test delivery, so run it only after your webhook route
112
+ is mounted and your app is running.
113
+
114
+ The FastAPI templates include both sync `Session` and async `AsyncSession`
115
+ SQLAlchemy adapters. The sandbox also exposes metered summary endpoints so you
116
+ can verify Micro / Nano seller-borne fee fields before using live credentials.
108
117
 
109
118
  Before implementation, confirm Hosted Checkout readiness in
110
119
  [Troubleshooting](./docs/troubleshooting.md#hosted-checkout-readiness). For
@@ -125,7 +134,7 @@ fulfilling orders.
125
134
 
126
135
  | Use case | Recommended path | 10-minute integration path? | Production work still required |
127
136
  | --- | --- | --- | --- |
128
- | EC one-time Standard payment | Hosted Checkout | Yes, with `siglume-check readiness` and `siglume-sdrp init` | Product DB adapter, refund/support process, monitoring |
137
+ | EC one-time Standard payment | Hosted Checkout | Yes, with `siglume-sdrp init`, sandbox, and `siglume-check verify` | Product DB adapter, refund/support process, monitoring |
129
138
  | Game consumables | Hosted Checkout or agent/API | Conditional | Idempotent entitlement grants, disconnect recovery, Micro / Nano settlement reconciliation and past-due handling |
130
139
  | Paid API / AtoA | Direct API or Siglume marketplace tool | Conditional | Request idempotency, buyer auth context, reconciliation |
131
140
  | SaaS subscription | Recurring challenge plus raw API | No | Renewal, cancellation, failed renewal, plan-change lifecycle |
@@ -139,7 +148,7 @@ case `createCheckoutSession(...)` / `getCheckoutSession(...)` raises
139
148
  `HostedCheckoutNotAvailableError` instead of exposing the raw rollout 404/409.
140
149
  Keep the signed `direct_payment.confirmed` webhook as the durable signal, and
141
150
  inspect its settlement machine fields before marking any order paid.
142
- Check readiness before you build the flow; see
151
+ Run preflight before route mounting and verify after your webhook is live; see
143
152
  [Hosted Checkout readiness](./docs/troubleshooting.md#hosted-checkout-readiness).
144
153
 
145
154
  Hosted Checkout is a Siglume-hosted page that turns a "Pay with Siglume" button
@@ -265,9 +274,11 @@ transaction. Micro / Nano settlement batches are aggregated on-chain after the
265
274
  weekly or monthly close, or earlier when the fixed amount threshold is reached.
266
275
 
267
276
  Micro Payment and Nano Payment are not separate products you opt into; they are
268
- amount bands Siglume applies on your behalf. Your integration code is the same
269
- regardless of which band a payment falls into. The full fee table and the exact
270
- weekly / monthly settlement schedule plus early threshold settlement rule are in
277
+ amount bands Siglume applies on your behalf. Payment initiation is the same
278
+ across amount bands. Fulfillment, revenue recognition, reconciliation, past-due
279
+ handling, and terminal write-off handling differ for Micro / Nano. The full fee
280
+ table and the exact weekly / monthly settlement schedule plus early threshold
281
+ settlement rule are in
271
282
  [docs/pricing.md](./docs/pricing.md).
272
283
  Provider revenue in the Micro and Nano bands is not settled revenue until the
273
284
  aggregated on-chain settlement succeeds. Siglume keeps outstanding failed
@@ -27,8 +27,14 @@ async function main() {
27
27
  printHelp();
28
28
  return;
29
29
  }
30
- if (command === "readiness" || command === "doctor") {
31
- await readiness(parseArgs(args));
30
+ if (command === "readiness" || command === "verify" || command === "doctor") {
31
+ await readiness(parseArgs(args), { requireProbe: true, label: command === "verify" ? "verify" : "readiness" });
32
+ return;
33
+ }
34
+ if (command === "preflight") {
35
+ const options = parseArgs(args);
36
+ options.probe = false;
37
+ await readiness(options, { requireProbe: false, label: "preflight" });
32
38
  return;
33
39
  }
34
40
  if (command === "sandbox") {
@@ -46,7 +52,9 @@ function printHelp() {
46
52
  console.log(`Siglume SDRP integration CLI
47
53
 
48
54
  Usage:
55
+ siglume-check preflight --merchant <key> --origin <https://shop.example> --webhook-url <https://api.example/siglume/webhook>
49
56
  siglume-check readiness --merchant <key> --origin <https://shop.example> --webhook-url <https://api.example/siglume/webhook>
57
+ siglume-check verify --merchant <key> --origin <https://shop.example> --webhook-url <https://api.example/siglume/webhook>
50
58
  siglume-sdrp sandbox --webhook-url <http://localhost:3000/payments/webhooks/siglume>
51
59
  siglume-sdrp init express --target src/siglume
52
60
  siglume-sdrp init fastapi --target app/siglume
@@ -60,7 +68,7 @@ Readiness options:
60
68
  --base-url <url> Siglume API base URL. Defaults to SIGLUME_API_BASE or production.
61
69
  --sandbox Use the local sandbox default API base (http://127.0.0.1:8787/v1).
62
70
  --no-api Validate local config only; do not call Siglume.
63
- --no-probe Partial API check only; readiness will not be reported as ready.
71
+ --no-probe Partial API check only; readiness/verify will not be reported as ready. preflight sets this automatically.
64
72
  --json Print machine-readable JSON.
65
73
 
66
74
  Sandbox options:
@@ -101,7 +109,7 @@ function parseArgs(args) {
101
109
  return out;
102
110
  }
103
111
 
104
- async function readiness(options) {
112
+ async function readiness(options, mode = { requireProbe: true, label: "readiness" }) {
105
113
  const checks = [];
106
114
  const sandboxMode = Boolean(options.sandbox) || String(process.env.SIGLUME_ENV || "").toLowerCase() === "sandbox";
107
115
  const merchant = options.merchant || process.env.SIGLUME_DIRECT_PAYMENT_MERCHANT || "";
@@ -133,7 +141,7 @@ async function readiness(options) {
133
141
  check(checks, "merchant_exists", Boolean(account.merchant), "Run merchant setup before checkout.");
134
142
  check(checks, "billing_mandate", Boolean(account.billing_mandate_id), "Complete the merchant billing mandate wallet approval.");
135
143
  check(checks, "billing_status_active", activeLike(account.billing_status), `Billing status is ${account.billing_status || "unknown"}; it must be active before accepting payments.`);
136
- warnIf(checks, "merchant_status", account.status && !activeLike(account.status), `Merchant status is ${account.status}; confirm it is allowed to accept payments.`);
144
+ check(checks, "merchant_status_active", merchantStatusAllowed(account.status), `Merchant status is ${account.status || "unknown"}; it must be active or ready before accepting payments.`);
137
145
  } catch (error) {
138
146
  check(checks, "merchant_api", false, apiErrorMessage(error, "Could not read the merchant account."));
139
147
  }
@@ -162,8 +170,10 @@ async function readiness(options) {
162
170
  }
163
171
  }
164
172
 
165
- if (!options.probe && !hasFailures(checks)) {
173
+ if (!options.probe && mode.requireProbe && !hasFailures(checks)) {
166
174
  check(checks, "hosted_checkout_probe", false, "--no-probe skips Hosted Checkout and webhook delivery probes. Remove --no-probe for readiness.");
175
+ } else if (!options.probe && !mode.requireProbe && !hasFailures(checks)) {
176
+ check(checks, "delivery_probe_skipped", true, "preflight only; run siglume-check verify after mounting and starting the webhook route.");
167
177
  }
168
178
 
169
179
  if (options.probe && !hasFailures(checks)) {
@@ -205,7 +215,11 @@ async function readiness(options) {
205
215
  if (ok && !options.api) {
206
216
  console.log("Local config checks passed. API, Hosted Checkout, and webhook delivery readiness were not verified.");
207
217
  } else {
208
- console.log(ok ? `Ready for 10-minute SDRP integration (${sandboxMode ? "sandbox" : "live"}).` : "Not ready. Fix the FAIL items before coding checkout.");
218
+ if (ok && !options.probe && !mode.requireProbe) {
219
+ console.log(`Preflight passed (${sandboxMode ? "sandbox" : "live"}). Mount the routes, start your app, then run siglume-check verify.`);
220
+ } else {
221
+ console.log(ok ? `Ready for 10-minute SDRP integration (${sandboxMode ? "sandbox" : "live"}).` : `Not ready. Fix the FAIL items before ${mode.label === "preflight" ? "mounting checkout" : "opening checkout"}.`);
222
+ }
209
223
  }
210
224
  }
211
225
  if (!ok) {
@@ -233,7 +247,7 @@ async function init(args) {
233
247
  }
234
248
  await copyDir(from, to, Boolean(parsed.force));
235
249
  console.log(`Copied ${framework} SDRP integration files to ${to}`);
236
- console.log("Wire the exported router into your app, then run siglume-check readiness before opening checkout.");
250
+ console.log("Wire the exported router into your app, start it, then run siglume-check verify before opening checkout.");
237
251
  }
238
252
 
239
253
  async function sandbox(options) {
@@ -260,6 +274,7 @@ async function sandbox(options) {
260
274
  subscriptionId: "whsub_sandbox_local",
261
275
  sessions: new Map(),
262
276
  deliveries: [],
277
+ meteredUsageEvents: [],
263
278
  };
264
279
 
265
280
  const server = createServer(async (req, res) => {
@@ -294,7 +309,7 @@ async function sandbox(options) {
294
309
  console.log(`SHOP_PUBLIC_ORIGIN=${origin}`);
295
310
  console.log(`SHOP_WEBHOOK_URL=${webhookUrl}`);
296
311
  console.log("");
297
- console.log(`Then run: siglume-check readiness --sandbox`);
312
+ console.log(`Then run after your app is running: siglume-check verify --sandbox`);
298
313
  }
299
314
  }
300
315
 
@@ -341,18 +356,36 @@ async function handleSandboxRequest(req, res, state, port) {
341
356
  sendJson(res, 404, { error: { code: "EXTERNAL_402_MERCHANT_NOT_FOUND", message: "sandbox merchant not found" } });
342
357
  return;
343
358
  }
359
+ let currency;
360
+ let amountMinor;
361
+ let successUrl;
362
+ let cancelUrl;
363
+ try {
364
+ currency = normalizeCurrency(body.currency || "JPY");
365
+ amountMinor = normalizePositiveAmountMinor(body.amount_minor);
366
+ successUrl = normalizeSandboxReturnUrl(body.success_url, "success_url");
367
+ cancelUrl = normalizeSandboxReturnUrl(body.cancel_url, "cancel_url");
368
+ } catch (error) {
369
+ sendJson(res, 400, {
370
+ error: {
371
+ code: "INVALID_CHECKOUT_SESSION_REQUEST",
372
+ message: error instanceof Error ? error.message : "invalid checkout session request",
373
+ },
374
+ });
375
+ return;
376
+ }
344
377
  const sessionId = `chk_sandbox_${state.sessions.size + 1}`;
345
378
  const challengeHash = `sha256:sandbox_${hashString(`${sessionId}:${body.nonce || ""}`).slice(0, 32)}`;
346
379
  const session = {
347
380
  session_id: sessionId,
348
381
  merchant: state.merchant,
349
- amount_minor: Number(body.amount_minor),
350
- currency: String(body.currency || "JPY").toUpperCase(),
351
- token_symbol: String(body.currency || "JPY").toUpperCase() === "USD" ? "USDC" : "JPYC",
382
+ amount_minor: amountMinor,
383
+ currency,
384
+ token_symbol: currency === "USD" ? "USDC" : "JPYC",
352
385
  status: "open",
353
386
  challenge_hash: challengeHash,
354
- success_url: String(body.success_url || ""),
355
- cancel_url: String(body.cancel_url || ""),
387
+ success_url: successUrl,
388
+ cancel_url: cancelUrl,
356
389
  metadata_jsonb: body.metadata && typeof body.metadata === "object" ? body.metadata : {},
357
390
  checkout_url: `http://127.0.0.1:${port}/pay/${sessionId}`,
358
391
  expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
@@ -399,6 +432,35 @@ async function handleSandboxRequest(req, res, state, port) {
399
432
  return;
400
433
  }
401
434
 
435
+ if (req.method === "GET" && url.pathname === "/v1/sdrp/metered/my-summary") {
436
+ sendEnvelope(res, 200, sandboxBuyerMeteredSummary(state, url));
437
+ return;
438
+ }
439
+
440
+ if (req.method === "GET" && url.pathname === "/v1/sdrp/metered/provider/summary") {
441
+ sendEnvelope(res, 200, sandboxProviderMeteredSummary(state, url));
442
+ return;
443
+ }
444
+
445
+ if (req.method === "GET" && (
446
+ url.pathname === "/v1/sdrp/metered/my-usage-events"
447
+ || url.pathname === "/v1/sdrp/metered/provider/usage-events"
448
+ )) {
449
+ sendEnvelope(res, 200, {
450
+ items: filterSandboxMeteredUsage(state, url),
451
+ next_cursor: null,
452
+ });
453
+ return;
454
+ }
455
+
456
+ if (req.method === "GET" && url.pathname === "/v1/sdrp/metered/provider/settlement-batches") {
457
+ sendEnvelope(res, 200, {
458
+ items: sandboxSettlementBatches(state, url),
459
+ next_cursor: null,
460
+ });
461
+ return;
462
+ }
463
+
402
464
  if (req.method === "GET" && url.pathname.startsWith("/pay/")) {
403
465
  const sessionId = decodeURIComponent(url.pathname.split("/").pop() || "");
404
466
  const session = state.sessions.get(sessionId);
@@ -418,15 +480,20 @@ async function handleSandboxRequest(req, res, state, port) {
418
480
  sendJson(res, 404, { error: { code: "CHECKOUT_SESSION_NOT_FOUND", message: "sandbox session not found" } });
419
481
  return;
420
482
  }
483
+ if (session.status === "paid" && session.confirmation_event) {
484
+ sendEnvelope(res, 200, sandboxConfirmResponse(session, session.confirmation_event));
485
+ return;
486
+ }
421
487
  session.status = "paid";
422
488
  session.requirement_id = `dpr_sandbox_${sessionId}`;
423
489
  const event = sandboxPaymentConfirmedEvent(session);
490
+ session.confirmation_event = event;
491
+ const usageEvent = sandboxMeteredUsageEvent(session, event);
492
+ if (usageEvent) {
493
+ state.meteredUsageEvents.unshift(usageEvent);
494
+ }
424
495
  await deliverSandboxWebhook(state, event);
425
- sendEnvelope(res, 200, {
426
- status: "paid",
427
- redirect_url: `${session.success_url}${session.success_url.includes("?") ? "&" : "?"}session_id=${encodeURIComponent(sessionId)}`,
428
- event: { id: event.id, type: event.type },
429
- });
496
+ sendEnvelope(res, 200, sandboxConfirmResponse(session, event));
430
497
  return;
431
498
  }
432
499
 
@@ -468,6 +535,7 @@ function sandboxEvent({ event_type, data }) {
468
535
  function sandboxPaymentConfirmedEvent(session) {
469
536
  const pricingBand = classifySandboxAmount(session.currency, Number(session.amount_minor));
470
537
  const metered = pricingBand === "micro" || pricingBand === "nano";
538
+ const accounting = metered ? sandboxMeteredAccounting(session, pricingBand) : null;
471
539
  return sandboxEvent({
472
540
  event_type: "direct_payment.confirmed",
473
541
  data: {
@@ -484,10 +552,196 @@ function sandboxPaymentConfirmedEvent(session) {
484
552
  settlement_status: metered ? "pending_settlement" : "settled",
485
553
  chain_receipt_id: metered ? undefined : `chain_sandbox_${session.session_id}`,
486
554
  environment: "sandbox",
555
+ ...(accounting ? {
556
+ provider_gross_amount_minor: accounting.provider_gross_amount_minor,
557
+ provider_usage_amount_minor: accounting.provider_usage_amount_minor,
558
+ protocol_fee_minor: accounting.protocol_fee_minor,
559
+ provider_receivable_minor: accounting.provider_receivable_minor,
560
+ gross_buyer_debit_minor: accounting.gross_buyer_debit_minor,
561
+ buyer_debit_minor: accounting.buyer_debit_minor,
562
+ rounding_delta_minor: accounting.rounding_delta_minor,
563
+ settlement_threshold_minor: accounting.settlement_threshold_minor,
564
+ } : {}),
487
565
  },
488
566
  });
489
567
  }
490
568
 
569
+ function sandboxMeteredUsageEvent(session, event) {
570
+ const pricingBand = classifySandboxAmount(session.currency, Number(session.amount_minor));
571
+ if (pricingBand !== "micro" && pricingBand !== "nano") return null;
572
+ const accounting = sandboxMeteredAccounting(session, pricingBand);
573
+ return {
574
+ metered_usage_id: `mu_sandbox_${session.session_id}`,
575
+ created_at: new Date().toISOString(),
576
+ plan_type: pricingBand,
577
+ pricing_band: pricingBand,
578
+ settlement_cadence: pricingBand === "micro" ? "weekly" : "monthly",
579
+ period_start: new Date().toISOString(),
580
+ period_end: null,
581
+ listing_id: session.metadata_jsonb?.listing_id || "sandbox_listing",
582
+ capability_key: session.metadata_jsonb?.capability_key || "sandbox_checkout",
583
+ operation_key: session.metadata_jsonb?.operation_key || "checkout.confirm",
584
+ currency: session.currency,
585
+ token_symbol: session.token_symbol,
586
+ status: "open",
587
+ settlement_batch_id: null,
588
+ buyer_period_ref: `buyer_sandbox:${session.merchant}:${session.token_symbol}:${pricingBand}`,
589
+ requirement_id: session.requirement_id,
590
+ challenge_hash: session.challenge_hash,
591
+ event_id: event.id,
592
+ ...accounting,
593
+ };
594
+ }
595
+
596
+ function sandboxMeteredAccounting(session, pricingBand) {
597
+ const currency = String(session.currency || "").toUpperCase();
598
+ const providerGrossTenths = Number(session.amount_minor) * 10;
599
+ const protocolFeeTenths = sandboxProtocolFeeTenths(currency, pricingBand);
600
+ const providerReceivableTenths = providerGrossTenths - protocolFeeTenths;
601
+ return {
602
+ provider_gross_amount_minor: formatTenths(providerGrossTenths),
603
+ provider_usage_amount_minor: formatTenths(providerGrossTenths),
604
+ protocol_fee_minor: formatTenths(protocolFeeTenths),
605
+ provider_receivable_minor: formatTenths(providerReceivableTenths),
606
+ gross_buyer_debit_minor: formatTenths(providerGrossTenths),
607
+ buyer_debit_minor: formatTenths(providerGrossTenths),
608
+ rounding_delta_minor: "0",
609
+ settlement_threshold_minor: "10000",
610
+ };
611
+ }
612
+
613
+ function sandboxProtocolFeeTenths(currency, pricingBand) {
614
+ if (pricingBand === "micro") return currency === "USD" ? 10 : 20;
615
+ return currency === "USD" ? 1 : 2;
616
+ }
617
+
618
+ function sandboxBuyerMeteredSummary(state, url) {
619
+ const events = filterSandboxMeteredUsage(state, url);
620
+ const batches = sandboxSettlementBatches(state, url);
621
+ return {
622
+ role: "buyer",
623
+ open_periods: sandboxOpenPeriods(events),
624
+ settlement_batches: batches,
625
+ past_due_blocks: [],
626
+ balance_sufficiency: { sufficient: true },
627
+ };
628
+ }
629
+
630
+ function sandboxProviderMeteredSummary(state, url) {
631
+ const events = filterSandboxMeteredUsage(state, url);
632
+ const totals = sandboxProviderTotals(events);
633
+ return {
634
+ role: "provider",
635
+ timezone: "UTC",
636
+ filters: Object.fromEntries(url.searchParams.entries()),
637
+ open_periods: sandboxOpenPeriods(events),
638
+ periods: sandboxSettlementBatches(state, url),
639
+ totals,
640
+ };
641
+ }
642
+
643
+ function filterSandboxMeteredUsage(state, url) {
644
+ const planType = url.searchParams.get("plan_type");
645
+ const tokenSymbol = url.searchParams.get("token_symbol");
646
+ const status = url.searchParams.get("status");
647
+ return state.meteredUsageEvents.filter((event) => {
648
+ if (planType && event.plan_type !== planType) return false;
649
+ if (tokenSymbol && event.token_symbol !== tokenSymbol) return false;
650
+ if (status && event.status !== status) return false;
651
+ return true;
652
+ });
653
+ }
654
+
655
+ function sandboxOpenPeriods(events) {
656
+ return Object.values(groupSandboxMeteredEvents(events)).map((group) => {
657
+ const grossTenths = sumTenths(group.events, "provider_gross_amount_minor");
658
+ const protocolFeeTenths = sumTenths(group.events, "protocol_fee_minor");
659
+ const receivableTenths = sumTenths(group.events, "provider_receivable_minor");
660
+ const buyerDebitTenths = sumTenths(group.events, "buyer_debit_minor");
661
+ const thresholdTenths = 10000 * 10;
662
+ const thresholdReached = grossTenths >= thresholdTenths;
663
+ return {
664
+ plan_type: group.plan_type,
665
+ settlement_cadence: group.plan_type === "micro" ? "weekly" : "monthly",
666
+ currency: group.currency,
667
+ token_symbol: group.token_symbol,
668
+ period_start: group.events[group.events.length - 1]?.created_at ?? null,
669
+ period_end: null,
670
+ close_at: null,
671
+ settlement_trigger: thresholdReached ? "amount_threshold" : null,
672
+ settlement_threshold_minor: "10000",
673
+ threshold_reached_at: thresholdReached ? group.events[0]?.created_at ?? null : null,
674
+ provider_gross_amount_minor: formatTenths(grossTenths),
675
+ provider_usage_amount_minor: formatTenths(grossTenths),
676
+ protocol_fee_minor: formatTenths(protocolFeeTenths),
677
+ provider_receivable_minor: formatTenths(receivableTenths),
678
+ buyer_debit_minor: formatTenths(buyerDebitTenths),
679
+ total_unsettled_exposure_minor: formatTenths(grossTenths),
680
+ };
681
+ });
682
+ }
683
+
684
+ function sandboxSettlementBatches(state, url) {
685
+ const events = filterSandboxMeteredUsage(state, url);
686
+ return sandboxOpenPeriods(events)
687
+ .filter((period) => period.settlement_trigger === "amount_threshold")
688
+ .map((period) => ({
689
+ settlement_batch_id: `batch_sandbox_${period.plan_type}_${period.currency}_${period.token_symbol}`,
690
+ status: "notice_pending",
691
+ notice_status: "pending",
692
+ not_before_attempt_at: new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString(),
693
+ expected_scheduled_debit_at: new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString(),
694
+ ...period,
695
+ }));
696
+ }
697
+
698
+ function sandboxProviderTotals(events) {
699
+ return {
700
+ settled_provider_receivable_minor: "0",
701
+ unsettled_provider_receivable_minor: formatTenths(sumTenths(events, "provider_receivable_minor")),
702
+ past_due_provider_receivable_minor: "0",
703
+ terminal_provider_receivable_minor: "0",
704
+ uncollectible_provider_receivable_minor: "0",
705
+ written_off_provider_receivable_minor: "0",
706
+ };
707
+ }
708
+
709
+ function groupSandboxMeteredEvents(events) {
710
+ const groups = {};
711
+ for (const event of events) {
712
+ const key = `${event.plan_type}:${event.currency}:${event.token_symbol}`;
713
+ groups[key] ||= {
714
+ plan_type: event.plan_type,
715
+ currency: event.currency,
716
+ token_symbol: event.token_symbol,
717
+ events: [],
718
+ };
719
+ groups[key].events.push(event);
720
+ }
721
+ return groups;
722
+ }
723
+
724
+ function sumTenths(items, field) {
725
+ return items.reduce((total, item) => total + parseTenths(item[field]), 0);
726
+ }
727
+
728
+ function parseTenths(value) {
729
+ return Math.round(Number(value || 0) * 10);
730
+ }
731
+
732
+ function formatTenths(value) {
733
+ if (value % 10 === 0) return String(value / 10);
734
+ return (value / 10).toFixed(1);
735
+ }
736
+
737
+ function sandboxConfirmResponse(session, event) {
738
+ return {
739
+ status: "paid",
740
+ redirect_url: `${session.success_url}${session.success_url.includes("?") ? "&" : "?"}session_id=${encodeURIComponent(session.session_id)}`,
741
+ event: { id: event.id, type: event.type },
742
+ };
743
+ }
744
+
491
745
  async function deliverSandboxWebhook(state, event) {
492
746
  const rawBody = JSON.stringify(event);
493
747
  const signature = await buildWebhookSignatureHeader(state.webhookSecret, rawBody);
@@ -540,6 +794,9 @@ function sandboxCheckoutHtml(session) {
540
794
  const body = await response.json();
541
795
  document.getElementById("status").textContent = body.data?.status || "failed";
542
796
  document.getElementById("output").textContent = JSON.stringify(body, null, 2);
797
+ if (body.data?.redirect_url) {
798
+ window.setTimeout(() => window.location.assign(body.data.redirect_url), 400);
799
+ }
543
800
  });
544
801
  </script>
545
802
  </body>`;
@@ -725,6 +982,27 @@ function normalizeCurrency(value) {
725
982
  return currency;
726
983
  }
727
984
 
985
+ function normalizePositiveAmountMinor(value) {
986
+ const amountMinor = Number(value);
987
+ if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
988
+ throw new Error("amount_minor must be a positive integer.");
989
+ }
990
+ return amountMinor;
991
+ }
992
+
993
+ function normalizeSandboxReturnUrl(value, name) {
994
+ const text = String(value || "").trim();
995
+ try {
996
+ const url = new URL(text);
997
+ if (url.protocol === "https:" || (url.protocol === "http:" && isLocalhost(url.hostname))) {
998
+ return url.href;
999
+ }
1000
+ } catch {
1001
+ // Fall through to a consistent validation error.
1002
+ }
1003
+ throw new Error(`${name} must be an https URL, or a local http URL in sandbox.`);
1004
+ }
1005
+
728
1006
  function isStandardAmount(currency, amountMinor) {
729
1007
  return Number.isSafeInteger(amountMinor) && amountMinor >= (currency === "USD" ? 301 : 501);
730
1008
  }
@@ -733,6 +1011,10 @@ function activeLike(value) {
733
1011
  return /^(active|ready|current|ok|enabled|paid|complete|completed)$/i.test(String(value || ""));
734
1012
  }
735
1013
 
1014
+ function merchantStatusAllowed(value) {
1015
+ return /^(active|ready)$/i.test(String(value || ""));
1016
+ }
1017
+
736
1018
  function includesEventType(eventTypes, eventType) {
737
1019
  if (!Array.isArray(eventTypes) || eventTypes.length === 0) return true;
738
1020
  return eventTypes.map((item) => String(item)).includes(eventType);
@@ -764,6 +1046,7 @@ async function checkWebhookDeliveryProbe(checks, merchantClient, { merchant, sub
764
1046
  subscription_ids: [id],
765
1047
  data: {
766
1048
  mode: "readiness_probe",
1049
+ readiness_probe: true,
767
1050
  merchant,
768
1051
  direct_payment_requirement_id: `dpr_readiness_${Date.now()}`,
769
1052
  requirement_id: `dpr_readiness_${Date.now()}`,
package/dist/index.cjs CHANGED
@@ -85,7 +85,7 @@ var DIRECT_REQUEST_PAYMENT_RECEIPT_KIND = "sdrp_direct_payment";
85
85
  var DIRECT_REQUEST_PAYMENT_ALLOWANCE_RECEIPT_KIND = "sdrp_direct_payment_allowance";
86
86
  var DIRECT_REQUEST_PAYMENT_REFERENCE_TYPE = "sdrp_direct_payment_requirement";
87
87
  var DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
88
- var DIRECT_REQUEST_PAYMENT_SDK_VERSION = "0.4.22";
88
+ var DIRECT_REQUEST_PAYMENT_SDK_VERSION = "0.4.24";
89
89
  var DIRECT_REQUEST_PAYMENT_STANDARD_SETTLED_STATUS = "settled";
90
90
  var DIRECT_REQUEST_PAYMENT_METERED_ACCEPTED_STATUS = "pending_settlement";
91
91
  var DIRECT_REQUEST_PAYMENT_STANDARD_FINALITY = "per_payment_onchain";