@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/README.md +1 -1
- package/dist/client.d.ts +89 -14
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +395 -25
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +18 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +19 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +17 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/types.d.ts +435 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +21 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
136
|
-
"replayed indefinitely.
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
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),
|
|
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
|
|
1119
|
-
//
|
|
1120
|
-
if (
|
|
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(".");
|