vona-module-test-pay 5.0.3 → 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,1259 @@
1
+ import { ApiError } from '@cabloy/paypal-server-sdk';
2
+ import assert from 'node:assert';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { after, before, describe, it } from 'node:test';
5
+ import { acquireTestLock, app } from 'vona-mock';
6
+
7
+ interface IFixture {
8
+ categoryId?: number;
9
+ productId?: number;
10
+ skuId?: number;
11
+ balanceId?: number;
12
+ addressId?: number;
13
+ cartId?: number;
14
+ userId?: number;
15
+ customerName?: string;
16
+ orderId?: number;
17
+ paymentAttemptId?: number;
18
+ paymentSessionId?: number;
19
+ }
20
+
21
+ interface IGatewayState {
22
+ orderId?: string;
23
+ captureId?: string;
24
+ refundId?: string;
25
+ orderStatus: string;
26
+ captureStatus?: string;
27
+ captureFailuresRemaining?: number;
28
+ refundStatus: string;
29
+ refundError?: Error;
30
+ captureLookupError?: Error;
31
+ refundAmount?: string;
32
+ providerInvoiceReference?: string;
33
+ providerCorrelationReference?: string;
34
+ refundProviderInvoiceReference?: string;
35
+ refundProviderCorrelationReference?: string;
36
+ calls: Array<{ kind: string; input: unknown }>;
37
+ }
38
+
39
+ function createGateway(state: IGatewayState) {
40
+ return {
41
+ async createOrder(_options: unknown, input: any) {
42
+ state.calls.push({ kind: 'createOrder', input });
43
+ const unit = input.body.purchaseUnits[0];
44
+ state.providerCorrelationReference = unit.customId;
45
+ state.providerInvoiceReference = unit.invoiceId;
46
+ state.orderId = `paypal-order-${String(unit.customId)}`;
47
+ return {
48
+ id: state.orderId,
49
+ links: [{ rel: 'approve', href: `https://sandbox.paypal.test/${state.orderId}` }],
50
+ };
51
+ },
52
+ async captureOrder(_options: unknown, input: any) {
53
+ state.calls.push({ kind: 'captureOrder', input });
54
+ if (state.captureFailuresRemaining) {
55
+ state.captureFailuresRemaining -= 1;
56
+ throw new Error('transient PayPal capture failure');
57
+ }
58
+ state.captureId = state.captureId ?? `paypal-capture-${state.orderId}`;
59
+ state.orderStatus = 'COMPLETED';
60
+ state.captureStatus ??= 'COMPLETED';
61
+ return orderRecord(state);
62
+ },
63
+ async getOrder(_options: unknown, input: any) {
64
+ state.calls.push({ kind: 'getOrder', input });
65
+ return orderRecord(state);
66
+ },
67
+ async refundCapturedPayment(_options: unknown, input: any) {
68
+ state.calls.push({ kind: 'refundCapturedPayment', input });
69
+ if (state.refundError) throw state.refundError;
70
+ state.refundProviderCorrelationReference = input.body.customId;
71
+ state.refundProviderInvoiceReference = input.body.invoiceId;
72
+ state.refundId = state.refundId ?? `paypal-refund-${input.body.customId}`;
73
+ return refundRecord(state);
74
+ },
75
+ async getCapturedPayment(_options: unknown, input: any) {
76
+ state.calls.push({ kind: 'getCapturedPayment', input });
77
+ if (state.captureLookupError) throw state.captureLookupError;
78
+ return { id: state.captureId };
79
+ },
80
+ async getRefund(_options: unknown, input: any) {
81
+ state.calls.push({ kind: 'getRefund', input });
82
+ return refundRecord(state);
83
+ },
84
+ async verifyWebhookSignature(_options: unknown, input: unknown) {
85
+ state.calls.push({ kind: 'verifyWebhookSignature', input });
86
+ },
87
+ };
88
+ }
89
+
90
+ function orderRecord(state: IGatewayState) {
91
+ return {
92
+ id: state.orderId,
93
+ status: state.orderStatus,
94
+ links: [{ rel: 'approve', href: `https://sandbox.paypal.test/${state.orderId}` }],
95
+ purchaseUnits: [
96
+ {
97
+ customId: state.providerCorrelationReference,
98
+ invoiceId: state.providerInvoiceReference,
99
+ amount: { currencyCode: 'USD', value: '12.99' },
100
+ payee: { merchantId: 'merchant-test' },
101
+ payments: state.captureStatus
102
+ ? {
103
+ captures: [
104
+ {
105
+ id: state.captureId,
106
+ status: state.captureStatus,
107
+ amount: { currencyCode: 'USD', value: '12.99' },
108
+ },
109
+ ],
110
+ }
111
+ : undefined,
112
+ },
113
+ ],
114
+ };
115
+ }
116
+
117
+ function refundRecord(state: IGatewayState) {
118
+ return {
119
+ id: state.refundId,
120
+ customId: state.refundProviderCorrelationReference,
121
+ invoiceId: state.refundProviderInvoiceReference,
122
+ amount: { currencyCode: 'USD', value: state.refundAmount ?? '5.00' },
123
+ status: state.refundStatus,
124
+ payer: { merchantId: 'merchant-test' },
125
+ };
126
+ }
127
+
128
+ function paypalApiError(statusCode: number) {
129
+ return new ApiError(
130
+ {
131
+ request: {} as never,
132
+ response: { statusCode, headers: {}, body: '' } as never,
133
+ },
134
+ 'opaque provider error',
135
+ );
136
+ }
137
+
138
+ function providerState(gateway: ReturnType<typeof createGateway>) {
139
+ return {
140
+ 'pay-paypal:paypal/default': {
141
+ secretCredential: { clientId: 'client-test', clientSecret: 'secret-test' },
142
+ webhookId: 'webhook-test',
143
+ merchantReference: 'merchant-test',
144
+ gateway,
145
+ },
146
+ } as never;
147
+ }
148
+
149
+ function providerExtraData(gateway: ReturnType<typeof createGateway>) {
150
+ return { state: { payProviderClientOptions: providerState(gateway) } as never };
151
+ }
152
+
153
+ async function cleanup(fixture: IFixture) {
154
+ const trade = app.scope('commerce-trade');
155
+ const payment = app.scope('commerce-payment');
156
+ const catalog = app.scope('commerce-catalog');
157
+ const member = app.scope('commerce-member');
158
+ const pay = app.scope('a-pay');
159
+ if (fixture.paymentSessionId !== undefined) {
160
+ const refunds = await pay.model.refundOperation.select({
161
+ where: { paymentSessionId: fixture.paymentSessionId },
162
+ });
163
+ for (const refund of refunds) {
164
+ const providerOperation = await pay.model.providerOperation.get({
165
+ refundOperationId: refund.id,
166
+ kind: 'refund',
167
+ });
168
+ if (providerOperation) {
169
+ await pay.model.providerOperationRecoveryAudit.delete({
170
+ providerOperationId: providerOperation.id,
171
+ });
172
+ }
173
+ await pay.model.providerOperation.delete({ refundOperationId: refund.id });
174
+ await pay.model.outboxEvent.delete({ refundOperationId: refund.id });
175
+ await pay.model.webhookInbox.delete({ refundOperationId: refund.id });
176
+ await pay.model.refundOperation.delete({ id: refund.id });
177
+ }
178
+ await pay.model.providerOperation.delete({ paymentSessionId: fixture.paymentSessionId });
179
+ await pay.model.outboxEvent.delete({ paymentSessionId: fixture.paymentSessionId });
180
+ await pay.model.webhookInbox.delete({ paymentSessionId: fixture.paymentSessionId });
181
+ await pay.model.paymentAudit.delete({ paymentSessionId: fixture.paymentSessionId });
182
+ }
183
+ if (fixture.orderId !== undefined) {
184
+ const requests = await payment.model.refundRequest.select({
185
+ where: { orderId: fixture.orderId },
186
+ });
187
+ for (const request of requests) {
188
+ await payment.model.refundAudit.delete({ refundRequestId: request.id });
189
+ await payment.model.refundAttempt.delete({ refundRequestId: request.id });
190
+ }
191
+ await payment.model.refundRequest.delete({ orderId: fixture.orderId });
192
+ await trade.model.orderAudit.delete({ orderId: fixture.orderId });
193
+ const lines = await trade.model.orderLine.select({ where: { orderId: fixture.orderId } });
194
+ for (const line of lines) await trade.model.stockReservation.delete({ orderLineId: line.id });
195
+ await trade.model.orderLine.delete({ orderId: fixture.orderId });
196
+ await trade.model.order.delete({ id: fixture.orderId });
197
+ }
198
+ if (fixture.paymentAttemptId !== undefined) {
199
+ await payment.model.paymentAudit.delete({ paymentAttemptId: fixture.paymentAttemptId });
200
+ await payment.model.paymentAttempt.delete({ id: fixture.paymentAttemptId });
201
+ }
202
+ if (fixture.paymentSessionId !== undefined)
203
+ await pay.model.paymentSession.delete({ id: fixture.paymentSessionId });
204
+ if (fixture.cartId !== undefined) {
205
+ await trade.model.cartItem.delete({ cartId: fixture.cartId });
206
+ await trade.model.cart.delete({ id: fixture.cartId });
207
+ }
208
+ if (fixture.addressId !== undefined) await member.model.address.delete({ id: fixture.addressId });
209
+ if (fixture.balanceId !== undefined)
210
+ await trade.model.stockBalance.delete({ id: fixture.balanceId });
211
+ if (fixture.skuId !== undefined) await catalog.model.sku.delete({ id: fixture.skuId });
212
+ if (fixture.productId !== undefined)
213
+ await catalog.model.product.delete({ id: fixture.productId });
214
+ if (fixture.categoryId !== undefined)
215
+ await catalog.model.category.delete({ id: fixture.categoryId });
216
+ if (fixture.userId !== undefined) {
217
+ await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
218
+ await app.bean.user.removeById(fixture.userId);
219
+ }
220
+ }
221
+
222
+ async function createCheckout(suffix: string, providerCandidateKey = 'paypal'): Promise<IFixture> {
223
+ const fixture: IFixture = {};
224
+ await app.bean.passport.signinMock();
225
+ fixture.categoryId = await app.bean.executor.performAction('post', '/commerce/catalog/category', {
226
+ body: { name: `paypal-category-${suffix}`, published: true },
227
+ });
228
+ fixture.productId = await app.bean.executor.performAction('post', '/commerce/catalog/product', {
229
+ body: { categoryId: fixture.categoryId, title: `paypal-product-${suffix}`, published: true },
230
+ });
231
+ fixture.skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
232
+ body: {
233
+ productId: fixture.productId,
234
+ code: `paypal-sku-${suffix}`,
235
+ priceCents: 1299,
236
+ attributes: [],
237
+ lifecycle: 'active',
238
+ },
239
+ });
240
+ fixture.balanceId = (
241
+ await app.scope('commerce-trade').service.stockBalance.adjustStock({
242
+ skuId: fixture.skuId,
243
+ delta: 1,
244
+ reason: 'PayPal lifecycle fixture',
245
+ correlationId: `paypal-stock-${suffix}`,
246
+ })
247
+ ).id as number;
248
+ await app.bean.passport.signout();
249
+ fixture.customerName = `paypal-customer-${suffix}`;
250
+ const customer = await app.bean.user.register({ name: fixture.customerName }, true);
251
+ fixture.userId = customer.id as number;
252
+ await app.bean.passport.signinMock(fixture.customerName as any);
253
+ const userId = app.bean.passport.currentUser!.id;
254
+ fixture.addressId = await app.bean.executor.performAction(
255
+ 'post',
256
+ '/commerce/member/address/createMine',
257
+ {
258
+ body: {
259
+ recipientName: 'PayPal Customer',
260
+ phone: '15555550136',
261
+ countryCode: 'US',
262
+ region: 'California',
263
+ city: 'San Francisco',
264
+ postalCode: '94105',
265
+ addressLine1: '9 Market Street',
266
+ },
267
+ },
268
+ );
269
+ const cart = await app.scope('commerce-trade').model.cart.insert({ userId });
270
+ fixture.cartId = cart.id as number;
271
+ await app.scope('commerce-trade').model.cartItem.insert({
272
+ cartId: cart.id,
273
+ skuId: fixture.skuId,
274
+ quantity: 1,
275
+ });
276
+ const checkout = await app.scope('commerce-trade').service.order.checkout({
277
+ addressId: fixture.addressId,
278
+ correlationId: `paypal-checkout-${suffix}`,
279
+ providerCandidateKey,
280
+ });
281
+ fixture.orderId = checkout.orderId as number;
282
+ fixture.paymentAttemptId = checkout.paymentAttemptId as number;
283
+ fixture.paymentSessionId = checkout.paymentSessionId as number;
284
+ return fixture;
285
+ }
286
+
287
+ async function callbackToken(sessionId: number, purpose: 'return' | 'cancel') {
288
+ const session = await app.scope('a-pay').model.paymentSession.getById(sessionId);
289
+ assert.ok(session);
290
+ return (await app.scope('a-pay').service.paymentCallback.createUrls(session))[
291
+ purpose === 'return' ? 'returnUrl' : 'cancelUrl'
292
+ ];
293
+ }
294
+
295
+ describe('paypalLifecycle.test.ts', { concurrency: false, sequential: true }, () => {
296
+ const releases: Array<() => void> = [];
297
+ let originalServe: { protocol?: string; host?: string };
298
+
299
+ before(async () => {
300
+ for (const scene of ['a-commerce', 'a-pay']) releases.push(await acquireTestLock(scene));
301
+ originalServe = { ...app.config.server.serve };
302
+ (app.config.server.serve as any).protocol = 'http';
303
+ (app.config.server.serve as any).host = 'localhost';
304
+ });
305
+
306
+ after(() => {
307
+ Object.assign(app.config.server.serve, originalServe);
308
+ for (const release of releases.reverse()) release();
309
+ });
310
+
311
+ it('selects PayPal, starts an order, and settles a return callback through Commerce', async () => {
312
+ const state: IGatewayState = {
313
+ orderStatus: 'CREATED',
314
+ refundStatus: 'PENDING',
315
+ calls: [],
316
+ };
317
+ const gateway = createGateway(state);
318
+ await app.bean.executor.mockCtx(
319
+ async () => {
320
+ const fixture: IFixture = {};
321
+ try {
322
+ const resolvedPaypalOptions = app.bean.payProvider.getOptions(
323
+ 'pay-paypal:paypal',
324
+ 'default',
325
+ ) as any;
326
+ assert.equal(resolvedPaypalOptions.secretCredential?.clientId, 'client-test');
327
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
328
+ const pay = app.scope('a-pay');
329
+ const session = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
330
+ assert.equal(session?.providerName, 'pay-paypal:paypal');
331
+ assert.equal(session?.environment, 'sandbox');
332
+ const started = await pay.service.paymentSession.start(fixture.paymentSessionId!);
333
+ assert.equal(started.state, 'requires_action');
334
+ assert.equal(started.nextAction?.kind, 'redirect');
335
+ assert.equal(
336
+ (await pay.model.providerOperation.get({ paymentSessionId: started.id, kind: 'start' }))
337
+ ?.state,
338
+ 'succeeded',
339
+ );
340
+ const startOperation = await pay.model.providerOperation.get({
341
+ paymentSessionId: started.id,
342
+ kind: 'start',
343
+ });
344
+ assert.match(
345
+ startOperation?.idempotencyKey ?? '',
346
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
347
+ );
348
+ assert.equal(startOperation?.providerRequestId, startOperation?.idempotencyKey);
349
+ const createCall = state.calls.find(call => call.kind === 'createOrder') as any;
350
+ assert.equal(createCall.input.paypalRequestId, startOperation?.idempotencyKey);
351
+ assert.equal(
352
+ createCall.input.body.purchaseUnits[0].customId,
353
+ started.providerCorrelationReference,
354
+ );
355
+ assert.equal(
356
+ createCall.input.body.purchaseUnits[0].invoiceId,
357
+ started.providerInvoiceReference,
358
+ );
359
+ assert.notEqual(started.providerCorrelationReference, String(started.id));
360
+ assert.notEqual(started.providerInvoiceReference, String(fixture.paymentAttemptId));
361
+
362
+ const returnUrl = await callbackToken(started.id as number, 'return');
363
+ const returnState = new URL(returnUrl).searchParams.get('state');
364
+ assert.ok(returnState);
365
+ await assert.rejects(
366
+ app.bean.executor.performAction('get', '/pay/payment-callback/return', {
367
+ query: { state: returnState },
368
+ extraData: { state: { payProviderClientOptions: providerState(gateway) } as never },
369
+ }),
370
+ { status: 302 },
371
+ );
372
+ const outbox = await pay.model.outboxEvent.get({
373
+ paymentSessionId: started.id,
374
+ eventType: 'payment.outcome.v1',
375
+ });
376
+ assert.ok(outbox);
377
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
378
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
379
+ const order = await app.scope('commerce-trade').model.order.getById(fixture.orderId!);
380
+ const attempt = await app
381
+ .scope('commerce-payment')
382
+ .model.paymentAttempt.getById(fixture.paymentAttemptId!);
383
+ assert.deepEqual(
384
+ [order?.state, attempt?.state, (await pay.model.outboxEvent.getById(outbox.id))?.state],
385
+ ['paid', 'succeeded', 'dispatched'],
386
+ );
387
+ const captureCall = state.calls.find(call => call.kind === 'captureOrder') as any;
388
+ assert.equal(captureCall.input.id, started.providerOrderId);
389
+ assert.equal(captureCall.input.prefer, 'return=representation');
390
+ assert.equal(state.calls.filter(call => call.kind === 'captureOrder').length, 1);
391
+ } finally {
392
+ await cleanup(fixture);
393
+ await app.bean.passport.signout();
394
+ }
395
+ },
396
+ { extraData: { state: { payProviderClientOptions: providerState(gateway) } as never } },
397
+ );
398
+ });
399
+
400
+ it('reconciles a pending PayPal return through the durable provider operation', async () => {
401
+ const state: IGatewayState = {
402
+ orderStatus: 'CREATED',
403
+ captureStatus: 'PENDING',
404
+ refundStatus: 'PENDING',
405
+ calls: [],
406
+ };
407
+ const gateway = createGateway(state);
408
+ await app.bean.executor.mockCtx(
409
+ async () => {
410
+ const fixture: IFixture = {};
411
+ try {
412
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
413
+ const pay = app.scope('a-pay');
414
+ const started = await pay.service.paymentSession.start(fixture.paymentSessionId!);
415
+ const returnState = new URL(
416
+ await callbackToken(started.id as number, 'return'),
417
+ ).searchParams.get('state');
418
+ assert.ok(returnState);
419
+ await assert.rejects(
420
+ app.bean.executor.performAction('get', '/pay/payment-callback/return', {
421
+ query: { state: returnState },
422
+ extraData: providerExtraData(gateway),
423
+ }),
424
+ { status: 302 },
425
+ );
426
+ const pendingSession = await pay.model.paymentSession.getById(started.id);
427
+ const operation = await pay.model.providerOperation.get({
428
+ paymentSessionId: started.id,
429
+ kind: 'confirm',
430
+ });
431
+ assert.equal(pendingSession?.state, 'processing');
432
+ assert.equal(operation?.state, 'reconciliation_required');
433
+ assert.ok(operation?.nextAttemptAt);
434
+ assert.equal(
435
+ await pay.model.outboxEvent.get({
436
+ paymentSessionId: started.id,
437
+ eventType: 'payment.outcome.v1',
438
+ }),
439
+ undefined,
440
+ );
441
+ assert.equal(
442
+ (await app.scope('commerce-trade').model.order.getById(fixture.orderId!))?.state,
443
+ 'awaiting_payment',
444
+ );
445
+
446
+ state.captureStatus = 'COMPLETED';
447
+ await pay.model.providerOperation.updateById(operation!.id, {
448
+ nextAttemptAt: new Date(Date.now() - 1_000),
449
+ });
450
+ assert.equal(await pay.service.providerOperation.queueDue(), 1);
451
+ const settledSession = await pay.model.paymentSession.getById(started.id);
452
+ const outbox = await pay.model.outboxEvent.get({
453
+ paymentSessionId: started.id,
454
+ eventType: 'payment.outcome.v1',
455
+ });
456
+ assert.equal(settledSession?.state, 'succeeded');
457
+ assert.ok(outbox);
458
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
459
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
460
+ assert.equal(
461
+ (await app.scope('commerce-trade').model.order.getById(fixture.orderId!))?.state,
462
+ 'paid',
463
+ );
464
+ } finally {
465
+ await cleanup(fixture);
466
+ await app.bean.passport.signout();
467
+ }
468
+ },
469
+ { extraData: providerExtraData(gateway) },
470
+ );
471
+ });
472
+
473
+ it('recovers a transient PayPal capture failure through the durable provider operation', async () => {
474
+ const state: IGatewayState = {
475
+ orderStatus: 'CREATED',
476
+ captureFailuresRemaining: 1,
477
+ refundStatus: 'PENDING',
478
+ calls: [],
479
+ };
480
+ const gateway = createGateway(state);
481
+ await app.bean.executor.mockCtx(
482
+ async () => {
483
+ const fixture: IFixture = {};
484
+ try {
485
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
486
+ const pay = app.scope('a-pay');
487
+ const started = await pay.service.paymentSession.start(fixture.paymentSessionId!);
488
+ const returnState = new URL(
489
+ await callbackToken(started.id as number, 'return'),
490
+ ).searchParams.get('state');
491
+ assert.ok(returnState);
492
+ await assert.rejects(
493
+ app.bean.executor.performAction('get', '/pay/payment-callback/return', {
494
+ query: { state: returnState },
495
+ extraData: providerExtraData(gateway),
496
+ }),
497
+ { status: 302 },
498
+ );
499
+ const operation = await pay.model.providerOperation.get({
500
+ paymentSessionId: started.id,
501
+ kind: 'confirm',
502
+ });
503
+ assert.equal(operation?.state, 'reconciliation_required');
504
+ assert.equal(operation?.errorSummary, 'Provider operation failed and will be reconciled');
505
+ assert.equal(
506
+ await pay.model.outboxEvent.get({
507
+ paymentSessionId: started.id,
508
+ eventType: 'payment.outcome.v1',
509
+ }),
510
+ undefined,
511
+ );
512
+
513
+ await pay.model.providerOperation.updateById(operation!.id, {
514
+ nextAttemptAt: new Date(Date.now() - 1_000),
515
+ });
516
+ assert.equal(await pay.service.providerOperation.queueDue(), 1);
517
+ const outbox = await pay.model.outboxEvent.get({
518
+ paymentSessionId: started.id,
519
+ eventType: 'payment.outcome.v1',
520
+ });
521
+ assert.equal((await pay.model.paymentSession.getById(started.id))?.state, 'succeeded');
522
+ assert.ok(outbox);
523
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
524
+ assert.equal(
525
+ (await app.scope('commerce-trade').model.order.getById(fixture.orderId!))?.state,
526
+ 'paid',
527
+ );
528
+ assert.equal(state.calls.filter(call => call.kind === 'captureOrder').length, 2);
529
+ } finally {
530
+ await cleanup(fixture);
531
+ await app.bean.passport.signout();
532
+ }
533
+ },
534
+ { extraData: providerExtraData(gateway) },
535
+ );
536
+ });
537
+
538
+ it('verifies a PayPal capture webhook and creates one durable payment outcome', async () => {
539
+ const state: IGatewayState = {
540
+ orderStatus: 'COMPLETED',
541
+ captureStatus: 'COMPLETED',
542
+ refundStatus: 'PENDING',
543
+ calls: [],
544
+ };
545
+ const gateway = createGateway(state);
546
+ await app.bean.executor.mockCtx(
547
+ async () => {
548
+ const fixture: IFixture = {};
549
+ try {
550
+ const resolvedPaypalOptions = app.bean.payProvider.getOptions(
551
+ 'pay-paypal:paypal',
552
+ 'default',
553
+ ) as any;
554
+ assert.equal(resolvedPaypalOptions.secretCredential?.clientId, 'client-test');
555
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
556
+ const pay = app.scope('a-pay');
557
+ const session = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
558
+ assert.ok(session);
559
+ state.providerCorrelationReference = session.providerCorrelationReference;
560
+ state.providerInvoiceReference = session.providerInvoiceReference;
561
+ state.orderId = `paypal-order-${session.providerCorrelationReference}`;
562
+ state.captureId = `paypal-capture-${session.id}`;
563
+ const rawBody = JSON.stringify({
564
+ id: `paypal-event-${session.id}`,
565
+ event_type: 'PAYMENT.CAPTURE.COMPLETED',
566
+ resource: {
567
+ id: state.captureId,
568
+ amount: { currency_code: 'USD', value: '12.99' },
569
+ supplementary_data: { related_ids: { order_id: state.orderId } },
570
+ },
571
+ });
572
+ const verified = await pay.bean.payProvider.get('pay-paypal:paypal').verifyWebhook(
573
+ {
574
+ rawBody,
575
+ body: JSON.parse(rawBody),
576
+ headers: { 'paypal-transmission-id': 'transmission-test' },
577
+ },
578
+ pay.bean.payProvider.getOptions('pay-paypal:paypal', 'default'),
579
+ );
580
+ await pay.service.webhook.receive({
581
+ providerName: 'pay-paypal:paypal',
582
+ clientName: 'default',
583
+ environment: 'sandbox',
584
+ rawBody,
585
+ verified,
586
+ });
587
+ const inboxes = await pay.model.webhookInbox.select({
588
+ where: { paymentSessionId: session.id },
589
+ });
590
+ assert.equal(inboxes.length, 1);
591
+ const outboxes = await pay.model.outboxEvent.select({
592
+ where: { paymentSessionId: session.id, eventType: 'payment.outcome.v1' },
593
+ });
594
+ assert.equal(outboxes.length, 1);
595
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outboxes[0]!.id });
596
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outboxes[0]!.id });
597
+ assert.equal(
598
+ (await app.scope('commerce-trade').model.order.getById(fixture.orderId!))?.state,
599
+ 'paid',
600
+ );
601
+ assert.equal(
602
+ state.calls.filter(call => call.kind === 'verifyWebhookSignature').length,
603
+ 1,
604
+ );
605
+ } finally {
606
+ await cleanup(fixture);
607
+ await app.bean.passport.signout();
608
+ }
609
+ },
610
+ { extraData: { state: { payProviderClientOptions: providerState(gateway) } as never } },
611
+ );
612
+ });
613
+
614
+ it('settles a VOIDED PayPal cancel callback through Commerce and releases stock', async () => {
615
+ const state: IGatewayState = {
616
+ orderStatus: 'CREATED',
617
+ refundStatus: 'PENDING',
618
+ calls: [],
619
+ };
620
+ const gateway = createGateway(state);
621
+ await app.bean.executor.mockCtx(
622
+ async () => {
623
+ const fixture: IFixture = {};
624
+ try {
625
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
626
+ const pay = app.scope('a-pay');
627
+ const session = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
628
+ assert.ok(session);
629
+ const started = await pay.service.paymentSession.start(session.id);
630
+ state.orderStatus = 'VOIDED';
631
+ const cancelUrl = await callbackToken(started.id as number, 'cancel');
632
+ const cancelState = new URL(cancelUrl).searchParams.get('state');
633
+ assert.ok(cancelState);
634
+ await assert.rejects(
635
+ app.bean.executor.performAction('get', '/pay/payment-callback/cancel', {
636
+ query: { state: cancelState },
637
+ extraData: providerExtraData(gateway),
638
+ }),
639
+ { status: 302 },
640
+ );
641
+ const operation = await pay.model.providerOperation.get({
642
+ paymentSessionId: started.id,
643
+ kind: 'query',
644
+ });
645
+ assert.equal(operation?.state, 'succeeded');
646
+ const outbox = await pay.model.outboxEvent.get({
647
+ paymentSessionId: started.id,
648
+ eventType: 'payment.outcome.v1',
649
+ });
650
+ assert.ok(outbox);
651
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
652
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
653
+ const order = await app.scope('commerce-trade').model.order.getById(fixture.orderId!);
654
+ const attempt = await app
655
+ .scope('commerce-payment')
656
+ .model.paymentAttempt.getById(fixture.paymentAttemptId!);
657
+ const line = await app
658
+ .scope('commerce-trade')
659
+ .model.orderLine.get({ orderId: fixture.orderId! });
660
+ assert.equal(order?.state, 'cancelled');
661
+ assert.equal(attempt?.state, 'cancelled');
662
+ assert.equal(
663
+ (
664
+ await app
665
+ .scope('commerce-trade')
666
+ .model.stockReservation.get({ orderLineId: line!.id })
667
+ )?.state,
668
+ 'released',
669
+ );
670
+ assert.equal(state.calls.filter(call => call.kind === 'getOrder').length, 1);
671
+ assert.equal(state.calls.filter(call => call.kind === 'captureOrder').length, 0);
672
+ } finally {
673
+ await cleanup(fixture);
674
+ await app.bean.passport.signout();
675
+ }
676
+ },
677
+ { extraData: providerExtraData(gateway) },
678
+ );
679
+ });
680
+
681
+ it('keeps a PayPal cancel callback nonterminal while the order remains CREATED', async () => {
682
+ const state: IGatewayState = {
683
+ orderStatus: 'CREATED',
684
+ refundStatus: 'PENDING',
685
+ calls: [],
686
+ };
687
+ const gateway = createGateway(state);
688
+ await app.bean.executor.mockCtx(
689
+ async () => {
690
+ const fixture: IFixture = {};
691
+ try {
692
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
693
+ const pay = app.scope('a-pay');
694
+ const session = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
695
+ assert.ok(session);
696
+ const started = await pay.service.paymentSession.start(session.id);
697
+ const cancelUrl = await callbackToken(started.id as number, 'cancel');
698
+ const cancelState = new URL(cancelUrl).searchParams.get('state');
699
+ assert.ok(cancelState);
700
+ await assert.rejects(
701
+ app.bean.executor.performAction('get', '/pay/payment-callback/cancel', {
702
+ query: { state: cancelState },
703
+ extraData: providerExtraData(gateway),
704
+ }),
705
+ { status: 302 },
706
+ );
707
+ const reconciled = await pay.model.paymentSession.getById(started.id);
708
+ assert.equal(reconciled?.state, 'requires_action');
709
+ assert.deepEqual(reconciled?.nextAction, {
710
+ kind: 'redirect',
711
+ url: `https://sandbox.paypal.test/${state.orderId}`,
712
+ });
713
+ assert.equal(
714
+ (await app.scope('commerce-trade').model.order.getById(fixture.orderId!))?.state,
715
+ 'awaiting_payment',
716
+ );
717
+ assert.equal(
718
+ (
719
+ await app
720
+ .scope('commerce-payment')
721
+ .model.paymentAttempt.getById(fixture.paymentAttemptId!)
722
+ )?.state,
723
+ 'created',
724
+ );
725
+ assert.equal(
726
+ (await pay.model.outboxEvent.get({
727
+ paymentSessionId: started.id,
728
+ eventType: 'payment.outcome.v1',
729
+ })) as any,
730
+ undefined,
731
+ );
732
+ assert.equal(state.calls.filter(call => call.kind === 'getOrder').length, 1);
733
+ } finally {
734
+ await cleanup(fixture);
735
+ await app.bean.passport.signout();
736
+ }
737
+ },
738
+ { extraData: providerExtraData(gateway) },
739
+ );
740
+ });
741
+
742
+ it('keeps a pending PayPal cancel callback in durable reconciliation', async () => {
743
+ const state: IGatewayState = {
744
+ orderStatus: 'CREATED',
745
+ captureId: 'paypal-capture-pending',
746
+ captureStatus: 'PENDING',
747
+ refundStatus: 'PENDING',
748
+ calls: [],
749
+ };
750
+ const gateway = createGateway(state);
751
+ await app.bean.executor.mockCtx(
752
+ async () => {
753
+ const fixture: IFixture = {};
754
+ try {
755
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
756
+ const pay = app.scope('a-pay');
757
+ const started = await pay.service.paymentSession.start(fixture.paymentSessionId!);
758
+ const cancelState = new URL(
759
+ await callbackToken(started.id as number, 'cancel'),
760
+ ).searchParams.get('state');
761
+ assert.ok(cancelState);
762
+ await assert.rejects(
763
+ app.bean.executor.performAction('get', '/pay/payment-callback/cancel', {
764
+ query: { state: cancelState },
765
+ extraData: providerExtraData(gateway),
766
+ }),
767
+ { status: 302 },
768
+ );
769
+ const reconciled = await pay.model.paymentSession.getById(started.id);
770
+ const operation = await pay.model.providerOperation.get({
771
+ paymentSessionId: started.id,
772
+ kind: 'query',
773
+ });
774
+ assert.equal(reconciled?.state, 'processing');
775
+ assert.equal(reconciled?.nextAction, undefined);
776
+ assert.equal(operation?.state, 'reconciliation_required');
777
+ assert.ok(operation?.nextAttemptAt);
778
+ assert.equal(
779
+ await pay.model.outboxEvent.get({
780
+ paymentSessionId: started.id,
781
+ eventType: 'payment.outcome.v1',
782
+ }),
783
+ undefined,
784
+ );
785
+ assert.equal(
786
+ (await app.scope('commerce-trade').model.order.getById(fixture.orderId!))?.state,
787
+ 'awaiting_payment',
788
+ );
789
+ assert.equal(state.calls.filter(call => call.kind === 'getOrder').length, 1);
790
+ assert.equal(state.calls.filter(call => call.kind === 'captureOrder').length, 0);
791
+ } finally {
792
+ await cleanup(fixture);
793
+ await app.bean.passport.signout();
794
+ }
795
+ },
796
+ { extraData: providerExtraData(gateway) },
797
+ );
798
+ });
799
+
800
+ it('rejects invalid PayPal callback state before provider or durable mutation', async () => {
801
+ const state: IGatewayState = {
802
+ orderStatus: 'CREATED',
803
+ refundStatus: 'PENDING',
804
+ calls: [],
805
+ };
806
+ const gateway = createGateway(state);
807
+ await app.bean.executor.mockCtx(
808
+ async () => {
809
+ const fixture: IFixture = {};
810
+ try {
811
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
812
+ const pay = app.scope('a-pay');
813
+ const session = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
814
+ assert.ok(session);
815
+ const started = await pay.service.paymentSession.start(session.id);
816
+ const returnUrl = await callbackToken(started.id as number, 'return');
817
+ const token = new URL(returnUrl).searchParams.get('state');
818
+ assert.ok(token);
819
+ const unsafeState = await app.bean.jwt.get('oauthstate').sign(
820
+ {
821
+ paymentSessionId: started.id,
822
+ providerName: started.providerName,
823
+ clientName: started.clientName,
824
+ environment: started.environment,
825
+ purpose: 'cancel',
826
+ continuationPath: '//untrusted.example/providerResult=cancel',
827
+ },
828
+ { path: '/pay/payment-callback/cancel', expiresIn: '15m' },
829
+ );
830
+ await assert.rejects(
831
+ app.bean.executor.performAction('get', '/pay/payment-callback/cancel', {
832
+ query: { state: unsafeState },
833
+ extraData: providerExtraData(gateway),
834
+ }),
835
+ { status: 401 },
836
+ );
837
+ await assert.rejects(
838
+ app.bean.executor.performAction('get', '/pay/payment-callback/cancel', {
839
+ query: { state: token },
840
+ extraData: providerExtraData(gateway),
841
+ }),
842
+ { status: 401 },
843
+ );
844
+ assert.equal(state.calls.filter(call => call.kind === 'getOrder').length, 0);
845
+ assert.equal(
846
+ (await pay.model.providerOperation.select({ where: { paymentSessionId: started.id } }))
847
+ .length,
848
+ 1,
849
+ );
850
+ assert.equal(
851
+ (await pay.model.outboxEvent.select({ where: { paymentSessionId: started.id } }))
852
+ .length,
853
+ 0,
854
+ );
855
+ } finally {
856
+ await cleanup(fixture);
857
+ await app.bean.passport.signout();
858
+ }
859
+ },
860
+ { extraData: providerExtraData(gateway) },
861
+ );
862
+ });
863
+
864
+ it('forwards raw PayPal webhook body and headers through the controller', async () => {
865
+ const state: IGatewayState = {
866
+ orderStatus: 'COMPLETED',
867
+ captureStatus: 'COMPLETED',
868
+ refundStatus: 'PENDING',
869
+ calls: [],
870
+ };
871
+ const gateway = createGateway(state);
872
+ await app.bean.executor.mockCtx(
873
+ async () => {
874
+ const fixture: IFixture = {};
875
+ try {
876
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
877
+ const pay = app.scope('a-pay');
878
+ const session = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
879
+ assert.ok(session);
880
+ state.providerCorrelationReference = session.providerCorrelationReference;
881
+ state.providerInvoiceReference = session.providerInvoiceReference;
882
+ state.orderId = `paypal-order-${session.providerCorrelationReference}`;
883
+ state.captureId = `paypal-capture-${session.id}`;
884
+ const rawBody = JSON.stringify({
885
+ id: `paypal-controller-event-${session.id}`,
886
+ event_type: 'PAYMENT.CAPTURE.COMPLETED',
887
+ resource: {
888
+ id: state.captureId,
889
+ amount: { currency_code: 'USD', value: '12.99' },
890
+ supplementary_data: { related_ids: { order_id: state.orderId } },
891
+ },
892
+ });
893
+ const headers = {
894
+ 'paypal-transmission-id': 'controller-transmission-test',
895
+ 'content-type': 'application/json',
896
+ };
897
+ app.ctx.request.rawBody = rawBody;
898
+ app.ctx.req.headers = { ...app.ctx.req.headers, ...headers };
899
+ const controller = app.bean._getBean('a-pay.controller.webhook' as never) as any;
900
+ const result = await controller.receive(
901
+ 'pay-paypal:paypal',
902
+ 'default',
903
+ JSON.parse(rawBody),
904
+ );
905
+ assert.deepEqual(result, { accepted: true });
906
+ const verifyCall = state.calls.find(
907
+ call => call.kind === 'verifyWebhookSignature',
908
+ ) as any;
909
+ assert.equal(verifyCall.input.rawBody, rawBody);
910
+ assert.deepEqual(verifyCall.input.body, JSON.parse(rawBody));
911
+ assert.equal(
912
+ verifyCall.input.headers['paypal-transmission-id'],
913
+ headers['paypal-transmission-id'],
914
+ );
915
+ const outbox = await pay.model.outboxEvent.get({
916
+ paymentSessionId: session.id,
917
+ eventType: 'payment.outcome.v1',
918
+ });
919
+ assert.ok(outbox);
920
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
921
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
922
+ assert.equal(
923
+ (await app.scope('commerce-trade').model.order.getById(fixture.orderId!))?.state,
924
+ 'paid',
925
+ );
926
+ } finally {
927
+ await cleanup(fixture);
928
+ await app.bean.passport.signout();
929
+ }
930
+ },
931
+ { extraData: providerExtraData(gateway) },
932
+ );
933
+ });
934
+
935
+ it('executes a PayPal refund and settles Commerce through one refund outbox event', async () => {
936
+ const state: IGatewayState = {
937
+ orderStatus: 'COMPLETED',
938
+ captureStatus: 'COMPLETED',
939
+ refundStatus: 'COMPLETED',
940
+ calls: [],
941
+ };
942
+ const gateway = createGateway(state);
943
+ await app.bean.executor.mockCtx(
944
+ async () => {
945
+ const fixture: IFixture = {};
946
+ try {
947
+ const resolvedPaypalOptions = app.bean.payProvider.getOptions(
948
+ 'pay-paypal:paypal',
949
+ 'default',
950
+ ) as any;
951
+ assert.equal(resolvedPaypalOptions.secretCredential?.clientId, 'client-test');
952
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
953
+ const pay = app.scope('a-pay');
954
+ const session = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
955
+ assert.ok(session);
956
+ state.providerCorrelationReference = session.providerCorrelationReference;
957
+ state.providerInvoiceReference = session.providerInvoiceReference;
958
+ state.orderId = `paypal-order-${session.providerCorrelationReference}`;
959
+ state.captureId = `paypal-capture-${session.id}`;
960
+ await pay.model.paymentSession.updateById(session.id, {
961
+ state: 'succeeded',
962
+ providerOrderId: state.orderId,
963
+ providerCaptureId: state.captureId,
964
+ finalizedAt: new Date(),
965
+ });
966
+ await app.scope('commerce-trade').service.order.settlePaymentFromProvider({
967
+ eventId: `paypal-paid-${session.id}`,
968
+ paymentSessionId: session.id,
969
+ businessReference: String(fixture.paymentAttemptId),
970
+ providerName: 'pay-paypal:paypal',
971
+ state: 'succeeded',
972
+ providerCaptureId: state.captureId,
973
+ amountMinor: 1299,
974
+ currency: 'USD',
975
+ });
976
+ const refund = await pay.service.refundOperation.create({
977
+ paymentSessionId: session.id,
978
+ businessReference: `refund-${fixture.orderId}`,
979
+ amountMinor: 500,
980
+ currency: 'USD',
981
+ idempotencyKey: `paypal-refund-${session.id}`,
982
+ correlationId: `paypal-refund-correlation-${session.id}`,
983
+ });
984
+ state.refundProviderCorrelationReference = refund.providerCorrelationReference;
985
+ state.refundProviderInvoiceReference = refund.providerInvoiceReference;
986
+ await pay.service.refundOperation.submit(refund.id);
987
+ const outbox = await pay.model.outboxEvent.get({
988
+ refundOperationId: refund.id,
989
+ eventType: 'refund.outcome.v1',
990
+ });
991
+ assert.ok(outbox);
992
+ const refundProviderOperation = await pay.model.providerOperation.get({
993
+ refundOperationId: refund.id,
994
+ kind: 'refund',
995
+ });
996
+ assert.match(
997
+ refundProviderOperation?.idempotencyKey ?? '',
998
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
999
+ );
1000
+ assert.equal(
1001
+ refundProviderOperation?.providerRequestId,
1002
+ refundProviderOperation?.idempotencyKey,
1003
+ );
1004
+ const refundCall = state.calls.find(call => call.kind === 'refundCapturedPayment') as any;
1005
+ assert.equal(refundCall.input.paypalRequestId, refundProviderOperation?.idempotencyKey);
1006
+ assert.deepEqual(refundCall.input.body, {
1007
+ amount: { currencyCode: 'USD', value: '5.00' },
1008
+ customId: refund.providerCorrelationReference,
1009
+ invoiceId: refund.providerInvoiceReference,
1010
+ });
1011
+ assert.notEqual(refund.providerCorrelationReference, String(refund.id));
1012
+ assert.notEqual(refund.providerInvoiceReference, refund.businessReference);
1013
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
1014
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
1015
+ assert.equal((await pay.model.refundOperation.getById(refund.id))?.state, 'succeeded');
1016
+ } finally {
1017
+ await cleanup(fixture);
1018
+ await app.bean.passport.signout();
1019
+ }
1020
+ },
1021
+ { extraData: { state: { payProviderClientOptions: providerState(gateway) } as never } },
1022
+ );
1023
+ });
1024
+
1025
+ it('settles a capture-refunded webhook with an alternate refund resource ID through Commerce', async () => {
1026
+ const state: IGatewayState = {
1027
+ orderStatus: 'COMPLETED',
1028
+ captureStatus: 'COMPLETED',
1029
+ refundStatus: 'COMPLETED',
1030
+ refundError: new Error('response lost after refund submission'),
1031
+ captureLookupError: paypalApiError(404),
1032
+ refundAmount: '12.99',
1033
+ calls: [],
1034
+ };
1035
+ const gateway = createGateway(state);
1036
+ await app.bean.executor.mockCtx(
1037
+ async () => {
1038
+ const fixture: IFixture = {};
1039
+ try {
1040
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
1041
+ const pay = app.scope('a-pay');
1042
+ const session = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
1043
+ assert.ok(session);
1044
+ state.providerCorrelationReference = session.providerCorrelationReference;
1045
+ state.providerInvoiceReference = session.providerInvoiceReference;
1046
+ state.orderId = `paypal-order-${session.providerCorrelationReference}`;
1047
+ state.captureId = `paypal-capture-${session.id}`;
1048
+ await pay.model.paymentSession.updateById(session.id, {
1049
+ state: 'succeeded',
1050
+ providerOrderId: state.orderId,
1051
+ providerCaptureId: state.captureId,
1052
+ finalizedAt: new Date(),
1053
+ });
1054
+ await app.scope('commerce-trade').service.order.settlePaymentFromProvider({
1055
+ eventId: `paypal-paid-${session.id}`,
1056
+ paymentSessionId: session.id,
1057
+ businessReference: String(fixture.paymentAttemptId),
1058
+ providerName: 'pay-paypal:paypal',
1059
+ state: 'succeeded',
1060
+ providerCaptureId: state.captureId,
1061
+ amountMinor: 1299,
1062
+ currency: 'USD',
1063
+ });
1064
+ const order = app.scope('commerce-trade').service.order;
1065
+ await order.requestRefund(fixture.orderId!, {
1066
+ reason: 'PayPal capture-refunded webhook',
1067
+ idempotencyKey: `paypal-webhook-request-${session.id}`,
1068
+ });
1069
+ await app.bean.passport.signout();
1070
+ await app.bean.passport.signinMock();
1071
+ await order.approveRefund(fixture.orderId!, {
1072
+ reason: 'approved for provider submission',
1073
+ idempotencyKey: `paypal-webhook-approve-${session.id}`,
1074
+ });
1075
+ const executed = await order.executeRefund(fixture.orderId!);
1076
+ const refundOperation = await pay.model.refundOperation.getById(
1077
+ executed.refundOperationId!,
1078
+ );
1079
+ assert.ok(refundOperation);
1080
+ state.refundProviderCorrelationReference = refundOperation.providerCorrelationReference;
1081
+ state.refundProviderInvoiceReference = refundOperation.providerInvoiceReference;
1082
+ state.refundId = `paypal-refund-${refundOperation.providerCorrelationReference}`;
1083
+ const rawBody = JSON.stringify({
1084
+ id: `paypal-capture-refunded-${session.id}`,
1085
+ event_type: 'PAYMENT.CAPTURE.REFUNDED',
1086
+ resource: {
1087
+ id: state.refundId,
1088
+ amount: { currency_code: 'USD', value: '12.99' },
1089
+ status: 'REFUNDED',
1090
+ supplementary_data: { related_ids: { capture_id: state.captureId } },
1091
+ },
1092
+ });
1093
+ const verified = await pay.bean.payProvider.get('pay-paypal:paypal').verifyWebhook(
1094
+ {
1095
+ rawBody,
1096
+ body: JSON.parse(rawBody),
1097
+ headers: { 'paypal-transmission-id': 'webhook-refunded-test' },
1098
+ },
1099
+ pay.bean.payProvider.getOptions('pay-paypal:paypal', 'default'),
1100
+ );
1101
+ assert.equal(verified.refundOperationId, executed.refundOperationId);
1102
+ await pay.service.webhook.receive({
1103
+ providerName: 'pay-paypal:paypal',
1104
+ clientName: 'default',
1105
+ environment: 'sandbox',
1106
+ rawBody,
1107
+ verified,
1108
+ });
1109
+ await pay.service.webhook.receive({
1110
+ providerName: 'pay-paypal:paypal',
1111
+ clientName: 'default',
1112
+ environment: 'sandbox',
1113
+ rawBody,
1114
+ verified,
1115
+ });
1116
+ const outboxes = await pay.model.outboxEvent.select({
1117
+ where: {
1118
+ refundOperationId: executed.refundOperationId,
1119
+ eventType: 'refund.outcome.v1',
1120
+ },
1121
+ });
1122
+ assert.equal(outboxes.length, 1);
1123
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outboxes[0]!.id });
1124
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outboxes[0]!.id });
1125
+ const [settledOrder, request, attempt, reservation] = await Promise.all([
1126
+ app.scope('commerce-trade').model.order.getById(fixture.orderId!),
1127
+ app.scope('commerce-payment').model.refundRequest.getById(executed.refundRequestId!),
1128
+ app.scope('commerce-payment').model.refundAttempt.getById(executed.refundAttemptId!),
1129
+ app.scope('commerce-trade').model.stockReservation.get({
1130
+ orderLineId: (await app
1131
+ .scope('commerce-trade')
1132
+ .model.orderLine.get({ orderId: fixture.orderId! }))!.id,
1133
+ }),
1134
+ ]);
1135
+ assert.deepEqual(
1136
+ [settledOrder?.state, request?.state, attempt?.state, reservation?.state],
1137
+ ['refunded', 'refunded', 'succeeded', 'restored'],
1138
+ );
1139
+ assert.equal(state.calls.filter(call => call.kind === 'refundCapturedPayment').length, 1);
1140
+ assert.equal(state.calls.filter(call => call.kind === 'getCapturedPayment').length, 1);
1141
+ assert.equal(state.calls.filter(call => call.kind === 'getRefund').length, 1);
1142
+ } finally {
1143
+ await cleanup(fixture);
1144
+ await app.bean.passport.signout();
1145
+ }
1146
+ },
1147
+ { extraData: providerExtraData(gateway) },
1148
+ );
1149
+ });
1150
+
1151
+ it('settles a definitive PayPal refund rejection through Commerce without restoring stock', async () => {
1152
+ const state: IGatewayState = {
1153
+ orderStatus: 'COMPLETED',
1154
+ captureStatus: 'COMPLETED',
1155
+ refundStatus: 'PENDING',
1156
+ refundError: paypalApiError(422),
1157
+ calls: [],
1158
+ };
1159
+ const gateway = createGateway(state);
1160
+ await app.bean.executor.mockCtx(
1161
+ async () => {
1162
+ const fixture: IFixture = {};
1163
+ try {
1164
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)), {});
1165
+ const pay = app.scope('a-pay');
1166
+ const session = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
1167
+ assert.ok(session);
1168
+ state.providerCorrelationReference = session.providerCorrelationReference;
1169
+ state.providerInvoiceReference = session.providerInvoiceReference;
1170
+ state.orderId = `paypal-order-${session.providerCorrelationReference}`;
1171
+ state.captureId = `paypal-capture-${session.id}`;
1172
+ await pay.model.paymentSession.updateById(session.id, {
1173
+ state: 'succeeded',
1174
+ providerOrderId: state.orderId,
1175
+ providerCaptureId: state.captureId,
1176
+ finalizedAt: new Date(),
1177
+ });
1178
+ await app.scope('commerce-trade').service.order.settlePaymentFromProvider({
1179
+ eventId: `paypal-paid-${session.id}`,
1180
+ paymentSessionId: session.id,
1181
+ businessReference: String(fixture.paymentAttemptId),
1182
+ providerName: 'pay-paypal:paypal',
1183
+ state: 'succeeded',
1184
+ providerCaptureId: state.captureId,
1185
+ amountMinor: 1299,
1186
+ currency: 'USD',
1187
+ });
1188
+ const order = app.scope('commerce-trade').service.order;
1189
+ await order.requestRefund(fixture.orderId!, {
1190
+ reason: 'PayPal rejected refund',
1191
+ idempotencyKey: `paypal-reject-request-${session.id}`,
1192
+ });
1193
+ await app.bean.passport.signout();
1194
+ await app.bean.passport.signinMock();
1195
+ const approved = await order.approveRefund(fixture.orderId!, {
1196
+ reason: 'approved for provider submission',
1197
+ idempotencyKey: `paypal-reject-approve-${session.id}`,
1198
+ });
1199
+ const executed = await order.executeRefund(fixture.orderId!);
1200
+ assert.deepEqual(
1201
+ [executed.orderState, executed.refundState, executed.refundAttemptState],
1202
+ ['refund_approved', 'approved', 'created'],
1203
+ );
1204
+ const [refund, providerOperation, outboxes] = await Promise.all([
1205
+ pay.model.refundOperation.getById(executed.refundOperationId!),
1206
+ pay.model.providerOperation.get({
1207
+ refundOperationId: executed.refundOperationId,
1208
+ kind: 'refund',
1209
+ }),
1210
+ pay.model.outboxEvent.select({
1211
+ where: {
1212
+ refundOperationId: executed.refundOperationId,
1213
+ eventType: 'refund.outcome.v1',
1214
+ },
1215
+ }),
1216
+ ]);
1217
+ assert.deepEqual([refund?.state, refund?.providerRefundId], ['failed', null]);
1218
+ assert.deepEqual(
1219
+ [
1220
+ providerOperation?.state,
1221
+ providerOperation?.errorCode,
1222
+ providerOperation?.errorSummary,
1223
+ ],
1224
+ ['succeeded', null, null],
1225
+ );
1226
+ assert.equal(outboxes.length, 1);
1227
+ assert.equal(outboxes[0]?.payload.state, 'failed');
1228
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outboxes[0]!.id });
1229
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outboxes[0]!.id });
1230
+ const [settledOrder, request, attempt, line, balance] = await Promise.all([
1231
+ app.scope('commerce-trade').model.order.getById(fixture.orderId!),
1232
+ app.scope('commerce-payment').model.refundRequest.getById(executed.refundRequestId!),
1233
+ app.scope('commerce-payment').model.refundAttempt.getById(executed.refundAttemptId!),
1234
+ app.scope('commerce-trade').model.orderLine.get({ orderId: fixture.orderId! }),
1235
+ app.scope('commerce-trade').model.stockBalance.getById(fixture.balanceId!),
1236
+ ]);
1237
+ const reservation = await app
1238
+ .scope('commerce-trade')
1239
+ .model.stockReservation.get({ orderLineId: line!.id });
1240
+ const audits = await app
1241
+ .scope('commerce-trade')
1242
+ .model.orderAudit.select({ where: { orderId: fixture.orderId } });
1243
+ assert.deepEqual(
1244
+ [settledOrder?.state, request?.state, attempt?.state, reservation?.state],
1245
+ ['paid', 'failed', 'failed', 'consumed'],
1246
+ );
1247
+ assert.deepEqual([balance?.onHand, balance?.reserved, balance?.available], [0, 0, 0]);
1248
+ assert.equal(audits.filter(item => item.operation === 'refund_failed').length, 1);
1249
+ assert.equal(state.calls.filter(call => call.kind === 'refundCapturedPayment').length, 1);
1250
+ assert.ok(approved.refundAttemptId);
1251
+ } finally {
1252
+ await cleanup(fixture);
1253
+ await app.bean.passport.signout();
1254
+ }
1255
+ },
1256
+ { extraData: providerExtraData(gateway) },
1257
+ );
1258
+ });
1259
+ });