vona-module-test-pay 5.0.2 → 5.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-test-pay",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
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.3",
34
- "vona-module-pay-mock": "5.0.3"
33
+ "vona-module-a-pay": "5.0.5",
34
+ "vona-module-pay-mock": "5.0.5"
35
35
  },
36
36
  "title": "test-pay",
37
37
  "vonaModule": {
@@ -51,5 +51,5 @@
51
51
  }
52
52
  }
53
53
  },
54
- "gitHead": "e86b8ee51b837417b7b28b2c67862877d4092371"
54
+ "gitHead": "f56eaba77347037643b625c8e6c5af408ff2d495"
55
55
  }
@@ -8,6 +8,18 @@ interface IFixture {
8
8
  paymentSessionId?: number;
9
9
  }
10
10
 
11
+ function isZodCustomError(error: unknown, path: string[], message: string) {
12
+ assert.equal((error as any).code, 422);
13
+ assert.deepEqual((error as any).message, [
14
+ {
15
+ code: 'custom',
16
+ path,
17
+ message,
18
+ },
19
+ ]);
20
+ return true;
21
+ }
22
+
11
23
  async function createFixture(expiresAt: Date): Promise<IFixture> {
12
24
  const scope = app.scope('a-pay');
13
25
  const suffix = randomUUID().slice(0, 12);
@@ -27,6 +39,20 @@ async function createFixture(expiresAt: Date): Promise<IFixture> {
27
39
  async function cleanup(fixture: IFixture) {
28
40
  const scope = app.scope('a-pay');
29
41
  if (fixture.paymentSessionId !== undefined) {
42
+ const refunds = await scope.model.refundOperation.select({
43
+ where: { paymentSessionId: fixture.paymentSessionId },
44
+ });
45
+ for (const refund of refunds) {
46
+ const providerOperation = await scope.model.providerOperation.get({
47
+ refundOperationId: refund.id,
48
+ kind: 'refund',
49
+ });
50
+ if (providerOperation) {
51
+ await scope.model.providerOperationRecoveryAudit.delete({
52
+ providerOperationId: providerOperation.id,
53
+ });
54
+ }
55
+ }
30
56
  await scope.model.outboxEvent.delete({ paymentSessionId: fixture.paymentSessionId });
31
57
  await scope.model.webhookInbox.delete({ paymentSessionId: fixture.paymentSessionId });
32
58
  await scope.model.providerOperation.delete({ paymentSessionId: fixture.paymentSessionId });
@@ -71,6 +97,18 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
71
97
  assert.equal(session.providerName, 'pay-mock:mock');
72
98
  assert.equal(session.clientName, 'default');
73
99
  assert.equal(session.environment, 'sandbox');
100
+ assert.match(
101
+ session.providerInvoiceReference,
102
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
103
+ );
104
+ assert.match(
105
+ session.providerCorrelationReference,
106
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
107
+ );
108
+ assert.notEqual(session.providerInvoiceReference, session.providerCorrelationReference);
109
+ const reloaded = await scope.model.paymentSession.getById(session.id);
110
+ assert.equal(reloaded?.providerInvoiceReference, session.providerInvoiceReference);
111
+ assert.equal(reloaded?.providerCorrelationReference, session.providerCorrelationReference);
74
112
  assert.ok(session.expiresAt.getTime() >= createdAt + 30 * 60 * 1000);
75
113
  assert.ok(session.expiresAt.getTime() <= Date.now() + 30 * 60 * 1000);
76
114
  } finally {
@@ -79,6 +117,36 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
79
117
  });
80
118
  });
81
119
 
120
+ it('rejects an unavailable provider candidate instead of falling back', async () => {
121
+ await app.bean.executor.mockCtx(async () => {
122
+ const scope = app.scope('a-pay');
123
+ const suffix = randomUUID().slice(0, 12);
124
+ const user = await app.bean.user.register({ name: `payment-candidate-${suffix}` }, true);
125
+ const fixture: IFixture = { userId: user.id as number };
126
+ try {
127
+ await assert.rejects(
128
+ scope.service.paymentSession.create({
129
+ userId: user.id,
130
+ payScene: 'commerce-payment:commerceOrder',
131
+ businessReference: `business-${suffix}`,
132
+ amountMinor: 1299,
133
+ currency: 'USD',
134
+ correlationId: `payment-${suffix}`,
135
+ providerCandidateKey: 'stripe',
136
+ }),
137
+ error =>
138
+ isZodCustomError(
139
+ error,
140
+ ['providerCandidateKey'],
141
+ 'payment provider candidate is unavailable',
142
+ ),
143
+ );
144
+ } finally {
145
+ await cleanup(fixture);
146
+ }
147
+ });
148
+ });
149
+
82
150
  it('rejects a currency outside the payment scene policy', async () => {
83
151
  await app.bean.executor.mockCtx(async () => {
84
152
  const scope = app.scope('a-pay');
@@ -95,7 +163,42 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
95
163
  currency: 'EUR',
96
164
  correlationId: `payment-${suffix}`,
97
165
  }),
98
- { status: 422 },
166
+ error =>
167
+ isZodCustomError(
168
+ error,
169
+ ['currency'],
170
+ 'payment currency is not allowed by the payment scene',
171
+ ),
172
+ );
173
+ assert.deepEqual(
174
+ await scope.model.paymentSession.select({
175
+ where: { correlationId: `payment-${suffix}` },
176
+ }),
177
+ [],
178
+ );
179
+ } finally {
180
+ await cleanup(fixture);
181
+ }
182
+ });
183
+ });
184
+
185
+ it('reports an invalid payment amount as a field error', async () => {
186
+ await app.bean.executor.mockCtx(async () => {
187
+ const scope = app.scope('a-pay');
188
+ const suffix = randomUUID().slice(0, 12);
189
+ const user = await app.bean.user.register({ name: `payment-session-${suffix}` }, true);
190
+ const fixture: IFixture = { userId: user.id as number };
191
+ try {
192
+ await assert.rejects(
193
+ scope.service.paymentSession.create({
194
+ userId: user.id,
195
+ payScene: 'commerce-payment:commerceOrder',
196
+ businessReference: `business-${suffix}`,
197
+ amountMinor: -1,
198
+ currency: 'USD',
199
+ correlationId: `payment-${suffix}`,
200
+ }),
201
+ error => isZodCustomError(error, ['amountMinor'], 'payment amount is invalid'),
99
202
  );
100
203
  assert.deepEqual(
101
204
  await scope.model.paymentSession.select({
@@ -142,6 +245,251 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
142
245
  });
143
246
  });
144
247
 
248
+ it('returns a terminal payment session when confirmation is replayed', async () => {
249
+ await app.bean.executor.mockCtx(async () => {
250
+ const fixture = await createFixture(new Date(Date.now() + 60_000));
251
+ try {
252
+ const scope = app.scope('a-pay');
253
+ await scope.model.paymentSession.updateById(fixture.paymentSessionId!, {
254
+ state: 'succeeded',
255
+ finalizedAt: new Date(),
256
+ });
257
+ const session = await scope.service.providerOperation.confirm(fixture.paymentSessionId!);
258
+ assert.equal(session.state, 'succeeded');
259
+ assert.deepEqual(
260
+ await scope.model.providerOperation.select({
261
+ where: { paymentSessionId: fixture.paymentSessionId!, kind: 'confirm' },
262
+ }),
263
+ [],
264
+ );
265
+ } finally {
266
+ await cleanup(fixture);
267
+ }
268
+ });
269
+ });
270
+
271
+ it('rejects refund commands that conflict with the payment state', async () => {
272
+ await app.bean.executor.mockCtx(async () => {
273
+ const fixture = await createFixture(new Date(Date.now() + 60_000));
274
+ try {
275
+ const scope = app.scope('a-pay');
276
+ await scope.model.paymentSession.updateById(fixture.paymentSessionId!, {
277
+ state: 'succeeded',
278
+ providerCaptureId: `capture-${randomUUID().slice(0, 12)}`,
279
+ finalizedAt: new Date(),
280
+ });
281
+ const command = {
282
+ paymentSessionId: fixture.paymentSessionId!,
283
+ businessReference: `refund-${randomUUID().slice(0, 12)}`,
284
+ currency: 'USD',
285
+ idempotencyKey: `refund-${randomUUID()}`,
286
+ correlationId: `refund-${randomUUID()}`,
287
+ };
288
+ await assert.rejects(scope.service.refundOperation.create({ ...command, amountMinor: 0 }), {
289
+ code: 409,
290
+ status: 409,
291
+ });
292
+ assert.deepEqual(
293
+ await scope.model.refundOperation.select({
294
+ where: { paymentSessionId: fixture.paymentSessionId! },
295
+ }),
296
+ [],
297
+ );
298
+ await scope.service.refundOperation.create({ ...command, amountMinor: 1_000 });
299
+ await assert.rejects(
300
+ scope.service.refundOperation.create({
301
+ ...command,
302
+ amountMinor: 300,
303
+ businessReference: `refund-${randomUUID().slice(0, 12)}`,
304
+ idempotencyKey: `refund-${randomUUID()}`,
305
+ correlationId: `refund-${randomUUID()}`,
306
+ }),
307
+ { code: 409, status: 409 },
308
+ );
309
+ assert.equal(
310
+ (
311
+ await scope.model.refundOperation.select({
312
+ where: { paymentSessionId: fixture.paymentSessionId! },
313
+ })
314
+ ).length,
315
+ 1,
316
+ );
317
+ } finally {
318
+ await cleanup(fixture);
319
+ }
320
+ });
321
+ });
322
+
323
+ it('schedules a pending refund for durable reconciliation', async () => {
324
+ await app.bean.executor.mockCtx(async () => {
325
+ const fixture = await createFixture(new Date(Date.now() + 60_000));
326
+ try {
327
+ const scope = app.scope('a-pay');
328
+ await scope.model.paymentSession.updateById(fixture.paymentSessionId!, {
329
+ state: 'succeeded',
330
+ providerCaptureId: `capture-${randomUUID().slice(0, 12)}`,
331
+ finalizedAt: new Date(),
332
+ });
333
+ const refund = await scope.service.refundOperation.create({
334
+ paymentSessionId: fixture.paymentSessionId!,
335
+ businessReference: `refund-${randomUUID().slice(0, 12)}`,
336
+ amountMinor: 1299,
337
+ currency: 'USD',
338
+ idempotencyKey: `refund-${randomUUID()}`,
339
+ correlationId: `refund-${randomUUID()}`,
340
+ });
341
+ const providerOperation = await scope.model.providerOperation.get({
342
+ refundOperationId: refund.id,
343
+ kind: 'refund',
344
+ });
345
+ assert.ok(providerOperation);
346
+ assert.match(
347
+ refund.providerInvoiceReference,
348
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
349
+ );
350
+ assert.match(
351
+ refund.providerCorrelationReference,
352
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
353
+ );
354
+ assert.notEqual(refund.providerInvoiceReference, refund.providerCorrelationReference);
355
+ assert.match(
356
+ providerOperation.idempotencyKey,
357
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
358
+ );
359
+ const claimed = await scope.service.providerOperation.claim(providerOperation.id);
360
+ assert.ok(claimed?.claimToken);
361
+ await scope.service.providerOperation.markSubmitted(
362
+ providerOperation.id,
363
+ claimed.claimToken,
364
+ );
365
+ await scope.service.refundOperation.settleProviderSnapshot(
366
+ providerOperation.id,
367
+ claimed.claimToken,
368
+ { state: 'pending', providerRefundId: `refund-${randomUUID().slice(0, 12)}` },
369
+ );
370
+ const [storedRefund, storedOperation, outbox] = await Promise.all([
371
+ scope.model.refundOperation.getById(refund.id),
372
+ scope.model.providerOperation.getById(providerOperation.id),
373
+ scope.model.outboxEvent.select({
374
+ where: { paymentSessionId: fixture.paymentSessionId! },
375
+ }),
376
+ ]);
377
+ assert.equal(storedRefund?.state, 'pending');
378
+ assert.equal(storedOperation?.state, 'reconciliation_required');
379
+ assert.equal(storedOperation?.providerResourceId, storedRefund?.providerRefundId);
380
+ assert.equal(storedOperation?.claimToken, undefined);
381
+ assert.equal(storedOperation?.claimExpiresAt, undefined);
382
+ assert.ok(storedOperation?.nextAttemptAt && storedOperation.nextAttemptAt > new Date());
383
+ assert.deepEqual(outbox, []);
384
+ } finally {
385
+ await cleanup(fixture);
386
+ }
387
+ });
388
+ });
389
+
390
+ it('fences an ambiguous refund submission until audited recovery', async () => {
391
+ await app.bean.executor.mockCtx(async () => {
392
+ const fixture = await createFixture(new Date(Date.now() + 60_000));
393
+ try {
394
+ const scope = app.scope('a-pay');
395
+ await scope.model.paymentSession.updateById(fixture.paymentSessionId!, {
396
+ state: 'succeeded',
397
+ providerCaptureId: `capture-${randomUUID().slice(0, 12)}`,
398
+ finalizedAt: new Date(),
399
+ });
400
+ const refund = await scope.service.refundOperation.create({
401
+ paymentSessionId: fixture.paymentSessionId!,
402
+ businessReference: `refund-${randomUUID().slice(0, 12)}`,
403
+ amountMinor: 1299,
404
+ currency: 'USD',
405
+ idempotencyKey: `refund-${randomUUID()}`,
406
+ correlationId: `refund-${randomUUID()}`,
407
+ });
408
+ const operation = await scope.model.providerOperation.get({
409
+ refundOperationId: refund.id,
410
+ kind: 'refund',
411
+ });
412
+ assert.ok(operation);
413
+ const providerRequestId = operation.idempotencyKey;
414
+ assert.match(
415
+ providerRequestId,
416
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
417
+ );
418
+ const claimed = await scope.service.providerOperation.claim(operation.id);
419
+ assert.ok(claimed?.claimToken);
420
+ await scope.service.providerOperation.markSubmitted(operation.id, claimed.claimToken);
421
+ await scope.service.providerOperation.releaseForReconciliation(
422
+ operation.id,
423
+ claimed.claimToken,
424
+ new Error('provider secret token must never persist'),
425
+ );
426
+ const failed = await scope.model.providerOperation.getById(operation.id);
427
+ assert.equal(failed?.state, 'reconciliation_required');
428
+ assert.equal(failed?.attemptCount, 1);
429
+ assert.equal(failed?.errorCode, 'refund_submission_outcome_unknown');
430
+ assert.equal(failed?.errorSummary, 'Provider refund submission outcome is unknown');
431
+ assert.equal(failed?.providerRequestId, providerRequestId);
432
+ assert.equal(failed?.idempotencyKey, providerRequestId);
433
+ assert.equal(failed?.errorSummary?.includes('secret'), false);
434
+ await scope.model.providerOperation.updateById(operation.id, {
435
+ nextAttemptAt: new Date(0),
436
+ });
437
+ await scope.service.providerOperation.queueDue();
438
+ const fenced = await scope.model.providerOperation.getById(operation.id);
439
+ assert.equal(fenced?.attemptCount, 1);
440
+ await scope.service.providerOperation.reconcileRefund(operation.id, {
441
+ actionIdempotencyKey: randomUUID(),
442
+ reason: 'Provider outcome could not be verified',
443
+ });
444
+ const audits = await scope.model.providerOperationRecoveryAudit.select({
445
+ where: { providerOperationId: operation.id },
446
+ });
447
+ assert.equal(audits.length, 1);
448
+ assert.equal(audits[0].action, 'reconcile');
449
+ assert.equal(audits[0].resolution, 'unresolved');
450
+ await assert.rejects(
451
+ scope.service.providerOperation.retryRefund(operation.id, {
452
+ actionIdempotencyKey: randomUUID(),
453
+ reason: 'Retry without acknowledgement',
454
+ }),
455
+ { status: 409 },
456
+ );
457
+ const retryKey = randomUUID();
458
+ await scope.service.providerOperation.retryRefund(operation.id, {
459
+ actionIdempotencyKey: retryKey,
460
+ reason: 'Retry the original provider request after reconciliation',
461
+ acknowledgeRetryRisk: true,
462
+ });
463
+ const retried = await scope.model.providerOperation.getById(operation.id);
464
+ assert.equal(retried?.attemptCount, 2);
465
+ assert.equal(retried?.providerRequestId, providerRequestId);
466
+ assert.equal(retried?.idempotencyKey, providerRequestId);
467
+ const retryAudit = await scope.model.providerOperationRecoveryAudit.get({
468
+ providerOperationId: operation.id,
469
+ actionIdempotencyKey: retryKey,
470
+ });
471
+ assert.equal(retryAudit?.action, 'retry');
472
+ assert.equal(retryAudit?.resolution, 'retried');
473
+ await scope.service.providerOperation.retryRefund(operation.id, {
474
+ actionIdempotencyKey: retryKey,
475
+ reason: 'Retry the original provider request after reconciliation',
476
+ acknowledgeRetryRisk: true,
477
+ });
478
+ assert.equal((await scope.model.providerOperation.getById(operation.id))?.attemptCount, 2);
479
+ await assert.rejects(
480
+ scope.service.providerOperation.retryRefund(operation.id, {
481
+ actionIdempotencyKey: randomUUID(),
482
+ reason: 'A second retry must be denied',
483
+ acknowledgeRetryRisk: true,
484
+ }),
485
+ { status: 409 },
486
+ );
487
+ } finally {
488
+ await cleanup(fixture);
489
+ }
490
+ });
491
+ });
492
+
145
493
  it('allows only one competing start to call the provider', async () => {
146
494
  const fixture = await app.bean.executor.mockCtx(async () => {
147
495
  return await createFixture(new Date(Date.now() + 60_000));