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
|
@@ -93,6 +93,13 @@ export class PayProviderMock
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
async confirmPayment(
|
|
97
|
+
input: IPayProviderPaymentInput,
|
|
98
|
+
clientOptions: IPayProviderMockClientOptions,
|
|
99
|
+
): Promise<IPayProviderPaymentSnapshot> {
|
|
100
|
+
return await this.queryPayment(input, clientOptions);
|
|
101
|
+
}
|
|
102
|
+
|
|
96
103
|
async queryPayment(
|
|
97
104
|
input: IPayProviderPaymentInput,
|
|
98
105
|
_clientOptions: IPayProviderMockClientOptions,
|
|
@@ -11,25 +11,29 @@ import type {
|
|
|
11
11
|
IPayProviderWebhookInput,
|
|
12
12
|
} from 'vona-module-a-pay';
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
CheckoutPaymentIntent,
|
|
16
|
-
Client,
|
|
17
|
-
Environment,
|
|
18
|
-
OrdersController,
|
|
19
|
-
OrderStatus,
|
|
20
|
-
PaymentsController,
|
|
21
|
-
} from '@cabloy/paypal-server-sdk';
|
|
14
|
+
import { CheckoutPaymentIntent, OrderStatus } from '@cabloy/paypal-server-sdk';
|
|
22
15
|
import { BeanBase, useApp } from 'vona';
|
|
23
16
|
import { PayProvider } from 'vona-module-a-pay';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
import type { IPaypalGateway, IPaypalGatewayOptions } from '../lib/paypalGateway.ts';
|
|
20
|
+
|
|
21
|
+
import { createPaypalGateway, PaypalGatewayError } from '../lib/paypalGateway.ts';
|
|
24
22
|
|
|
25
23
|
export interface IPayProviderPaypalClientRecord extends IPayProviderClientRecord {}
|
|
26
24
|
|
|
27
25
|
export interface IPayProviderPaypalClientOptions extends IPayProviderClientOptions {
|
|
28
26
|
secretCredential: { clientId: string | undefined; clientSecret: string | undefined };
|
|
29
27
|
webhookId: string | undefined;
|
|
28
|
+
gateway?: IPaypalGateway;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
const app = useApp();
|
|
32
|
+
const PaypalWebhookSchema = z.object({
|
|
33
|
+
id: z.string().min(1).max(255),
|
|
34
|
+
event_type: z.string().min(1).max(255),
|
|
35
|
+
resource: z.record(z.string(), z.unknown()),
|
|
36
|
+
});
|
|
33
37
|
|
|
34
38
|
export interface IPayProviderOptionsPaypal extends IDecoratorPayProviderOptions<
|
|
35
39
|
IPayProviderPaypalClientRecord,
|
|
@@ -50,12 +54,13 @@ export interface IPayProviderOptionsPaypal extends IDecoratorPayProviderOptions<
|
|
|
50
54
|
},
|
|
51
55
|
clients: {
|
|
52
56
|
default: {
|
|
53
|
-
environment: 'sandbox',
|
|
57
|
+
environment: app.meta.env.PAYPAL_ENVIRONMENT as 'sandbox' | 'live',
|
|
54
58
|
secretCredential: {
|
|
55
59
|
clientId: app.meta.env.PAYPAL_CLIENT_ID,
|
|
56
60
|
clientSecret: app.meta.env.PAYPAL_CLIENT_SECRET,
|
|
57
61
|
},
|
|
58
62
|
webhookId: app.meta.env.PAYPAL_WEBHOOK_ID,
|
|
63
|
+
merchantReference: app.meta.env.PAYPAL_MERCHANT_REFERENCE,
|
|
59
64
|
},
|
|
60
65
|
},
|
|
61
66
|
})
|
|
@@ -71,111 +76,362 @@ export class PayProviderPaypal
|
|
|
71
76
|
if (!input.returnUrl || !input.cancelUrl) {
|
|
72
77
|
this.app.throw(422, 'PayPal payment requires return and cancel URLs');
|
|
73
78
|
}
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
79
|
+
this._assertReady(clientOptions);
|
|
80
|
+
const order = await this._gateway(clientOptions).createOrder(
|
|
81
|
+
this._gatewayOptions(clientOptions),
|
|
82
|
+
{
|
|
83
|
+
paypalRequestId: input.idempotencyKey,
|
|
84
|
+
body: {
|
|
85
|
+
intent: CheckoutPaymentIntent.Capture,
|
|
86
|
+
applicationContext: { returnUrl: input.returnUrl, cancelUrl: input.cancelUrl },
|
|
87
|
+
purchaseUnits: [
|
|
88
|
+
{
|
|
89
|
+
customId: String(input.paymentSessionId),
|
|
90
|
+
invoiceId: input.businessReference,
|
|
91
|
+
amount: {
|
|
92
|
+
currencyCode: input.currency,
|
|
93
|
+
value: formatMinorAmount(input.amountMinor, input.currency),
|
|
94
|
+
},
|
|
87
95
|
},
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
],
|
|
97
|
+
},
|
|
90
98
|
},
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const approvalUrl =
|
|
94
|
-
|
|
99
|
+
);
|
|
100
|
+
const orderRecord = asRecord(order);
|
|
101
|
+
const approvalUrl = readString(
|
|
102
|
+
asArray(orderRecord.links)
|
|
103
|
+
.map(asRecord)
|
|
104
|
+
.find(item => item.rel === 'approve')?.href,
|
|
105
|
+
);
|
|
106
|
+
const orderId = readString(orderRecord.id);
|
|
107
|
+
if (!orderId || !approvalUrl) this.app.throw(502, 'PayPal did not return an approval order');
|
|
95
108
|
return {
|
|
96
109
|
state: 'requires_action',
|
|
97
|
-
providerPaymentId:
|
|
98
|
-
providerOrderId:
|
|
110
|
+
providerPaymentId: orderId,
|
|
111
|
+
providerOrderId: orderId,
|
|
99
112
|
nextAction: { kind: 'redirect', url: approvalUrl },
|
|
100
113
|
};
|
|
101
114
|
}
|
|
102
115
|
|
|
116
|
+
async confirmPayment(
|
|
117
|
+
input: IPayProviderPaymentInput,
|
|
118
|
+
clientOptions: IPayProviderPaypalClientOptions,
|
|
119
|
+
): Promise<IPayProviderPaymentSnapshot> {
|
|
120
|
+
this._assertReady(clientOptions);
|
|
121
|
+
if (!input.providerOrderId) this.app.throw(409, 'PayPal payment has no provider order');
|
|
122
|
+
const order = await this._gateway(clientOptions).captureOrder(
|
|
123
|
+
this._gatewayOptions(clientOptions),
|
|
124
|
+
{
|
|
125
|
+
id: input.providerOrderId,
|
|
126
|
+
paypalRequestId: input.idempotencyKey,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
return this._mapOrder(order, input, clientOptions);
|
|
130
|
+
}
|
|
131
|
+
|
|
103
132
|
async queryPayment(
|
|
104
133
|
input: IPayProviderPaymentInput,
|
|
105
134
|
clientOptions: IPayProviderPaypalClientOptions,
|
|
106
135
|
): Promise<IPayProviderPaymentSnapshot> {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (capture?.status === 'COMPLETED') {
|
|
114
|
-
return {
|
|
115
|
-
state: 'succeeded',
|
|
116
|
-
providerPaymentId: order.id,
|
|
117
|
-
providerOrderId: order.id,
|
|
118
|
-
providerCaptureId: capture.id,
|
|
119
|
-
nextAction: { kind: 'completed' },
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
if (capture?.status === 'FAILED' || capture?.status === 'DECLINED') {
|
|
123
|
-
return { state: 'failed', providerPaymentId: order.id, providerOrderId: order.id };
|
|
124
|
-
}
|
|
125
|
-
if (order.status === OrderStatus.Voided) {
|
|
126
|
-
return { state: 'cancelled', providerPaymentId: order.id, providerOrderId: order.id };
|
|
127
|
-
}
|
|
128
|
-
if (capture?.status === 'PENDING') {
|
|
129
|
-
return { state: 'processing', providerPaymentId: order.id, providerOrderId: order.id };
|
|
130
|
-
}
|
|
131
|
-
return { state: 'requires_action', providerPaymentId: order.id, providerOrderId: order.id };
|
|
136
|
+
this._assertReady(clientOptions);
|
|
137
|
+
if (!input.providerOrderId) this.app.throw(409, 'PayPal payment has no provider order');
|
|
138
|
+
const order = await this._gateway(clientOptions).getOrder(this._gatewayOptions(clientOptions), {
|
|
139
|
+
id: input.providerOrderId,
|
|
140
|
+
});
|
|
141
|
+
return this._mapOrder(order, input, clientOptions);
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
async createRefund(
|
|
135
145
|
input: IPayProviderRefundInput,
|
|
136
146
|
clientOptions: IPayProviderPaypalClientOptions,
|
|
137
147
|
): Promise<IPayProviderRefundSnapshot> {
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
this._assertReady(clientOptions);
|
|
149
|
+
const refund = await this._gateway(clientOptions).refundCapturedPayment(
|
|
150
|
+
this._gatewayOptions(clientOptions),
|
|
151
|
+
{
|
|
152
|
+
captureId: input.providerCaptureId,
|
|
153
|
+
paypalRequestId: input.idempotencyKey,
|
|
154
|
+
body: {
|
|
155
|
+
amount: {
|
|
156
|
+
currencyCode: input.currency,
|
|
157
|
+
value: formatMinorAmount(input.amountMinor, input.currency),
|
|
158
|
+
},
|
|
159
|
+
customId: String(input.refundOperationId),
|
|
160
|
+
invoiceId: input.businessReference,
|
|
146
161
|
},
|
|
147
162
|
},
|
|
148
|
-
|
|
149
|
-
return
|
|
163
|
+
);
|
|
164
|
+
return this._mapRefund(refund, input, clientOptions);
|
|
150
165
|
}
|
|
151
166
|
|
|
152
167
|
async queryRefund(
|
|
153
|
-
|
|
154
|
-
|
|
168
|
+
input: IPayProviderRefundInput,
|
|
169
|
+
clientOptions: IPayProviderPaypalClientOptions,
|
|
155
170
|
): Promise<IPayProviderRefundSnapshot> {
|
|
156
|
-
this.
|
|
171
|
+
this._assertReady(clientOptions);
|
|
172
|
+
if (!input.providerRefundId)
|
|
173
|
+
this.app.throw(409, 'PayPal refund has no provider refund identifier');
|
|
174
|
+
const refund = await this._gateway(clientOptions).getRefund(
|
|
175
|
+
this._gatewayOptions(clientOptions),
|
|
176
|
+
{
|
|
177
|
+
refundId: input.providerRefundId,
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
return this._mapRefund(refund, input, clientOptions);
|
|
157
181
|
}
|
|
158
182
|
|
|
159
183
|
async verifyWebhook(
|
|
160
|
-
|
|
161
|
-
|
|
184
|
+
input: IPayProviderWebhookInput,
|
|
185
|
+
clientOptions: IPayProviderPaypalClientOptions,
|
|
186
|
+
): Promise<IPayProviderVerifiedWebhook> {
|
|
187
|
+
this._assertReady(clientOptions);
|
|
188
|
+
const webhook = PaypalWebhookSchema.safeParse(input.body);
|
|
189
|
+
if (!webhook.success) this.app.throw(400, 'PayPal webhook is invalid');
|
|
190
|
+
try {
|
|
191
|
+
await this._gateway(clientOptions).verifyWebhookSignature(
|
|
192
|
+
this._gatewayOptions(clientOptions),
|
|
193
|
+
input,
|
|
194
|
+
);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
this._throwGatewayError(error);
|
|
197
|
+
}
|
|
198
|
+
return await this._mapWebhook(webhook.data, clientOptions);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async _mapWebhook(
|
|
202
|
+
event: z.infer<typeof PaypalWebhookSchema>,
|
|
203
|
+
clientOptions: IPayProviderPaypalClientOptions,
|
|
162
204
|
): Promise<IPayProviderVerifiedWebhook> {
|
|
163
|
-
|
|
205
|
+
const resource = event.resource;
|
|
206
|
+
const amount = parsePaypalAmount(resource.amount);
|
|
207
|
+
const currency = parsePaypalCurrency(resource.amount);
|
|
208
|
+
const resourceId = readString(resource.id);
|
|
209
|
+
if (!resourceId || !amount || !currency)
|
|
210
|
+
this.app.throw(400, 'PayPal webhook has invalid resource');
|
|
211
|
+
|
|
212
|
+
if (isRefundEvent(event.event_type)) {
|
|
213
|
+
const refundOperationId = readString(readField(resource, 'customId', 'custom_id'));
|
|
214
|
+
const captureId = readNestedString(resource, [
|
|
215
|
+
'supplementary_data',
|
|
216
|
+
'related_ids',
|
|
217
|
+
'capture_id',
|
|
218
|
+
]);
|
|
219
|
+
if (!refundOperationId || !captureId)
|
|
220
|
+
this.app.throw(400, 'PayPal refund webhook is not correlated');
|
|
221
|
+
const refund = await this.$scope.pay.model.refundOperation.getById(
|
|
222
|
+
refundOperationId as never,
|
|
223
|
+
);
|
|
224
|
+
if (!refund) this.app.throw(404, 'PayPal refund operation not found');
|
|
225
|
+
const session = await this.$scope.pay.model.paymentSession.getById(refund.paymentSessionId);
|
|
226
|
+
if (!session || session.providerCaptureId !== captureId) {
|
|
227
|
+
this.app.throw(409, 'PayPal refund webhook capture conflicts');
|
|
228
|
+
}
|
|
229
|
+
const snapshot = this._mapRefund(
|
|
230
|
+
resource,
|
|
231
|
+
{
|
|
232
|
+
paymentSessionId: session.id,
|
|
233
|
+
refundOperationId: refund.id,
|
|
234
|
+
businessReference: refund.businessReference,
|
|
235
|
+
idempotencyKey: '',
|
|
236
|
+
amountMinor: refund.amountMinor,
|
|
237
|
+
currency: refund.currency,
|
|
238
|
+
providerCaptureId: captureId,
|
|
239
|
+
providerRefundId: resourceId,
|
|
240
|
+
},
|
|
241
|
+
clientOptions,
|
|
242
|
+
);
|
|
243
|
+
return {
|
|
244
|
+
eventId: event.id,
|
|
245
|
+
eventType: event.event_type,
|
|
246
|
+
refundOperationId: refund.id,
|
|
247
|
+
refund: snapshot,
|
|
248
|
+
summary: { amountMinor: amount, currency },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (event.event_type.startsWith('PAYMENT.CAPTURE.')) {
|
|
253
|
+
const orderId = readNestedString(resource, ['supplementaryData', 'relatedIds', 'orderId']);
|
|
254
|
+
if (!orderId) this.app.throw(400, 'PayPal capture webhook has no order identifier');
|
|
255
|
+
const order = await this._gateway(clientOptions).getOrder(
|
|
256
|
+
this._gatewayOptions(clientOptions),
|
|
257
|
+
{
|
|
258
|
+
id: orderId,
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
const input = this._inputFromOrder(order, orderId);
|
|
262
|
+
const payment = this._mapOrder(order, input, clientOptions);
|
|
263
|
+
if (!['succeeded', 'failed', 'cancelled'].includes(payment.state)) {
|
|
264
|
+
this.app.throw(400, 'PayPal capture webhook is not terminal');
|
|
265
|
+
}
|
|
266
|
+
if (payment.providerCaptureId && payment.providerCaptureId !== resourceId) {
|
|
267
|
+
this.app.throw(409, 'PayPal capture webhook conflicts with order capture');
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
eventId: event.id,
|
|
271
|
+
eventType: event.event_type,
|
|
272
|
+
paymentSessionId: input.paymentSessionId,
|
|
273
|
+
payment,
|
|
274
|
+
summary: { amountMinor: amount, currency },
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
this.app.throw(400, 'PayPal webhook event type is unsupported');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private _inputFromOrder(order: unknown, providerOrderId: string): IPayProviderPaymentInput {
|
|
281
|
+
const orderRecord = asRecord(order);
|
|
282
|
+
const unit = asArray(readField(orderRecord, 'purchaseUnits', 'purchase_units'))[0];
|
|
283
|
+
const unitRecord = asRecord(unit);
|
|
284
|
+
const customId = readString(readField(unitRecord, 'customId', 'custom_id'));
|
|
285
|
+
const businessReference = readString(readField(unitRecord, 'invoiceId', 'invoice_id'));
|
|
286
|
+
const amountMinor = parsePaypalAmount(readField(unitRecord, 'amount'));
|
|
287
|
+
const currency = parsePaypalCurrency(readField(unitRecord, 'amount'));
|
|
288
|
+
if (!customId || !businessReference || amountMinor === undefined || !currency) {
|
|
289
|
+
this.app.throw(400, 'PayPal order is not correlated');
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
paymentSessionId: customId as never,
|
|
293
|
+
businessReference,
|
|
294
|
+
idempotencyKey: '',
|
|
295
|
+
amountMinor,
|
|
296
|
+
currency,
|
|
297
|
+
providerOrderId,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private _mapOrder(
|
|
302
|
+
order: unknown,
|
|
303
|
+
input: IPayProviderPaymentInput,
|
|
304
|
+
clientOptions: IPayProviderPaypalClientOptions,
|
|
305
|
+
): IPayProviderPaymentSnapshot {
|
|
306
|
+
const orderRecord = asRecord(order);
|
|
307
|
+
const orderId = readString(orderRecord.id);
|
|
308
|
+
if (!orderId || orderId !== input.providerOrderId) {
|
|
309
|
+
this.app.throw(409, 'PayPal order identifier conflicts');
|
|
310
|
+
}
|
|
311
|
+
const units = asArray(readField(orderRecord, 'purchaseUnits', 'purchase_units'));
|
|
312
|
+
if (units.length !== 1) this.app.throw(409, 'PayPal order has an invalid purchase-unit count');
|
|
313
|
+
const unit = asRecord(units[0]);
|
|
314
|
+
if (
|
|
315
|
+
readString(readField(unit, 'customId', 'custom_id')) !== String(input.paymentSessionId) ||
|
|
316
|
+
readString(readField(unit, 'invoiceId', 'invoice_id')) !== input.businessReference ||
|
|
317
|
+
parsePaypalAmount(readField(unit, 'amount')) !== input.amountMinor ||
|
|
318
|
+
parsePaypalCurrency(readField(unit, 'amount')) !== input.currency
|
|
319
|
+
) {
|
|
320
|
+
this.app.throw(409, 'PayPal order facts conflict with the payment session');
|
|
321
|
+
}
|
|
322
|
+
this._assertMerchant(unit, clientOptions);
|
|
323
|
+
const captures = asArray(readField(asRecord(readField(unit, 'payments')), 'captures'));
|
|
324
|
+
if (captures.length > 1) this.app.throw(409, 'PayPal order has multiple captures');
|
|
325
|
+
const capture = asRecord(captures[0]);
|
|
326
|
+
const captureId = readString(capture.id);
|
|
327
|
+
const captureStatus = readString(capture.status);
|
|
328
|
+
if (captureId) {
|
|
329
|
+
if (
|
|
330
|
+
parsePaypalAmount(capture.amount) !== input.amountMinor ||
|
|
331
|
+
parsePaypalCurrency(capture.amount) !== input.currency
|
|
332
|
+
) {
|
|
333
|
+
this.app.throw(409, 'PayPal capture facts conflict with the payment session');
|
|
334
|
+
}
|
|
335
|
+
if (captureStatus === 'COMPLETED') {
|
|
336
|
+
return {
|
|
337
|
+
state: 'succeeded',
|
|
338
|
+
providerPaymentId: orderId,
|
|
339
|
+
providerOrderId: orderId,
|
|
340
|
+
providerCaptureId: captureId,
|
|
341
|
+
nextAction: { kind: 'completed' },
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (captureStatus === 'FAILED' || captureStatus === 'DECLINED') {
|
|
345
|
+
return {
|
|
346
|
+
state: 'failed',
|
|
347
|
+
providerPaymentId: orderId,
|
|
348
|
+
providerOrderId: orderId,
|
|
349
|
+
providerCaptureId: captureId,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (captureStatus === 'PENDING') {
|
|
353
|
+
return {
|
|
354
|
+
state: 'processing',
|
|
355
|
+
providerPaymentId: orderId,
|
|
356
|
+
providerOrderId: orderId,
|
|
357
|
+
providerCaptureId: captureId,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (orderRecord.status === OrderStatus.Voided) {
|
|
362
|
+
return { state: 'cancelled', providerPaymentId: orderId, providerOrderId: orderId };
|
|
363
|
+
}
|
|
364
|
+
return { state: 'requires_action', providerPaymentId: orderId, providerOrderId: orderId };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private _mapRefund(
|
|
368
|
+
refund: unknown,
|
|
369
|
+
input: IPayProviderRefundInput,
|
|
370
|
+
clientOptions: IPayProviderPaypalClientOptions,
|
|
371
|
+
): IPayProviderRefundSnapshot {
|
|
372
|
+
const record = asRecord(refund);
|
|
373
|
+
const providerRefundId = readString(record.id);
|
|
374
|
+
if (!providerRefundId) this.app.throw(502, 'PayPal refund returned no identifier');
|
|
375
|
+
if (
|
|
376
|
+
(input.providerRefundId && input.providerRefundId !== providerRefundId) ||
|
|
377
|
+
readString(readField(record, 'customId', 'custom_id')) !== String(input.refundOperationId) ||
|
|
378
|
+
readString(readField(record, 'invoiceId', 'invoice_id')) !== input.businessReference ||
|
|
379
|
+
parsePaypalAmount(record.amount) !== input.amountMinor ||
|
|
380
|
+
parsePaypalCurrency(record.amount) !== input.currency
|
|
381
|
+
) {
|
|
382
|
+
this.app.throw(409, 'PayPal refund facts conflict with the refund operation');
|
|
383
|
+
}
|
|
384
|
+
this._assertMerchant(record, clientOptions);
|
|
385
|
+
return mapRefund(readString(record.status), providerRefundId);
|
|
164
386
|
}
|
|
165
387
|
|
|
166
|
-
private
|
|
388
|
+
private _gateway(clientOptions: IPayProviderPaypalClientOptions) {
|
|
389
|
+
return clientOptions.gateway ?? createPaypalGateway(this.bean.core.fetch);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private _gatewayOptions(clientOptions: IPayProviderPaypalClientOptions): IPaypalGatewayOptions {
|
|
167
393
|
const { clientId, clientSecret } = clientOptions.secretCredential;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
394
|
+
const webhookId = clientOptions.webhookId;
|
|
395
|
+
if (!clientId || !clientSecret || !webhookId) {
|
|
396
|
+
this.app.throw(503, 'PayPal is not fully configured');
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
environment: clientOptions.environment,
|
|
400
|
+
clientId,
|
|
401
|
+
clientSecret,
|
|
402
|
+
webhookId,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private _throwGatewayError(error: unknown): never {
|
|
407
|
+
if (error instanceof PaypalGatewayError) this.app.throw(error.status, error.message);
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private _assertReady(clientOptions: IPayProviderPaypalClientOptions) {
|
|
412
|
+
const { clientId, clientSecret } = clientOptions.secretCredential;
|
|
413
|
+
if (
|
|
414
|
+
!clientId ||
|
|
415
|
+
!clientSecret ||
|
|
416
|
+
!clientOptions.webhookId ||
|
|
417
|
+
!clientOptions.merchantReference
|
|
418
|
+
) {
|
|
419
|
+
this.app.throw(503, 'PayPal is not fully configured');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private _assertMerchant(
|
|
424
|
+
value: Record<string, unknown>,
|
|
425
|
+
clientOptions: IPayProviderPaypalClientOptions,
|
|
426
|
+
) {
|
|
427
|
+
const merchantId =
|
|
428
|
+
readNestedString(value, ['payee', 'merchantId']) ??
|
|
429
|
+
readNestedString(value, ['payee', 'merchant_id']) ??
|
|
430
|
+
readNestedString(value, ['sellerReceivableBreakdown', 'payee', 'merchantId']) ??
|
|
431
|
+
readNestedString(value, ['seller_receivable_breakdown', 'payee', 'merchant_id']);
|
|
432
|
+
if (!merchantId || merchantId !== clientOptions.merchantReference) {
|
|
433
|
+
this.app.throw(409, 'PayPal merchant reference conflicts');
|
|
434
|
+
}
|
|
179
435
|
}
|
|
180
436
|
}
|
|
181
437
|
|
|
@@ -186,13 +442,68 @@ function formatMinorAmount(amountMinor: number, currency: string) {
|
|
|
186
442
|
return `${Math.floor(amountMinor / 100)}.${String(amountMinor % 100).padStart(2, '0')}`;
|
|
187
443
|
}
|
|
188
444
|
|
|
189
|
-
function mapRefund(
|
|
445
|
+
function mapRefund(
|
|
446
|
+
status: string | undefined,
|
|
447
|
+
providerRefundId: string,
|
|
448
|
+
): IPayProviderRefundSnapshot {
|
|
190
449
|
if (status === 'COMPLETED') return { state: 'succeeded', providerRefundId };
|
|
191
450
|
if (status === 'FAILED') return { state: 'failed', providerRefundId };
|
|
192
451
|
if (status === 'CANCELLED') return { state: 'cancelled', providerRefundId };
|
|
193
452
|
return { state: 'pending', providerRefundId };
|
|
194
453
|
}
|
|
195
454
|
|
|
455
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
456
|
+
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function asArray(value: unknown): unknown[] {
|
|
460
|
+
return Array.isArray(value) ? value : [];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function readString(value: unknown): string | undefined {
|
|
464
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function readField(record: Record<string, unknown>, ...keys: string[]) {
|
|
468
|
+
for (const key of keys) {
|
|
469
|
+
if (record[key] !== undefined) return record[key];
|
|
470
|
+
}
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function readNestedString(value: unknown, path: string[]) {
|
|
475
|
+
let current: unknown = value;
|
|
476
|
+
for (const key of path) {
|
|
477
|
+
const record = asRecord(current);
|
|
478
|
+
current = record[key] ?? record[camelToSnake(key)];
|
|
479
|
+
}
|
|
480
|
+
return readString(current);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function camelToSnake(value: string) {
|
|
484
|
+
return value.replace(/[A-Z]/g, character => `_${character.toLowerCase()}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function parsePaypalAmount(value: unknown): number | undefined {
|
|
488
|
+
const amount = asRecord(value);
|
|
489
|
+
if (readString(readField(amount, 'currencyCode', 'currency_code')) !== 'USD') return undefined;
|
|
490
|
+
const decimal = readString(amount.value);
|
|
491
|
+
if (!decimal || !/^\d+\.\d{2}$/.test(decimal)) return undefined;
|
|
492
|
+
const [whole, fractional] = decimal.split('.');
|
|
493
|
+
const result = Number(whole) * 100 + Number(fractional);
|
|
494
|
+
return Number.isSafeInteger(result) ? result : undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function parsePaypalCurrency(value: unknown) {
|
|
498
|
+
const amount = asRecord(value);
|
|
499
|
+
const currency = readString(readField(amount, 'currencyCode', 'currency_code'));
|
|
500
|
+
return currency === 'USD' ? currency : undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isRefundEvent(eventType: string) {
|
|
504
|
+
return eventType === 'PAYMENT.CAPTURE.REFUNDED' || eventType.startsWith('PAYMENT.REFUND.');
|
|
505
|
+
}
|
|
506
|
+
|
|
196
507
|
declare module 'vona-module-a-pay' {
|
|
197
508
|
export interface IPayProviderRecord {
|
|
198
509
|
'pay-paypal:paypal': IPayProviderOptionsPaypal;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from './paypalGateway.ts';
|