@vonpay/checkout-node 0.15.1 → 1.0.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/README.md CHANGED
@@ -95,7 +95,7 @@ Write endpoints (create / update / delete / rotate-signing-secret / send-test-ev
95
95
 
96
96
  - **Typed session / webhook / error objects** — full `CheckoutSession`, `SessionStatus`, `WebhookEvent`, `WebhookSubscription`, `WebhookEventRecord`, `VonPayError`, discriminated-union `ErrorCode`.
97
97
  - **Webhook verification** — `webhooks.constructEvent(rawBody, signatureHeader, signingSecret)` parses the `x-vonpay-signature: t=<unix>,v1=<hex>` header, verifies HMAC-SHA256 over `${t}.${rawBody}` keyed by your per-endpoint signing secret (`whsec_…`), and enforces the freshness window (≤5 min old / ≤30 sec future). Accepts multiple `v1=` entries for zero-downtime secret rotation.
98
- - **Signed return URL verification (v1 + v2)** `VonPayCheckout.verifyReturnSignature()` supports both legacy v1 signatures and v2 signatures that bind `successUrl`, `keyMode`, and `iat` freshness.
98
+ - **Return confirmation** `client.sessions.confirmReturn(params, sessionSecret)` verifies the signature **and** confirms server-side that the payment succeeded, returning `{ paid, signatureValid, status, reason }`. **Use this, and branch on `paid`.** ⚠️ `verifyReturnSignature()` is the low-level primitive: it proves the message is AUTHENTIC, and a **declined payment is signed just as validly**, so a `true` from it is not proof of payment. ⚠️ `reason === "still_pending"` means the charge is in flight (the ordinary 3-D Secure case) — show a neutral "confirming your payment", never a failure. ⚠️ `paid: true` still does not mean safe to fulfil: record which session IDs you have already fulfilled, and prefer fulfilling from the `session.succeeded` webhook.
99
99
  - **Auto-retry** — exponential backoff on 429 / 5xx with `Retry-After` header support.
100
100
  - **Request ID tracing** — every response includes `X-Request-Id` for support tickets.
101
101
  - **Rate-limit info** — parsed from response headers into `VonPayError.rateLimit`.
package/dist/client.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- import type { VonPayCheckoutConfig, CreateSessionParams, CheckoutSession, SessionStatus, DryRunResult, WebhookEvent, HealthStatus, RequestOptions, ReturnParams, PaymentIntent, CreatePaymentIntentParams, CapturePaymentIntentParams, Capabilities, Refund, CreateRefundParams, Token, CreateTokenParams, WebhookSubscription, ListWebhookSubscriptionsParams, WebhookSubscriptionsList, WebhookEventRecord } from "./types.js";
2
- /** Test-only: reset the one-time v1 replay warning. Underscore-prefixed; not public API. */
1
+ import type { VonPayCheckoutConfig, CreateSessionParams, CheckoutSession, SessionStatus, DryRunResult, WebhookEvent, HealthStatus, RequestOptions, ReturnOutcome, ReturnParams, PaymentIntent, CreatePaymentIntentParams, CapturePaymentIntentParams, Capabilities, Refund, CreateRefundParams, Token, CreateTokenParams, WebhookSubscription, ListWebhookSubscriptionsParams, WebhookSubscriptionsList, WebhookEventRecord } from "./types.js";
2
+ export declare const KNOWN_RETURN_SCHEMES: readonly ["v1", "v2"];
3
+ export type ReturnScheme = (typeof KNOWN_RETURN_SCHEMES)[number];
4
+ export declare const DEFAULT_RETURN_SCHEMES: readonly ReturnScheme[];
5
+ /** Test-only: reset the one-time return-signature advisories. Not public API. */
3
6
  export declare function __resetReturnSignatureWarning(): void;
4
7
  /** Test-only: reset the warn-once latch so each case can assert on it. */
5
8
  export declare function __resetDeprecationWarningsForTests(): void;
@@ -88,6 +91,57 @@ export declare class VonPayCheckout {
88
91
  sessions: {
89
92
  create: (params: CreateSessionParams, options?: RequestOptions) => Promise<CheckoutSession>;
90
93
  /** Retrieve the current state of a checkout session. Requires a secret key (vp_sk_*). Publishable keys are rejected with 403. */
94
+ /**
95
+ * Confirm, in one call, that a returning buyer actually paid.
96
+ *
97
+ * **Use this instead of `verifyReturnSignature` unless you have a specific
98
+ * reason not to.** That function answers a narrower question than it
99
+ * appears to: it returns `true` for an *authentic* message, and a DECLINED
100
+ * payment is signed just as authentically as an approved one. Reading its
101
+ * boolean as "they paid" is the expensive mistake on this path.
102
+ *
103
+ * Sequence: verify the signature, then read the session status from the
104
+ * SERVER — not from the redirect URL. The URL is a hint carried by the
105
+ * buyer's browser; the server is the authority, and it is fresher. An
106
+ * unauthenticated return never reaches the network, so a forged URL cannot
107
+ * make your server issue API calls.
108
+ *
109
+ * ⚠️ **This does not make fulfilment safe on its own, and cannot.**
110
+ * `paid: true` means this buyer paid; it does not mean you have not already
111
+ * shipped their order. The status keeps reading `succeeded` on every replay
112
+ * of the same URL, so record which session IDs you have fulfilled and
113
+ * refuse to fulfil one twice. That needs your database.
114
+ *
115
+ * ⚠️ **The redirect is not a guarantee of anything.** Buyers close laptops
116
+ * and never load your success page. Webhooks are the reliable fulfilment
117
+ * trigger; this is for what you show the buyer who did arrive.
118
+ * See https://docs.vonpay.com/integration/handle-return
119
+ *
120
+ * @throws on a failed session lookup — `VonPayError` for API errors, and the
121
+ * underlying transport error (e.g. a `TypeError` from `fetch`) for network
122
+ * failures, which is NOT wrapped. Catch broadly rather than narrowing to
123
+ * `VonPayError`, or a network blip will escape and 500 a buyer who just paid.
124
+ *
125
+ * A failed lookup is deliberately NOT reported as `paid: false` — that would
126
+ * turn our outage into the merchant's silent under-fulfilment, and the two
127
+ * need opposite handling.
128
+ *
129
+ * Requires a secret key (`vp_sk_*`): this reads `GET /v1/sessions/:id`,
130
+ * which rejects publishable keys with 403.
131
+ */
132
+ confirmReturn: (params: ReturnParams | Record<string, string>, secret: string, options?: {
133
+ expectedSuccessUrl?: string;
134
+ expectedKeyMode?: "test" | "live";
135
+ maxAgeSeconds?: number;
136
+ acceptedSchemes?: readonly ReturnScheme[];
137
+ /**
138
+ * @deprecated Use `acceptedSchemes: ["v2"]`. Accepted here so a merchant
139
+ * who already hardened against v1 can move to this helper without
140
+ * dropping that protection — without it, the documented migration is
141
+ * blocked for exactly the most security-conscious integrators.
142
+ */
143
+ rejectV1?: boolean;
144
+ }) => Promise<ReturnOutcome>;
91
145
  get: (sessionId: string) => Promise<SessionStatus>;
92
146
  validate: (params: CreateSessionParams) => Promise<DryRunResult>;
93
147
  };
@@ -216,26 +270,37 @@ export declare class VonPayCheckout {
216
270
  * whether you have already fulfilled this order, and it keeps returning
217
271
  * `succeeded` on a replay. Record which session IDs you have fulfilled
218
272
  * (e.g. a UNIQUE column on the order row) and refuse to fulfil one twice.
219
- * Prefer v2 (pass `expectedSuccessUrl` + `expectedKeyMode`), which is
220
- * freshness- and URL-bound, or pass `{ rejectV1: true }` to refuse v1 outright
221
- * once your checkout server emits v2 returns. Note: `rejectV1` only refuses
222
- * v1 it does not by itself require a valid v2 signature, so still supply the
223
- * v2 options. A successful v1 verification logs a warning to this effect once
224
- * per process.
273
+ * `acceptedSchemes` is the allowlist THIS VERIFIER will honour. It exists
274
+ * because the scheme is otherwise chosen by the incoming signature i.e. by
275
+ * the sender and a verifier should declare what it accepts rather than let
276
+ * untrusted input select its own algorithm.
277
+ *
278
+ * ⚠️ `{ acceptedSchemes: ["v2"] }` refuses the v1 SCHEME. It does not by
279
+ * itself require a valid v2 signature, so keep supplying the v2 options. And
280
+ * confirm your account already issues v2 returns before setting it — that is
281
+ * a server-side setting, so if your account still issues v1 this refuses
282
+ * EVERY return you receive.
225
283
  *
226
284
  * @param params - URL search params from the redirect (session, status, amount, currency, transaction_id, sig)
227
285
  * @param secret - Your session signing secret, NOT your API key
228
- * @param options - expectedSuccessUrl (required for v2), expectedKeyMode (required for v2), maxAgeSeconds (v2 freshness, default 600), rejectV1 (refuse legacy v1 signatures)
286
+ * @param options - expectedSuccessUrl (required for v2), expectedKeyMode (required for v2), maxAgeSeconds (v2 freshness, default 600), acceptedSchemes (which signature schemes to honour)
287
+ * @throws TypeError if `acceptedSchemes` is empty or names an unknown scheme — a typo there would silently refuse every return.
229
288
  */
230
289
  static verifyReturnSignature(params: ReturnParams | Record<string, string>, secret: string, options?: {
231
290
  expectedSuccessUrl?: string;
232
291
  expectedKeyMode?: "test" | "live";
233
292
  maxAgeSeconds?: number;
234
293
  /**
235
- * When true, reject legacy v1 signatures outright (return `false`) and
236
- * only accept replay-safe v2 signatures. v1 binds no timestamp and no
237
- * success-URL/key-mode, so a captured v1 return URL replays indefinitely
238
- * (kaiju #425). Default `false` for backward compatibility.
294
+ * Signature schemes this verifier accepts. Defaults to `["v1", "v2"]`,
295
+ * preserving existing behaviour. Pass `["v2"]` to refuse the replayable
296
+ * legacy scheme. Mirrors Python's `accepted_schemes`.
297
+ */
298
+ acceptedSchemes?: readonly ReturnScheme[];
299
+ /**
300
+ * @deprecated Since 2026-08-17. Use `acceptedSchemes: ["v2"]` instead.
301
+ * Still honoured (it published in 0.12.0 on 2026-07-01, so upgrades must
302
+ * not break) and removed in the next major. An explicit `acceptedSchemes`
303
+ * takes precedence over this.
239
304
  */
240
305
  rejectV1?: boolean;
241
306
  }): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js CHANGED
@@ -17,6 +17,30 @@ const DEFAULT_API_VERSION = "2026-05-05";
17
17
  const DEFAULT_MAX_RETRIES = 2;
18
18
  const DEFAULT_TIMEOUT = 30_000;
19
19
  const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
20
+ // The subset of RETRYABLE_STATUS_CODES whose outcome is AMBIGUOUS — the
21
+ // request may already have been applied at the origin even though we never
22
+ // saw a usable response. 429 is deliberately excluded: a rate-limited request
23
+ // is rejected by the limiter BEFORE the handler runs, so it is not applied and
24
+ // stays safe to repeat with or without an idempotency key.
25
+ //
26
+ // ⚠️ That ordering is a property of the SERVER (vonpay-checkout), which this
27
+ // repo cannot see. It is the documented design, not something verified from
28
+ // here — if a limiter is ever placed behind a money-moving handler, 429 must
29
+ // join the ambiguous set.
30
+ //
31
+ // Ambiguity is the whole problem. A vault-forward charge that times out on our
32
+ // side still lands downstream — measured 2026-08-15, buyer charged, response
33
+ // never seen — and the upstream vendor does no de-duplication on a forward.
34
+ // So a blind retry of an ambiguous state-changing call is a second charge.
35
+ const AMBIGUOUS_STATUS_CODES = new Set([500, 502, 503, 504]);
36
+ //
37
+ // The guard is scoped by an explicit `movesMoney` marker on the call site
38
+ // rather than by HTTP verb. Deliberate: a blanket "never retry a POST" rule
39
+ // would also strip retries from `sessions.create`, `tokens.create` and the
40
+ // webhook-subscription writes, none of which can debit a buyer a second time —
41
+ // a resilience regression bought for no safety. The marked set is exactly the
42
+ // review-rule definition (`api/idempotency-on-money-writes`): a call whose
43
+ // repeat "triggers a charge, refund, credit, or other money movement".
20
44
  // Outbound webhook signature freshness window (see docs/webhook-signature-v1.md
21
45
  // in vonpay-checkout — the frozen contract). Asymmetric on purpose: a webhook
22
46
  // older than 5 min is a stale-at-rest replay; a webhook more than 30 sec in the
@@ -123,26 +147,115 @@ function resolveConfig(config) {
123
147
  function sleep(ms) {
124
148
  return new Promise((resolve) => setTimeout(resolve, ms));
125
149
  }
126
- // One-time warning when a replayable legacy v1 return signature is accepted
127
- // (kaiju #425). Module-scoped so it fires once per process, not once per call
128
- // (verifyReturnSignature is on a hot return-handling path).
150
+ /**
151
+ * Return-signature schemes this SDK knows how to verify.
152
+ *
153
+ * The DEFAULT accepts both, preserving the behaviour every existing caller has.
154
+ * Deliberately NOT v2-only: whether a merchant's account is issued v2 returns is
155
+ * a server-side setting they do not control from here, so a strict default would
156
+ * stop confirmations working for anyone still issued v1 — a revenue-visible
157
+ * outage arriving on a routine upgrade. v1 retires 2026-11-21; the default flips
158
+ * at the next major, once the server side is confirmed.
159
+ */
160
+ /**
161
+ * Session states meaning "not finished yet", as distinct from "did not work".
162
+ * The server sits in `processing` while a charge is in flight, and on the 3-D
163
+ * Secure path the buyer returns to successUrl *before* settlement.
164
+ */
165
+ const PENDING_SESSION_STATES = new Set(["pending", "processing"]);
166
+ export const KNOWN_RETURN_SCHEMES = ["v1", "v2"];
167
+ export const DEFAULT_RETURN_SCHEMES = ["v1", "v2"];
168
+ // One-time advisories on the return-signature path. Module-scoped so each fires
169
+ // once per process, not once per call (verifyReturnSignature is on a hot
170
+ // return-handling path).
171
+ //
172
+ // These use console.warn deliberately: it cannot throw. The Python twin had to
173
+ // be moved off `warnings.warn` for exactly this reason — that channel raises
174
+ // under -W error, which turned a verifier documented "never raises" into one
175
+ // that threw on the buyer-return path after the card was charged.
129
176
  let warnedV1ReturnSignatureReplayable = false;
130
177
  function warnV1ReturnSignatureReplayable() {
131
178
  if (warnedV1ReturnSignatureReplayable)
132
179
  return;
133
180
  warnedV1ReturnSignatureReplayable = true;
134
181
  console.warn("[vonpay] verifyReturnSignature: accepted a legacy v1 return signature. " +
135
- "v1 binds no timestamp or success URL, so a captured return URL can be " +
136
- "replayed indefinitely. Before fulfilling: (1) confirm payment via " +
137
- "sessions.get(session).status === 'succeeded', and (2) guard your own " +
138
- "idempotency sessions.get cannot tell you whether you already fulfilled " +
139
- "this order and returns 'succeeded' on replay, so track fulfilled session " +
140
- "IDs. Prefer v2 returns (expectedSuccessUrl + expectedKeyMode), or pass " +
141
- "{ rejectV1: true } to refuse v1. See https://docs.vonpay.com/return-signatures");
182
+ "v1 binds no timestamp and no success URL, so a captured return URL can " +
183
+ "be replayed indefinitely. A valid signature proves the message is " +
184
+ "AUTHENTIC it does not prove the payment succeeded, and a decline is " +
185
+ "signed just as validly. Before fulfilling: (1) check " +
186
+ "status === 'succeeded'; (2) confirm server-side via sessions.get(session); " +
187
+ "and (3) guard your own idempotency sessions.get cannot tell you whether " +
188
+ "you already fulfilled this order and keeps returning 'succeeded' on " +
189
+ "replay, so track fulfilled session IDs. v1 is scheduled for retirement " +
190
+ "on 2026-11-21. To refuse it now, pass { acceptedSchemes: ['v2'] } — but " +
191
+ "confirm your account already issues v2 returns first, or that will " +
192
+ "refuse every return you receive. " +
193
+ "See https://docs.vonpay.com/integration/handle-return");
194
+ }
195
+ let warnedV1ReturnSignatureRefused = false;
196
+ function warnV1ReturnSignatureRefused() {
197
+ if (warnedV1ReturnSignatureRefused)
198
+ return;
199
+ warnedV1ReturnSignatureRefused = true;
200
+ console.warn("[vonpay] verifyReturnSignature: refused a v1 return signature because " +
201
+ "acceptedSchemes excludes it. If your Vonpay account still issues v1 " +
202
+ "returns, this refuses EVERY return — verify which scheme your account " +
203
+ "issues before keeping this setting. If it already issues v2, a v1 " +
204
+ "signature arriving now is either a replay of a pre-migration capture or " +
205
+ "a misconfiguration, and refusing it is correct. " +
206
+ "See https://docs.vonpay.com/integration/handle-return");
142
207
  }
143
- /** Test-only: reset the one-time v1 replay warning. Underscore-prefixed; not public API. */
208
+ let warnedRejectV1Deprecated = false;
209
+ function warnRejectV1Deprecated() {
210
+ if (warnedRejectV1Deprecated)
211
+ return;
212
+ warnedRejectV1Deprecated = true;
213
+ console.warn("[vonpay] verifyReturnSignature: `rejectV1` is deprecated and will be " +
214
+ "removed in the next major version. Replace `{ rejectV1: true }` with " +
215
+ "`{ acceptedSchemes: ['v2'] }`. The verifier now declares which schemes " +
216
+ "it accepts rather than naming one legacy version to refuse, which also " +
217
+ "scales to future schemes. Behaviour is unchanged for now.");
218
+ }
219
+ /** Test-only: reset the one-time return-signature advisories. Not public API. */
144
220
  export function __resetReturnSignatureWarning() {
145
221
  warnedV1ReturnSignatureReplayable = false;
222
+ warnedV1ReturnSignatureRefused = false;
223
+ warnedRejectV1Deprecated = false;
224
+ }
225
+ /**
226
+ * Resolve the effective scheme allowlist from the (possibly legacy) options.
227
+ *
228
+ * `rejectV1` published in 0.12.0 on 2026-07-01, so it stays working — merchants
229
+ * must not have an upgrade break under them. It is a strict subset of what
230
+ * `acceptedSchemes` expresses, so it maps cleanly onto it.
231
+ *
232
+ * Precedence: an explicit `acceptedSchemes` always wins. Supplying both is a
233
+ * contradiction only the caller can resolve, and silently honouring the
234
+ * deprecated one would make the new, more specific option appear ignored.
235
+ */
236
+ function resolveAcceptedSchemes(options) {
237
+ if (options?.acceptedSchemes !== undefined) {
238
+ const list = options.acceptedSchemes;
239
+ // `str` has no JS analogue here, but an empty or bogus list fails the same
240
+ // way the Python side does: refusing every return, silently. Say so loudly.
241
+ if (!Array.isArray(list) || list.length === 0) {
242
+ throw new TypeError("acceptedSchemes must be a non-empty array of scheme names " +
243
+ `(${KNOWN_RETURN_SCHEMES.join(", ")}); got ${JSON.stringify(list)}. ` +
244
+ "An empty list would refuse every return signature.");
245
+ }
246
+ const unknown = list.filter((s) => !KNOWN_RETURN_SCHEMES.includes(s));
247
+ if (unknown.length > 0) {
248
+ throw new TypeError(`acceptedSchemes contains unknown scheme(s) ${JSON.stringify(unknown)}. ` +
249
+ `Known schemes: ${KNOWN_RETURN_SCHEMES.join(", ")}. A typo here would ` +
250
+ "silently refuse every return signature.");
251
+ }
252
+ return new Set(list);
253
+ }
254
+ if (options?.rejectV1) {
255
+ warnRejectV1Deprecated();
256
+ return new Set(["v2"]);
257
+ }
258
+ return new Set(DEFAULT_RETURN_SCHEMES);
146
259
  }
147
260
  /**
148
261
  * Canonicalise a success URL the same way the Von Payments return-URL
@@ -675,6 +788,16 @@ export class VonPayCheckout {
675
788
  if (options?.idempotencyKey) {
676
789
  headers["Idempotency-Key"] = options.idempotencyKey;
677
790
  }
791
+ // Can the SDK repeat this request on its own initiative after an AMBIGUOUS
792
+ // failure? Only when repeating it cannot charge twice: either it moves no
793
+ // money, or the caller gave us a key the server can collapse the repeat
794
+ // against. The key is set once, above, and therefore rides every attempt of
795
+ // this call — a retry never mints a fresh one.
796
+ //
797
+ // Note the asymmetry with `RETRYABLE_STATUS_CODES`: this does not make the
798
+ // error non-retryable, it makes it non-retryable BY US. See VonPayError
799
+ // `retryWithheld`.
800
+ const retrySafe = !options?.movesMoney || Boolean(options?.idempotencyKey);
678
801
  let lastError;
679
802
  for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
680
803
  if (attempt > 0) {
@@ -715,7 +838,10 @@ export class VonPayCheckout {
715
838
  docs: "https://docs.vonpay.com",
716
839
  };
717
840
  }
718
- lastError = new VonPayError(response.status, errorData, requestId, rateLimit);
841
+ // Withhold OUR retry when the outcome is ambiguous and a repeat could
842
+ // charge twice. Decided before construction so the error can carry it.
843
+ const retryWithheld = !retrySafe && AMBIGUOUS_STATUS_CODES.has(response.status);
844
+ lastError = new VonPayError(response.status, errorData, requestId, rateLimit, retryWithheld);
719
845
  // Only retry on retryable status codes
720
846
  if (!RETRYABLE_STATUS_CODES.has(response.status)) {
721
847
  this.reportError(lastError, {
@@ -728,6 +854,19 @@ export class VonPayCheckout {
728
854
  });
729
855
  throw lastError;
730
856
  }
857
+ // Ambiguous outcome on a state-changing call with no idempotency key —
858
+ // surface the first failure rather than risk a duplicate charge.
859
+ if (retryWithheld) {
860
+ this.reportError(lastError, {
861
+ method: options?.reporterMethod ?? `${method} ${path}`,
862
+ url: VonPayCheckout.safeUrl(url),
863
+ status: response.status,
864
+ requestId,
865
+ code: errorData.code,
866
+ attempt,
867
+ });
868
+ throw lastError;
869
+ }
731
870
  // Don't retry if we've exhausted attempts
732
871
  if (attempt === this.config.maxRetries) {
733
872
  this.reportError(lastError, {
@@ -746,9 +885,20 @@ export class VonPayCheckout {
746
885
  if (err instanceof VonPayError) {
747
886
  throw err;
748
887
  }
749
- // Network/timeout error — retryable
888
+ // Network/timeout error — retryable in principle, but ALWAYS ambiguous:
889
+ // an aborted fetch tells us nothing about whether the origin applied
890
+ // the request. On a state-changing call with no idempotency key that
891
+ // makes a retry a potential second charge, so we stop here.
750
892
  lastError =
751
893
  err instanceof Error ? err : new Error("Unknown fetch error");
894
+ if (!retrySafe) {
895
+ this.reportError(lastError, {
896
+ method: options?.reporterMethod ?? `${method} ${path}`,
897
+ url: VonPayCheckout.safeUrl(url),
898
+ attempt,
899
+ });
900
+ throw lastError;
901
+ }
752
902
  if (attempt === this.config.maxRetries) {
753
903
  this.reportError(lastError, {
754
904
  method: options?.reporterMethod ?? `${method} ${path}`,
@@ -790,6 +940,7 @@ export class VonPayCheckout {
790
940
  body: nestMirrorUnderMetadata(buildSnakeBodyOpaqueMetadata(prepared)),
791
941
  idempotencyKey: options?.idempotencyKey,
792
942
  reporterMethod: "paymentIntents.create",
943
+ movesMoney: true,
793
944
  });
794
945
  return paymentIntentFromWire(data);
795
946
  },
@@ -814,6 +965,7 @@ export class VonPayCheckout {
814
965
  body,
815
966
  idempotencyKey: options?.idempotencyKey,
816
967
  reporterMethod: "paymentIntents.capture",
968
+ movesMoney: true,
817
969
  });
818
970
  return paymentIntentFromWire(data);
819
971
  },
@@ -854,6 +1006,7 @@ export class VonPayCheckout {
854
1006
  body: buildSnakeBodyOpaqueMetadata(params),
855
1007
  idempotencyKey: options?.idempotencyKey,
856
1008
  reporterMethod: "refunds.create",
1009
+ movesMoney: true,
857
1010
  });
858
1011
  return refundFromWire(data);
859
1012
  },
@@ -892,6 +1045,73 @@ export class VonPayCheckout {
892
1045
  return data;
893
1046
  },
894
1047
  /** Retrieve the current state of a checkout session. Requires a secret key (vp_sk_*). Publishable keys are rejected with 403. */
1048
+ /**
1049
+ * Confirm, in one call, that a returning buyer actually paid.
1050
+ *
1051
+ * **Use this instead of `verifyReturnSignature` unless you have a specific
1052
+ * reason not to.** That function answers a narrower question than it
1053
+ * appears to: it returns `true` for an *authentic* message, and a DECLINED
1054
+ * payment is signed just as authentically as an approved one. Reading its
1055
+ * boolean as "they paid" is the expensive mistake on this path.
1056
+ *
1057
+ * Sequence: verify the signature, then read the session status from the
1058
+ * SERVER — not from the redirect URL. The URL is a hint carried by the
1059
+ * buyer's browser; the server is the authority, and it is fresher. An
1060
+ * unauthenticated return never reaches the network, so a forged URL cannot
1061
+ * make your server issue API calls.
1062
+ *
1063
+ * ⚠️ **This does not make fulfilment safe on its own, and cannot.**
1064
+ * `paid: true` means this buyer paid; it does not mean you have not already
1065
+ * shipped their order. The status keeps reading `succeeded` on every replay
1066
+ * of the same URL, so record which session IDs you have fulfilled and
1067
+ * refuse to fulfil one twice. That needs your database.
1068
+ *
1069
+ * ⚠️ **The redirect is not a guarantee of anything.** Buyers close laptops
1070
+ * and never load your success page. Webhooks are the reliable fulfilment
1071
+ * trigger; this is for what you show the buyer who did arrive.
1072
+ * See https://docs.vonpay.com/integration/handle-return
1073
+ *
1074
+ * @throws on a failed session lookup — `VonPayError` for API errors, and the
1075
+ * underlying transport error (e.g. a `TypeError` from `fetch`) for network
1076
+ * failures, which is NOT wrapped. Catch broadly rather than narrowing to
1077
+ * `VonPayError`, or a network blip will escape and 500 a buyer who just paid.
1078
+ *
1079
+ * A failed lookup is deliberately NOT reported as `paid: false` — that would
1080
+ * turn our outage into the merchant's silent under-fulfilment, and the two
1081
+ * need opposite handling.
1082
+ *
1083
+ * Requires a secret key (`vp_sk_*`): this reads `GET /v1/sessions/:id`,
1084
+ * which rejects publishable keys with 403.
1085
+ */
1086
+ confirmReturn: async (params, secret, options) => {
1087
+ const signatureValid = VonPayCheckout.verifyReturnSignature(params, secret, options);
1088
+ if (!signatureValid) {
1089
+ return {
1090
+ paid: false,
1091
+ signatureValid: false,
1092
+ reason: "invalid_signature",
1093
+ };
1094
+ }
1095
+ const sessionId = params.session ?? "";
1096
+ // Deliberately NOT wrapped: a lookup failure is an exception, not a
1097
+ // `paid: false`. Collapsing "we could not check" into "they did not pay"
1098
+ // is the false-negative class that makes a merchant silently withhold
1099
+ // goods a buyer already paid for.
1100
+ const { status } = await this.sessions.get(sessionId);
1101
+ const paid = status === "succeeded";
1102
+ // ⛔ "not finished yet" is NOT "did not work", and collapsing them costs
1103
+ // money in the worst direction. On the 3-D Secure path the buyer returns
1104
+ // to successUrl and the charge "settles on its own" (the API spec's own
1105
+ // words), so they routinely arrive while the session is still
1106
+ // `processing`. Reporting that as a failure tells someone whose card IS
1107
+ // being charged that it did not work; they retry and pay twice.
1108
+ const reason = paid
1109
+ ? undefined
1110
+ : PENDING_SESSION_STATES.has(status)
1111
+ ? "still_pending"
1112
+ : "not_succeeded";
1113
+ return { paid, signatureValid: true, sessionId, status, reason };
1114
+ },
895
1115
  get: async (sessionId) => {
896
1116
  assertResourceId(sessionId, "sessionId");
897
1117
  const { data } = await this.request("GET", `/v1/sessions/${encodeURIComponent(sessionId)}`, { reporterMethod: "sessions.get" });
@@ -1095,30 +1315,44 @@ export class VonPayCheckout {
1095
1315
  * whether you have already fulfilled this order, and it keeps returning
1096
1316
  * `succeeded` on a replay. Record which session IDs you have fulfilled
1097
1317
  * (e.g. a UNIQUE column on the order row) and refuse to fulfil one twice.
1098
- * Prefer v2 (pass `expectedSuccessUrl` + `expectedKeyMode`), which is
1099
- * freshness- and URL-bound, or pass `{ rejectV1: true }` to refuse v1 outright
1100
- * once your checkout server emits v2 returns. Note: `rejectV1` only refuses
1101
- * v1 it does not by itself require a valid v2 signature, so still supply the
1102
- * v2 options. A successful v1 verification logs a warning to this effect once
1103
- * per process.
1318
+ * `acceptedSchemes` is the allowlist THIS VERIFIER will honour. It exists
1319
+ * because the scheme is otherwise chosen by the incoming signature i.e. by
1320
+ * the sender and a verifier should declare what it accepts rather than let
1321
+ * untrusted input select its own algorithm.
1322
+ *
1323
+ * ⚠️ `{ acceptedSchemes: ["v2"] }` refuses the v1 SCHEME. It does not by
1324
+ * itself require a valid v2 signature, so keep supplying the v2 options. And
1325
+ * confirm your account already issues v2 returns before setting it — that is
1326
+ * a server-side setting, so if your account still issues v1 this refuses
1327
+ * EVERY return you receive.
1104
1328
  *
1105
1329
  * @param params - URL search params from the redirect (session, status, amount, currency, transaction_id, sig)
1106
1330
  * @param secret - Your session signing secret, NOT your API key
1107
- * @param options - expectedSuccessUrl (required for v2), expectedKeyMode (required for v2), maxAgeSeconds (v2 freshness, default 600), rejectV1 (refuse legacy v1 signatures)
1331
+ * @param options - expectedSuccessUrl (required for v2), expectedKeyMode (required for v2), maxAgeSeconds (v2 freshness, default 600), acceptedSchemes (which signature schemes to honour)
1332
+ * @throws TypeError if `acceptedSchemes` is empty or names an unknown scheme — a typo there would silently refuse every return.
1108
1333
  */
1109
1334
  static verifyReturnSignature(params, secret, options) {
1335
+ const schemes = resolveAcceptedSchemes(options);
1110
1336
  const { sig, session, status, amount, currency, transaction_id } = params;
1111
1337
  if (!sig || !session || !status || !amount || !currency)
1112
1338
  return false;
1339
+ // Scheme dispatch. Each arm is gated on the allowlist FIRST, so an
1340
+ // unaccepted scheme is refused before any HMAC or payload work — never a
1341
+ // partial verification, and never an advisory about a signature we did not
1342
+ // honour.
1113
1343
  if (sig.startsWith("v2.")) {
1344
+ if (!schemes.has("v2"))
1345
+ return false;
1114
1346
  return VonPayCheckout.verifyReturnSignatureV2(sig, { session, status, amount, currency, transaction_id: transaction_id ?? "" }, secret, options ?? {});
1115
1347
  }
1116
1348
  // --- Legacy v1 path: plain HMAC over the 5 fields, with NO freshness or
1117
1349
  // success-URL/key-mode binding. A captured v1 return URL is replayable
1118
- // indefinitely (kaiju #425). v2 (handled above) binds iat + successUrl +
1119
- // keyMode and is the replay-safe path.
1120
- if (options?.rejectV1)
1350
+ // indefinitely. v2 (handled above) binds iat + successUrl + keyMode and is
1351
+ // the replay-safe path.
1352
+ if (!schemes.has("v1")) {
1353
+ warnV1ReturnSignatureRefused();
1121
1354
  return false;
1355
+ }
1122
1356
  if (!/^[0-9a-f]{64}$/.test(sig))
1123
1357
  return false;
1124
1358
  const data = [session, status, amount, currency, transaction_id ?? ""].join(".");