vona-module-test-pay 5.0.2 → 5.0.4

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,481 @@
1
+ import { ApiError } from '@cabloy/paypal-server-sdk';
2
+ import assert from 'node:assert';
3
+ import { describe, it } from 'node:test';
4
+ import { app } from 'vona-mock';
5
+
6
+ function createOptions(gateway: Record<string, unknown>) {
7
+ const { clientOptions } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
8
+ return {
9
+ ...clientOptions,
10
+ secretCredential: { clientId: 'client-1', clientSecret: 'secret-1' },
11
+ webhookId: 'webhook-1',
12
+ merchantReference: 'merchant-1',
13
+ gateway,
14
+ } as never;
15
+ }
16
+
17
+ function orderRecord(
18
+ status: string,
19
+ captureStatus?: string,
20
+ purchaseUnitOverrides: Record<string, unknown> = {},
21
+ ) {
22
+ return {
23
+ id: 'order-1',
24
+ status,
25
+ purchaseUnits: [
26
+ {
27
+ customId: 'payment-correlation-reference',
28
+ invoiceId: 'payment-invoice-reference',
29
+ amount: { currencyCode: 'USD', value: '12.99' },
30
+ payee: { merchantId: 'merchant-1' },
31
+ payments: captureStatus
32
+ ? {
33
+ captures: [
34
+ {
35
+ id: 'capture-1',
36
+ status: captureStatus,
37
+ amount: { currencyCode: 'USD', value: '12.99' },
38
+ },
39
+ ],
40
+ }
41
+ : undefined,
42
+ ...purchaseUnitOverrides,
43
+ },
44
+ ],
45
+ };
46
+ }
47
+
48
+ const paymentInput = {
49
+ paymentSessionId: 101,
50
+ businessReference: 'business-1',
51
+ providerInvoiceReference: 'payment-invoice-reference',
52
+ providerCorrelationReference: 'payment-correlation-reference',
53
+ idempotencyKey: 'payment-key',
54
+ amountMinor: 1299,
55
+ currency: 'USD',
56
+ providerOrderId: 'order-1',
57
+ };
58
+
59
+ const refundInput = {
60
+ paymentSessionId: 101,
61
+ refundOperationId: 201,
62
+ businessReference: 'refund-business-1',
63
+ providerInvoiceReference: 'refund-invoice-reference',
64
+ providerCorrelationReference: 'refund-correlation-reference',
65
+ idempotencyKey: 'refund-key',
66
+ amountMinor: 500,
67
+ currency: 'USD',
68
+ providerCaptureId: 'capture-1',
69
+ };
70
+
71
+ function paypalApiError(statusCode: number) {
72
+ return new ApiError(
73
+ {
74
+ request: {} as never,
75
+ response: { statusCode, headers: {}, body: '' } as never,
76
+ },
77
+ 'opaque provider error',
78
+ );
79
+ }
80
+
81
+ describe('paypalProvider.test.ts', { concurrency: false }, () => {
82
+ it('resolves the authoritative Sandbox environment and creates an approval order', async () => {
83
+ await app.bean.executor.mockCtx(async () => {
84
+ const calls: Array<{ kind: string; input: unknown; options: unknown }> = [];
85
+ const gateway = {
86
+ async createOrder(options: unknown, input: unknown) {
87
+ calls.push({ kind: 'createOrder', input, options });
88
+ return {
89
+ id: 'order-1',
90
+ links: [
91
+ { rel: 'approve', href: 'https://www.sandbox.paypal.com/checkoutnow?token=order-1' },
92
+ ],
93
+ };
94
+ },
95
+ };
96
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
97
+ const options = createOptions(gateway);
98
+ const payment = await provider.startPayment(
99
+ {
100
+ ...paymentInput,
101
+ providerOrderId: undefined,
102
+ returnUrl: 'https://shop.test/pay/return',
103
+ cancelUrl: 'https://shop.test/pay/cancel',
104
+ },
105
+ options,
106
+ );
107
+
108
+ assert.equal((options as any).environment, 'sandbox');
109
+ assert.deepEqual(payment, {
110
+ state: 'requires_action',
111
+ providerPaymentId: 'order-1',
112
+ providerOrderId: 'order-1',
113
+ nextAction: {
114
+ kind: 'redirect',
115
+ url: 'https://www.sandbox.paypal.com/checkoutnow?token=order-1',
116
+ },
117
+ });
118
+ assert.deepEqual(calls[0]?.input, {
119
+ paypalRequestId: 'payment-key',
120
+ body: {
121
+ intent: 'CAPTURE',
122
+ applicationContext: {
123
+ returnUrl: 'https://shop.test/pay/return',
124
+ cancelUrl: 'https://shop.test/pay/cancel',
125
+ },
126
+ purchaseUnits: [
127
+ {
128
+ customId: 'payment-correlation-reference',
129
+ invoiceId: 'payment-invoice-reference',
130
+ amount: { currencyCode: 'USD', value: '12.99' },
131
+ },
132
+ ],
133
+ },
134
+ });
135
+ assert.deepEqual(calls[0]?.options, {
136
+ environment: 'sandbox',
137
+ clientId: 'client-1',
138
+ clientSecret: 'secret-1',
139
+ webhookId: 'webhook-1',
140
+ });
141
+ });
142
+ });
143
+
144
+ it('rejects missing callback URLs and incomplete PayPal configuration', async () => {
145
+ await app.bean.executor.mockCtx(async () => {
146
+ const gateway = {
147
+ async createOrder() {
148
+ throw new Error('unexpected createOrder');
149
+ },
150
+ async getOrder() {
151
+ throw new Error('unexpected getOrder');
152
+ },
153
+ };
154
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
155
+ const options = createOptions(gateway) as any;
156
+ await assert.rejects(
157
+ provider.startPayment({ ...paymentInput, providerOrderId: undefined }, options),
158
+ { code: 500, status: 500 },
159
+ );
160
+ await assert.rejects(
161
+ provider.queryPayment(paymentInput, {
162
+ ...options,
163
+ secretCredential: { clientId: undefined, clientSecret: undefined },
164
+ }),
165
+ { status: 503 },
166
+ );
167
+ });
168
+ });
169
+
170
+ it('requests a complete PayPal capture representation', async () => {
171
+ await app.bean.executor.mockCtx(async () => {
172
+ const calls: unknown[] = [];
173
+ const gateway = {
174
+ async captureOrder(_options: unknown, input: unknown) {
175
+ calls.push(input);
176
+ return orderRecord('COMPLETED', 'COMPLETED');
177
+ },
178
+ };
179
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
180
+ const payment = await provider.confirmPayment(paymentInput, createOptions(gateway));
181
+
182
+ assert.equal(payment.state, 'succeeded');
183
+ assert.equal(payment.providerCaptureId, 'capture-1');
184
+ assert.deepEqual(payment.nextAction, { kind: 'completed' });
185
+ assert.deepEqual(calls, [
186
+ {
187
+ id: 'order-1',
188
+ paypalRequestId: 'payment-key',
189
+ prefer: 'return=representation',
190
+ },
191
+ ]);
192
+ });
193
+ });
194
+
195
+ it('maps PayPal capture states and rejects conflicting order facts', async () => {
196
+ await app.bean.executor.mockCtx(async () => {
197
+ let result: unknown = orderRecord('CREATED');
198
+ const gateway = {
199
+ async getOrder() {
200
+ return result;
201
+ },
202
+ };
203
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
204
+ const options = createOptions(gateway);
205
+
206
+ for (const [captureStatus, state] of [
207
+ ['COMPLETED', 'succeeded'],
208
+ ['PENDING', 'processing'],
209
+ ['FAILED', 'failed'],
210
+ ['DECLINED', 'failed'],
211
+ ] as const) {
212
+ result = orderRecord('CREATED', captureStatus);
213
+ const payment = await provider.queryPayment(paymentInput, options);
214
+ assert.equal(payment.state, state);
215
+ }
216
+ result = orderRecord('VOIDED');
217
+ assert.equal((await provider.queryPayment(paymentInput, options)).state, 'cancelled');
218
+ result = {
219
+ ...orderRecord('CREATED'),
220
+ links: [
221
+ { rel: 'approve', href: 'https://www.sandbox.paypal.com/checkoutnow?token=order-1' },
222
+ ],
223
+ };
224
+ assert.deepEqual(await provider.queryPayment(paymentInput, options), {
225
+ state: 'requires_action',
226
+ providerPaymentId: 'order-1',
227
+ providerOrderId: 'order-1',
228
+ nextAction: {
229
+ kind: 'redirect',
230
+ url: 'https://www.sandbox.paypal.com/checkoutnow?token=order-1',
231
+ },
232
+ });
233
+ result = orderRecord('CREATED');
234
+ const missingApproval = await provider.queryPayment(paymentInput, options);
235
+ assert.equal(missingApproval.state, 'requires_action');
236
+ assert.equal(missingApproval.nextAction, undefined);
237
+
238
+ result = orderRecord('CREATED', 'COMPLETED', {
239
+ amount: { currencyCode: 'USD', value: '13.99' },
240
+ });
241
+ await assert.rejects(provider.queryPayment(paymentInput, options), { status: 409 });
242
+ });
243
+ });
244
+
245
+ it('logs only allowlisted merchant diagnostics before rejecting order and refund conflicts', async () => {
246
+ await app.bean.executor.mockCtx(async () => {
247
+ const warnings: unknown[] = [];
248
+ const gateway = {
249
+ async getOrder() {
250
+ return orderRecord('COMPLETED', 'COMPLETED', {
251
+ payee: { merchantId: 'merchant-order-observed' },
252
+ payer: { merchantId: 'merchant-1' },
253
+ });
254
+ },
255
+ async refundCapturedPayment() {
256
+ return {
257
+ id: 'refund-1',
258
+ customId: 'refund-correlation-reference',
259
+ invoiceId: 'refund-invoice-reference',
260
+ amount: { currencyCode: 'USD', value: '5.00' },
261
+ status: 'COMPLETED',
262
+ payee: { merchantId: 'merchant-1' },
263
+ payer: { merchantId: 'merchant-refund-observed' },
264
+ };
265
+ },
266
+ };
267
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
268
+ const warn = (provider as any).$logger.warn;
269
+ (provider as any).$logger.warn = (message: unknown) => warnings.push(message);
270
+ try {
271
+ await assert.rejects(provider.queryPayment(paymentInput, createOptions(gateway)), {
272
+ status: 409,
273
+ });
274
+ await assert.rejects(provider.createRefund(refundInput, createOptions(gateway)), {
275
+ status: 409,
276
+ });
277
+ } finally {
278
+ (provider as any).$logger.warn = warn;
279
+ }
280
+ assert.deepEqual(warnings, [
281
+ {
282
+ event: 'paypal.merchant_reference_conflict',
283
+ observedMerchantSource: 'payee.merchant_id',
284
+ },
285
+ {
286
+ event: 'paypal.merchant_reference_conflict',
287
+ observedMerchantSource: 'payer.merchant_id',
288
+ },
289
+ ]);
290
+ });
291
+ });
292
+
293
+ it('maps refund statuses and preserves refund request facts', async () => {
294
+ await app.bean.executor.mockCtx(async () => {
295
+ const calls: unknown[] = [];
296
+ let status = 'COMPLETED';
297
+ function refundRecord() {
298
+ return {
299
+ id: 'refund-1',
300
+ customId: 'refund-correlation-reference',
301
+ invoiceId: 'refund-invoice-reference',
302
+ amount: { currencyCode: 'USD', value: '5.00' },
303
+ status,
304
+ payer: { merchantId: 'merchant-1' },
305
+ };
306
+ }
307
+ const gateway = {
308
+ async refundCapturedPayment(_options: unknown, input: unknown) {
309
+ calls.push(input);
310
+ return refundRecord();
311
+ },
312
+ async getRefund() {
313
+ return refundRecord();
314
+ },
315
+ };
316
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
317
+ const options = createOptions(gateway);
318
+ for (const [nextStatus, state] of [
319
+ ['COMPLETED', 'succeeded'],
320
+ ['PENDING', 'pending'],
321
+ ['FAILED', 'failed'],
322
+ ['CANCELLED', 'cancelled'],
323
+ ] as const) {
324
+ status = nextStatus;
325
+ assert.equal((await provider.createRefund(refundInput, options)).state, state);
326
+ assert.equal(
327
+ (await provider.queryRefund!({ ...refundInput, providerRefundId: 'refund-1' }, options))
328
+ .state,
329
+ state,
330
+ );
331
+ }
332
+ assert.deepEqual(calls[0], {
333
+ captureId: 'capture-1',
334
+ paypalRequestId: 'refund-key',
335
+ body: {
336
+ amount: { currencyCode: 'USD', value: '5.00' },
337
+ customId: 'refund-correlation-reference',
338
+ invoiceId: 'refund-invoice-reference',
339
+ },
340
+ });
341
+ });
342
+ });
343
+
344
+ it('treats a definitive PayPal refund rejection as terminal but preserves ambiguous errors', async () => {
345
+ await app.bean.executor.mockCtx(async () => {
346
+ let error: Error = paypalApiError(422);
347
+ const gateway = {
348
+ async refundCapturedPayment() {
349
+ throw error;
350
+ },
351
+ };
352
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
353
+ const options = createOptions(gateway);
354
+
355
+ assert.deepEqual(await provider.createRefund(refundInput, options), { state: 'failed' });
356
+ error = paypalApiError(409);
357
+ await assert.rejects(provider.createRefund(refundInput, options), { statusCode: 409 });
358
+ error = new Error('transport failure');
359
+ await assert.rejects(provider.createRefund(refundInput, options), /transport failure/);
360
+ });
361
+ });
362
+
363
+ it('keeps capture-only refund notifications unresolved without posting another refund', async () => {
364
+ await app.bean.executor.mockCtx(async () => {
365
+ const calls: string[] = [];
366
+ const gateway = {
367
+ async verifyWebhookSignature() {},
368
+ async getCapturedPayment() {
369
+ calls.push('getCapturedPayment');
370
+ return { id: 'capture-1' };
371
+ },
372
+ async refundCapturedPayment() {
373
+ calls.push('refundCapturedPayment');
374
+ },
375
+ };
376
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
377
+ const verified = await provider.verifyWebhook(
378
+ {
379
+ rawBody: '{"id":"event-refunded"}',
380
+ body: {
381
+ id: 'event-refunded',
382
+ event_type: 'PAYMENT.CAPTURE.REFUNDED',
383
+ resource: {
384
+ id: 'capture-1',
385
+ status: 'REFUNDED',
386
+ amount: { currency_code: 'USD', value: '12.99' },
387
+ supplementary_data: { related_ids: { capture_id: 'capture-1' } },
388
+ },
389
+ },
390
+ headers: {},
391
+ },
392
+ createOptions(gateway),
393
+ );
394
+ assert.equal(verified.ignored, true);
395
+ assert.equal(verified.providerCaptureId, 'capture-1');
396
+ assert.equal(verified.refund, undefined);
397
+ assert.equal(verified.refundOperationId, undefined);
398
+ assert.deepEqual(calls, ['getCapturedPayment']);
399
+ });
400
+ });
401
+
402
+ it('resolves an alternate capture-refunded resource ID only with refund metadata', async () => {
403
+ await app.bean.executor.mockCtx(async () => {
404
+ const gateway = {
405
+ async verifyWebhookSignature() {},
406
+ async getCapturedPayment() {
407
+ throw paypalApiError(404);
408
+ },
409
+ async getRefund() {
410
+ return {
411
+ id: 'refund-1',
412
+ customId: 'not-a-local-refund',
413
+ invoiceId: 'refund-invoice-reference',
414
+ amount: { currencyCode: 'USD', value: '5.00' },
415
+ status: 'COMPLETED',
416
+ payer: { merchantId: 'merchant-1' },
417
+ };
418
+ },
419
+ };
420
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
421
+ await assert.rejects(
422
+ provider.verifyWebhook(
423
+ {
424
+ rawBody: '{"id":"event-refunded-alternate"}',
425
+ body: {
426
+ id: 'event-refunded-alternate',
427
+ event_type: 'PAYMENT.CAPTURE.REFUNDED',
428
+ resource: {
429
+ id: 'refund-1',
430
+ status: 'REFUNDED',
431
+ amount: { currency_code: 'USD', value: '5.00' },
432
+ },
433
+ },
434
+ headers: {},
435
+ },
436
+ createOptions(gateway),
437
+ ),
438
+ { status: 400 },
439
+ );
440
+ });
441
+ });
442
+
443
+ it('passes webhook facts to the gateway and rejects uncorrelated capture events', async () => {
444
+ await app.bean.executor.mockCtx(async () => {
445
+ const calls: unknown[] = [];
446
+ const gateway = {
447
+ async verifyWebhookSignature(options: unknown, input: unknown) {
448
+ calls.push({ options, input });
449
+ },
450
+ async getOrder() {
451
+ return orderRecord('COMPLETED', 'COMPLETED');
452
+ },
453
+ };
454
+ const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
455
+ const options = createOptions(gateway);
456
+ const webhookInput = {
457
+ rawBody: '{"id":"event-1"}',
458
+ body: {
459
+ id: 'event-1',
460
+ event_type: 'PAYMENT.CAPTURE.COMPLETED',
461
+ resource: {
462
+ id: 'capture-1',
463
+ amount: { currency_code: 'USD', value: '12.99' },
464
+ supplementary_data: { related_ids: { order_id: 'order-1' } },
465
+ },
466
+ },
467
+ headers: { 'paypal-transmission-id': 'transmission-1' },
468
+ };
469
+ await assert.rejects(provider.verifyWebhook(webhookInput, options), { status: 400 });
470
+ assert.deepEqual(calls[0], {
471
+ options: {
472
+ environment: 'sandbox',
473
+ clientId: 'client-1',
474
+ clientSecret: 'secret-1',
475
+ webhookId: 'webhook-1',
476
+ },
477
+ input: webhookInput,
478
+ });
479
+ });
480
+ });
481
+ });
@@ -6,6 +6,7 @@ import { acquireTestLock, app } from 'vona-mock';
6
6
  interface IFixture {
7
7
  userId?: number;
8
8
  paymentSessionId?: number;
9
+ providerCorrelationReference?: string;
9
10
  }
10
11
 
11
12
  async function createFixture(suffix: string): Promise<IFixture> {
@@ -19,7 +20,11 @@ async function createFixture(suffix: string): Promise<IFixture> {
19
20
  currency: 'USD',
20
21
  correlationId: `payment-${suffix}`,
21
22
  });
22
- return { userId: user.id as number, paymentSessionId: session.id as number };
23
+ return {
24
+ userId: user.id as number,
25
+ paymentSessionId: session.id as number,
26
+ providerCorrelationReference: session.providerCorrelationReference,
27
+ };
23
28
  }
24
29
 
25
30
  async function cleanup(fixture: IFixture) {
@@ -46,7 +51,7 @@ function createRawBody(
46
51
  state,
47
52
  amountMinor: 1299,
48
53
  eventType: `payment.${state}`,
49
- paymentSessionId: String(fixture.paymentSessionId),
54
+ providerCorrelationReference: fixture.providerCorrelationReference,
50
55
  eventId: options?.eventId ?? `mock-${suffix}`,
51
56
  providerPaymentId: options?.providerPaymentId ?? `payment-${suffix}`,
52
57
  providerCaptureId: options?.providerCaptureId ?? `capture-${suffix}`,
@@ -54,7 +59,7 @@ function createRawBody(
54
59
  });
55
60
  }
56
61
 
57
- function createCommand(rawBody: string) {
62
+ function createCommand(rawBody: string, paymentSessionId: number) {
58
63
  const body = JSON.parse(rawBody);
59
64
  return {
60
65
  providerName: 'pay-mock:mock',
@@ -64,7 +69,7 @@ function createCommand(rawBody: string) {
64
69
  verified: {
65
70
  eventId: body.eventId,
66
71
  eventType: body.eventType,
67
- paymentSessionId: body.paymentSessionId,
72
+ paymentSessionId,
68
73
  payment: {
69
74
  state: body.state,
70
75
  providerPaymentId: body.providerPaymentId,
@@ -202,8 +207,12 @@ describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
202
207
  const fixture = await createFixture(randomUUID().slice(0, 12));
203
208
  try {
204
209
  const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
205
- const first = await app.scope('a-pay').service.webhook.receive(createCommand(rawBody));
206
- const replay = await app.scope('a-pay').service.webhook.receive(createCommand(rawBody));
210
+ const first = await app
211
+ .scope('a-pay')
212
+ .service.webhook.receive(createCommand(rawBody, fixture.paymentSessionId!));
213
+ const replay = await app
214
+ .scope('a-pay')
215
+ .service.webhook.receive(createCommand(rawBody, fixture.paymentSessionId!));
207
216
  assert.equal(replay.id, first.id);
208
217
  assert.equal(replay.state, 'processed');
209
218
  const session = await app
@@ -231,7 +240,9 @@ describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
231
240
  const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
232
241
  const receive = async () => {
233
242
  return await app.bean.executor.mockCtx(async () => {
234
- return await app.scope('a-pay').service.webhook.receive(createCommand(rawBody));
243
+ return await app
244
+ .scope('a-pay')
245
+ .service.webhook.receive(createCommand(rawBody, fixture.paymentSessionId!));
235
246
  });
236
247
  };
237
248
  const results = await Promise.allSettled([receive(), receive()]);
@@ -244,7 +255,7 @@ describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
244
255
  const failedRawBody = createRawBody(fixture, randomUUID().slice(0, 12), 'failed');
245
256
  const ignored = await app
246
257
  .scope('a-pay')
247
- .service.webhook.receive(createCommand(failedRawBody));
258
+ .service.webhook.receive(createCommand(failedRawBody, fixture.paymentSessionId!));
248
259
  const session = await app
249
260
  .scope('a-pay')
250
261
  .model.paymentSession.getById(fixture.paymentSessionId!);
@@ -267,12 +278,16 @@ describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
267
278
  const fixture = await createFixture(randomUUID().slice(0, 12));
268
279
  try {
269
280
  const first = createRawBody(fixture, randomUUID().slice(0, 12));
270
- await app.scope('a-pay').service.webhook.receive(createCommand(first));
281
+ await app
282
+ .scope('a-pay')
283
+ .service.webhook.receive(createCommand(first, fixture.paymentSessionId!));
271
284
  const conflict = createRawBody(fixture, randomUUID().slice(0, 12), 'succeeded', {
272
285
  providerCaptureId: `other-capture-${randomUUID().slice(0, 12)}`,
273
286
  });
274
287
  await assert.rejects(
275
- app.scope('a-pay').service.webhook.receive(createCommand(conflict)),
288
+ app
289
+ .scope('a-pay')
290
+ .service.webhook.receive(createCommand(conflict, fixture.paymentSessionId!)),
276
291
  (error: any) => error.code === 409,
277
292
  );
278
293
  const facts = await countFacts(fixture.paymentSessionId!);