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.
- package/.cabloy-version +1 -1
- package/CHANGELOG.md +23 -0
- package/cabloy-docs/backend/i18n-guide.md +86 -7
- package/e2e/specs/a-commerce/commerce.spec.ts +93 -8
- package/package.json +1 -1
- package/vona/env/.env +2 -0
- package/vona/env/.env.prod +6 -2
- package/vona/packages-cli/cabloy-cli/package.json +1 -1
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-utils/module-glob/package.json +1 -1
- package/vona/packages-utils/utils/package.json +1 -1
- package/vona/packages-utils/utils/src/vona.ts +8 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +142 -21
- package/vona/pnpm-workspace.yaml +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productSelectResItem.tsx +3 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productView.tsx +3 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/product.tsx +23 -3
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/model/product.ts +1 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/product.ts +7 -2
- package/vona/src/suite/a-commerce/modules/commerce-catalog/test/productPresentation.test.ts +214 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/payScene.commerceOrder.ts +39 -3
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/commercePayScene.ts +14 -1
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +5 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +17 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +18 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutCreate.tsx +3 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutPaymentMethod.tsx +19 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutPaymentMethods.tsx +17 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +51 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/paymentOutcome.test.ts +2 -1
- package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/.metadata/index.ts +94 -55
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/bean.payScene.ts +65 -1
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.redlock.ts +2 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/controller/paymentCallback.ts +32 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/controller/paymentSession.ts +26 -8
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/paymentCallback.ts +99 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/paymentSession.ts +26 -3
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/providerOperation.ts +214 -82
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/refundOperation.ts +27 -2
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payScene.ts +9 -0
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payment.ts +5 -0
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/bean/payProvider.mock.ts +7 -0
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/bean/payProvider.paypal.ts +395 -84
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/lib/index.ts +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/lib/paypalGateway.ts +129 -0
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/types/index.ts +2 -0
- package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-stripe/src/bean/payProvider.stripe.ts +7 -0
- package/vona/src/suite-vendor/a-pay/package.json +5 -5
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/packages-cli/cli/package.json +3 -3
- package/zova/packages-cli/cli-set-front/package.json +4 -4
- package/zova/packages-utils/zova-jsx/package.json +3 -3
- package/zova/packages-utils/zova-vite/package.json +2 -2
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +2 -2
- package/zova/pnpm-lock.yaml +41 -41
- package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/schemas.ts +8 -0
- package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/types.ts +175 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeCheckout.ts +16 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +8 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +177 -2
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeCheckout.ts +12 -1
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +11 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/checkout/controller.tsx +25 -1
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/payment/controller.tsx +27 -4
- package/zova/src/suite-vendor/a-pay/modules/a-pay/cli/openapi.config.ts +1 -1
- package/zova/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
- package/zova/src/suite-vendor/a-pay/modules/a-pay/src/api/openapi/schemas.ts +20 -8
- package/zova/src/suite-vendor/a-pay/modules/a-pay/src/api/openapi/types.ts +292 -32
- package/zova/src/suite-vendor/a-pay/modules/a-pay/src/api/paymentSession.ts +26 -4
- package/zova/src/suite-vendor/a-pay/modules/a-pay/src/apiSchema/paymentSession.ts +5 -0
- package/zova/src/suite-vendor/a-pay/modules/a-pay/src/component/paymentNextAction/controller.tsx +2 -1
- package/zova/src/suite-vendor/a-pay/modules/a-pay/src/model/paymentSession.ts +12 -0
- package/zova/src/suite-vendor/a-pay/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +4 -4
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
- /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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
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: '
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
215
|
-
providerPaymentId
|
|
216
|
-
providerOrderId
|
|
217
|
-
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:
|
|
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
|
|
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
|
|
243
|
-
providerPaymentId
|
|
244
|
-
providerOrderId
|
|
245
|
-
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:
|
|
346
|
+
source: `providerOperation.${operation.kind}`,
|
|
254
347
|
occurredAt: new Date(),
|
|
255
348
|
});
|
|
256
349
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
|
|
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
|
-
|
|
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,
|