@vonpay/checkout-node 0.15.1 → 1.1.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/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,151 @@ 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 that are genuinely FINISHED and not paid.
162
+ *
163
+ * ⚠️ Deliberately a denylist of terminal states, not an allowlist of pending
164
+ * ones. Both express the same thing for the states we know about today
165
+ * (`pending`/`processing` are in flight; `failed`/`expired` are over), but they
166
+ * fail in OPPOSITE directions the moment the server adds a state this SDK
167
+ * predates — and only one of those directions is survivable.
168
+ *
169
+ * With an allowlist, an unrecognised future state (say `requires_action`) is
170
+ * "not pending", so it reads as `not_succeeded` — telling a buyer who is
171
+ * mid-authentication that their payment failed, which is the exact
172
+ * pay-twice outcome the surrounding code exists to prevent. With this
173
+ * denylist it reads as `still_pending`: "wait for the webhook". An SDK that
174
+ * is out of date should degrade toward patience, not toward accusing a buyer
175
+ * of not paying.
176
+ *
177
+ * Kept in sync with the `SessionState` union; the server's enum is the
178
+ * contract (`openapi/openapi.yaml`, SessionStatus.status).
179
+ */
180
+ const TERMINAL_SESSION_STATES = new Set(["failed", "expired"]);
181
+ export const KNOWN_RETURN_SCHEMES = ["v1", "v2"];
182
+ export const DEFAULT_RETURN_SCHEMES = ["v1", "v2"];
183
+ // One-time advisories on the return-signature path. Module-scoped so each fires
184
+ // once per process, not once per call (verifyReturnSignature is on a hot
185
+ // return-handling path).
186
+ //
187
+ // These use console.warn deliberately: it cannot throw. The Python twin had to
188
+ // be moved off `warnings.warn` for exactly this reason — that channel raises
189
+ // under -W error, which turned a verifier documented "never raises" into one
190
+ // that threw on the buyer-return path after the card was charged.
129
191
  let warnedV1ReturnSignatureReplayable = false;
130
192
  function warnV1ReturnSignatureReplayable() {
131
193
  if (warnedV1ReturnSignatureReplayable)
132
194
  return;
133
195
  warnedV1ReturnSignatureReplayable = true;
134
196
  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");
197
+ "v1 binds no timestamp and no success URL, so a captured return URL can " +
198
+ "be replayed indefinitely. A valid signature proves the message is " +
199
+ "AUTHENTIC it does not prove the payment succeeded, and a decline is " +
200
+ "signed just as validly. Before fulfilling: (1) check " +
201
+ "status === 'succeeded'; (2) confirm server-side via sessions.get(session); " +
202
+ "and (3) guard your own idempotency sessions.get cannot tell you whether " +
203
+ "you already fulfilled this order and keeps returning 'succeeded' on " +
204
+ "replay, so track fulfilled session IDs. v1 is scheduled for retirement " +
205
+ "on 2026-11-21. To refuse it now, pass { acceptedSchemes: ['v2'] } — but " +
206
+ "confirm your account already issues v2 returns first, or that will " +
207
+ "refuse every return you receive. " +
208
+ "See https://docs.vonpay.com/integration/handle-return");
142
209
  }
143
- /** Test-only: reset the one-time v1 replay warning. Underscore-prefixed; not public API. */
210
+ let warnedV1ReturnSignatureRefused = false;
211
+ function warnV1ReturnSignatureRefused() {
212
+ if (warnedV1ReturnSignatureRefused)
213
+ return;
214
+ warnedV1ReturnSignatureRefused = true;
215
+ console.warn("[vonpay] verifyReturnSignature: refused a v1 return signature because " +
216
+ "acceptedSchemes excludes it. If your Vonpay account still issues v1 " +
217
+ "returns, this refuses EVERY return — verify which scheme your account " +
218
+ "issues before keeping this setting. If it already issues v2, a v1 " +
219
+ "signature arriving now is either a replay of a pre-migration capture or " +
220
+ "a misconfiguration, and refusing it is correct. " +
221
+ "See https://docs.vonpay.com/integration/handle-return");
222
+ }
223
+ let warnedReturnSignatureMismatch = false;
224
+ function warnReturnSignatureMismatch() {
225
+ if (warnedReturnSignatureMismatch)
226
+ return;
227
+ warnedReturnSignatureMismatch = true;
228
+ console.warn("[vonpay] confirmReturn: a `secret` was supplied and the return signature " +
229
+ "did NOT verify. This no longer affects `paid` — the session status is " +
230
+ "read from the server, which is authenticated by your API key — but the " +
231
+ "check itself is failing, and the usual cause is a secret that cannot " +
232
+ "match rather than a tampered URL. Returns are signed with a " +
233
+ "platform-wide secret, so a per-merchant `ss_*` from the dashboard will " +
234
+ "never verify. If that is your situation, omit the `secret` argument " +
235
+ "entirely rather than passing one that always fails. " +
236
+ "See https://docs.vonpay.com/integration/handle-return");
237
+ }
238
+ let warnedRejectV1Deprecated = false;
239
+ function warnRejectV1Deprecated() {
240
+ if (warnedRejectV1Deprecated)
241
+ return;
242
+ warnedRejectV1Deprecated = true;
243
+ console.warn("[vonpay] verifyReturnSignature: `rejectV1` is deprecated and will be " +
244
+ "removed in the next major version. Replace `{ rejectV1: true }` with " +
245
+ "`{ acceptedSchemes: ['v2'] }`. The verifier now declares which schemes " +
246
+ "it accepts rather than naming one legacy version to refuse, which also " +
247
+ "scales to future schemes. Behaviour is unchanged for now.");
248
+ }
249
+ /** Test-only: reset the one-time return-signature advisories. Not public API. */
144
250
  export function __resetReturnSignatureWarning() {
145
251
  warnedV1ReturnSignatureReplayable = false;
252
+ warnedV1ReturnSignatureRefused = false;
253
+ warnedRejectV1Deprecated = false;
254
+ // Added 2026-08-18 with the mismatch warning itself. Omitting it made the
255
+ // warning untestable after any earlier test in the same process had already
256
+ // consumed the once-only flag — which is precisely how it shipped with no
257
+ // assertion that it fires at all (found by mutation: deleting the call left
258
+ // every suite green). Any new once-per-process warning belongs here too.
259
+ warnedReturnSignatureMismatch = false;
260
+ }
261
+ /**
262
+ * Resolve the effective scheme allowlist from the (possibly legacy) options.
263
+ *
264
+ * `rejectV1` published in 0.12.0 on 2026-07-01, so it stays working — merchants
265
+ * must not have an upgrade break under them. It is a strict subset of what
266
+ * `acceptedSchemes` expresses, so it maps cleanly onto it.
267
+ *
268
+ * Precedence: an explicit `acceptedSchemes` always wins. Supplying both is a
269
+ * contradiction only the caller can resolve, and silently honouring the
270
+ * deprecated one would make the new, more specific option appear ignored.
271
+ */
272
+ function resolveAcceptedSchemes(options) {
273
+ if (options?.acceptedSchemes !== undefined) {
274
+ const list = options.acceptedSchemes;
275
+ // `str` has no JS analogue here, but an empty or bogus list fails the same
276
+ // way the Python side does: refusing every return, silently. Say so loudly.
277
+ if (!Array.isArray(list) || list.length === 0) {
278
+ throw new TypeError("acceptedSchemes must be a non-empty array of scheme names " +
279
+ `(${KNOWN_RETURN_SCHEMES.join(", ")}); got ${JSON.stringify(list)}. ` +
280
+ "An empty list would refuse every return signature.");
281
+ }
282
+ const unknown = list.filter((s) => !KNOWN_RETURN_SCHEMES.includes(s));
283
+ if (unknown.length > 0) {
284
+ throw new TypeError(`acceptedSchemes contains unknown scheme(s) ${JSON.stringify(unknown)}. ` +
285
+ `Known schemes: ${KNOWN_RETURN_SCHEMES.join(", ")}. A typo here would ` +
286
+ "silently refuse every return signature.");
287
+ }
288
+ return new Set(list);
289
+ }
290
+ if (options?.rejectV1) {
291
+ warnRejectV1Deprecated();
292
+ return new Set(["v2"]);
293
+ }
294
+ return new Set(DEFAULT_RETURN_SCHEMES);
146
295
  }
147
296
  /**
148
297
  * Canonicalise a success URL the same way the Von Payments return-URL
@@ -675,6 +824,16 @@ export class VonPayCheckout {
675
824
  if (options?.idempotencyKey) {
676
825
  headers["Idempotency-Key"] = options.idempotencyKey;
677
826
  }
827
+ // Can the SDK repeat this request on its own initiative after an AMBIGUOUS
828
+ // failure? Only when repeating it cannot charge twice: either it moves no
829
+ // money, or the caller gave us a key the server can collapse the repeat
830
+ // against. The key is set once, above, and therefore rides every attempt of
831
+ // this call — a retry never mints a fresh one.
832
+ //
833
+ // Note the asymmetry with `RETRYABLE_STATUS_CODES`: this does not make the
834
+ // error non-retryable, it makes it non-retryable BY US. See VonPayError
835
+ // `retryWithheld`.
836
+ const retrySafe = !options?.movesMoney || Boolean(options?.idempotencyKey);
678
837
  let lastError;
679
838
  for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
680
839
  if (attempt > 0) {
@@ -715,7 +874,10 @@ export class VonPayCheckout {
715
874
  docs: "https://docs.vonpay.com",
716
875
  };
717
876
  }
718
- lastError = new VonPayError(response.status, errorData, requestId, rateLimit);
877
+ // Withhold OUR retry when the outcome is ambiguous and a repeat could
878
+ // charge twice. Decided before construction so the error can carry it.
879
+ const retryWithheld = !retrySafe && AMBIGUOUS_STATUS_CODES.has(response.status);
880
+ lastError = new VonPayError(response.status, errorData, requestId, rateLimit, retryWithheld);
719
881
  // Only retry on retryable status codes
720
882
  if (!RETRYABLE_STATUS_CODES.has(response.status)) {
721
883
  this.reportError(lastError, {
@@ -728,6 +890,19 @@ export class VonPayCheckout {
728
890
  });
729
891
  throw lastError;
730
892
  }
893
+ // Ambiguous outcome on a state-changing call with no idempotency key —
894
+ // surface the first failure rather than risk a duplicate charge.
895
+ if (retryWithheld) {
896
+ this.reportError(lastError, {
897
+ method: options?.reporterMethod ?? `${method} ${path}`,
898
+ url: VonPayCheckout.safeUrl(url),
899
+ status: response.status,
900
+ requestId,
901
+ code: errorData.code,
902
+ attempt,
903
+ });
904
+ throw lastError;
905
+ }
731
906
  // Don't retry if we've exhausted attempts
732
907
  if (attempt === this.config.maxRetries) {
733
908
  this.reportError(lastError, {
@@ -746,9 +921,20 @@ export class VonPayCheckout {
746
921
  if (err instanceof VonPayError) {
747
922
  throw err;
748
923
  }
749
- // Network/timeout error — retryable
924
+ // Network/timeout error — retryable in principle, but ALWAYS ambiguous:
925
+ // an aborted fetch tells us nothing about whether the origin applied
926
+ // the request. On a state-changing call with no idempotency key that
927
+ // makes a retry a potential second charge, so we stop here.
750
928
  lastError =
751
929
  err instanceof Error ? err : new Error("Unknown fetch error");
930
+ if (!retrySafe) {
931
+ this.reportError(lastError, {
932
+ method: options?.reporterMethod ?? `${method} ${path}`,
933
+ url: VonPayCheckout.safeUrl(url),
934
+ attempt,
935
+ });
936
+ throw lastError;
937
+ }
752
938
  if (attempt === this.config.maxRetries) {
753
939
  this.reportError(lastError, {
754
940
  method: options?.reporterMethod ?? `${method} ${path}`,
@@ -771,7 +957,7 @@ export class VonPayCheckout {
771
957
  * fail at the same instant (e.g. a dependency blip hitting a whole fleet). The
772
958
  * prior ±10% one-sided window (`base` … `1.1×base`) was narrow enough that a
773
959
  * fleet retried in near-lockstep and re-amplified the storm against a service
774
- * that was already struggling (kaiju #428). The mean delay is unchanged — the
960
+ * that was already struggling. The mean delay is unchanged — the
775
961
  * jitter is zero-centred — so backoff timing is preserved; only the spread
776
962
  * widens.
777
963
  */
@@ -790,6 +976,7 @@ export class VonPayCheckout {
790
976
  body: nestMirrorUnderMetadata(buildSnakeBodyOpaqueMetadata(prepared)),
791
977
  idempotencyKey: options?.idempotencyKey,
792
978
  reporterMethod: "paymentIntents.create",
979
+ movesMoney: true,
793
980
  });
794
981
  return paymentIntentFromWire(data);
795
982
  },
@@ -814,6 +1001,7 @@ export class VonPayCheckout {
814
1001
  body,
815
1002
  idempotencyKey: options?.idempotencyKey,
816
1003
  reporterMethod: "paymentIntents.capture",
1004
+ movesMoney: true,
817
1005
  });
818
1006
  return paymentIntentFromWire(data);
819
1007
  },
@@ -854,6 +1042,7 @@ export class VonPayCheckout {
854
1042
  body: buildSnakeBodyOpaqueMetadata(params),
855
1043
  idempotencyKey: options?.idempotencyKey,
856
1044
  reporterMethod: "refunds.create",
1045
+ movesMoney: true,
857
1046
  });
858
1047
  return refundFromWire(data);
859
1048
  },
@@ -866,10 +1055,26 @@ export class VonPayCheckout {
866
1055
  * iframe-vault providers require a `providerReference` minted
867
1056
  * browser-side; the server returns 422 validation_error otherwise.
868
1057
  * Sandbox keys auto-mint a mock card token if no card data is supplied.
1058
+ *
1059
+ * ⚠️ If you want the saved card to appear in the buyer's saved-card picker
1060
+ * later, pass `allowRedisplay: "always"` HERE. That permission can only be
1061
+ * recorded at save time and can never be added afterwards.
869
1062
  */
870
1063
  create: async (params, options) => {
871
1064
  const { data } = await this.request("POST", "/v1/tokens", {
872
- body: buildSnakeBodyOpaqueMetadata(params),
1065
+ body: buildSnakeBodyOpaqueMetadata({
1066
+ ...params,
1067
+ // The display permission rides EVERY save, even an unasked one.
1068
+ // The contract is explicit that `unspecified` is a real answer and
1069
+ // an absent field is not: omitting it records a blank that cannot
1070
+ // be told apart from a card saved before the permission existed,
1071
+ // and the value can never be added afterwards. `unspecified`
1072
+ // grants nothing, so defaulting here cannot over-grant — it only
1073
+ // makes the record honest. Callers who asked the buyer pass
1074
+ // `"always"` (the only value the saved-card picker will show) or
1075
+ // `"limited"`.
1076
+ allowRedisplay: params.allowRedisplay ?? "unspecified",
1077
+ }),
873
1078
  idempotencyKey: options?.idempotencyKey,
874
1079
  reporterMethod: "tokens.create",
875
1080
  });
@@ -892,6 +1097,157 @@ export class VonPayCheckout {
892
1097
  return data;
893
1098
  },
894
1099
  /** Retrieve the current state of a checkout session. Requires a secret key (vp_sk_*). Publishable keys are rejected with 403. */
1100
+ /**
1101
+ * Confirm, in one call, that a returning buyer actually paid.
1102
+ *
1103
+ * **Use this instead of `verifyReturnSignature` unless you have a specific
1104
+ * reason not to.** That function answers a narrower question than it
1105
+ * appears to: it returns `true` for an *authentic* message, and a DECLINED
1106
+ * payment is signed just as authentically as an approved one. Reading its
1107
+ * boolean as "they paid" is the expensive mistake on this path.
1108
+ *
1109
+ * Reads the session status from the SERVER — not from the redirect URL. The
1110
+ * URL is a hint carried by the buyer's browser; the server is the authority,
1111
+ * it is fresher, and the read is already authenticated by your API key.
1112
+ *
1113
+ * ⚠️ **Do not pass a `secret` unless you actually hold the one the redirect
1114
+ * was signed with.** Returns are signed with a platform-wide secret, so a
1115
+ * per-merchant `ss_*` from the dashboard can never verify. In 1.0.0 a failed
1116
+ * signature short-circuited to `paid: false` before the server was asked, so
1117
+ * that combination reported every genuinely successful payment as unpaid.
1118
+ * The signature is now optional hardening and cannot mask the truth;
1119
+ * `signatureValid` is `null` when no check was performed.
1120
+ *
1121
+ * ⚠️ **This does not make fulfilment safe on its own, and cannot.**
1122
+ * `paid: true` means this buyer paid; it does not mean you have not already
1123
+ * shipped their order. The status keeps reading `succeeded` on every replay
1124
+ * of the same URL, so record which session IDs you have fulfilled and
1125
+ * refuse to fulfil one twice. That needs your database.
1126
+ *
1127
+ * ⚠️ **The redirect is not a guarantee of anything.** Buyers close laptops
1128
+ * and never load your success page. Webhooks are the reliable fulfilment
1129
+ * trigger; this is for what you show the buyer who did arrive.
1130
+ * See https://docs.vonpay.com/integration/handle-return
1131
+ *
1132
+ * @throws on a failed session lookup — `VonPayError` for API errors, and the
1133
+ * underlying transport error (e.g. a `TypeError` from `fetch`) for network
1134
+ * failures, which is NOT wrapped. Catch broadly rather than narrowing to
1135
+ * `VonPayError`, or a network blip will escape and 500 a buyer who just paid.
1136
+ *
1137
+ * A failed lookup is deliberately NOT reported as `paid: false` — that would
1138
+ * turn our outage into the merchant's silent under-fulfilment, and the two
1139
+ * need opposite handling.
1140
+ *
1141
+ * Requires a secret key (`vp_sk_*`): this reads `GET /v1/sessions/:id`,
1142
+ * which rejects publishable keys with 403.
1143
+ */
1144
+ confirmReturn: async (params,
1145
+ /**
1146
+ * OPTIONAL. Omit it unless you genuinely hold the secret the redirect was
1147
+ * signed with.
1148
+ *
1149
+ * Most integrators should not pass anything here. The redirect is signed
1150
+ * with a platform-wide secret, so a per-merchant `ss_*` from the dashboard
1151
+ * will never match it, and passing one buys a check that always fails. The
1152
+ * session read below is authenticated by your API key and is the actual
1153
+ * answer to "did this buyer pay".
1154
+ *
1155
+ * Kept (rather than removed) so this stays source-compatible with 1.0.0
1156
+ * callers, and so a first-party integration that DOES hold the signing
1157
+ * secret can still assert the redirect was untampered.
1158
+ */
1159
+ secret, options) => {
1160
+ const sessionId = params.session ?? "";
1161
+ // No session id means there is nothing to ask the server about. This is
1162
+ // NOT the "we could not check" case below — there is no session that
1163
+ // could have been paid — so reporting it as unpaid cannot under-fulfil.
1164
+ if (!sessionId) {
1165
+ return {
1166
+ paid: false,
1167
+ signatureValid: secret === undefined || secret === null
1168
+ ? null
1169
+ : VonPayCheckout.verifyReturnSignature(params, secret, options),
1170
+ reason: "missing_session",
1171
+ };
1172
+ }
1173
+ // ⛔ THE SIGNATURE IS NOT A GATE. Read this before "simplifying" it back.
1174
+ //
1175
+ // 1.0.0 verified first and returned `paid: false` on failure, without ever
1176
+ // asking the server. That looks like defence in depth and was in fact a
1177
+ // money bug, because the redirect is signed with a PLATFORM-WIDE secret
1178
+ // (`VON_PAY_SESSION_SECRET`, read at module scope with no merchant
1179
+ // argument) that no merchant holds — and could not safely be given one,
1180
+ // since holding it would let any merchant forge any other merchant's
1181
+ // confirmations. Our own documentation told merchants to verify with a
1182
+ // per-merchant `ss_*` from the dashboard. Those can never match, so every
1183
+ // check failed, and the documented `if (outcome.paid)` showed FAILURE to
1184
+ // every buyer whose card had just been charged.
1185
+ //
1186
+ // Measured before the fix: 0 merchants affected, because the endpoint had
1187
+ // 0 authenticated reads in 90 days. The trigger was adoption — and our own
1188
+ // 1.0 docs were what would have caused it.
1189
+ //
1190
+ // The server read is already authenticated by the merchant's API key, so
1191
+ // it is the answer to "did they pay". This is Stripe's model: the redirect
1192
+ // carries a session id, you retrieve it server-side, and per-merchant
1193
+ // signatures live on WEBHOOKS, where they are actually issued per
1194
+ // merchant. The signature here is optional hardening — it may tell you a
1195
+ // URL looks tampered with; it must never stop you learning the truth.
1196
+ const signatureValid = secret === undefined || secret === null
1197
+ ? null
1198
+ : VonPayCheckout.verifyReturnSignature(params, secret, options);
1199
+ if (signatureValid === false) {
1200
+ // Loud, because the overwhelmingly likely cause is a misconfigured
1201
+ // secret rather than an attack — and a merchant who never sees this
1202
+ // will believe they have a tamper check that has never once passed.
1203
+ warnReturnSignatureMismatch();
1204
+ }
1205
+ // Deliberately NOT wrapped: a lookup failure is an exception, not a
1206
+ // `paid: false`. Collapsing "we could not check" into "they did not pay"
1207
+ // is the false-negative class that makes a merchant silently withhold
1208
+ // goods a buyer already paid for.
1209
+ const session = await this.sessions.get(sessionId);
1210
+ const status = session.status;
1211
+ const paid = status === "succeeded";
1212
+ // ⛔ "not finished yet" is NOT "did not work", and collapsing them costs
1213
+ // money in the worst direction. On the 3-D Secure path the buyer returns
1214
+ // to successUrl and the charge "settles on its own" (the API spec's own
1215
+ // words), so they routinely arrive while the session is still
1216
+ // `processing`. Reporting that as a failure tells someone whose card IS
1217
+ // being charged that it did not work; they retry and pay twice.
1218
+ //
1219
+ // ⚠️ Expressed as "NOT a known terminal state" rather than "in a known
1220
+ // pending set". The two agree today, but they fail in opposite directions
1221
+ // when the server adds a state this SDK has not heard of — and one of
1222
+ // those directions is the whole reason this function exists. An allowlist
1223
+ // would route an unknown future state (say `requires_action`) to
1224
+ // `not_succeeded`, i.e. tell a buyer mid-authentication that they failed.
1225
+ // A terminal denylist routes it to `still_pending`, which is the safe
1226
+ // direction: it says "wait for the webhook" rather than "you did not pay".
1227
+ const reason = paid
1228
+ ? undefined
1229
+ : TERMINAL_SESSION_STATES.has(status)
1230
+ ? "not_succeeded"
1231
+ : "still_pending";
1232
+ return {
1233
+ paid,
1234
+ signatureValid,
1235
+ sessionId,
1236
+ status,
1237
+ reason,
1238
+ // Surfaced from the AUTHENTICATED read, not the redirect URL.
1239
+ //
1240
+ // Omitting these was a design flaw, not an omission of convenience: we
1241
+ // already fetch them here and then discarded them, which structurally
1242
+ // forced every integrator back onto the buyer-controlled query string
1243
+ // for anything beyond a boolean. Two independent reviews traced the
1244
+ // same fake-receipt path to that gap — a buyer editing `amount` in
1245
+ // their own return URL and screenshotting the rendered "confirmation".
1246
+ amount: session.amount,
1247
+ currency: session.currency,
1248
+ transactionId: session.transactionId,
1249
+ };
1250
+ },
895
1251
  get: async (sessionId) => {
896
1252
  assertResourceId(sessionId, "sessionId");
897
1253
  const { data } = await this.request("GET", `/v1/sessions/${encodeURIComponent(sessionId)}`, { reporterMethod: "sessions.get" });
@@ -1095,30 +1451,44 @@ export class VonPayCheckout {
1095
1451
  * whether you have already fulfilled this order, and it keeps returning
1096
1452
  * `succeeded` on a replay. Record which session IDs you have fulfilled
1097
1453
  * (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.
1454
+ * `acceptedSchemes` is the allowlist THIS VERIFIER will honour. It exists
1455
+ * because the scheme is otherwise chosen by the incoming signature i.e. by
1456
+ * the sender and a verifier should declare what it accepts rather than let
1457
+ * untrusted input select its own algorithm.
1458
+ *
1459
+ * ⚠️ `{ acceptedSchemes: ["v2"] }` refuses the v1 SCHEME. It does not by
1460
+ * itself require a valid v2 signature, so keep supplying the v2 options. And
1461
+ * confirm your account already issues v2 returns before setting it — that is
1462
+ * a server-side setting, so if your account still issues v1 this refuses
1463
+ * EVERY return you receive.
1104
1464
  *
1105
1465
  * @param params - URL search params from the redirect (session, status, amount, currency, transaction_id, sig)
1106
1466
  * @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)
1467
+ * @param options - expectedSuccessUrl (required for v2), expectedKeyMode (required for v2), maxAgeSeconds (v2 freshness, default 600), acceptedSchemes (which signature schemes to honour)
1468
+ * @throws TypeError if `acceptedSchemes` is empty or names an unknown scheme — a typo there would silently refuse every return.
1108
1469
  */
1109
1470
  static verifyReturnSignature(params, secret, options) {
1471
+ const schemes = resolveAcceptedSchemes(options);
1110
1472
  const { sig, session, status, amount, currency, transaction_id } = params;
1111
1473
  if (!sig || !session || !status || !amount || !currency)
1112
1474
  return false;
1475
+ // Scheme dispatch. Each arm is gated on the allowlist FIRST, so an
1476
+ // unaccepted scheme is refused before any HMAC or payload work — never a
1477
+ // partial verification, and never an advisory about a signature we did not
1478
+ // honour.
1113
1479
  if (sig.startsWith("v2.")) {
1480
+ if (!schemes.has("v2"))
1481
+ return false;
1114
1482
  return VonPayCheckout.verifyReturnSignatureV2(sig, { session, status, amount, currency, transaction_id: transaction_id ?? "" }, secret, options ?? {});
1115
1483
  }
1116
1484
  // --- Legacy v1 path: plain HMAC over the 5 fields, with NO freshness or
1117
1485
  // 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)
1486
+ // indefinitely. v2 (handled above) binds iat + successUrl + keyMode and is
1487
+ // the replay-safe path.
1488
+ if (!schemes.has("v1")) {
1489
+ warnV1ReturnSignatureRefused();
1121
1490
  return false;
1491
+ }
1122
1492
  if (!/^[0-9a-f]{64}$/.test(sig))
1123
1493
  return false;
1124
1494
  const data = [session, status, amount, currency, transaction_id ?? ""].join(".");