vona-module-test-pay 5.0.4 → 5.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-test-pay",
3
- "version": "5.0.4",
3
+ "version": "5.0.6",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -30,8 +30,9 @@
30
30
  "postpack": "clean-package restore"
31
31
  },
32
32
  "dependencies": {
33
- "vona-module-a-pay": "5.0.5",
34
- "vona-module-pay-mock": "5.0.5"
33
+ "vona-module-a-pay": "5.0.7",
34
+ "vona-module-pay-mock": "5.0.7",
35
+ "vona-module-pay-stripe": "5.0.7"
35
36
  },
36
37
  "title": "test-pay",
37
38
  "vonaModule": {
@@ -51,5 +52,5 @@
51
52
  }
52
53
  }
53
54
  },
54
- "gitHead": "f56eaba77347037643b625c8e6c5af408ff2d495"
55
+ "gitHead": "eb85d771ef75776a439e56df3af8be6a3a36c1c2"
55
56
  }
@@ -19,11 +19,11 @@ declare module 'vona' {
19
19
  export interface IBeanScopeContainer {
20
20
  testPay: ScopeModuleTestPay;
21
21
  }
22
-
23
-
24
22
 
25
-
26
23
 
27
-
24
+
25
+
26
+
27
+
28
28
  }
29
29
  /** scope: end */
@@ -132,7 +132,7 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
132
132
  amountMinor: 1299,
133
133
  currency: 'USD',
134
134
  correlationId: `payment-${suffix}`,
135
- providerCandidateKey: 'stripe',
135
+ providerCandidateKey: 'unavailable-provider',
136
136
  }),
137
137
  error =>
138
138
  isZodCustomError(
@@ -490,6 +490,97 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
490
490
  });
491
491
  });
492
492
 
493
+ it('finalizes an exhausted payment start operation from provider failure', async () => {
494
+ await app.bean.executor.mockCtx(async () => {
495
+ const fixture = await createFixture(new Date(Date.now() + 60_000));
496
+ try {
497
+ const scope = app.scope('a-pay');
498
+ const operation = await scope.service.providerOperation.ensureStart(
499
+ fixture.paymentSessionId!,
500
+ );
501
+ const claimed = await scope.service.providerOperation.claim(operation.id);
502
+ assert.ok(claimed?.claimToken);
503
+ await scope.service.providerOperation.markSubmitted(operation.id, claimed.claimToken);
504
+ await scope.model.providerOperation.updateById(operation.id, { attemptCount: 10 });
505
+
506
+ await scope.service.providerOperation.releaseForReconciliation(
507
+ operation.id,
508
+ claimed.claimToken,
509
+ new Error('provider start failed'),
510
+ );
511
+
512
+ const [storedSession, storedOperation, audits, outbox] = await Promise.all([
513
+ scope.model.paymentSession.getById(fixture.paymentSessionId!),
514
+ scope.model.providerOperation.getById(operation.id),
515
+ scope.model.paymentAudit.select({
516
+ where: { paymentSessionId: fixture.paymentSessionId! },
517
+ }),
518
+ scope.model.outboxEvent.select({
519
+ where: { paymentSessionId: fixture.paymentSessionId! },
520
+ }),
521
+ ]);
522
+ assert.equal(storedSession?.state, 'failed');
523
+ assert.ok(storedSession?.finalizedAt);
524
+ assert.equal(storedOperation?.state, 'failed');
525
+ assert.equal(storedOperation?.claimToken, undefined);
526
+ assert.equal(storedOperation?.nextAttemptAt, undefined);
527
+ assert.equal(storedOperation?.errorCode, 'provider_operation_failed');
528
+ assert.equal(
529
+ storedOperation?.errorSummary,
530
+ 'Provider operation failed and will be reconciled',
531
+ );
532
+ assert.equal(audits.length, 2);
533
+ assert.equal(audits[1].providerOperationId, operation.id);
534
+ assert.equal(audits[1].fromState, 'starting');
535
+ assert.equal(audits[1].toState, 'failed');
536
+ assert.equal(audits[1].source, 'providerOperation.start.attemptsExhausted');
537
+ assert.equal(outbox.length, 1);
538
+ assert.equal(outbox[0].eventType, 'payment.outcome.v1');
539
+ assert.deepEqual((outbox[0].payload as any).state, 'failed');
540
+ } finally {
541
+ await cleanup(fixture);
542
+ }
543
+ });
544
+ });
545
+
546
+ it('finalizes an exhausted eligible payment operation only once', async () => {
547
+ await app.bean.executor.mockCtx(async () => {
548
+ const fixture = await createFixture(new Date(Date.now() + 60_000));
549
+ try {
550
+ const scope = app.scope('a-pay');
551
+ const operation = await scope.service.providerOperation.ensureStart(
552
+ fixture.paymentSessionId!,
553
+ );
554
+ await scope.model.providerOperation.updateById(operation.id, {
555
+ attemptCount: 10,
556
+ nextAttemptAt: new Date(0),
557
+ errorCode: 'provider_operation_failed',
558
+ errorSummary: 'Provider operation failed and will be reconciled',
559
+ });
560
+
561
+ assert.equal(await scope.service.providerOperation.claim(operation.id), undefined);
562
+ assert.equal(await scope.service.providerOperation.claim(operation.id), undefined);
563
+
564
+ const [storedSession, storedOperation, audits, outbox] = await Promise.all([
565
+ scope.model.paymentSession.getById(fixture.paymentSessionId!),
566
+ scope.model.providerOperation.getById(operation.id),
567
+ scope.model.paymentAudit.select({
568
+ where: { paymentSessionId: fixture.paymentSessionId! },
569
+ }),
570
+ scope.model.outboxEvent.select({
571
+ where: { paymentSessionId: fixture.paymentSessionId! },
572
+ }),
573
+ ]);
574
+ assert.equal(storedSession?.state, 'failed');
575
+ assert.equal(storedOperation?.state, 'failed');
576
+ assert.equal(audits.length, 2);
577
+ assert.equal(outbox.length, 1);
578
+ } finally {
579
+ await cleanup(fixture);
580
+ }
581
+ });
582
+ });
583
+
493
584
  it('allows only one competing start to call the provider', async () => {
494
585
  const fixture = await app.bean.executor.mockCtx(async () => {
495
586
  return await createFixture(new Date(Date.now() + 60_000));
@@ -362,6 +362,11 @@ describe('paypalLifecycle.test.ts', { concurrency: false, sequential: true }, ()
362
362
  const returnUrl = await callbackToken(started.id as number, 'return');
363
363
  const returnState = new URL(returnUrl).searchParams.get('state');
364
364
  assert.ok(returnState);
365
+ const callback = await pay.service.paymentCallback.consume('return', returnState);
366
+ assert.equal(
367
+ callback.continuationPath,
368
+ `/commerce/commerce/trade/payment/${started.id}/${fixture.orderId}?providerResult=return`,
369
+ );
365
370
  await assert.rejects(
366
371
  app.bean.executor.performAction('get', '/pay/payment-callback/return', {
367
372
  query: { state: returnState },
@@ -1227,6 +1232,8 @@ describe('paypalLifecycle.test.ts', { concurrency: false, sequential: true }, ()
1227
1232
  assert.equal(outboxes[0]?.payload.state, 'failed');
1228
1233
  await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outboxes[0]!.id });
1229
1234
  await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outboxes[0]!.id });
1235
+ const dispatched = await pay.model.outboxEvent.getById(outboxes[0]!.id);
1236
+ assert.equal(dispatched?.state, 'dispatched', dispatched?.errorSummary);
1230
1237
  const [settledOrder, request, attempt, line, balance] = await Promise.all([
1231
1238
  app.scope('commerce-trade').model.order.getById(fixture.orderId!),
1232
1239
  app.scope('commerce-payment').model.refundRequest.getById(executed.refundRequestId!),
@@ -0,0 +1,545 @@
1
+ import assert from 'node:assert';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { after, before, describe, it } from 'node:test';
4
+ import { acquireTestLock, app } from 'vona-mock';
5
+
6
+ interface IFixture {
7
+ categoryId?: number;
8
+ productId?: number;
9
+ skuId?: number;
10
+ balanceId?: number;
11
+ addressId?: number;
12
+ cartId?: number;
13
+ userId?: number;
14
+ customerName?: string;
15
+ orderId?: number;
16
+ paymentAttemptId?: number;
17
+ paymentSessionId?: number;
18
+ }
19
+
20
+ interface IGatewayState {
21
+ checkoutSessionId?: string;
22
+ paymentIntentId?: string;
23
+ providerInvoiceReference?: string;
24
+ providerCorrelationReference?: string;
25
+ createFailuresRemaining?: number;
26
+ retrieveFailuresRemaining?: number;
27
+ calls: Array<{ kind: string; input: unknown }>;
28
+ }
29
+
30
+ function createGateway(state: IGatewayState) {
31
+ return {
32
+ async createCheckoutSession(_options: unknown, input: any) {
33
+ state.calls.push({ kind: 'createCheckoutSession', input });
34
+ state.providerCorrelationReference = input.body.client_reference_id;
35
+ state.providerInvoiceReference = input.body.metadata.providerInvoiceReference;
36
+ if (state.createFailuresRemaining) {
37
+ state.createFailuresRemaining -= 1;
38
+ throw new Error('transient Stripe Checkout creation failure');
39
+ }
40
+ state.checkoutSessionId = `stripe-checkout-${state.providerCorrelationReference}`;
41
+ state.paymentIntentId = `stripe-payment-${state.providerCorrelationReference}`;
42
+ return checkoutSessionRecord(state);
43
+ },
44
+ async retrieveCheckoutSession(_options: unknown, input: unknown) {
45
+ state.calls.push({ kind: 'retrieveCheckoutSession', input });
46
+ if (state.retrieveFailuresRemaining) {
47
+ state.retrieveFailuresRemaining -= 1;
48
+ throw new Error('transient Stripe Checkout retrieval failure');
49
+ }
50
+ return checkoutSessionRecord(state, { settled: true });
51
+ },
52
+ async constructWebhookEvent(_options: unknown, input: unknown) {
53
+ state.calls.push({ kind: 'constructWebhookEvent', input });
54
+ return {
55
+ id: `evt_${state.providerCorrelationReference}`,
56
+ type: 'checkout.session.completed',
57
+ data: { object: checkoutSessionRecord(state, { settled: true }) },
58
+ };
59
+ },
60
+ };
61
+ }
62
+
63
+ function checkoutSessionRecord(state: IGatewayState, options: { settled?: boolean } = {}) {
64
+ return {
65
+ id: state.checkoutSessionId,
66
+ amount_total: 1299,
67
+ currency: 'usd',
68
+ livemode: false,
69
+ status: options.settled ? 'complete' : 'open',
70
+ payment_status: options.settled ? 'paid' : 'unpaid',
71
+ url: `https://checkout.stripe.test/${state.checkoutSessionId}`,
72
+ client_reference_id: state.providerCorrelationReference,
73
+ metadata: {
74
+ providerCorrelationReference: state.providerCorrelationReference,
75
+ providerInvoiceReference: state.providerInvoiceReference,
76
+ },
77
+ ...(options.settled && {
78
+ payment_intent: {
79
+ id: state.paymentIntentId,
80
+ amount: 1299,
81
+ currency: 'usd',
82
+ status: 'succeeded',
83
+ metadata: {
84
+ providerCorrelationReference: state.providerCorrelationReference,
85
+ providerInvoiceReference: state.providerInvoiceReference,
86
+ },
87
+ },
88
+ }),
89
+ };
90
+ }
91
+
92
+ function providerState(gateway: ReturnType<typeof createGateway>) {
93
+ return {
94
+ 'pay-stripe:stripe/default': {
95
+ secretCredential: 'sk_test_lifecycle',
96
+ secretWebhook: 'whsec_test_lifecycle',
97
+ gateway,
98
+ },
99
+ } as never;
100
+ }
101
+
102
+ function providerExtraData(gateway: ReturnType<typeof createGateway>) {
103
+ return {
104
+ state: { payProviderClientOptions: providerState(gateway) } as never,
105
+ };
106
+ }
107
+
108
+ async function cleanup(fixture: IFixture) {
109
+ const trade = app.scope('commerce-trade');
110
+ const payment = app.scope('commerce-payment');
111
+ const catalog = app.scope('commerce-catalog');
112
+ const member = app.scope('commerce-member');
113
+ const pay = app.scope('a-pay');
114
+ if (fixture.paymentSessionId !== undefined) {
115
+ const refunds = await pay.model.refundOperation.select({
116
+ where: { paymentSessionId: fixture.paymentSessionId },
117
+ });
118
+ for (const refund of refunds) {
119
+ const providerOperation = await pay.model.providerOperation.get({
120
+ refundOperationId: refund.id,
121
+ kind: 'refund',
122
+ });
123
+ if (providerOperation) {
124
+ await pay.model.providerOperationRecoveryAudit.delete({
125
+ providerOperationId: providerOperation.id,
126
+ });
127
+ }
128
+ await pay.model.providerOperation.delete({
129
+ refundOperationId: refund.id,
130
+ });
131
+ await pay.model.outboxEvent.delete({ refundOperationId: refund.id });
132
+ await pay.model.webhookInbox.delete({ refundOperationId: refund.id });
133
+ await pay.model.refundOperation.delete({ id: refund.id });
134
+ }
135
+ await pay.model.providerOperation.delete({
136
+ paymentSessionId: fixture.paymentSessionId,
137
+ });
138
+ await pay.model.outboxEvent.delete({
139
+ paymentSessionId: fixture.paymentSessionId,
140
+ });
141
+ await pay.model.webhookInbox.delete({
142
+ paymentSessionId: fixture.paymentSessionId,
143
+ });
144
+ await pay.model.paymentAudit.delete({
145
+ paymentSessionId: fixture.paymentSessionId,
146
+ });
147
+ }
148
+ if (fixture.orderId !== undefined) {
149
+ const requests = await payment.model.refundRequest.select({
150
+ where: { orderId: fixture.orderId },
151
+ });
152
+ for (const request of requests) {
153
+ await payment.model.refundAudit.delete({ refundRequestId: request.id });
154
+ await payment.model.refundAttempt.delete({ refundRequestId: request.id });
155
+ }
156
+ await payment.model.refundRequest.delete({ orderId: fixture.orderId });
157
+ await trade.model.orderAudit.delete({ orderId: fixture.orderId });
158
+ const lines = await trade.model.orderLine.select({
159
+ where: { orderId: fixture.orderId },
160
+ });
161
+ for (const line of lines) await trade.model.stockReservation.delete({ orderLineId: line.id });
162
+ await trade.model.orderLine.delete({ orderId: fixture.orderId });
163
+ await trade.model.order.delete({ id: fixture.orderId });
164
+ }
165
+ if (fixture.paymentAttemptId !== undefined) {
166
+ await payment.model.paymentAudit.delete({
167
+ paymentAttemptId: fixture.paymentAttemptId,
168
+ });
169
+ await payment.model.paymentAttempt.delete({ id: fixture.paymentAttemptId });
170
+ }
171
+ if (fixture.paymentSessionId !== undefined) {
172
+ await pay.model.paymentSession.delete({ id: fixture.paymentSessionId });
173
+ }
174
+ if (fixture.cartId !== undefined) {
175
+ await trade.model.cartItem.delete({ cartId: fixture.cartId });
176
+ await trade.model.cart.delete({ id: fixture.cartId });
177
+ }
178
+ if (fixture.addressId !== undefined) await member.model.address.delete({ id: fixture.addressId });
179
+ if (fixture.balanceId !== undefined)
180
+ await trade.model.stockBalance.delete({ id: fixture.balanceId });
181
+ if (fixture.skuId !== undefined) await catalog.model.sku.delete({ id: fixture.skuId });
182
+ if (fixture.productId !== undefined)
183
+ await catalog.model.product.delete({ id: fixture.productId });
184
+ if (fixture.categoryId !== undefined) {
185
+ await catalog.model.category.delete({ id: fixture.categoryId });
186
+ }
187
+ if (fixture.userId !== undefined) {
188
+ await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
189
+ await app.bean.user.removeById(fixture.userId);
190
+ }
191
+ }
192
+
193
+ async function createCheckout(suffix: string): Promise<IFixture> {
194
+ const fixture: IFixture = {};
195
+ await app.bean.passport.signinMock();
196
+ fixture.categoryId = await app.bean.executor.performAction('post', '/commerce/catalog/category', {
197
+ body: { name: `stripe-category-${suffix}`, published: true },
198
+ });
199
+ fixture.productId = await app.bean.executor.performAction('post', '/commerce/catalog/product', {
200
+ body: {
201
+ categoryId: fixture.categoryId,
202
+ title: `stripe-product-${suffix}`,
203
+ published: true,
204
+ },
205
+ });
206
+ fixture.skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
207
+ body: {
208
+ productId: fixture.productId,
209
+ code: `stripe-sku-${suffix}`,
210
+ priceCents: 1299,
211
+ attributes: [],
212
+ lifecycle: 'active',
213
+ },
214
+ });
215
+ fixture.balanceId = (
216
+ await app.scope('commerce-trade').service.stockBalance.adjustStock({
217
+ skuId: fixture.skuId,
218
+ delta: 1,
219
+ reason: 'Stripe lifecycle fixture',
220
+ correlationId: `stripe-stock-${suffix}`,
221
+ })
222
+ ).id as number;
223
+ await app.bean.passport.signout();
224
+ fixture.customerName = `stripe-customer-${suffix}`;
225
+ const customer = await app.bean.user.register({ name: fixture.customerName }, true);
226
+ fixture.userId = customer.id as number;
227
+ await app.bean.passport.signinMock(fixture.customerName as any);
228
+ const userId = app.bean.passport.currentUser!.id;
229
+ fixture.addressId = await app.bean.executor.performAction(
230
+ 'post',
231
+ '/commerce/member/address/createMine',
232
+ {
233
+ body: {
234
+ recipientName: 'Stripe Customer',
235
+ phone: '15555550137',
236
+ countryCode: 'US',
237
+ region: 'California',
238
+ city: 'San Francisco',
239
+ postalCode: '94105',
240
+ addressLine1: '10 Market Street',
241
+ },
242
+ },
243
+ );
244
+ const cart = await app.scope('commerce-trade').model.cart.insert({ userId });
245
+ fixture.cartId = cart.id as number;
246
+ await app.scope('commerce-trade').model.cartItem.insert({
247
+ cartId: cart.id,
248
+ skuId: fixture.skuId,
249
+ quantity: 1,
250
+ });
251
+ const checkout = await app.scope('commerce-trade').service.order.checkout({
252
+ addressId: fixture.addressId,
253
+ correlationId: `stripe-checkout-${suffix}`,
254
+ providerCandidateKey: 'stripe',
255
+ });
256
+ fixture.orderId = checkout.orderId as number;
257
+ fixture.paymentAttemptId = checkout.paymentAttemptId as number;
258
+ fixture.paymentSessionId = checkout.paymentSessionId as number;
259
+ return fixture;
260
+ }
261
+
262
+ async function callbackToken(sessionId: number) {
263
+ const session = await app.scope('a-pay').model.paymentSession.getById(sessionId);
264
+ assert.ok(session);
265
+ return (await app.scope('a-pay').service.paymentCallback.createUrls(session)).returnUrl;
266
+ }
267
+
268
+ async function consumeReturnCallback(sessionId: number, gateway: ReturnType<typeof createGateway>) {
269
+ const state = new URL(await callbackToken(sessionId)).searchParams.get('state');
270
+ assert.ok(state);
271
+ await assert.rejects(
272
+ app.bean.executor.performAction('get', '/pay/payment-callback/return', {
273
+ query: { state },
274
+ extraData: providerExtraData(gateway),
275
+ }),
276
+ { status: 302 },
277
+ );
278
+ }
279
+
280
+ async function dispatchPaymentOutcome(paymentSessionId: number) {
281
+ const pay = app.scope('a-pay');
282
+ const outbox = await pay.model.outboxEvent.get({
283
+ paymentSessionId,
284
+ eventType: 'payment.outcome.v1',
285
+ });
286
+ assert.ok(outbox);
287
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
288
+ await pay.queue.outboxDispatch.pushAsync({ outboxEventId: outbox.id });
289
+ return outbox;
290
+ }
291
+
292
+ describe('stripeLifecycle.test.ts', { concurrency: false, sequential: true }, () => {
293
+ const releases: Array<() => void> = [];
294
+ let originalServe: { protocol?: string; host?: string };
295
+
296
+ before(async () => {
297
+ for (const scene of ['a-commerce', 'a-pay']) releases.push(await acquireTestLock(scene));
298
+ originalServe = { ...app.config.server.serve };
299
+ (app.config.server.serve as any).protocol = 'http';
300
+ (app.config.server.serve as any).host = 'localhost';
301
+ });
302
+
303
+ after(() => {
304
+ Object.assign(app.config.server.serve, originalServe);
305
+ for (const release of releases.reverse()) release();
306
+ });
307
+
308
+ it('starts Stripe Checkout and settles its return callback through Commerce', async () => {
309
+ const state: IGatewayState = { calls: [] };
310
+ const gateway = createGateway(state);
311
+ await app.bean.executor.mockCtx(
312
+ async () => {
313
+ const fixture: IFixture = {};
314
+ try {
315
+ const options = app.bean.payProvider.getOptions('pay-stripe:stripe', 'default') as any;
316
+ assert.equal(options.secretCredential, 'sk_test_lifecycle');
317
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)));
318
+ const pay = app.scope('a-pay');
319
+ const selected = await pay.model.paymentSession.getById(fixture.paymentSessionId!);
320
+ assert.equal(selected?.providerName, 'pay-stripe:stripe');
321
+ assert.equal(selected?.environment, 'sandbox');
322
+ const started = await pay.service.paymentSession.start(fixture.paymentSessionId!);
323
+ assert.equal(started.state, 'requires_action');
324
+ assert.deepEqual(started.nextAction, {
325
+ kind: 'redirect',
326
+ url: `https://checkout.stripe.test/${state.checkoutSessionId}`,
327
+ });
328
+ const operation = await pay.model.providerOperation.get({
329
+ paymentSessionId: started.id,
330
+ kind: 'start',
331
+ });
332
+ assert.equal(operation?.state, 'succeeded');
333
+ assert.match(
334
+ operation?.idempotencyKey ?? '',
335
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
336
+ );
337
+ assert.equal(operation?.providerRequestId, operation?.idempotencyKey);
338
+ const createCall = state.calls.find(call => call.kind === 'createCheckoutSession') as any;
339
+ assert.equal(createCall.input.idempotencyKey, operation?.idempotencyKey);
340
+ assert.equal(
341
+ createCall.input.body.client_reference_id,
342
+ started.providerCorrelationReference,
343
+ );
344
+ assert.deepEqual(createCall.input.body.metadata, {
345
+ paymentSessionId: String(started.id),
346
+ providerCorrelationReference: started.providerCorrelationReference,
347
+ providerInvoiceReference: started.providerInvoiceReference,
348
+ });
349
+ assert.equal(started.providerOrderId, state.checkoutSessionId);
350
+ await consumeReturnCallback(started.id as number, gateway);
351
+ const outbox = await dispatchPaymentOutcome(started.id as number);
352
+ const order = await app.scope('commerce-trade').model.order.getById(fixture.orderId!);
353
+ const attempt = await app
354
+ .scope('commerce-payment')
355
+ .model.paymentAttempt.getById(fixture.paymentAttemptId!);
356
+ assert.deepEqual(
357
+ [order?.state, attempt?.state, (await pay.model.outboxEvent.getById(outbox.id))?.state],
358
+ ['paid', 'succeeded', 'dispatched'],
359
+ );
360
+ assert.equal(
361
+ state.calls.filter(call => call.kind === 'retrieveCheckoutSession').length,
362
+ 1,
363
+ );
364
+ } finally {
365
+ await cleanup(fixture);
366
+ await app.bean.passport.signout();
367
+ }
368
+ },
369
+ { extraData: providerExtraData(gateway) },
370
+ );
371
+ });
372
+
373
+ it('reuses an immutable Checkout request after a transient creation failure', async () => {
374
+ const state: IGatewayState = { createFailuresRemaining: 1, calls: [] };
375
+ const gateway = createGateway(state);
376
+ await app.bean.executor.mockCtx(
377
+ async () => {
378
+ const fixture: IFixture = {};
379
+ try {
380
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)));
381
+ const pay = app.scope('a-pay');
382
+ const started = await pay.service.paymentSession.start(fixture.paymentSessionId!);
383
+ assert.equal(started.state, 'starting');
384
+ const operation = await pay.model.providerOperation.get({
385
+ paymentSessionId: started.id,
386
+ kind: 'start',
387
+ });
388
+ assert.equal(operation?.state, 'reconciliation_required');
389
+ assert.ok(operation?.startInputSnapshot);
390
+ assert.match(operation?.startInputSnapshot?.returnUrl ?? '', /payment-callback\/return/);
391
+ assert.match(operation?.startInputSnapshot?.cancelUrl ?? '', /payment-callback\/cancel/);
392
+ await pay.model.providerOperation.updateById(operation!.id, {
393
+ nextAttemptAt: new Date(Date.now() - 1_000),
394
+ });
395
+ assert.equal(await pay.service.providerOperation.queueDue(), 1);
396
+ const retried = await pay.model.paymentSession.getById(started.id);
397
+ assert.equal(retried?.state, 'requires_action');
398
+ const createCalls = state.calls.filter(
399
+ call => call.kind === 'createCheckoutSession',
400
+ ) as any[];
401
+ assert.equal(createCalls.length, 2);
402
+ assert.deepEqual(createCalls[1]?.input, createCalls[0]?.input);
403
+ assert.equal(createCalls[0]?.input.idempotencyKey, operation?.idempotencyKey);
404
+ assert.deepEqual(createCalls[0]?.input.body.managed_payments, { enabled: false });
405
+ } finally {
406
+ await cleanup(fixture);
407
+ await app.bean.passport.signout();
408
+ }
409
+ },
410
+ { extraData: providerExtraData(gateway) },
411
+ );
412
+ });
413
+
414
+ it('deduplicates a signed Stripe payment webhook before Commerce settlement', async () => {
415
+ const state: IGatewayState = { calls: [] };
416
+ const gateway = createGateway(state);
417
+ await app.bean.executor.mockCtx(
418
+ async () => {
419
+ const fixture: IFixture = {};
420
+ try {
421
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)));
422
+ const pay = app.scope('a-pay');
423
+ const started = await pay.service.paymentSession.start(fixture.paymentSessionId!);
424
+ const rawBody = JSON.stringify({
425
+ id: `stripe-webhook-${started.id}`,
426
+ });
427
+ const verified = await pay.bean.payProvider.get('pay-stripe:stripe').verifyWebhook(
428
+ {
429
+ rawBody,
430
+ body: JSON.parse(rawBody),
431
+ headers: { 'stripe-signature': 'stripe-signature-test' },
432
+ },
433
+ pay.bean.payProvider.getOptions('pay-stripe:stripe', 'default'),
434
+ );
435
+ await pay.service.webhook.receive({
436
+ providerName: 'pay-stripe:stripe',
437
+ clientName: 'default',
438
+ environment: 'sandbox',
439
+ rawBody,
440
+ verified,
441
+ });
442
+ await pay.service.webhook.receive({
443
+ providerName: 'pay-stripe:stripe',
444
+ clientName: 'default',
445
+ environment: 'sandbox',
446
+ rawBody,
447
+ verified,
448
+ });
449
+ const inboxes = await pay.model.webhookInbox.select({
450
+ where: { paymentSessionId: started.id },
451
+ });
452
+ assert.equal(inboxes.length, 1);
453
+ const outboxes = await pay.model.outboxEvent.select({
454
+ where: {
455
+ paymentSessionId: started.id,
456
+ eventType: 'payment.outcome.v1',
457
+ },
458
+ });
459
+ assert.equal(outboxes.length, 1);
460
+ const webhookCall = state.calls.find(
461
+ call => call.kind === 'constructWebhookEvent',
462
+ ) as any;
463
+ assert.deepEqual(webhookCall.input, {
464
+ rawBody,
465
+ body: JSON.parse(rawBody),
466
+ headers: { 'stripe-signature': 'stripe-signature-test' },
467
+ });
468
+ await pay.queue.outboxDispatch.pushAsync({
469
+ outboxEventId: outboxes[0]!.id,
470
+ });
471
+ await pay.queue.outboxDispatch.pushAsync({
472
+ outboxEventId: outboxes[0]!.id,
473
+ });
474
+ const dispatched = await pay.model.outboxEvent.getById(outboxes[0]!.id);
475
+ assert.equal(
476
+ dispatched?.state,
477
+ 'dispatched',
478
+ dispatched?.errorSummary ?? 'outbox did not dispatch',
479
+ );
480
+ const order = await app.scope('commerce-trade').model.order.getById(fixture.orderId!);
481
+ const attempt = await app
482
+ .scope('commerce-payment')
483
+ .model.paymentAttempt.getById(fixture.paymentAttemptId!);
484
+ assert.deepEqual([order?.state, attempt?.state], ['paid', 'succeeded']);
485
+ } finally {
486
+ await cleanup(fixture);
487
+ await app.bean.passport.signout();
488
+ }
489
+ },
490
+ { extraData: providerExtraData(gateway) },
491
+ );
492
+ });
493
+
494
+ it('recovers a transient Stripe Checkout retrieval failure through reconciliation', async () => {
495
+ const state: IGatewayState = { retrieveFailuresRemaining: 1, calls: [] };
496
+ const gateway = createGateway(state);
497
+ await app.bean.executor.mockCtx(
498
+ async () => {
499
+ const fixture: IFixture = {};
500
+ try {
501
+ Object.assign(fixture, await createCheckout(randomUUID().slice(0, 12)));
502
+ const pay = app.scope('a-pay');
503
+ const started = await pay.service.paymentSession.start(fixture.paymentSessionId!);
504
+ await consumeReturnCallback(started.id as number, gateway);
505
+ const operation = await pay.model.providerOperation.get({
506
+ paymentSessionId: started.id,
507
+ kind: 'confirm',
508
+ });
509
+ assert.equal(operation?.state, 'reconciliation_required');
510
+ assert.equal(operation?.errorSummary, 'Provider operation failed and will be reconciled');
511
+ assert.ok(operation?.nextAttemptAt);
512
+ assert.equal(
513
+ await pay.model.outboxEvent.get({
514
+ paymentSessionId: started.id,
515
+ eventType: 'payment.outcome.v1',
516
+ }),
517
+ undefined,
518
+ );
519
+ assert.equal(
520
+ (await app.scope('commerce-trade').model.order.getById(fixture.orderId!))?.state,
521
+ 'awaiting_payment',
522
+ );
523
+ await pay.model.providerOperation.updateById(operation!.id, {
524
+ nextAttemptAt: new Date(Date.now() - 1_000),
525
+ });
526
+ assert.equal(await pay.service.providerOperation.queueDue(), 1);
527
+ assert.equal((await pay.model.paymentSession.getById(started.id))?.state, 'succeeded');
528
+ await dispatchPaymentOutcome(started.id as number);
529
+ assert.equal(
530
+ (await app.scope('commerce-trade').model.order.getById(fixture.orderId!))?.state,
531
+ 'paid',
532
+ );
533
+ assert.equal(
534
+ state.calls.filter(call => call.kind === 'retrieveCheckoutSession').length,
535
+ 2,
536
+ );
537
+ } finally {
538
+ await cleanup(fixture);
539
+ await app.bean.passport.signout();
540
+ }
541
+ },
542
+ { extraData: providerExtraData(gateway) },
543
+ );
544
+ });
545
+ });
@@ -0,0 +1,300 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+ import { app } from 'vona-mock';
4
+
5
+ function createOptions(gateway: Record<string, unknown>) {
6
+ const { clientOptions } = app.bean.payProvider.resolveByName('pay-stripe:stripe', 'default');
7
+ return {
8
+ ...clientOptions,
9
+ secretCredential: 'sk_test_1',
10
+ secretWebhook: 'whsec_1',
11
+ gateway,
12
+ } as never;
13
+ }
14
+
15
+ const paymentInput = {
16
+ paymentSessionId: 101,
17
+ businessReference: 'business-1',
18
+ providerInvoiceReference: 'payment-invoice-reference',
19
+ providerCorrelationReference: 'payment-correlation-reference',
20
+ idempotencyKey: 'payment-key',
21
+ amountMinor: 1299,
22
+ currency: 'USD',
23
+ providerOrderId: 'cs_1',
24
+ };
25
+
26
+ const refundInput = {
27
+ paymentSessionId: 101,
28
+ refundOperationId: 201,
29
+ businessReference: 'refund-business-1',
30
+ providerInvoiceReference: 'refund-invoice-reference',
31
+ providerCorrelationReference: 'refund-correlation-reference',
32
+ idempotencyKey: 'refund-key',
33
+ amountMinor: 500,
34
+ currency: 'USD',
35
+ providerCaptureId: 'pi_1',
36
+ };
37
+
38
+ function checkoutSession(overrides: Record<string, unknown> = {}) {
39
+ return {
40
+ id: 'cs_1',
41
+ amount_total: 1299,
42
+ currency: 'usd',
43
+ livemode: false,
44
+ status: 'open',
45
+ payment_status: 'unpaid',
46
+ url: 'https://checkout.stripe.test/c/pay/cs_1',
47
+ client_reference_id: 'payment-correlation-reference',
48
+ metadata: {
49
+ paymentSessionId: '101',
50
+ providerCorrelationReference: 'payment-correlation-reference',
51
+ providerInvoiceReference: 'payment-invoice-reference',
52
+ },
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ function paymentIntent(status: string, overrides: Record<string, unknown> = {}) {
58
+ return {
59
+ id: 'pi_1',
60
+ amount: 1299,
61
+ currency: 'usd',
62
+ status,
63
+ metadata: {
64
+ paymentSessionId: '101',
65
+ providerCorrelationReference: 'payment-correlation-reference',
66
+ providerInvoiceReference: 'payment-invoice-reference',
67
+ },
68
+ ...overrides,
69
+ };
70
+ }
71
+
72
+ function refundRecord(status: string, overrides: Record<string, unknown> = {}) {
73
+ return {
74
+ id: 're_1',
75
+ amount: 500,
76
+ currency: 'usd',
77
+ payment_intent: 'pi_1',
78
+ status,
79
+ metadata: {
80
+ paymentSessionId: '101',
81
+ refundOperationId: '201',
82
+ providerCorrelationReference: 'refund-correlation-reference',
83
+ providerInvoiceReference: 'refund-invoice-reference',
84
+ },
85
+ ...overrides,
86
+ };
87
+ }
88
+
89
+ describe('stripeProvider.test.ts', { concurrency: false }, () => {
90
+ it('creates a hosted Checkout Session with durable references and redirect action', async () => {
91
+ await app.bean.executor.mockCtx(async () => {
92
+ const calls: unknown[] = [];
93
+ const gateway = {
94
+ async createCheckoutSession(_options: unknown, input: unknown) {
95
+ calls.push(input);
96
+ return checkoutSession();
97
+ },
98
+ };
99
+ const { provider } = app.bean.payProvider.resolveByName('pay-stripe:stripe', 'default');
100
+ const payment = await provider.startPayment(
101
+ {
102
+ ...paymentInput,
103
+ providerOrderId: undefined,
104
+ returnUrl: 'https://shop.test/pay/return',
105
+ cancelUrl: 'https://shop.test/pay/cancel',
106
+ },
107
+ createOptions(gateway),
108
+ );
109
+
110
+ assert.deepEqual(payment, {
111
+ state: 'requires_action',
112
+ providerPaymentId: 'cs_1',
113
+ providerOrderId: 'cs_1',
114
+ nextAction: {
115
+ kind: 'redirect',
116
+ url: 'https://checkout.stripe.test/c/pay/cs_1',
117
+ },
118
+ });
119
+ assert.deepEqual(calls, [
120
+ {
121
+ idempotencyKey: 'payment-key',
122
+ body: {
123
+ mode: 'payment',
124
+ success_url: 'https://shop.test/pay/return',
125
+ cancel_url: 'https://shop.test/pay/cancel',
126
+ client_reference_id: 'payment-correlation-reference',
127
+ metadata: {
128
+ paymentSessionId: '101',
129
+ providerCorrelationReference: 'payment-correlation-reference',
130
+ providerInvoiceReference: 'payment-invoice-reference',
131
+ },
132
+ payment_intent_data: {
133
+ metadata: {
134
+ paymentSessionId: '101',
135
+ providerCorrelationReference: 'payment-correlation-reference',
136
+ providerInvoiceReference: 'payment-invoice-reference',
137
+ },
138
+ },
139
+ managed_payments: { enabled: false },
140
+ line_items: [
141
+ {
142
+ quantity: 1,
143
+ price_data: {
144
+ currency: 'usd',
145
+ unit_amount: 1299,
146
+ product_data: { name: 'Payment payment-invoice-reference' },
147
+ },
148
+ },
149
+ ],
150
+ },
151
+ },
152
+ ]);
153
+ });
154
+ });
155
+
156
+ it('treats a definitive Stripe Checkout rejection as terminal', async () => {
157
+ await app.bean.executor.mockCtx(async () => {
158
+ const gateway = {
159
+ async createCheckoutSession() {
160
+ const error = new Error('Stripe rejected the request');
161
+ (error as Error & { type: string }).type = 'StripeInvalidRequestError';
162
+ throw error;
163
+ },
164
+ };
165
+ const { provider } = app.bean.payProvider.resolveByName('pay-stripe:stripe', 'default');
166
+ await assert.rejects(
167
+ provider.startPayment(
168
+ {
169
+ ...paymentInput,
170
+ providerOrderId: undefined,
171
+ returnUrl: 'https://shop.test/pay/return',
172
+ cancelUrl: 'https://shop.test/pay/cancel',
173
+ },
174
+ createOptions(gateway),
175
+ ),
176
+ { failureCode: 'stripe_request_rejected' },
177
+ );
178
+ });
179
+ });
180
+
181
+ it('maps Checkout and PaymentIntent facts without trusting conflicting resources', async () => {
182
+ await app.bean.executor.mockCtx(async () => {
183
+ let result: unknown = checkoutSession({
184
+ payment_intent: paymentIntent('succeeded'),
185
+ });
186
+ const gateway = {
187
+ async retrieveCheckoutSession() {
188
+ return result;
189
+ },
190
+ };
191
+ const { provider } = app.bean.payProvider.resolveByName('pay-stripe:stripe', 'default');
192
+ const options = createOptions(gateway);
193
+
194
+ const payment = await provider.queryPayment(paymentInput, options);
195
+ assert.deepEqual(payment, {
196
+ state: 'succeeded',
197
+ providerPaymentId: 'pi_1',
198
+ providerOrderId: 'cs_1',
199
+ providerCaptureId: 'pi_1',
200
+ nextAction: { kind: 'completed' },
201
+ });
202
+ result = checkoutSession({ amount_total: 1399 });
203
+ await assert.rejects(provider.queryPayment(paymentInput, options), {
204
+ status: 409,
205
+ });
206
+ });
207
+ });
208
+
209
+ it('maps full and partial Stripe refunds and preserves ambiguous failures', async () => {
210
+ await app.bean.executor.mockCtx(async () => {
211
+ let status = 'succeeded';
212
+ let error: Error | undefined;
213
+ const gateway = {
214
+ async createRefund(_options: unknown, input: unknown) {
215
+ if (error) {
216
+ throw new Error(`Stripe gateway failed: ${error.message}`, {
217
+ cause: error,
218
+ });
219
+ }
220
+ assert.deepEqual(input, {
221
+ idempotencyKey: 'refund-key',
222
+ body: {
223
+ payment_intent: 'pi_1',
224
+ amount: 500,
225
+ metadata: {
226
+ paymentSessionId: '101',
227
+ refundOperationId: '201',
228
+ providerCorrelationReference: 'refund-correlation-reference',
229
+ providerInvoiceReference: 'refund-invoice-reference',
230
+ },
231
+ },
232
+ });
233
+ return refundRecord(status);
234
+ },
235
+ async retrieveRefund() {
236
+ return refundRecord(status);
237
+ },
238
+ };
239
+ const { provider } = app.bean.payProvider.resolveByName('pay-stripe:stripe', 'default');
240
+ const options = createOptions(gateway);
241
+ for (const [nextStatus, state] of [
242
+ ['succeeded', 'succeeded'],
243
+ ['pending', 'pending'],
244
+ ['failed', 'failed'],
245
+ ['canceled', 'cancelled'],
246
+ ] as const) {
247
+ status = nextStatus;
248
+ assert.equal((await provider.createRefund(refundInput, options)).state, state);
249
+ assert.equal(
250
+ (await provider.queryRefund!({ ...refundInput, providerRefundId: 're_1' }, options))
251
+ .state,
252
+ state,
253
+ );
254
+ }
255
+ error = new Error('transport failure');
256
+ await assert.rejects(provider.createRefund(refundInput, options), /transport failure/);
257
+ });
258
+ });
259
+
260
+ it('maps signed terminal Stripe webhook facts through its gateway', async () => {
261
+ await app.bean.executor.mockCtx(async () => {
262
+ const gateway = {
263
+ async constructWebhookEvent(_options: unknown, input: unknown) {
264
+ assert.deepEqual(input, {
265
+ rawBody: '{"id":"evt_1"}',
266
+ body: {},
267
+ headers: { 'stripe-signature': 'sig' },
268
+ });
269
+ return {
270
+ id: 'evt_1',
271
+ type: 'checkout.session.async_payment_failed',
272
+ data: { object: checkoutSession() },
273
+ };
274
+ },
275
+ };
276
+ const pay = app.scope('a-pay');
277
+ const originalGet = pay.model.paymentSession.get;
278
+ (pay.model.paymentSession as any).get = async () => ({
279
+ ...paymentInput,
280
+ id: 101,
281
+ providerOrderId: 'cs_1',
282
+ });
283
+ try {
284
+ const { provider } = app.bean.payProvider.resolveByName('pay-stripe:stripe', 'default');
285
+ const verified = await provider.verifyWebhook(
286
+ {
287
+ rawBody: '{"id":"evt_1"}',
288
+ body: {},
289
+ headers: { 'stripe-signature': 'sig' },
290
+ },
291
+ createOptions(gateway),
292
+ );
293
+ assert.equal(verified.paymentSessionId, 101);
294
+ assert.equal(verified.payment?.state, 'failed');
295
+ } finally {
296
+ (pay.model.paymentSession as any).get = originalGet;
297
+ }
298
+ });
299
+ });
300
+ });