@bayarkit/testing 0.2.1 → 0.3.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.
@@ -0,0 +1,628 @@
1
+ /**
2
+ * A fake PayPal REST API (OAuth token, Orders v2 with intent CAPTURE, capture refunds and the webhook postback check),
3
+ * built from the official docs in `gateways/paypal/spec.md`. Not yet checked against the sandbox: every rule below is
4
+ * the docs' word until a sandbox run says otherwise. Change this file first when PayPal changes.
5
+ *
6
+ * What it checks, and where each rule comes from (spec sections in brackets):
7
+ * - token: `POST /v1/oauth2/token`, HTTP Basic `client_id:secret`, form body `grant_type=client_credentials`; answers
8
+ * `{ access_token, token_type: 'Bearer', expires_in, ... }` [§1]. A wrong client gets the OAuth 2.0 error body
9
+ * (`401 { error: 'invalid_client' }`, RFC 6749 §5.2: the PayPal page shows no failure sample). Tokens expire after
10
+ * `tokenTtlSeconds`; `expireTokens()` ends them all at once so a test proves the adapter fetches a new one;
11
+ * - every other call: `Authorization: Bearer <live token>`, else 401 `AUTHENTICATION_FAILURE`; errors
12
+ * `{ name, message, debug_id, details[]: { field?, location?, issue, description } }` [§1];
13
+ * - create: intent `CAPTURE` only (AUTHORIZE is out of bayarkit's scope, so the fake refuses it), exactly one purchase
14
+ * unit (PayPal takes 1–10; bayarkit sends one), `amount.value` a positive decimal STRING with no decimals for HUF,
15
+ * JPY and TWD and at most two otherwise, a currency from the 24 [§2.1, §7]; `custom_id` 1–255, `invoice_id` 1–127
16
+ * [§2.1]. With `payment_source.paypal.experience_context` the order is `PAYER_ACTION_REQUIRED` with a `payer-action`
17
+ * link, without it `CREATED` with an `approve` link [§2.2]. `Prefer` defaults to `return=minimal` (id, status,
18
+ * links) [OpenAPI `Prefer`];
19
+ * - `PayPal-Request-Id`: a repeat of create, capture or refund with the same key (per call type) answers the first
20
+ * result again with `200` (the OpenAPI idempotent samples) and never acts twice [§4, §6];
21
+ * - the buyer: `approve()` = `APPROVED` (nothing captured). `markPaid()` = approved AND captured, the state an app
22
+ * reaches after its own capture call, because the shared conformance suite expects `markPaid` to mean paid.
23
+ * `nextCapture(id, 'DECLINED' | 'PENDING')` makes the next capture end like the card-testing guide's declined card
24
+ * (order `COMPLETED`, capture `DECLINED`) or an unheld currency (capture `PENDING`, reason
25
+ * `RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION`, as measured with JPY) [§3];
26
+ * - capture: only an `APPROVED` order [§4]; the sandbox answers 422 `ORDER_NOT_APPROVED` before approval and
27
+ * `ORDER_ALREADY_CAPTURED` for a second capture without the first `PayPal-Request-Id` (measured);
28
+ * - issue names the official OpenAPI files do not show are the fake's own: `DECIMAL_PRECISION`,
29
+ * `AMOUNT_NOT_POSITIVE`, `REFUND_CURRENCY_MISMATCH`, `INVALID_RESOURCE_ID` (PayPal lists its issues on a page that
30
+ * renders in JavaScript). The HTTP status and `name` follow the errors page; an adapter must not depend on the
31
+ * issue text;
32
+ * - refunds: `POST /v2/payments/captures/{id}/refund` on a `COMPLETED`/`PARTIALLY_REFUNDED` capture, empty body =
33
+ * what remains, `amount` = partial in the capture's currency, above what remains = 422 `REFUND_AMOUNT_EXCEEDED`.
34
+ * A refund is `COMPLETED` at once, as measured, unless `nextRefund(captureId, 'PENDING')` [§6];
35
+ * - webhooks: `callbackFor` sends a `PAYMENT.CAPTURE.COMPLETED` event (resource = a capture with `custom_id` and
36
+ * `supplementary_data.related_ids.order_id`) with the five `PAYPAL-*` headers [§5]. With `event_type:
37
+ * 'PAYMENT.CAPTURE.REFUNDED'` the resource is the order's latest refund, which names no order (measured). The
38
+ * signature is the fake's own HMAC, so `POST /v1/notifications/verify-webhook-signature` answers `SUCCESS` only for
39
+ * an event this fake sent, with the same headers, this fake's `webhookId`, and `webhook_event` spliced in BYTE FOR
40
+ * BYTE as delivered. Anything else is `FAILURE`, as measured for a changed value and another webhook id [§5.1].
41
+ * The sandbox also accepted a whitespace-only change; the fake stays stricter (the event is indented, so
42
+ * re-serialising it fails) because re-serialising can change more than whitespace.
43
+ */
44
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
45
+ import { createServer } from 'node:http';
46
+ import { flip } from './types.js';
47
+ /** The 24 currencies PayPal lists [§7]. */
48
+ const CURRENCIES = new Set('AUD BRL CAD CNY CZK DKK EUR HKD HUF ILS JPY MYR MXN TWD NZD NOK PHP PLN GBP SGD SEK CHF THB USD'.split(' '));
49
+ /** No decimal places in PayPal, whatever ISO 4217 says [§7]. */
50
+ const ZERO_DECIMAL = new Set(['HUF', 'JPY', 'TWD']);
51
+ /** The headers PayPal sends with every webhook; all five go into the postback check [§5]. */
52
+ export const PAYPAL_HEADERS = [
53
+ 'paypal-transmission-id',
54
+ 'paypal-transmission-time',
55
+ 'paypal-transmission-sig',
56
+ 'paypal-cert-url',
57
+ 'paypal-auth-algo',
58
+ ];
59
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
60
+ /** Integer in PayPal's smallest unit → the decimal string PayPal takes (`1050` USD → `"10.50"`, `500` JPY → `"500"`). */
61
+ const toValue = (minor, currency) => ZERO_DECIMAL.has(currency)
62
+ ? String(minor)
63
+ : `${Math.trunc(minor / 100)}.${String(minor % 100).padStart(2, '0')}`;
64
+ /** The decimal string back to an integer, or null when it has more decimals than the currency allows. */
65
+ const toMinor = (value, currency) => {
66
+ const m = /^(\d*)(?:\.(\d+))?$/.exec(value);
67
+ if (!m || value === '')
68
+ return null;
69
+ const decimals = ZERO_DECIMAL.has(currency) ? 0 : 2;
70
+ const frac = m[2] ?? '';
71
+ if (frac.length > decimals)
72
+ return null;
73
+ return Number(m[1]) * 10 ** decimals + Number(frac.padEnd(decimals, '0') || '0');
74
+ };
75
+ const paypalId = () => {
76
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
77
+ return [...randomBytes(17)].map((b) => chars[b % chars.length]).join('');
78
+ };
79
+ class Refused extends Error {
80
+ status;
81
+ errorName;
82
+ issue;
83
+ description;
84
+ field;
85
+ constructor(status, errorName, issue, description, field) {
86
+ super(description);
87
+ this.status = status;
88
+ this.errorName = errorName;
89
+ this.issue = issue;
90
+ this.description = description;
91
+ this.field = field;
92
+ }
93
+ }
94
+ const invalid = (issue, field, description) => new Refused(400, 'INVALID_REQUEST', issue, description, field);
95
+ const unprocessable = (issue, description) => new Refused(422, 'UNPROCESSABLE_ENTITY', issue, description);
96
+ const MESSAGES = {
97
+ INVALID_REQUEST: 'Request is not well-formed, syntactically incorrect, or violates schema.',
98
+ AUTHENTICATION_FAILURE: 'Authentication failed due to missing Authorization header, or invalid authentication credentials.',
99
+ RESOURCE_NOT_FOUND: 'The specified resource does not exist.',
100
+ UNPROCESSABLE_ENTITY: 'The requested action could not be performed, semantically incorrect, or failed business validation.',
101
+ };
102
+ /**
103
+ * The raw text of the top-level `key` value in a JSON object, exactly as written. Used to read `webhook_event` from the
104
+ * postback without re-serialising it.
105
+ */
106
+ function rawMember(json, key) {
107
+ // Walks the top-level object only: a key is a string at depth 1 followed by ':'.
108
+ let depth = 0;
109
+ let valueStart = -1;
110
+ for (let i = 0; i < json.length; i++) {
111
+ const c = json[i];
112
+ if (c === '"') {
113
+ const from = i;
114
+ for (i++; i < json.length && json[i] !== '"'; i++)
115
+ if (json[i] === '\\')
116
+ i++;
117
+ if (depth !== 1 || valueStart >= 0)
118
+ continue;
119
+ let k = i + 1;
120
+ while (/\s/.test(json[k] ?? ''))
121
+ k++;
122
+ if (json[k] !== ':' || json.slice(from, i + 1) !== `"${key}"`)
123
+ continue;
124
+ k++;
125
+ while (/\s/.test(json[k] ?? ''))
126
+ k++;
127
+ valueStart = k;
128
+ i = k - 1;
129
+ }
130
+ else if (c === '{' || c === '[')
131
+ depth++;
132
+ else if (c === '}' || c === ']') {
133
+ depth--;
134
+ if (valueStart >= 0 && depth === 1)
135
+ return json.slice(valueStart, i + 1);
136
+ }
137
+ }
138
+ return undefined;
139
+ }
140
+ export async function startFakePayPal(opts) {
141
+ const ttl = opts.tokenTtlSeconds ?? 32400;
142
+ const signingKey = randomBytes(32);
143
+ const tokens = new Map();
144
+ const orders = new Map();
145
+ const captures = new Map();
146
+ const refunds = new Map();
147
+ const replies = new Map();
148
+ const captureOutcome = new Map();
149
+ const refundOutcome = new Map();
150
+ const requests = [];
151
+ const iso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
152
+ let url = '';
153
+ const server = createServer((req, res) => {
154
+ let raw = '';
155
+ req.on('data', (d) => {
156
+ raw += d;
157
+ });
158
+ req.on('end', () => {
159
+ const path = (req.url ?? '').split('?')[0] ?? '';
160
+ const send = (status, o) => {
161
+ res.writeHead(status, o === undefined ? {} : { 'content-type': 'application/json' });
162
+ res.end(o === undefined ? undefined : JSON.stringify(o));
163
+ };
164
+ const debug_id = randomBytes(7).toString('hex').slice(0, 13);
165
+ const fail = (e) => send(e.status, {
166
+ name: e.errorName,
167
+ message: MESSAGES[e.errorName] ?? e.errorName,
168
+ debug_id,
169
+ details: [
170
+ {
171
+ ...(e.field ? { field: e.field, location: 'body' } : {}),
172
+ issue: e.issue,
173
+ description: e.description,
174
+ },
175
+ ],
176
+ links: [],
177
+ });
178
+ let body = null;
179
+ if (raw && path !== '/v1/oauth2/token') {
180
+ try {
181
+ body = JSON.parse(raw);
182
+ }
183
+ catch {
184
+ body = undefined;
185
+ }
186
+ }
187
+ requests.push({ method: req.method ?? '', path, body: body ?? raw, headers: req.headers });
188
+ if (req.method === 'POST' && path === '/v1/oauth2/token')
189
+ return token(req, raw, send);
190
+ const auth = /^Bearer (\S+)$/.exec(req.headers.authorization ?? '')?.[1];
191
+ const expires = auth ? tokens.get(auth) : undefined;
192
+ if (expires === undefined || expires <= Date.now()) {
193
+ return send(401, {
194
+ name: 'AUTHENTICATION_FAILURE',
195
+ message: MESSAGES.AUTHENTICATION_FAILURE,
196
+ debug_id,
197
+ links: [],
198
+ });
199
+ }
200
+ if (req.method === 'POST') {
201
+ if (!String(req.headers['content-type'] ?? '').includes('application/json')) {
202
+ return fail(new Refused(415, 'UNSUPPORTED_MEDIA_TYPE', 'UNSUPPORTED_MEDIA_TYPE', 'Send application/json.'));
203
+ }
204
+ if (body === undefined || (raw && (typeof body !== 'object' || body === null))) {
205
+ return fail(invalid('MALFORMED_REQUEST_JSON', '/', 'The request JSON is not well formed.'));
206
+ }
207
+ }
208
+ const requestId = req.headers['paypal-request-id'];
209
+ const once = (kind, run) => {
210
+ if (typeof requestId !== 'string' || !requestId) {
211
+ const r = run();
212
+ return send(r.status, r.body);
213
+ }
214
+ if (requestId.length > 108) {
215
+ return fail(invalid('INVALID_STRING_LENGTH', 'PayPal-Request-Id', 'At most 108 characters.'));
216
+ }
217
+ const key = `${kind}:${requestId}`;
218
+ const seen = replies.get(key);
219
+ if (seen)
220
+ return send(200, seen.body);
221
+ const r = run();
222
+ replies.set(key, r);
223
+ return send(r.status, r.body);
224
+ };
225
+ const minimal = !String(req.headers.prefer ?? '').includes('return=representation');
226
+ try {
227
+ if (req.method === 'POST' && path === '/v2/checkout/orders') {
228
+ return once('create', () => {
229
+ const o = create(obj(body));
230
+ return {
231
+ status: 201,
232
+ body: minimal ? { id: o.id, status: o.status, links: o.links } : o,
233
+ };
234
+ });
235
+ }
236
+ const get = /^\/v2\/checkout\/orders\/([A-Z0-9]+)$/.exec(path);
237
+ if (req.method === 'GET' && get)
238
+ return send(200, findOrder(get[1] ?? ''));
239
+ const cap = /^\/v2\/checkout\/orders\/([A-Z0-9]+)\/capture$/.exec(path);
240
+ if (req.method === 'POST' && cap) {
241
+ return once(`capture:${cap[1]}`, () => ({ status: 201, body: captureOrder(cap[1] ?? '') }));
242
+ }
243
+ const getCap = /^\/v2\/payments\/captures\/([A-Z0-9]+)$/.exec(path);
244
+ if (req.method === 'GET' && getCap)
245
+ return send(200, findCapture(getCap[1] ?? '').capture);
246
+ const ref = /^\/v2\/payments\/captures\/([A-Z0-9]+)\/refund$/.exec(path);
247
+ if (req.method === 'POST' && ref) {
248
+ return once(`refund:${ref[1]}`, () => ({ status: 201, body: refund(ref[1] ?? '', obj(body)) }));
249
+ }
250
+ const getRef = /^\/v2\/payments\/refunds\/([A-Z0-9]+)$/.exec(path);
251
+ if (req.method === 'GET' && getRef) {
252
+ const r = refunds.get(getRef[1] ?? '');
253
+ if (!r)
254
+ throw notFound(getRef[1] ?? '');
255
+ return send(200, r);
256
+ }
257
+ if (req.method === 'POST' && path === '/v1/notifications/verify-webhook-signature') {
258
+ return send(200, { verification_status: verify(obj(body), raw) });
259
+ }
260
+ return send(404, {
261
+ name: 'RESOURCE_NOT_FOUND',
262
+ message: MESSAGES.RESOURCE_NOT_FOUND,
263
+ debug_id,
264
+ details: [{ issue: 'INVALID_URI', description: `${req.method} ${path}` }],
265
+ links: [],
266
+ });
267
+ }
268
+ catch (e) {
269
+ if (e instanceof Refused)
270
+ return fail(e);
271
+ throw e;
272
+ }
273
+ });
274
+ });
275
+ function token(req, raw, send) {
276
+ const basic = /^Basic (\S+)$/.exec(req.headers.authorization ?? '')?.[1];
277
+ const expected = Buffer.from(`${opts.clientId}:${opts.clientSecret}`).toString('base64');
278
+ if (!basic || basic !== expected) {
279
+ return send(401, { error: 'invalid_client', error_description: 'Client Authentication failed' });
280
+ }
281
+ if (!String(req.headers['content-type'] ?? '').includes('application/x-www-form-urlencoded')) {
282
+ return send(400, { error: 'invalid_request', error_description: 'Send a form body.' });
283
+ }
284
+ if (new URLSearchParams(raw).get('grant_type') !== 'client_credentials') {
285
+ return send(400, {
286
+ error: 'unsupported_grant_type',
287
+ error_description: 'Grant Type is NULL or unsupported',
288
+ });
289
+ }
290
+ const access_token = `A21AA${randomBytes(24).toString('base64url')}`;
291
+ tokens.set(access_token, Date.now() + ttl * 1000);
292
+ send(200, {
293
+ scope: 'https://uri.paypal.com/services/payments/refund',
294
+ access_token,
295
+ token_type: 'Bearer',
296
+ app_id: `APP-${paypalId()}`,
297
+ expires_in: ttl,
298
+ nonce: `${iso()}${randomBytes(8).toString('hex')}`,
299
+ });
300
+ }
301
+ const notFound = (id) => new Refused(404, 'RESOURCE_NOT_FOUND', 'INVALID_RESOURCE_ID', `Specified resource ID ${id} does not exist.`);
302
+ function findOrder(id) {
303
+ const o = orders.get(id);
304
+ if (!o)
305
+ throw notFound(id);
306
+ return o;
307
+ }
308
+ function findCapture(id) {
309
+ const c = captures.get(id);
310
+ if (!c)
311
+ throw notFound(id);
312
+ return c;
313
+ }
314
+ function create(body) {
315
+ if (body.intent !== 'CAPTURE') {
316
+ if (body.intent === undefined)
317
+ throw invalid('MISSING_REQUIRED_PARAMETER', '/intent', 'intent is required.');
318
+ throw invalid('INVALID_PARAMETER_VALUE', '/intent', 'fake PayPal: only intent CAPTURE (pay now).');
319
+ }
320
+ const units = body.purchase_units;
321
+ if (!Array.isArray(units) || units.length < 1) {
322
+ throw invalid('INVALID_ARRAY_MIN_ITEMS', '/purchase_units', 'At least one purchase unit.');
323
+ }
324
+ if (units.length > 1) {
325
+ throw invalid('INVALID_PARAMETER_VALUE', '/purchase_units', 'fake PayPal: one purchase unit.');
326
+ }
327
+ const unit = obj(units[0]);
328
+ const amount = obj(unit.amount);
329
+ const currency = amount.currency_code;
330
+ if (typeof currency !== 'string' || !CURRENCIES.has(currency)) {
331
+ throw invalid('INVALID_CURRENCY_CODE', '/purchase_units/@reference_id==default/amount/currency_code', 'Currency code is invalid or is not currently supported.');
332
+ }
333
+ const value = amount.value;
334
+ if (typeof value !== 'string' ||
335
+ value.length > 32 ||
336
+ !/^((-?[0-9]+)|(-?([0-9]+)?[.][0-9]+))$/.test(value)) {
337
+ throw invalid('INVALID_PARAMETER_SYNTAX', '/purchase_units/@reference_id==default/amount/value', 'amount.value must be a decimal string.');
338
+ }
339
+ if (value.startsWith('-'))
340
+ throw unprocessable('AMOUNT_NOT_POSITIVE', 'amount.value must be greater than zero.');
341
+ const minor = toMinor(value, currency);
342
+ if (minor === null) {
343
+ throw unprocessable('DECIMAL_PRECISION', `${currency} takes ${ZERO_DECIMAL.has(currency) ? 'no' : 'at most two'} decimal places.`);
344
+ }
345
+ if (minor <= 0)
346
+ throw unprocessable('AMOUNT_NOT_POSITIVE', 'amount.value must be greater than zero.');
347
+ for (const [field, max] of [
348
+ ['custom_id', 255],
349
+ ['invoice_id', 127],
350
+ ['reference_id', 256],
351
+ ]) {
352
+ const v = unit[field];
353
+ if (v !== undefined && (typeof v !== 'string' || v.length < 1 || v.length > max)) {
354
+ throw invalid('INVALID_STRING_LENGTH', `/purchase_units/@reference_id==default/${field}`, `${field}: 1 to ${max}.`);
355
+ }
356
+ }
357
+ const id = paypalId();
358
+ const action = Object.keys(obj(obj(obj(body.payment_source).paypal).experience_context)).length > 0;
359
+ const now = iso();
360
+ const order = {
361
+ id,
362
+ intent: 'CAPTURE',
363
+ status: action ? 'PAYER_ACTION_REQUIRED' : 'CREATED',
364
+ purchase_units: [
365
+ {
366
+ ...unit,
367
+ reference_id: typeof unit.reference_id === 'string' ? unit.reference_id : 'default',
368
+ amount: { currency_code: currency, value },
369
+ ...(typeof unit.description === 'string' ? { description: unit.description.slice(0, 127) } : {}),
370
+ },
371
+ ],
372
+ ...(body.payment_source ? { payment_source: body.payment_source } : {}),
373
+ create_time: now,
374
+ update_time: now,
375
+ links: [
376
+ { href: `${url}/v2/checkout/orders/${id}`, rel: 'self', method: 'GET' },
377
+ { href: `${url}/checkoutnow?token=${id}`, rel: action ? 'payer-action' : 'approve', method: 'GET' },
378
+ { href: `${url}/v2/checkout/orders/${id}`, rel: 'update', method: 'PATCH' },
379
+ { href: `${url}/v2/checkout/orders/${id}/capture`, rel: 'capture', method: 'POST' },
380
+ ],
381
+ };
382
+ orders.set(id, order);
383
+ return order;
384
+ }
385
+ function captureOrder(id) {
386
+ const o = findOrder(id);
387
+ if (o.status === 'COMPLETED') {
388
+ throw unprocessable('ORDER_ALREADY_CAPTURED', 'Order already captured.');
389
+ }
390
+ if (o.status !== 'APPROVED') {
391
+ throw unprocessable('ORDER_NOT_APPROVED', 'Payer has not yet approved the Order for payment.');
392
+ }
393
+ const unit = o.purchase_units[0];
394
+ if (!unit)
395
+ throw notFound(id);
396
+ const outcome = captureOutcome.get(id);
397
+ captureOutcome.delete(id);
398
+ const now = iso();
399
+ const captureId = paypalId();
400
+ const capture = {
401
+ id: captureId,
402
+ status: outcome ?? 'COMPLETED',
403
+ ...(outcome === 'PENDING'
404
+ ? { status_details: { reason: 'RECEIVING_PREFERENCE_MANDATES_MANUAL_ACTION' } }
405
+ : {}),
406
+ amount: { ...unit.amount },
407
+ ...(unit.custom_id ? { custom_id: unit.custom_id } : {}),
408
+ ...(unit.invoice_id ? { invoice_id: unit.invoice_id } : {}),
409
+ final_capture: true,
410
+ seller_receivable_breakdown: { gross_amount: { ...unit.amount } },
411
+ supplementary_data: { related_ids: { order_id: id } },
412
+ create_time: now,
413
+ update_time: now,
414
+ links: [
415
+ { href: `${url}/v2/payments/captures/${captureId}`, rel: 'self', method: 'GET' },
416
+ { href: `${url}/v2/payments/captures/${captureId}/refund`, rel: 'refund', method: 'POST' },
417
+ { href: `${url}/v2/checkout/orders/${id}`, rel: 'up', method: 'GET' },
418
+ ],
419
+ };
420
+ captures.set(captureId, { capture, orderId: id });
421
+ unit.payments = { captures: [capture] };
422
+ o.status = 'COMPLETED';
423
+ o.update_time = now;
424
+ o.links = o.links.filter((l) => l.rel === 'self');
425
+ return o;
426
+ }
427
+ function refund(captureId, body) {
428
+ const { capture, orderId } = findCapture(captureId);
429
+ if (capture.status !== 'COMPLETED' && capture.status !== 'PARTIALLY_REFUNDED') {
430
+ throw unprocessable('CAPTURE_STATUS_NOT_VALID', `The capture is ${capture.status}.`);
431
+ }
432
+ const currency = capture.amount.currency_code;
433
+ const done = [...refunds.values()]
434
+ .filter((r) => r.capture_id === captureId && r.status !== 'FAILED' && r.status !== 'CANCELLED')
435
+ .reduce((s, r) => s + (toMinor(r.amount.value, currency) ?? 0), 0);
436
+ const left = (toMinor(capture.amount.value, currency) ?? 0) - done;
437
+ let minor = left;
438
+ if (body.amount !== undefined) {
439
+ const a = obj(body.amount);
440
+ if (a.currency_code !== currency) {
441
+ throw unprocessable('REFUND_CURRENCY_MISMATCH', `Refund in ${currency}, the capture's currency.`);
442
+ }
443
+ const m = typeof a.value === 'string' ? toMinor(a.value, currency) : null;
444
+ if (m === null)
445
+ throw invalid('INVALID_PARAMETER_SYNTAX', '/amount/value', 'amount.value must be a decimal string.');
446
+ if (m <= 0)
447
+ throw unprocessable('AMOUNT_NOT_POSITIVE', 'amount.value must be greater than zero.');
448
+ minor = m;
449
+ }
450
+ if (minor > left || left <= 0) {
451
+ throw unprocessable('REFUND_AMOUNT_EXCEEDED', 'The refund amount must be less than or equal to the capture amount that has not yet been refunded.');
452
+ }
453
+ const status = refundOutcome.get(captureId) ?? 'COMPLETED';
454
+ refundOutcome.delete(captureId);
455
+ const now = iso();
456
+ const id = paypalId();
457
+ const r = {
458
+ id,
459
+ status,
460
+ ...(status === 'PENDING' ? { status_details: { reason: 'ECHECK' } } : {}),
461
+ amount: { currency_code: currency, value: toValue(minor, currency) },
462
+ capture_id: captureId,
463
+ ...(typeof body.note_to_payer === 'string' ? { note_to_payer: body.note_to_payer } : {}),
464
+ ...(capture.custom_id ? { custom_id: capture.custom_id } : {}),
465
+ create_time: now,
466
+ update_time: now,
467
+ links: [
468
+ { href: `${url}/v2/payments/refunds/${id}`, rel: 'self', method: 'GET' },
469
+ { href: `${url}/v2/payments/captures/${captureId}`, rel: 'up', method: 'GET' },
470
+ ],
471
+ };
472
+ refunds.set(id, r);
473
+ capture.status = minor === left ? 'REFUNDED' : 'PARTIALLY_REFUNDED';
474
+ capture.update_time = now;
475
+ const payments = orders.get(orderId)?.purchase_units[0]?.payments;
476
+ if (payments)
477
+ payments.refunds = [...(payments.refunds ?? []), r];
478
+ return r;
479
+ }
480
+ const sign = (headers, rawEvent) => createHmac('sha256', signingKey)
481
+ .update([
482
+ headers['paypal-transmission-id'],
483
+ headers['paypal-transmission-time'],
484
+ opts.webhookId,
485
+ createHash('sha256').update(rawEvent, 'utf8').digest('hex'),
486
+ ].join('|'))
487
+ .digest('base64');
488
+ function verify(body, raw) {
489
+ const required = [
490
+ 'auth_algo',
491
+ 'cert_url',
492
+ 'transmission_id',
493
+ 'transmission_sig',
494
+ 'transmission_time',
495
+ 'webhook_id',
496
+ 'webhook_event',
497
+ ];
498
+ for (const f of required) {
499
+ if (body[f] === undefined || body[f] === null || body[f] === '') {
500
+ throw invalid('MISSING_REQUIRED_PARAMETER', `/${f}`, `${f} is required.`);
501
+ }
502
+ }
503
+ const rawEvent = rawMember(raw, 'webhook_event');
504
+ if (body.webhook_id !== opts.webhookId || body.auth_algo !== 'SHA256withRSA' || !rawEvent)
505
+ return 'FAILURE';
506
+ const expected = Buffer.from(sign({
507
+ 'paypal-transmission-id': String(body.transmission_id),
508
+ 'paypal-transmission-time': String(body.transmission_time),
509
+ }, rawEvent));
510
+ const given = Buffer.from(String(body.transmission_sig));
511
+ return given.length === expected.length && timingSafeEqual(given, expected) ? 'SUCCESS' : 'FAILURE';
512
+ }
513
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
514
+ url = `http://127.0.0.1:${server.address().port}`;
515
+ const known = (id) => {
516
+ const o = orders.get(id);
517
+ if (!o)
518
+ throw new Error(`fake PayPal: no order ${id}`);
519
+ return o;
520
+ };
521
+ const approve = (id) => {
522
+ const o = known(id);
523
+ if (o.status !== 'CREATED' && o.status !== 'PAYER_ACTION_REQUIRED')
524
+ return;
525
+ o.status = 'APPROVED';
526
+ o.update_time = iso();
527
+ o.payer = { payer_id: paypalId().slice(0, 13), email_address: 'buyer@example.com' };
528
+ };
529
+ return {
530
+ url,
531
+ requests,
532
+ order: (id) => orders.get(id),
533
+ capture: (id) => captures.get(id)?.capture,
534
+ refund: (id) => refunds.get(id),
535
+ approve,
536
+ markPaid(id) {
537
+ approve(id);
538
+ if (known(id).status === 'APPROVED')
539
+ captureOrder(id);
540
+ },
541
+ nextCapture(id, status) {
542
+ known(id);
543
+ captureOutcome.set(id, status);
544
+ },
545
+ nextRefund(captureId, status) {
546
+ if (!captures.has(captureId))
547
+ throw new Error(`fake PayPal: no capture ${captureId}`);
548
+ refundOutcome.set(captureId, status);
549
+ },
550
+ expireTokens() {
551
+ for (const t of tokens.keys())
552
+ tokens.set(t, 0);
553
+ },
554
+ /**
555
+ * A `PAYMENT.CAPTURE.COMPLETED` event claiming payment (even before `markPaid`: the capture in it is the order's
556
+ * real one when there is one, else made up, and the fake's record stays unpaid). `override` changes fields of the
557
+ * capture `resource` before signing; the key `event_type` changes the event instead.
558
+ */
559
+ callbackFor(id, override = {}) {
560
+ const { event_type, ...fields } = override;
561
+ const o = known(id);
562
+ const unit = o.purchase_units[0];
563
+ const real = unit?.payments?.captures[0];
564
+ const now = iso();
565
+ const resource = {
566
+ ...(real ?? {
567
+ id: paypalId(),
568
+ amount: unit?.amount,
569
+ ...(unit?.custom_id ? { custom_id: unit.custom_id } : {}),
570
+ final_capture: true,
571
+ supplementary_data: { related_ids: { order_id: id } },
572
+ create_time: now,
573
+ update_time: now,
574
+ }),
575
+ status: 'COMPLETED',
576
+ ...fields,
577
+ };
578
+ const event = {
579
+ id: `WH-${paypalId()}-${paypalId()}`,
580
+ event_version: '1.0',
581
+ create_time: now,
582
+ resource_type: 'capture',
583
+ resource_version: '2.0',
584
+ event_type: event_type ?? 'PAYMENT.CAPTURE.COMPLETED',
585
+ summary: `Payment completed for ${resource.amount?.value} ${resource.amount?.currency_code}`,
586
+ resource: resource,
587
+ };
588
+ if (event_type === 'PAYMENT.CAPTURE.REFUNDED') {
589
+ // Measured: the resource is the refund (the order's latest), with no order id; `up` links the capture.
590
+ const latest = unit?.payments?.refunds?.at(-1);
591
+ const captureId = real?.id ?? paypalId();
592
+ const { capture_id: _, ...refund } = latest ?? {
593
+ id: paypalId(),
594
+ status: 'COMPLETED',
595
+ amount: unit?.amount,
596
+ ...(unit?.custom_id ? { custom_id: unit.custom_id } : {}),
597
+ create_time: now,
598
+ update_time: now,
599
+ links: [{ href: `${url}/v2/payments/captures/${captureId}`, rel: 'up', method: 'GET' }],
600
+ };
601
+ event.resource_type = 'refund';
602
+ const money = obj(refund.amount);
603
+ event.summary = `A ${money.currency_code} ${money.value} capture payment was refunded`;
604
+ event.resource = { ...refund, ...fields };
605
+ }
606
+ // Indented, so a handler that re-serialises the parsed JSON posts different bytes and verification fails.
607
+ const rawBody = JSON.stringify(event, null, 2);
608
+ const headers = {
609
+ 'content-type': 'application/json',
610
+ 'paypal-transmission-id': randomBytes(16).toString('hex'),
611
+ 'paypal-transmission-time': now,
612
+ 'paypal-cert-url': `${url}/v1/notifications/certs/CERT-fake`,
613
+ 'paypal-auth-algo': 'SHA256withRSA',
614
+ };
615
+ headers['paypal-transmission-sig'] = sign(headers, rawBody);
616
+ return { fields: {}, rawBody, headers };
617
+ },
618
+ tamper: (cb) => ({
619
+ ...cb,
620
+ headers: {
621
+ ...cb.headers,
622
+ 'paypal-transmission-sig': flip(cb.headers['paypal-transmission-sig'] ?? ''),
623
+ },
624
+ }),
625
+ close: () => new Promise((r) => server.close(() => r())),
626
+ };
627
+ }
628
+ //# sourceMappingURL=paypal.js.map