vona-module-test-pay 5.0.4 → 5.0.5
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.
|
|
3
|
+
"version": "5.0.5",
|
|
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.
|
|
34
|
-
"vona-module-pay-mock": "5.0.
|
|
33
|
+
"vona-module-a-pay": "5.0.6",
|
|
34
|
+
"vona-module-pay-mock": "5.0.6",
|
|
35
|
+
"vona-module-pay-stripe": "5.0.6"
|
|
35
36
|
},
|
|
36
37
|
"title": "test-pay",
|
|
37
38
|
"vonaModule": {
|
|
@@ -51,5 +52,5 @@
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
},
|
|
54
|
-
"gitHead": "
|
|
55
|
+
"gitHead": "75a09e6a22891e958802ee649ed454db38f90c16"
|
|
55
56
|
}
|
|
@@ -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: '
|
|
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
|
+
});
|