vona-module-test-pay 5.0.1 → 5.0.3

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.1",
3
+ "version": "5.0.3",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -30,8 +30,8 @@
30
30
  "postpack": "clean-package restore"
31
31
  },
32
32
  "dependencies": {
33
- "vona-module-a-pay": "5.0.2",
34
- "vona-module-pay-mock": "5.0.2"
33
+ "vona-module-a-pay": "5.0.4",
34
+ "vona-module-pay-mock": "5.0.4"
35
35
  },
36
36
  "title": "test-pay",
37
37
  "vonaModule": {
@@ -51,5 +51,5 @@
51
51
  }
52
52
  }
53
53
  },
54
- "gitHead": "95488ea0a7779f34f6ebddeca7719d8545bc55b3"
54
+ "gitHead": "c408492ce774644ac8a2530702db916986f52151"
55
55
  }
@@ -2,8 +2,8 @@ import type { IPaymentOutcomeEvent } from 'vona-module-a-pay';
2
2
 
3
3
  import assert from 'node:assert';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { describe, it } from 'node:test';
6
- import { app } from 'vona-mock';
5
+ import { after, before, describe, it } from 'node:test';
6
+ import { acquireTestLock, app } from 'vona-mock';
7
7
 
8
8
  interface IFixture {
9
9
  userId?: number;
@@ -79,6 +79,16 @@ async function cleanup(fixture: IFixture) {
79
79
  }
80
80
 
81
81
  describe('outbox.test.ts', { concurrency: false }, () => {
82
+ let releaseTestLock: (() => void) | undefined;
83
+
84
+ before(async () => {
85
+ releaseTestLock = await acquireTestLock('a-pay');
86
+ });
87
+
88
+ after(() => {
89
+ releaseTestLock?.();
90
+ });
91
+
82
92
  it('claims, backs off, and exhausts durable delivery attempts', async () => {
83
93
  await app.bean.executor.mockCtx(async () => {
84
94
  const fixture = await createFixture(randomUUID().slice(0, 12));
@@ -100,7 +110,7 @@ describe('outbox.test.ts', { concurrency: false }, () => {
100
110
  assert.ok((persisted?.nextAttemptAt?.getTime() ?? 0) >= releaseStart + 900);
101
111
  assert.equal(await scope.service.outbox.claim(event.id), undefined);
102
112
  await scope.model.outboxEvent.updateById(event.id, {
103
- nextAttemptAt: new Date(Date.now() - 1),
113
+ nextAttemptAt: new Date(Date.now() - 1_000),
104
114
  });
105
115
  const second = await scope.service.outbox.claim(event.id);
106
116
  assert.equal(second?.attemptCount, 2);
@@ -150,7 +160,7 @@ describe('outbox.test.ts', { concurrency: false }, () => {
150
160
  const scope = app.scope('a-pay');
151
161
  const first = await scope.service.outbox.claim(event.id);
152
162
  await scope.model.outboxEvent.updateById(event.id, {
153
- claimExpiresAt: new Date(Date.now() - 1),
163
+ claimExpiresAt: new Date(Date.now() - 1_000),
154
164
  });
155
165
  const second = await scope.service.outbox.claim(event.id);
156
166
  assert.ok(second?.claimToken);
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { describe, it } from 'node:test';
4
- import { app } from 'vona-mock';
3
+ import { after, before, describe, it } from 'node:test';
4
+ import { acquireTestLock, app } from 'vona-mock';
5
5
 
6
6
  interface IFixture {
7
7
  userId?: number;
@@ -38,7 +38,17 @@ async function cleanup(fixture: IFixture) {
38
38
  await app.bean.passport.signout();
39
39
  }
40
40
 
41
- describe('payMock.test.ts', { concurrency: false }, () => {
41
+ describe('payMock.test.ts', { concurrency: false, sequential: true }, () => {
42
+ let releaseTestLock: (() => void) | undefined;
43
+
44
+ before(async () => {
45
+ releaseTestLock = await acquireTestLock('a-pay');
46
+ });
47
+
48
+ after(() => {
49
+ releaseTestLock?.();
50
+ });
51
+
42
52
  it('captures Vona env secrets in the mock Provider options', () => {
43
53
  const options = app.bean.payProvider.getOptions('pay-mock:mock', 'default');
44
54
  assert.equal(options.secretCredential, app.meta.env.PAY_MOCK_DEFAULT_CREDENTIAL);
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { describe, it } from 'node:test';
4
- import { app } from 'vona-mock';
3
+ import { after, before, describe, it } from 'node:test';
4
+ import { acquireTestLock, app } from 'vona-mock';
5
5
 
6
6
  interface IFixture {
7
7
  userId?: number;
@@ -27,6 +27,10 @@ async function createFixture(expiresAt: Date): Promise<IFixture> {
27
27
  async function cleanup(fixture: IFixture) {
28
28
  const scope = app.scope('a-pay');
29
29
  if (fixture.paymentSessionId !== undefined) {
30
+ await scope.model.outboxEvent.delete({ paymentSessionId: fixture.paymentSessionId });
31
+ await scope.model.webhookInbox.delete({ paymentSessionId: fixture.paymentSessionId });
32
+ await scope.model.providerOperation.delete({ paymentSessionId: fixture.paymentSessionId });
33
+ await scope.model.refundOperation.delete({ paymentSessionId: fixture.paymentSessionId });
30
34
  await scope.model.paymentAudit.delete({ paymentSessionId: fixture.paymentSessionId });
31
35
  await scope.model.paymentSession.delete({ id: fixture.paymentSessionId });
32
36
  }
@@ -37,6 +41,16 @@ async function cleanup(fixture: IFixture) {
37
41
  }
38
42
 
39
43
  describe('paymentSession.test.ts', { concurrency: false }, () => {
44
+ let releaseTestLock: (() => void) | undefined;
45
+
46
+ before(async () => {
47
+ releaseTestLock = await acquireTestLock('a-pay');
48
+ });
49
+
50
+ after(() => {
51
+ releaseTestLock?.();
52
+ });
53
+
40
54
  it('derives provider facts and expiry from the payment scene', async () => {
41
55
  await app.bean.executor.mockCtx(async () => {
42
56
  const scope = app.scope('a-pay');
@@ -65,6 +79,31 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
65
79
  });
66
80
  });
67
81
 
82
+ it('rejects an unavailable provider candidate instead of falling back', async () => {
83
+ await app.bean.executor.mockCtx(async () => {
84
+ const scope = app.scope('a-pay');
85
+ const suffix = randomUUID().slice(0, 12);
86
+ const user = await app.bean.user.register({ name: `payment-candidate-${suffix}` }, true);
87
+ const fixture: IFixture = { userId: user.id as number };
88
+ try {
89
+ await assert.rejects(
90
+ scope.service.paymentSession.create({
91
+ userId: user.id,
92
+ payScene: 'commerce-payment:commerceOrder',
93
+ businessReference: `business-${suffix}`,
94
+ amountMinor: 1299,
95
+ currency: 'USD',
96
+ correlationId: `payment-${suffix}`,
97
+ providerCandidateKey: 'stripe',
98
+ }),
99
+ { status: 422 },
100
+ );
101
+ } finally {
102
+ await cleanup(fixture);
103
+ }
104
+ });
105
+ });
106
+
68
107
  it('rejects a currency outside the payment scene policy', async () => {
69
108
  await app.bean.executor.mockCtx(async () => {
70
109
  const scope = app.scope('a-pay');
@@ -102,9 +141,8 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
102
141
  const scope = app.scope('a-pay');
103
142
  const session = await scope.service.paymentSession.start(fixture.paymentSessionId!);
104
143
  assert.equal(session.state, 'requires_action');
105
- await assert.rejects(scope.service.paymentSession.start(fixture.paymentSessionId!), {
106
- status: 409,
107
- });
144
+ const replayed = await scope.service.paymentSession.start(fixture.paymentSessionId!);
145
+ assert.equal(replayed.state, 'requires_action');
108
146
  } finally {
109
147
  await cleanup(fixture);
110
148
  }
@@ -113,7 +151,7 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
113
151
 
114
152
  it('does not start an expired payment session', async () => {
115
153
  await app.bean.executor.mockCtx(async () => {
116
- const fixture = await createFixture(new Date(Date.now() - 1));
154
+ const fixture = await createFixture(new Date(Date.now() - 1_000));
117
155
  try {
118
156
  const scope = app.scope('a-pay');
119
157
  await assert.rejects(scope.service.paymentSession.start(fixture.paymentSessionId!), {
@@ -129,6 +167,83 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
129
167
  });
130
168
  });
131
169
 
170
+ it('returns a terminal payment session when confirmation is replayed', async () => {
171
+ await app.bean.executor.mockCtx(async () => {
172
+ const fixture = await createFixture(new Date(Date.now() + 60_000));
173
+ try {
174
+ const scope = app.scope('a-pay');
175
+ await scope.model.paymentSession.updateById(fixture.paymentSessionId!, {
176
+ state: 'succeeded',
177
+ finalizedAt: new Date(),
178
+ });
179
+ const session = await scope.service.providerOperation.confirm(fixture.paymentSessionId!);
180
+ assert.equal(session.state, 'succeeded');
181
+ assert.deepEqual(
182
+ await scope.model.providerOperation.select({
183
+ where: { paymentSessionId: fixture.paymentSessionId!, kind: 'confirm' },
184
+ }),
185
+ [],
186
+ );
187
+ } finally {
188
+ await cleanup(fixture);
189
+ }
190
+ });
191
+ });
192
+
193
+ it('schedules a pending refund for durable reconciliation', async () => {
194
+ await app.bean.executor.mockCtx(async () => {
195
+ const fixture = await createFixture(new Date(Date.now() + 60_000));
196
+ try {
197
+ const scope = app.scope('a-pay');
198
+ await scope.model.paymentSession.updateById(fixture.paymentSessionId!, {
199
+ state: 'succeeded',
200
+ providerCaptureId: `capture-${randomUUID().slice(0, 12)}`,
201
+ finalizedAt: new Date(),
202
+ });
203
+ const refund = await scope.service.refundOperation.create({
204
+ paymentSessionId: fixture.paymentSessionId!,
205
+ businessReference: `refund-${randomUUID().slice(0, 12)}`,
206
+ amountMinor: 1299,
207
+ currency: 'USD',
208
+ idempotencyKey: `refund-${randomUUID()}`,
209
+ correlationId: `refund-${randomUUID()}`,
210
+ });
211
+ const providerOperation = await scope.model.providerOperation.get({
212
+ refundOperationId: refund.id,
213
+ kind: 'refund',
214
+ });
215
+ assert.ok(providerOperation);
216
+ const claimed = await scope.service.providerOperation.claim(providerOperation.id);
217
+ assert.ok(claimed?.claimToken);
218
+ await scope.service.providerOperation.markSubmitted(
219
+ providerOperation.id,
220
+ claimed.claimToken,
221
+ );
222
+ await scope.service.refundOperation.settleProviderSnapshot(
223
+ providerOperation.id,
224
+ claimed.claimToken,
225
+ { state: 'pending', providerRefundId: `refund-${randomUUID().slice(0, 12)}` },
226
+ );
227
+ const [storedRefund, storedOperation, outbox] = await Promise.all([
228
+ scope.model.refundOperation.getById(refund.id),
229
+ scope.model.providerOperation.getById(providerOperation.id),
230
+ scope.model.outboxEvent.select({
231
+ where: { paymentSessionId: fixture.paymentSessionId! },
232
+ }),
233
+ ]);
234
+ assert.equal(storedRefund?.state, 'pending');
235
+ assert.equal(storedOperation?.state, 'reconciliation_required');
236
+ assert.equal(storedOperation?.providerResourceId, storedRefund?.providerRefundId);
237
+ assert.equal(storedOperation?.claimToken, undefined);
238
+ assert.equal(storedOperation?.claimExpiresAt, undefined);
239
+ assert.ok(storedOperation?.nextAttemptAt && storedOperation.nextAttemptAt > new Date());
240
+ assert.deepEqual(outbox, []);
241
+ } finally {
242
+ await cleanup(fixture);
243
+ }
244
+ });
245
+ });
246
+
132
247
  it('allows only one competing start to call the provider', async () => {
133
248
  const fixture = await app.bean.executor.mockCtx(async () => {
134
249
  return await createFixture(new Date(Date.now() + 60_000));
@@ -142,8 +257,13 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
142
257
  return await app.scope('a-pay').service.paymentSession.start(fixture.paymentSessionId!);
143
258
  }),
144
259
  ]);
145
- assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
146
- assert.equal(results.filter(result => result.status === 'rejected').length, 1);
260
+ assert.equal(results.filter(result => result.status === 'fulfilled').length, 2);
261
+ const operations = await app.bean.executor.mockCtx(async () => {
262
+ return await app.scope('a-pay').model.providerOperation.select({
263
+ where: { paymentSessionId: fixture.paymentSessionId, kind: 'start' },
264
+ });
265
+ });
266
+ assert.equal(operations.length, 1);
147
267
  } finally {
148
268
  await app.bean.executor.mockCtx(async () => {
149
269
  await cleanup(fixture);
@@ -0,0 +1,118 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+ import { app } from 'vona-mock';
4
+
5
+ describe('paypalProvider.test.ts', { concurrency: false }, () => {
6
+ it('maps SDK camelCase payment and refund facts through the PayPal gateway', async () => {
7
+ await app.bean.executor.mockCtx(async () => {
8
+ const calls: Array<{ kind: string; input: unknown }> = [];
9
+ const gateway = {
10
+ async createOrder(_options: unknown, input: unknown) {
11
+ calls.push({ kind: 'createOrder', input });
12
+ return {
13
+ id: 'order-1',
14
+ links: [
15
+ { rel: 'approve', href: 'https://www.sandbox.paypal.com/checkoutnow?token=order-1' },
16
+ ],
17
+ };
18
+ },
19
+ async captureOrder() {
20
+ throw new Error('unexpected captureOrder');
21
+ },
22
+ async getOrder(_options: unknown, input: unknown) {
23
+ calls.push({ kind: 'getOrder', input });
24
+ return {
25
+ id: 'order-1',
26
+ purchaseUnits: [
27
+ {
28
+ customId: '101',
29
+ invoiceId: 'business-1',
30
+ amount: { currencyCode: 'USD', value: '12.99' },
31
+ payee: { merchantId: 'merchant-1' },
32
+ payments: {
33
+ captures: [
34
+ {
35
+ id: 'capture-1',
36
+ status: 'COMPLETED',
37
+ amount: { currencyCode: 'USD', value: '12.99' },
38
+ },
39
+ ],
40
+ },
41
+ },
42
+ ],
43
+ };
44
+ },
45
+ async refundCapturedPayment(_options: unknown, input: unknown) {
46
+ calls.push({ kind: 'refundCapturedPayment', input });
47
+ return {
48
+ id: 'refund-1',
49
+ customId: '201',
50
+ invoiceId: 'refund-business-1',
51
+ amount: { currencyCode: 'USD', value: '5.00' },
52
+ status: 'COMPLETED',
53
+ payee: { merchantId: 'merchant-1' },
54
+ };
55
+ },
56
+ async getRefund() {
57
+ throw new Error('unexpected getRefund');
58
+ },
59
+ async verifyWebhookSignature() {
60
+ throw new Error('unexpected verifyWebhookSignature');
61
+ },
62
+ };
63
+ const { provider, clientOptions } = app.bean.payProvider.resolveByName(
64
+ 'pay-paypal:paypal',
65
+ 'default',
66
+ );
67
+ const options = {
68
+ ...clientOptions,
69
+ secretCredential: { clientId: 'client-1', clientSecret: 'secret-1' },
70
+ webhookId: 'webhook-1',
71
+ merchantReference: 'merchant-1',
72
+ gateway,
73
+ };
74
+ const paymentInput = {
75
+ paymentSessionId: 101,
76
+ businessReference: 'business-1',
77
+ idempotencyKey: 'payment-key',
78
+ amountMinor: 1299,
79
+ currency: 'USD',
80
+ providerOrderId: 'order-1',
81
+ };
82
+ const payment = await provider.queryPayment(paymentInput, options as never);
83
+ assert.deepEqual(payment, {
84
+ state: 'succeeded',
85
+ providerPaymentId: 'order-1',
86
+ providerOrderId: 'order-1',
87
+ providerCaptureId: 'capture-1',
88
+ nextAction: { kind: 'completed' },
89
+ });
90
+ const refund = await provider.createRefund(
91
+ {
92
+ paymentSessionId: 101,
93
+ refundOperationId: 201,
94
+ businessReference: 'refund-business-1',
95
+ idempotencyKey: 'refund-key',
96
+ amountMinor: 500,
97
+ currency: 'USD',
98
+ providerCaptureId: 'capture-1',
99
+ },
100
+ options as never,
101
+ );
102
+ assert.deepEqual(refund, { state: 'succeeded', providerRefundId: 'refund-1' });
103
+ assert.deepEqual(
104
+ calls.map(item => item.kind),
105
+ ['getOrder', 'refundCapturedPayment'],
106
+ );
107
+ assert.deepEqual(calls[1]?.input, {
108
+ captureId: 'capture-1',
109
+ paypalRequestId: 'refund-key',
110
+ body: {
111
+ amount: { currencyCode: 'USD', value: '5.00' },
112
+ customId: '201',
113
+ invoiceId: 'refund-business-1',
114
+ },
115
+ });
116
+ });
117
+ });
118
+ });
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert';
2
2
  import { createHmac, randomUUID } from 'node:crypto';
3
- import { describe, it } from 'node:test';
4
- import { app } from 'vona-mock';
3
+ import { after, before, describe, it } from 'node:test';
4
+ import { acquireTestLock, app } from 'vona-mock';
5
5
 
6
6
  interface IFixture {
7
7
  userId?: number;
@@ -100,6 +100,16 @@ async function countFacts(paymentSessionId: number) {
100
100
  }
101
101
 
102
102
  describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
103
+ let releaseTestLock: (() => void) | undefined;
104
+
105
+ before(async () => {
106
+ releaseTestLock = await acquireTestLock('a-pay');
107
+ });
108
+
109
+ after(() => {
110
+ releaseTestLock?.();
111
+ });
112
+
103
113
  it('accepts a raw-body signed HTTP webhook and rejects an invalid signature without mutation', async () => {
104
114
  const fixture: IFixture = {};
105
115
  try {