@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 +1 -1
- package/dist/client.d.ts +78 -13
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +257 -23
- 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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +16 -0
- package/dist/telemetry.js.map +1 -1
- package/dist/types.d.ts +247 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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),
|
|
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
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
|
|
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;
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,oBAAoB,EACpB,mBAAmB,EACnB,eAAe,EACf,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,cAAc,EAGd,YAAY,EAGZ,aAAa,EACb,yBAAyB,EACzB,0BAA0B,EAE1B,YAAY,EACZ,MAAM,EACN,kBAAkB,EAClB,KAAK,EACL,iBAAiB,EACjB,mBAAmB,EACnB,8BAA8B,EAC9B,wBAAwB,EACxB,kBAAkB,EACnB,MAAM,YAAY,CAAC;
|
|
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
|
-
|
|
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 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
|
|
136
|
-
"replayed indefinitely.
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
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),
|
|
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
|
|
1119
|
-
//
|
|
1120
|
-
if (
|
|
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(".");
|