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
@@ -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,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-pay-paypal",
3
- "version": "5.0.3",
3
+ "version": "5.0.4",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -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
- const orders = new OrdersController(this._createClient(clientOptions));
75
- const response = await orders.createOrder({
76
- paypalRequestId: input.idempotencyKey,
77
- body: {
78
- intent: CheckoutPaymentIntent.Capture,
79
- applicationContext: { returnUrl: input.returnUrl, cancelUrl: input.cancelUrl },
80
- purchaseUnits: [
81
- {
82
- customId: String(input.paymentSessionId),
83
- invoiceId: input.businessReference,
84
- amount: {
85
- currencyCode: input.currency,
86
- value: formatMinorAmount(input.amountMinor, input.currency),
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
- } as never);
92
- const order = response.result;
93
- const approvalUrl = order?.links?.find(item => item.rel === 'approve')?.href;
94
- if (!order?.id || !approvalUrl) this.app.throw(502, 'PayPal did not return an approval order');
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: order.id,
98
- providerOrderId: order.id,
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
- if (!input.providerOrderId) return { state: 'requires_action' };
108
- const orders = new OrdersController(this._createClient(clientOptions));
109
- const response = await orders.getOrder({ id: input.providerOrderId });
110
- const order = response.result;
111
- if (!order?.id) this.app.throw(502, 'PayPal order query returned no order');
112
- const capture = order.purchaseUnits?.[0]?.payments?.captures?.[0];
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
- const payments = new PaymentsController(this._createClient(clientOptions));
139
- const response = await payments.refundCapturedPayment({
140
- captureId: input.providerCaptureId,
141
- paypalRequestId: input.idempotencyKey,
142
- body: {
143
- amount: {
144
- currencyCode: input.currency,
145
- value: formatMinorAmount(input.amountMinor, input.currency),
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
- } as never);
149
- return mapRefund(response.result?.status, response.result?.id);
163
+ );
164
+ return this._mapRefund(refund, input, clientOptions);
150
165
  }
151
166
 
152
167
  async queryRefund(
153
- _input: IPayProviderRefundInput,
154
- _clientOptions: IPayProviderPaypalClientOptions,
168
+ input: IPayProviderRefundInput,
169
+ clientOptions: IPayProviderPaypalClientOptions,
155
170
  ): Promise<IPayProviderRefundSnapshot> {
156
- this.app.throw(501, 'PayPal refund query requires a persisted provider refund identifier');
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
- _input: IPayProviderWebhookInput,
161
- _clientOptions: IPayProviderPaypalClientOptions,
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
- this.app.throw(501, 'PayPal webhook verification requires configured webhook verification');
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 _createClient(clientOptions: IPayProviderPaypalClientOptions) {
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
- if (!clientId || !clientSecret)
169
- this.app.throw(503, 'PayPal client credentials are not configured');
170
- return new Client({
171
- clientCredentialsAuthCredentials: {
172
- oAuthClientId: clientId,
173
- oAuthClientSecret: clientSecret,
174
- },
175
- timeout: 0,
176
- environment:
177
- clientOptions.environment === 'live' ? Environment.Production : Environment.Sandbox,
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(status?: string, providerRefundId?: string): IPayProviderRefundSnapshot {
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';