vona-module-test-pay 5.0.3 → 5.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-test-pay",
3
- "version": "5.0.3",
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.4",
34
- "vona-module-pay-mock": "5.0.4"
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": "c408492ce774644ac8a2530702db916986f52151"
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 {
@@ -96,7 +134,12 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
96
134
  correlationId: `payment-${suffix}`,
97
135
  providerCandidateKey: 'stripe',
98
136
  }),
99
- { status: 422 },
137
+ error =>
138
+ isZodCustomError(
139
+ error,
140
+ ['providerCandidateKey'],
141
+ 'payment provider candidate is unavailable',
142
+ ),
100
143
  );
101
144
  } finally {
102
145
  await cleanup(fixture);
@@ -120,7 +163,42 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
120
163
  currency: 'EUR',
121
164
  correlationId: `payment-${suffix}`,
122
165
  }),
123
- { 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'),
124
202
  );
125
203
  assert.deepEqual(
126
204
  await scope.model.paymentSession.select({
@@ -190,6 +268,58 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
190
268
  });
191
269
  });
192
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
+
193
323
  it('schedules a pending refund for durable reconciliation', async () => {
194
324
  await app.bean.executor.mockCtx(async () => {
195
325
  const fixture = await createFixture(new Date(Date.now() + 60_000));
@@ -213,6 +343,19 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
213
343
  kind: 'refund',
214
344
  });
215
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
+ );
216
359
  const claimed = await scope.service.providerOperation.claim(providerOperation.id);
217
360
  assert.ok(claimed?.claimToken);
218
361
  await scope.service.providerOperation.markSubmitted(
@@ -244,6 +387,109 @@ describe('paymentSession.test.ts', { concurrency: false }, () => {
244
387
  });
245
388
  });
246
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
+
247
493
  it('allows only one competing start to call the provider', async () => {
248
494
  const fixture = await app.bean.executor.mockCtx(async () => {
249
495
  return await createFixture(new Date(Date.now() + 60_000));