cabloy 5.1.127 → 5.1.129

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.
Files changed (88) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/cabloy-docs/backend/i18n-guide.md +86 -7
  4. package/e2e/specs/a-commerce/commerce.spec.ts +93 -8
  5. package/package.json +1 -1
  6. package/vona/env/.env +2 -0
  7. package/vona/env/.env.prod +6 -2
  8. package/vona/packages-cli/cabloy-cli/package.json +1 -1
  9. package/vona/packages-cli/cli/package.json +1 -1
  10. package/vona/packages-cli/cli-set-api/package.json +1 -1
  11. package/vona/packages-utils/module-glob/package.json +1 -1
  12. package/vona/packages-utils/utils/package.json +1 -1
  13. package/vona/packages-utils/utils/src/vona.ts +8 -1
  14. package/vona/packages-vona/vona/package.json +1 -1
  15. package/vona/packages-vona/vona-core/package.json +1 -1
  16. package/vona/packages-vona/vona-mock/package.json +1 -1
  17. package/vona/pnpm-lock.yaml +142 -21
  18. package/vona/pnpm-workspace.yaml +1 -1
  19. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +1 -1
  20. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productSelectResItem.tsx +3 -1
  21. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productView.tsx +3 -1
  22. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/product.tsx +23 -3
  23. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/model/product.ts +1 -0
  24. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/product.ts +7 -2
  25. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/productPresentation.test.ts +214 -0
  26. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/payScene.commerceOrder.ts +39 -3
  27. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/commercePayScene.ts +14 -1
  28. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +5 -1
  29. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +17 -0
  30. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +18 -0
  31. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutCreate.tsx +3 -0
  32. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutPaymentMethod.tsx +19 -0
  33. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutPaymentMethods.tsx +17 -0
  34. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +51 -0
  35. package/vona/src/suite/a-commerce/modules/commerce-trade/test/paymentOutcome.test.ts +2 -1
  36. package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
  37. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/.metadata/index.ts +94 -55
  38. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/bean.payScene.ts +65 -1
  39. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.redlock.ts +2 -0
  40. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/controller/paymentCallback.ts +32 -0
  41. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/controller/paymentSession.ts +26 -8
  42. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/paymentCallback.ts +99 -0
  43. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/paymentSession.ts +26 -3
  44. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/providerOperation.ts +214 -82
  45. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/refundOperation.ts +27 -2
  46. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payScene.ts +9 -0
  47. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payment.ts +5 -0
  48. package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
  49. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/bean/payProvider.mock.ts +7 -0
  50. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +1 -1
  51. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/bean/payProvider.paypal.ts +395 -84
  52. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/lib/index.ts +1 -1
  53. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/lib/paypalGateway.ts +129 -0
  54. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/types/index.ts +2 -0
  55. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
  56. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/src/bean/payProvider.stripe.ts +7 -0
  57. package/vona/src/suite-vendor/a-pay/package.json +5 -5
  58. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  59. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  60. package/zova/packages-cli/cli/package.json +3 -3
  61. package/zova/packages-cli/cli-set-front/package.json +4 -4
  62. package/zova/packages-utils/zova-jsx/package.json +3 -3
  63. package/zova/packages-utils/zova-vite/package.json +2 -2
  64. package/zova/packages-zova/zova/package.json +3 -3
  65. package/zova/packages-zova/zova-core/package.json +2 -2
  66. package/zova/pnpm-lock.yaml +41 -41
  67. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/schemas.ts +8 -0
  68. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/types.ts +175 -0
  69. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
  70. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeCheckout.ts +16 -0
  71. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +8 -0
  72. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +177 -2
  73. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeCheckout.ts +12 -1
  74. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +11 -0
  75. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/checkout/controller.tsx +25 -1
  76. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/payment/controller.tsx +27 -4
  77. package/zova/src/suite-vendor/a-pay/modules/a-pay/cli/openapi.config.ts +1 -1
  78. package/zova/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
  79. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/api/openapi/schemas.ts +20 -8
  80. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/api/openapi/types.ts +292 -32
  81. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/api/paymentSession.ts +26 -4
  82. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/apiSchema/paymentSession.ts +5 -0
  83. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/component/paymentNextAction/controller.tsx +2 -1
  84. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/model/paymentSession.ts +12 -0
  85. package/zova/src/suite-vendor/a-pay/package.json +2 -2
  86. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +4 -4
  87. package/zova/src/suite-vendor/a-zova/package.json +2 -2
  88. /package/vona/patches/{zova-core@5.1.81.patch → zova-core@5.1.82.patch} +0 -0
@@ -0,0 +1,99 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+
3
+ import { BeanBase } from 'vona';
4
+ import { Service } from 'vona-module-a-bean';
5
+
6
+ import type { EntityPaymentSession } from '../entity/paymentSession.tsx';
7
+
8
+ type TypePaymentCallbackPurpose = 'return' | 'cancel';
9
+
10
+ interface IPaymentCallbackState {
11
+ paymentSessionId: TableIdentity;
12
+ providerName: string;
13
+ clientName: string;
14
+ environment: 'sandbox' | 'live';
15
+ purpose: TypePaymentCallbackPurpose;
16
+ continuationPath: string;
17
+ }
18
+
19
+ @Service()
20
+ export class ServicePaymentCallback extends BeanBase {
21
+ async createUrls(session: EntityPaymentSession) {
22
+ const continuationPath = await this._getContinuationPath(session);
23
+ return {
24
+ returnUrl: await this._createUrl(session, 'return', continuationPath),
25
+ cancelUrl: await this._createUrl(session, 'cancel', continuationPath),
26
+ };
27
+ }
28
+
29
+ async consume(
30
+ purpose: TypePaymentCallbackPurpose,
31
+ token: string | undefined,
32
+ ): Promise<IPaymentCallbackState> {
33
+ const path = `/pay/payment-callback/${purpose}`;
34
+ const state = (await this.bean.jwt.get('oauthstate').verify(token, { path })) as
35
+ | IPaymentCallbackState
36
+ | undefined;
37
+ if (
38
+ !state ||
39
+ state.purpose !== purpose ||
40
+ !this._isSafeContinuationPath(state.continuationPath)
41
+ ) {
42
+ this.app.throw(401, 'payment callback state is invalid');
43
+ }
44
+ const session = await this.scope.model.paymentSession.getById(state.paymentSessionId);
45
+ if (
46
+ !session ||
47
+ session.providerName !== state.providerName ||
48
+ session.clientName !== state.clientName ||
49
+ session.environment !== state.environment
50
+ ) {
51
+ this.app.throw(404, 'payment session not found');
52
+ }
53
+ return state;
54
+ }
55
+
56
+ private async _createUrl(
57
+ session: EntityPaymentSession,
58
+ purpose: TypePaymentCallbackPurpose,
59
+ continuationPath: string,
60
+ ) {
61
+ const path = `/pay/payment-callback/${purpose}`;
62
+ const continuationPathWithResult = `${continuationPath}?providerResult=${purpose}`;
63
+ const state = await this.bean.jwt.get('oauthstate').sign(
64
+ {
65
+ paymentSessionId: session.id,
66
+ providerName: session.providerName,
67
+ clientName: session.clientName,
68
+ environment: session.environment,
69
+ purpose,
70
+ continuationPath: continuationPathWithResult,
71
+ },
72
+ { path, expiresIn: '15m' },
73
+ );
74
+ this._assertTrustedOrigin();
75
+ const url = new URL(this.app.util.getAbsoluteUrlByApiPath(path));
76
+ url.searchParams.set('state', state);
77
+ return url.toString();
78
+ }
79
+
80
+ private async _getContinuationPath(session: EntityPaymentSession) {
81
+ const scene = this.bean.payScene.get(session.payScene as never);
82
+ const continuationPath = await scene.getPaymentCallbackPath(session);
83
+ if (!this._isSafeContinuationPath(continuationPath)) {
84
+ this.app.throw(500, 'payment scene returned an invalid callback continuation path');
85
+ }
86
+ return continuationPath;
87
+ }
88
+
89
+ private _assertTrustedOrigin() {
90
+ const { protocol, host } = this.app.config.server.serve;
91
+ if (!protocol || !host) {
92
+ this.app.throw(503, 'payment callbacks require configured server serve protocol and host');
93
+ }
94
+ }
95
+
96
+ private _isSafeContinuationPath(value: unknown): value is string {
97
+ return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//');
98
+ }
99
+ }
@@ -20,6 +20,7 @@ export interface IPaymentSessionCreateCommand {
20
20
  amountMinor: number;
21
21
  currency: string;
22
22
  correlationId: string;
23
+ providerCandidateKey?: string;
23
24
  }
24
25
 
25
26
  @Service()
@@ -150,15 +151,37 @@ export class ServicePaymentSession extends BeanBase {
150
151
  return { session, changed: false };
151
152
  }
152
153
  if (['succeeded', 'failed', 'cancelled', 'expired'].includes(session.state)) {
154
+ if (
155
+ session.state === 'expired' &&
156
+ options.state === 'succeeded' &&
157
+ options.providerCaptureId
158
+ ) {
159
+ const finalizedAt = new Date();
160
+ await this.scope.model.paymentSession.updateById(session.id, {
161
+ providerPaymentId: options.providerPaymentId,
162
+ providerCaptureId: options.providerCaptureId,
163
+ });
164
+ await this.scope.model.paymentAudit.insert({
165
+ paymentSessionId: session.id,
166
+ webhookInboxId: options.webhookInboxId,
167
+ fromState: 'expired',
168
+ toState: 'expired',
169
+ correlationId: session.correlationId,
170
+ source: 'webhook.lateCapture',
171
+ occurredAt: finalizedAt,
172
+ });
173
+ return {
174
+ session: { ...session, ...options },
175
+ changed: true,
176
+ ignoredReason: 'late successful capture requires compensation',
177
+ };
178
+ }
153
179
  return {
154
180
  session,
155
181
  changed: false,
156
182
  ignoredReason: `terminal payment state ${options.state} ignored after ${session.state}`,
157
183
  };
158
184
  }
159
- if (session.expiresAt <= new Date()) {
160
- this.app.throw(409, 'payment session is expired');
161
- }
162
185
  const finalizedAt = new Date();
163
186
  await this.scope.model.paymentSession.updateById(session.id, {
164
187
  state: options.state,
@@ -7,7 +7,12 @@ import { Core } from 'vona-module-a-core';
7
7
 
8
8
  import type { EntityPaymentSession } from '../entity/paymentSession.tsx';
9
9
  import type { EntityProviderOperation } from '../entity/providerOperation.tsx';
10
- import type { IPayProviderPaymentSnapshot } from '../types/payment.ts';
10
+ import type {
11
+ IPayProviderPaymentInput,
12
+ IPayProviderPaymentSnapshot,
13
+ IPayProviderRefundInput,
14
+ TypeProviderOperationKind,
15
+ } from '../types/payment.ts';
11
16
 
12
17
  const ClaimLeaseMilliseconds = 60_000;
13
18
  const MaxAttempts = 10;
@@ -20,9 +25,29 @@ export class ServiceProviderOperation extends BeanBase {
20
25
  async () => {
21
26
  const operation = await this.ensureStart(paymentSessionId);
22
27
  await this.execute(operation.id);
23
- const session = await this.scope.model.paymentSession.getById(paymentSessionId);
24
- if (!session) this.app.throw(404, 'payment session not found');
25
- return session;
28
+ return await this._getSession(paymentSessionId);
29
+ },
30
+ );
31
+ }
32
+
33
+ async confirm(paymentSessionId: TableIdentity): Promise<EntityPaymentSession> {
34
+ return await this.scope.redlock.lock(
35
+ `pay.providerOperation.confirm.${paymentSessionId}`,
36
+ async () => {
37
+ const operation = await this.ensureConfirm(paymentSessionId);
38
+ if (operation) await this.execute(operation.id);
39
+ return await this._getSession(paymentSessionId);
40
+ },
41
+ );
42
+ }
43
+
44
+ async reconcile(paymentSessionId: TableIdentity): Promise<EntityPaymentSession> {
45
+ return await this.scope.redlock.lock(
46
+ `pay.providerOperation.query.${paymentSessionId}`,
47
+ async () => {
48
+ const operation = await this.ensureQuery(paymentSessionId);
49
+ await this.execute(operation.id);
50
+ return await this._getSession(paymentSessionId);
26
51
  },
27
52
  );
28
53
  }
@@ -31,7 +56,6 @@ export class ServiceProviderOperation extends BeanBase {
31
56
  async ensureStart(paymentSessionId: TableIdentity): Promise<EntityProviderOperation> {
32
57
  const session = await this.scope.model.paymentSession.getByIdForUpdate(paymentSessionId);
33
58
  if (!session) this.app.throw(404, 'payment session not found');
34
-
35
59
  const existing = await this.scope.model.providerOperation.getForUpdate({
36
60
  paymentSessionId: session.id,
37
61
  kind: 'start',
@@ -50,23 +74,62 @@ export class ServiceProviderOperation extends BeanBase {
50
74
  source: 'providerOperation.start',
51
75
  occurredAt: now,
52
76
  });
53
- return await this.scope.model.providerOperation.insert({
77
+ return await this._insertOperation(session, 'start', `${session.correlationId}:start`, now);
78
+ }
79
+
80
+ @Core.transaction()
81
+ async ensureConfirm(
82
+ paymentSessionId: TableIdentity,
83
+ ): Promise<EntityProviderOperation | undefined> {
84
+ const session = await this.scope.model.paymentSession.getByIdForUpdate(paymentSessionId);
85
+ if (!session) this.app.throw(404, 'payment session not found');
86
+ if (['succeeded', 'failed', 'cancelled', 'expired'].includes(session.state)) return undefined;
87
+ if (!session.providerOrderId) this.app.throw(409, 'payment session has no provider order');
88
+ if (session.providerCaptureId || session.state === 'processing') {
89
+ return await this._ensureQueryLocked(session);
90
+ }
91
+ const existing = await this.scope.model.providerOperation.getForUpdate({
54
92
  paymentSessionId: session.id,
55
- kind: 'start',
56
- state: 'created',
57
- idempotencyKey: `${session.correlationId}:start`,
58
- correlationId: session.correlationId,
59
- attemptCount: 0,
60
- nextAttemptAt: new Date(now.getTime() - 1_000),
93
+ kind: 'confirm',
61
94
  });
95
+ if (existing && !['succeeded', 'failed'].includes(existing.state)) return existing;
96
+ return await this._insertOperation(
97
+ session,
98
+ 'confirm',
99
+ `${session.correlationId}:confirm`,
100
+ new Date(),
101
+ );
102
+ }
103
+
104
+ @Core.transaction()
105
+ async ensureQuery(paymentSessionId: TableIdentity): Promise<EntityProviderOperation> {
106
+ const session = await this.scope.model.paymentSession.getByIdForUpdate(paymentSessionId);
107
+ if (!session) this.app.throw(404, 'payment session not found');
108
+ if (!session.providerOrderId && !session.providerCaptureId) {
109
+ this.app.throw(409, 'payment session has no provider resource to reconcile');
110
+ }
111
+ return await this._ensureQueryLocked(session);
112
+ }
113
+
114
+ private async _ensureQueryLocked(session: EntityPaymentSession) {
115
+ const existing = await this.scope.model.providerOperation.getForUpdate({
116
+ paymentSessionId: session.id,
117
+ kind: 'query',
118
+ });
119
+ if (existing && !['succeeded', 'failed'].includes(existing.state)) return existing;
120
+ return await this._insertOperation(
121
+ session,
122
+ 'query',
123
+ `${session.correlationId}:query`,
124
+ new Date(),
125
+ );
62
126
  }
63
127
 
64
128
  async execute(providerOperationId: TableIdentity) {
65
129
  const operation = await this.claim(providerOperationId);
66
130
  if (!operation) return undefined;
67
131
  try {
68
- const session = await this.scope.model.paymentSession.getById(operation.paymentSessionId);
69
- if (!session) this.app.throw(404, 'payment session not found');
132
+ const session = await this._getSession(operation.paymentSessionId);
70
133
  const { provider, clientOptions } = this.bean.payProvider.resolveByName(
71
134
  session.providerName,
72
135
  session.clientName,
@@ -74,50 +137,11 @@ export class ServiceProviderOperation extends BeanBase {
74
137
  if (clientOptions.environment !== session.environment) {
75
138
  this.app.throw(500, 'payment session provider environment is inconsistent');
76
139
  }
77
- const isReconciliation =
78
- operation.state === 'reconciliation_required' ||
79
- operation.state === 'claimed' ||
80
- operation.state === 'submitted';
81
140
  await this.markSubmitted(operation.id, operation.claimToken!);
82
141
  if (operation.kind === 'refund') {
83
- const refundOperationId = operation.refundOperationId;
84
- if (!refundOperationId) this.app.throw(500, 'refund provider operation is not linked');
85
- const refund = await this.scope.model.refundOperation.getById(refundOperationId);
86
- if (!refund) this.app.throw(404, 'refund operation not found');
87
- if (!session.providerCaptureId)
88
- this.app.throw(409, 'payment session has no provider capture');
89
- const input = {
90
- paymentSessionId: session.id,
91
- refundOperationId: refund.id,
92
- businessReference: refund.businessReference,
93
- idempotencyKey: operation.idempotencyKey,
94
- amountMinor: refund.amountMinor,
95
- currency: refund.currency,
96
- providerCaptureId: session.providerCaptureId,
97
- };
98
- const snapshot =
99
- isReconciliation && provider.queryRefund
100
- ? await provider.queryRefund(input, clientOptions)
101
- : await provider.createRefund(input, clientOptions);
102
- await this.scope.service.refundOperation.settleProviderSnapshot(
103
- operation.id,
104
- operation.claimToken!,
105
- snapshot,
106
- );
107
- return snapshot;
142
+ return await this._executeRefund(operation, session, provider, clientOptions);
108
143
  }
109
- const input = {
110
- paymentSessionId: session.id,
111
- businessReference: session.businessReference,
112
- idempotencyKey: operation.idempotencyKey,
113
- amountMinor: session.amountMinor,
114
- currency: session.currency,
115
- providerOrderId: session.providerOrderId,
116
- };
117
- const snapshot =
118
- operation.state === 'reconciliation_required'
119
- ? await provider.queryPayment(input, clientOptions)
120
- : await provider.startPayment(input, clientOptions);
144
+ const snapshot = await this._executePayment(operation, session, provider, clientOptions);
121
145
  await this.settlePaymentSnapshot(operation.id, operation.claimToken!, snapshot);
122
146
  return snapshot;
123
147
  } catch (error) {
@@ -126,6 +150,72 @@ export class ServiceProviderOperation extends BeanBase {
126
150
  }
127
151
  }
128
152
 
153
+ private async _executePayment(
154
+ operation: EntityProviderOperation,
155
+ session: EntityPaymentSession,
156
+ provider: ReturnType<typeof this.bean.payProvider.resolveByName>['provider'],
157
+ clientOptions: ReturnType<typeof this.bean.payProvider.resolveByName>['clientOptions'],
158
+ ) {
159
+ const callbackUrls =
160
+ operation.kind === 'start' && clientOptions.capabilities.redirectCheckout
161
+ ? await this.scope.service.paymentCallback.createUrls(session)
162
+ : undefined;
163
+ const input: IPayProviderPaymentInput = {
164
+ paymentSessionId: session.id,
165
+ businessReference: session.businessReference,
166
+ idempotencyKey: operation.idempotencyKey,
167
+ amountMinor: session.amountMinor,
168
+ currency: session.currency,
169
+ providerOrderId: session.providerOrderId,
170
+ ...callbackUrls,
171
+ };
172
+ if (operation.kind === 'start') {
173
+ return session.providerOrderId
174
+ ? await provider.queryPayment(input, clientOptions)
175
+ : await provider.startPayment(input, clientOptions);
176
+ }
177
+ if (operation.kind === 'confirm') {
178
+ return session.providerCaptureId
179
+ ? await provider.queryPayment(input, clientOptions)
180
+ : await provider.confirmPayment(input, clientOptions);
181
+ }
182
+ if (operation.kind === 'query') return await provider.queryPayment(input, clientOptions);
183
+ this.app.throw(500, `unsupported payment provider operation: ${operation.kind}`);
184
+ }
185
+
186
+ private async _executeRefund(
187
+ operation: EntityProviderOperation,
188
+ session: EntityPaymentSession,
189
+ provider: ReturnType<typeof this.bean.payProvider.resolveByName>['provider'],
190
+ clientOptions: ReturnType<typeof this.bean.payProvider.resolveByName>['clientOptions'],
191
+ ) {
192
+ const refundOperationId = operation.refundOperationId;
193
+ if (!refundOperationId) this.app.throw(500, 'refund provider operation is not linked');
194
+ const refund = await this.scope.model.refundOperation.getById(refundOperationId);
195
+ if (!refund) this.app.throw(404, 'refund operation not found');
196
+ if (!session.providerCaptureId) this.app.throw(409, 'payment session has no provider capture');
197
+ const input: IPayProviderRefundInput = {
198
+ paymentSessionId: session.id,
199
+ refundOperationId: refund.id,
200
+ businessReference: refund.businessReference,
201
+ idempotencyKey: operation.idempotencyKey,
202
+ amountMinor: refund.amountMinor,
203
+ currency: refund.currency,
204
+ providerCaptureId: session.providerCaptureId,
205
+ providerRefundId: refund.providerRefundId,
206
+ };
207
+ const snapshot =
208
+ refund.providerRefundId && provider.queryRefund
209
+ ? await provider.queryRefund(input, clientOptions)
210
+ : await provider.createRefund(input, clientOptions);
211
+ await this.scope.service.refundOperation.settleProviderSnapshot(
212
+ operation.id,
213
+ operation.claimToken!,
214
+ snapshot,
215
+ );
216
+ return snapshot;
217
+ }
218
+
129
219
  @Core.transaction()
130
220
  async claim(id: TableIdentity) {
131
221
  const operation = await this.scope.model.providerOperation.getByIdForUpdate(id);
@@ -161,7 +251,6 @@ export class ServiceProviderOperation extends BeanBase {
161
251
  });
162
252
  return {
163
253
  ...operation,
164
- state: operation.state,
165
254
  claimedAt: now,
166
255
  claimToken,
167
256
  claimExpiresAt,
@@ -207,14 +296,18 @@ export class ServiceProviderOperation extends BeanBase {
207
296
  return session;
208
297
  }
209
298
 
299
+ const nextAction = snapshot.nextAction ?? session.nextAction;
300
+ const providerPaymentId = snapshot.providerPaymentId ?? session.providerPaymentId;
301
+ const providerOrderId = snapshot.providerOrderId ?? session.providerOrderId;
302
+ const providerCaptureId = snapshot.providerCaptureId ?? session.providerCaptureId;
210
303
  if (isTerminalPaymentState(snapshot.state)) {
211
304
  const finalizedAt = new Date();
212
305
  await this.scope.model.paymentSession.updateById(session.id, {
213
306
  state: snapshot.state,
214
- nextAction: snapshot.nextAction,
215
- providerPaymentId: snapshot.providerPaymentId,
216
- providerOrderId: snapshot.providerOrderId,
217
- providerCaptureId: snapshot.providerCaptureId,
307
+ nextAction,
308
+ providerPaymentId,
309
+ providerOrderId,
310
+ providerCaptureId,
218
311
  finalizedAt,
219
312
  });
220
313
  await this.scope.model.paymentAudit.insert({
@@ -223,7 +316,7 @@ export class ServiceProviderOperation extends BeanBase {
223
316
  fromState: session.state,
224
317
  toState: snapshot.state,
225
318
  correlationId: session.correlationId,
226
- source: 'providerOperation',
319
+ source: `providerOperation.${operation.kind}`,
227
320
  occurredAt: finalizedAt,
228
321
  });
229
322
  await this.scope.service.outbox.enqueue(session.id, 'payment.outcome.v1', {
@@ -232,17 +325,17 @@ export class ServiceProviderOperation extends BeanBase {
232
325
  businessReference: session.businessReference,
233
326
  providerName: session.providerName,
234
327
  state: snapshot.state,
235
- providerCaptureId: snapshot.providerCaptureId,
328
+ providerCaptureId,
236
329
  amountMinor: session.amountMinor,
237
330
  currency: session.currency,
238
331
  });
239
332
  } else {
240
333
  await this.scope.model.paymentSession.updateById(session.id, {
241
334
  state: snapshot.state,
242
- nextAction: snapshot.nextAction,
243
- providerPaymentId: snapshot.providerPaymentId,
244
- providerOrderId: snapshot.providerOrderId,
245
- providerCaptureId: snapshot.providerCaptureId,
335
+ nextAction,
336
+ providerPaymentId,
337
+ providerOrderId,
338
+ providerCaptureId,
246
339
  });
247
340
  await this.scope.model.paymentAudit.insert({
248
341
  paymentSessionId: session.id,
@@ -250,14 +343,23 @@ export class ServiceProviderOperation extends BeanBase {
250
343
  fromState: session.state,
251
344
  toState: snapshot.state,
252
345
  correlationId: session.correlationId,
253
- source: 'providerOperation',
346
+ source: `providerOperation.${operation.kind}`,
254
347
  occurredAt: new Date(),
255
348
  });
256
349
  }
257
- await this._complete(operation, {
258
- providerResourceId: snapshot.providerCaptureId ?? snapshot.providerOrderId,
259
- });
260
- return { ...session, ...snapshot };
350
+ if (snapshot.state === 'processing' && ['confirm', 'query'].includes(operation.kind)) {
351
+ await this._scheduleReconciliation(operation, providerCaptureId ?? providerOrderId);
352
+ } else {
353
+ await this._complete(operation, { providerResourceId: providerCaptureId ?? providerOrderId });
354
+ }
355
+ return {
356
+ ...session,
357
+ ...snapshot,
358
+ nextAction,
359
+ providerPaymentId,
360
+ providerOrderId,
361
+ providerCaptureId,
362
+ };
261
363
  }
262
364
 
263
365
  @Core.transaction()
@@ -298,10 +400,7 @@ export class ServiceProviderOperation extends BeanBase {
298
400
  async queueDue(limit = 100) {
299
401
  const now = new Date();
300
402
  const due = await this.scope.model.providerOperation.select({
301
- where: {
302
- state: ['created', 'reconciliation_required'],
303
- nextAttemptAt: { _lte_: now },
304
- },
403
+ where: { state: ['created', 'reconciliation_required'], nextAttemptAt: { _lte_: now } },
305
404
  orders: [
306
405
  ['nextAttemptAt', 'asc'],
307
406
  ['id', 'asc'],
@@ -309,10 +408,7 @@ export class ServiceProviderOperation extends BeanBase {
309
408
  limit,
310
409
  });
311
410
  const expiredClaims = await this.scope.model.providerOperation.select({
312
- where: {
313
- state: ['claimed', 'submitted'],
314
- claimExpiresAt: { _lte_: now },
315
- },
411
+ where: { state: ['claimed', 'submitted'], claimExpiresAt: { _lte_: now } },
316
412
  orders: [
317
413
  ['claimExpiresAt', 'asc'],
318
414
  ['id', 'asc'],
@@ -320,12 +416,48 @@ export class ServiceProviderOperation extends BeanBase {
320
416
  limit: Math.max(0, limit - due.length),
321
417
  });
322
418
  const operations = [...due, ...expiredClaims];
323
- for (const operation of operations) {
324
- await this.execute(operation.id);
325
- }
419
+ for (const operation of operations) await this.execute(operation.id);
326
420
  return operations.length;
327
421
  }
328
422
 
423
+ private async _insertOperation(
424
+ session: EntityPaymentSession,
425
+ kind: Exclude<TypeProviderOperationKind, 'refund'>,
426
+ idempotencyKey: string,
427
+ now: Date,
428
+ ) {
429
+ return await this.scope.model.providerOperation.insert({
430
+ paymentSessionId: session.id,
431
+ kind,
432
+ state: 'created',
433
+ idempotencyKey,
434
+ correlationId: session.correlationId,
435
+ attemptCount: 0,
436
+ nextAttemptAt: new Date(now.getTime() - 1_000),
437
+ });
438
+ }
439
+
440
+ private async _getSession(paymentSessionId: TableIdentity) {
441
+ const session = await this.scope.model.paymentSession.getById(paymentSessionId);
442
+ if (!session) this.app.throw(404, 'payment session not found');
443
+ return session;
444
+ }
445
+
446
+ private async _scheduleReconciliation(
447
+ operation: EntityProviderOperation,
448
+ providerResourceId?: string,
449
+ ) {
450
+ await this.scope.model.providerOperation.updateById(operation.id, {
451
+ state: 'reconciliation_required',
452
+ providerResourceId,
453
+ claimToken: undefined,
454
+ claimExpiresAt: undefined,
455
+ nextAttemptAt: new Date(Date.now() + 5_000),
456
+ errorCode: undefined,
457
+ errorSummary: undefined,
458
+ });
459
+ }
460
+
329
461
  private async _complete(
330
462
  operation: EntityProviderOperation,
331
463
  options: { providerResourceId?: string },
@@ -27,12 +27,20 @@ export class ServiceRefundOperation extends BeanBase {
27
27
  command.paymentSessionId,
28
28
  );
29
29
  if (!session) this.app.throw(404, 'payment session not found');
30
- if (session.state !== 'succeeded' || !session.providerCaptureId) {
30
+ const isLateCaptureCompensation = command.businessReference.startsWith('late-capture:');
31
+ if (
32
+ (session.state !== 'succeeded' &&
33
+ !(session.state === 'expired' && isLateCaptureCompensation)) ||
34
+ !session.providerCaptureId
35
+ ) {
31
36
  this.app.throw(409, 'payment session is not refundable');
32
37
  }
33
38
  if (session.currency !== command.currency) this.app.throw(409, 'refund currency conflicts');
34
39
  const scene = this.bean.payScene.getOptions(session.payScene as never);
35
40
  if (!scene.refund?.enabled) this.app.throw(409, 'payment scene refunds are disabled');
41
+ if (isLateCaptureCompensation && command.amountMinor !== session.amountMinor) {
42
+ this.app.throw(422, 'late capture compensation must refund the full captured amount');
43
+ }
36
44
  if (!scene.refund.allowPartial && command.amountMinor !== session.amountMinor) {
37
45
  this.app.throw(422, 'payment scene does not allow partial refunds');
38
46
  }
@@ -143,8 +151,10 @@ export class ServiceRefundOperation extends BeanBase {
143
151
  amountMinor: refund.amountMinor,
144
152
  currency: refund.currency,
145
153
  });
154
+ await this._completeProviderOperation(providerOperation, snapshot.providerRefundId);
155
+ } else {
156
+ await this._scheduleReconciliation(providerOperation, snapshot.providerRefundId);
146
157
  }
147
- await this._completeProviderOperation(providerOperation, snapshot.providerRefundId);
148
158
  return { ...refund, ...snapshot, finalizedAt };
149
159
  }
150
160
 
@@ -202,6 +212,21 @@ export class ServiceRefundOperation extends BeanBase {
202
212
  };
203
213
  }
204
214
 
215
+ private async _scheduleReconciliation(
216
+ providerOperation: { id: TableIdentity },
217
+ providerResourceId?: string,
218
+ ) {
219
+ await this.scope.model.providerOperation.updateById(providerOperation.id, {
220
+ state: 'reconciliation_required',
221
+ providerResourceId,
222
+ claimToken: undefined,
223
+ claimExpiresAt: undefined,
224
+ nextAttemptAt: new Date(Date.now() + 5_000),
225
+ errorCode: undefined,
226
+ errorSummary: undefined,
227
+ });
228
+ }
229
+
205
230
  private async _completeProviderOperation(
206
231
  providerOperation: { id: TableIdentity },
207
232
  providerResourceId?: string,
@@ -2,12 +2,14 @@ import type { TableIdentity } from 'table-identity';
2
2
  import type { OmitNever, VonaContext } from 'vona';
3
3
  import type { ServiceOnion } from 'vona-module-a-onion';
4
4
 
5
+ import type { EntityPaymentSession } from '../entity/paymentSession.tsx';
5
6
  import type { IPaymentOutcomeEvent, IRefundOutcomeEvent } from './payment.ts';
6
7
  import type { IPayProviderRecord } from './payProvider.ts';
7
8
 
8
9
  export interface IPaySceneExecute {
9
10
  onPaymentOutcome(event: IPaymentOutcomeEvent): Promise<void>;
10
11
  onRefundOutcome(event: IRefundOutcomeEvent): Promise<void>;
12
+ getPaymentCallbackPath(session: EntityPaymentSession): Promise<string>;
11
13
  }
12
14
 
13
15
  export interface IPaySceneRecord {}
@@ -27,6 +29,7 @@ export interface IPaySceneResolveProviderInput {
27
29
  businessReference: string;
28
30
  amountMinor: number;
29
31
  currency: string;
32
+ providerCandidateKey?: string;
30
33
  providers: readonly IPaySceneProviderCandidate[];
31
34
  }
32
35
 
@@ -35,8 +38,14 @@ export type TypePaySceneResolveProvider = (
35
38
  input: IPaySceneResolveProviderInput,
36
39
  ) => string | Promise<string>;
37
40
 
41
+ export type TypePaySceneIsProviderAvailable = (
42
+ ctx: VonaContext,
43
+ input: IPaySceneResolveProviderInput & { candidate: IPaySceneProviderCandidate },
44
+ ) => boolean | Promise<boolean>;
45
+
38
46
  export interface IDecoratorPaySceneOptions {
39
47
  providers?: readonly IPaySceneProviderCandidate[];
48
+ isProviderAvailable?: TypePaySceneIsProviderAvailable;
40
49
  resolveProvider?: TypePaySceneResolveProvider;
41
50
  currencies?: readonly string[];
42
51
  captureMode?: 'automatic' | 'manual';
@@ -70,6 +70,7 @@ export interface IPayProviderRefundInput extends IPayMoney {
70
70
  businessReference: string;
71
71
  idempotencyKey: string;
72
72
  providerCaptureId: string;
73
+ providerRefundId?: string;
73
74
  }
74
75
 
75
76
  export interface IPayProviderPaymentSnapshot {
@@ -113,6 +114,10 @@ export interface IPayProviderExecute<
113
114
  input: IPayProviderPaymentInput,
114
115
  clientOptions: TClientOptions,
115
116
  ): Promise<IPayProviderPaymentSnapshot>;
117
+ confirmPayment(
118
+ input: IPayProviderPaymentInput,
119
+ clientOptions: TClientOptions,
120
+ ): Promise<IPayProviderPaymentSnapshot>;
116
121
  queryPayment(
117
122
  input: IPayProviderPaymentInput,
118
123
  clientOptions: TClientOptions,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-pay-mock",
3
- "version": "5.0.3",
3
+ "version": "5.0.4",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"