cabloy 5.1.127 → 5.1.128

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 (85) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +16 -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 +10 -39
  18. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +1 -1
  19. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productSelectResItem.tsx +3 -1
  20. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/productView.tsx +3 -1
  21. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/product.tsx +23 -3
  22. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/model/product.ts +1 -0
  23. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/product.ts +7 -2
  24. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/productPresentation.test.ts +214 -0
  25. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/payScene.commerceOrder.ts +39 -3
  26. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/commercePayScene.ts +14 -1
  27. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +5 -1
  28. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +17 -0
  29. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +18 -0
  30. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutCreate.tsx +3 -0
  31. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutPaymentMethod.tsx +19 -0
  32. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutPaymentMethods.tsx +17 -0
  33. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +51 -0
  34. package/vona/src/suite/a-commerce/modules/commerce-trade/test/paymentOutcome.test.ts +2 -1
  35. package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
  36. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/.metadata/index.ts +94 -55
  37. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/bean.payScene.ts +65 -1
  38. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.redlock.ts +2 -0
  39. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/controller/paymentCallback.ts +32 -0
  40. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/controller/paymentSession.ts +26 -8
  41. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/paymentCallback.ts +99 -0
  42. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/paymentSession.ts +26 -3
  43. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/providerOperation.ts +214 -82
  44. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/service/refundOperation.ts +27 -2
  45. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payScene.ts +9 -0
  46. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/types/payment.ts +5 -0
  47. package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
  48. package/vona/src/suite-vendor/a-pay/modules/pay-mock/src/bean/payProvider.mock.ts +7 -0
  49. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +1 -1
  50. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/bean/payProvider.paypal.ts +395 -84
  51. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/lib/index.ts +1 -1
  52. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/lib/paypalGateway.ts +129 -0
  53. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/src/types/index.ts +2 -0
  54. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
  55. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/src/bean/payProvider.stripe.ts +7 -0
  56. package/vona/src/suite-vendor/a-pay/package.json +5 -5
  57. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  58. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  59. package/zova/packages-cli/cli/package.json +3 -3
  60. package/zova/packages-cli/cli-set-front/package.json +4 -4
  61. package/zova/packages-utils/zova-jsx/package.json +3 -3
  62. package/zova/packages-utils/zova-vite/package.json +2 -2
  63. package/zova/packages-zova/zova/package.json +3 -3
  64. package/zova/packages-zova/zova-core/package.json +2 -2
  65. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/schemas.ts +8 -0
  66. package/zova/src/suite/a-commerce/modules/commerce-member/src/api/openapi/types.ts +175 -0
  67. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
  68. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeCheckout.ts +16 -0
  69. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +8 -0
  70. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +177 -2
  71. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeCheckout.ts +12 -1
  72. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +11 -0
  73. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/checkout/controller.tsx +25 -1
  74. package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/payment/controller.tsx +27 -4
  75. package/zova/src/suite-vendor/a-pay/modules/a-pay/cli/openapi.config.ts +1 -1
  76. package/zova/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
  77. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/api/openapi/schemas.ts +20 -8
  78. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/api/openapi/types.ts +292 -32
  79. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/api/paymentSession.ts +26 -4
  80. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/apiSchema/paymentSession.ts +5 -0
  81. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/component/paymentNextAction/controller.tsx +2 -1
  82. package/zova/src/suite-vendor/a-pay/modules/a-pay/src/model/paymentSession.ts +12 -0
  83. package/zova/src/suite-vendor/a-pay/package.json +2 -2
  84. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +4 -4
  85. package/zova/src/suite-vendor/a-zova/package.json +2 -2
@@ -0,0 +1,214 @@
1
+ import assert from 'node:assert';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { describe, it } from 'node:test';
4
+ import { app } from 'vona-mock';
5
+ import {
6
+ DtoProductCreate,
7
+ DtoProductSelectResItem,
8
+ DtoProductUpdate,
9
+ DtoProductView,
10
+ } from 'vona-module-commerce-catalog';
11
+
12
+ function findComponent(apiJson: any, predicate: (properties: any) => boolean) {
13
+ return Object.values(apiJson.components!.schemas as any).find(item => {
14
+ return predicate((item as any).properties);
15
+ }) as any;
16
+ }
17
+
18
+ function getEntryForm(component: any) {
19
+ const pageEntry = component?.rest?.blocks?.[0];
20
+ const formBlock = pageEntry?.options?.blocks?.[0];
21
+ const toolbarBlock = pageEntry?.options?.blocks?.[1];
22
+ assert.equal(pageEntry?.render, 'basic-pageentry:blockPageEntry');
23
+ assert.equal(formBlock?.render, 'basic-pageentry:blockForm');
24
+ assert.equal(toolbarBlock?.render, 'basic-pageentry:blockToolbarRow');
25
+ return toolbarBlock;
26
+ }
27
+
28
+ describe('productPresentation.test.ts', () => {
29
+ it('action:product:presentationMetadata', async () => {
30
+ await app.bean.executor.mockCtx(async () => {
31
+ const createJson = await app.bean.openapi.generateJsonOfClass(DtoProductCreate);
32
+ const createComponent = findComponent(createJson, properties => {
33
+ return properties?.title && properties?.categoryId && properties?.published;
34
+ });
35
+ assert.deepEqual(Object.keys(createComponent.properties).sort(), [
36
+ 'categoryId',
37
+ 'description',
38
+ 'published',
39
+ 'title',
40
+ ]);
41
+ assert.equal(
42
+ createComponent.properties.categoryId.rest.form.render,
43
+ 'basic-resource:formFieldResourcePicker',
44
+ );
45
+ assert.deepEqual(createComponent.properties.categoryId.rest.form.options, {
46
+ resource: 'commerce-catalog:category',
47
+ relationName: 'category',
48
+ });
49
+ assert.equal(
50
+ createComponent.properties.published.rest.form.render,
51
+ 'basic-select:formFieldSelect',
52
+ );
53
+ assert.deepEqual(
54
+ createComponent.properties.published.rest.form.options.items.map((item: any) => item.value),
55
+ [false, true],
56
+ );
57
+ const createToolbar = getEntryForm(createComponent);
58
+ assert.deepEqual(
59
+ createToolbar.options.actions.map((action: any) => action.render),
60
+ ['basic-form:actionSubmit', 'basic-form:actionBack'],
61
+ );
62
+
63
+ const updateJson = await app.bean.openapi.generateJsonOfClass(DtoProductUpdate);
64
+ const updateComponent = findComponent(updateJson, properties => {
65
+ return properties?.title && properties?.categoryId && properties?.published;
66
+ });
67
+ assert.deepEqual(Object.keys(updateComponent.properties).sort(), [
68
+ 'categoryId',
69
+ 'description',
70
+ 'published',
71
+ 'title',
72
+ ]);
73
+ assert.equal(
74
+ updateComponent.properties.categoryId.rest.form.render,
75
+ 'basic-resource:formFieldResourcePicker',
76
+ );
77
+ assert.equal(
78
+ updateComponent.properties.published.rest.form.render,
79
+ 'basic-select:formFieldSelect',
80
+ );
81
+
82
+ const viewJson = await app.bean.openapi.generateJsonOfClass(DtoProductView);
83
+ const viewComponent = findComponent(viewJson, properties => {
84
+ return (
85
+ properties?.id && properties?.categoryId && properties?.category && properties?.published
86
+ );
87
+ });
88
+ assert.deepEqual(Object.keys(viewComponent.properties.category.properties).sort(), [
89
+ 'id',
90
+ 'name',
91
+ 'published',
92
+ ]);
93
+ assert.equal(viewComponent.properties.iid.rest.visible, false);
94
+ assert.equal(viewComponent.properties.deleted.rest.visible, false);
95
+ assert.equal(
96
+ viewComponent.properties.categoryId.rest.form.render,
97
+ 'basic-resource:formFieldResourcePicker',
98
+ );
99
+ assert.deepEqual(viewComponent.properties.categoryId.rest.form.options, {
100
+ resource: 'commerce-catalog:category',
101
+ relationName: 'category',
102
+ });
103
+ assert.equal(
104
+ viewComponent.properties.published.rest.form.render,
105
+ 'basic-select:formFieldSelect',
106
+ );
107
+ const viewToolbar = getEntryForm(viewComponent);
108
+ assert.deepEqual(
109
+ viewToolbar.options.actions.map((action: any) => action.render),
110
+ ['basic-form:actionBack'],
111
+ );
112
+
113
+ const listJson = await app.bean.openapi.generateJsonOfClass(DtoProductSelectResItem);
114
+ const listComponent = findComponent(listJson, properties => properties?._operationsRow);
115
+ assert.deepEqual(Object.keys(listComponent.properties.category.properties).sort(), [
116
+ 'id',
117
+ 'name',
118
+ 'published',
119
+ ]);
120
+ assert.equal(
121
+ listComponent.properties.categoryId.rest.table.render,
122
+ 'basic-resource:resourcePicker',
123
+ );
124
+ assert.deepEqual(listComponent.properties.categoryId.rest.table.columnProps, {
125
+ resource: 'commerce-catalog:category',
126
+ relationName: 'category',
127
+ });
128
+ assert.equal(listComponent.properties.published.rest.table.render, 'basic-select:select');
129
+ assert.deepEqual(
130
+ listComponent.properties.published.rest.table.columnProps.items.map(
131
+ (item: any) => item.value,
132
+ ),
133
+ [false, true],
134
+ );
135
+ assert.deepEqual(
136
+ listComponent.properties._operationsRow.rest.table.columnProps.actions.map(
137
+ (action: any) => action.render,
138
+ ),
139
+ ['basic-table:actionUpdate', 'basic-table:actionDelete'],
140
+ );
141
+
142
+ const filterBlock = listComponent.rest.blocks[0].options.blocks[0];
143
+ const filterLayout = filterBlock.options.blocks[0].options.formLayout;
144
+ const section = filterLayout.children[0];
145
+ assert.equal(section.type, 'section');
146
+ assert.equal(section.layout, 'flow');
147
+ assert.deepEqual(
148
+ section.children.slice(0, 2).map((field: any) => field.name),
149
+ ['title', 'createdAt'],
150
+ );
151
+ assert.equal(section.children[2].block.render, 'basic-page:blockFilterActions');
152
+ });
153
+ });
154
+
155
+ it('action:product:categoryProjection', async () => {
156
+ await app.bean.executor.mockCtx(async () => {
157
+ const suffix = randomUUID().slice(0, 12);
158
+ let categoryId: number | string | undefined;
159
+ let productId: number | string | undefined;
160
+ await app.bean.passport.signinMock();
161
+ try {
162
+ categoryId = await app.bean.executor.performAction('post', '/commerce/catalog/category', {
163
+ body: { name: `presentation-product-category-${suffix}`, published: true },
164
+ });
165
+ productId = await app.bean.executor.performAction('post', '/commerce/catalog/product', {
166
+ body: {
167
+ categoryId,
168
+ title: `presentation-product-${suffix}`,
169
+ published: false,
170
+ },
171
+ });
172
+
173
+ const view: any = await app.bean.executor.performAction(
174
+ 'get',
175
+ '/commerce/catalog/product/:id',
176
+ {
177
+ params: { id: productId },
178
+ },
179
+ );
180
+ assert.equal(String(view.categoryId), String(categoryId));
181
+ assert.deepEqual(view.category, {
182
+ id: categoryId,
183
+ name: `presentation-product-category-${suffix}`,
184
+ published: true,
185
+ });
186
+
187
+ const select: any = await app.bean.executor.performAction(
188
+ 'get',
189
+ '/commerce/catalog/product',
190
+ );
191
+ const item = select.list.find((entry: any) => String(entry.id) === String(productId));
192
+ assert.ok(item);
193
+ assert.deepEqual(item.category, {
194
+ id: categoryId,
195
+ name: `presentation-product-category-${suffix}`,
196
+ published: true,
197
+ });
198
+ assert.equal(item.published, false);
199
+ } finally {
200
+ if (productId) {
201
+ await app.bean.executor.performAction('delete', '/commerce/catalog/product/:id', {
202
+ params: { id: productId },
203
+ });
204
+ }
205
+ if (categoryId) {
206
+ await app.bean.executor.performAction('delete', '/commerce/catalog/category/:id', {
207
+ params: { id: categoryId },
208
+ });
209
+ }
210
+ await app.bean.passport.signout();
211
+ }
212
+ });
213
+ });
214
+ });
@@ -1,4 +1,8 @@
1
- import type { IPaymentOutcomeEvent, IRefundOutcomeEvent } from 'vona-module-a-pay';
1
+ import type {
2
+ EntityPaymentSession,
3
+ IPaymentOutcomeEvent,
4
+ IRefundOutcomeEvent,
5
+ } from 'vona-module-a-pay';
2
6
 
3
7
  import { BeanBase } from 'vona';
4
8
  import { PayScene } from 'vona-module-a-pay';
@@ -8,8 +12,33 @@ import { PayScene } from 'vona-module-a-pay';
8
12
  { key: 'mock', providerName: 'pay-mock:mock', clientName: 'default' },
9
13
  { key: 'paypal', providerName: 'pay-paypal:paypal', clientName: 'default' },
10
14
  ],
11
- resolveProvider: (_ctx, input) =>
12
- input.providers.find(item => item.key === 'mock')?.key ?? input.providers[0]!.key,
15
+ isProviderAvailable: (_ctx, input) => {
16
+ if (input.candidate.key === 'mock') return true;
17
+ if (input.candidate.key !== 'paypal') return false;
18
+ const { clientOptions } = _ctx.bean.payProvider.resolveByName(
19
+ input.candidate.providerName,
20
+ input.candidate.clientName,
21
+ );
22
+ const options = clientOptions as {
23
+ secretCredential?: { clientId?: string; clientSecret?: string };
24
+ webhookId?: string;
25
+ merchantReference?: string;
26
+ };
27
+ const server = _ctx.app.config.server.serve;
28
+ return !!(
29
+ options.secretCredential?.clientId &&
30
+ options.secretCredential.clientSecret &&
31
+ options.webhookId &&
32
+ options.merchantReference &&
33
+ server.protocol &&
34
+ server.host
35
+ );
36
+ },
37
+ resolveProvider: (_ctx, input) => {
38
+ const mock = input.providers.find(item => item.key === 'mock');
39
+ if (!mock) return input.providers[0]!.key;
40
+ return mock.key;
41
+ },
13
42
  currencies: ['USD'],
14
43
  captureMode: 'automatic',
15
44
  sessionExpiresIn: 30 * 60 * 1000,
@@ -19,11 +48,18 @@ import { PayScene } from 'vona-module-a-pay';
19
48
  },
20
49
  })
21
50
  export class PaySceneCommerceOrder extends BeanBase {
51
+ async getPaymentCallbackPath(session: EntityPaymentSession): Promise<string> {
52
+ const attempt = await this.scope.model.paymentAttempt.get({ paymentSessionId: session.id });
53
+ if (!attempt) this.app.throw(404, 'commerce payment attempt not found');
54
+ return `/commerce/payment/${session.id}/${attempt.orderId}`;
55
+ }
56
+
22
57
  async onPaymentOutcome(event: IPaymentOutcomeEvent): Promise<void> {
23
58
  await this.$scope.commerceTrade.service.order.settlePaymentFromProvider(event);
24
59
  }
25
60
 
26
61
  async onRefundOutcome(event: IRefundOutcomeEvent): Promise<void> {
62
+ if (event.businessReference.startsWith('late-capture:')) return;
27
63
  await this.$scope.commerceTrade.service.order.settleRefundFromProvider(event);
28
64
  }
29
65
  }
@@ -9,7 +9,19 @@ import type { EntityPaymentAttempt } from '../entity/paymentAttempt.tsx';
9
9
 
10
10
  @Service()
11
11
  export class ServiceCommercePayScene extends BeanBase {
12
- async createSession(attempt: EntityPaymentAttempt) {
12
+ async availableProviderCandidates(userId: TableIdentity) {
13
+ return await this.bean.payScene.getAvailableProviderCandidates(
14
+ 'commerce-payment:commerceOrder',
15
+ {
16
+ userId,
17
+ businessReference: 'checkout-preview',
18
+ amountMinor: 0,
19
+ currency: 'USD',
20
+ },
21
+ );
22
+ }
23
+
24
+ async createSession(attempt: EntityPaymentAttempt, providerCandidateKey?: string) {
13
25
  const session = await this.$scope.pay.service.paymentSession.create({
14
26
  userId: attempt.userId,
15
27
  payScene: 'commerce-payment:commerceOrder',
@@ -17,6 +29,7 @@ export class ServiceCommercePayScene extends BeanBase {
17
29
  amountMinor: attempt.amountCents,
18
30
  currency: attempt.currency,
19
31
  correlationId: `${attempt.correlationId}:session`,
32
+ providerCandidateKey,
20
33
  });
21
34
  await this.scope.model.paymentAttempt.updateById(attempt.id, {
22
35
  paymentSessionId: session.id,
@@ -12,6 +12,7 @@ export interface IPaymentAttemptCreateCommand {
12
12
  currency: 'USD';
13
13
  amountCents: number;
14
14
  correlationId: string;
15
+ providerCandidateKey?: string;
15
16
  }
16
17
 
17
18
  export type TypePaymentAttemptFinalState = Extract<
@@ -30,7 +31,10 @@ export class ServicePaymentAttempt extends BeanBase {
30
31
  amountCents: command.amountCents,
31
32
  correlationId: command.correlationId,
32
33
  });
33
- const session = await this.scope.service.commercePayScene.createSession(attempt);
34
+ const session = await this.scope.service.commercePayScene.createSession(
35
+ attempt,
36
+ command.providerCandidateKey,
37
+ );
34
38
  return {
35
39
  ...attempt,
36
40
  paymentSessionId: session.id,
@@ -834,6 +834,8 @@ export * from '../dto/cartItem.tsx';
834
834
  export * from '../dto/cartUpdateItem.tsx';
835
835
  export * from '../dto/cartView.tsx';
836
836
  export * from '../dto/checkoutCreate.tsx';
837
+ export * from '../dto/checkoutPaymentMethod.tsx';
838
+ export * from '../dto/checkoutPaymentMethods.tsx';
837
839
  export * from '../dto/checkoutResult.tsx';
838
840
  export * from '../dto/orderAddressSnapshot.tsx';
839
841
  export * from '../dto/orderCouponSnapshot.tsx';
@@ -872,6 +874,8 @@ import type { IDtoOptionsCartItem } from '../dto/cartItem.tsx';
872
874
  import type { IDtoOptionsCartUpdateItem } from '../dto/cartUpdateItem.tsx';
873
875
  import type { IDtoOptionsCartView } from '../dto/cartView.tsx';
874
876
  import type { IDtoOptionsCheckoutCreate } from '../dto/checkoutCreate.tsx';
877
+ import type { IDtoOptionsCheckoutPaymentMethod } from '../dto/checkoutPaymentMethod.tsx';
878
+ import type { IDtoOptionsCheckoutPaymentMethods } from '../dto/checkoutPaymentMethods.tsx';
875
879
  import type { IDtoOptionsCheckoutResult } from '../dto/checkoutResult.tsx';
876
880
  import type { IDtoOptionsOrderAddressSnapshot } from '../dto/orderAddressSnapshot.tsx';
877
881
  import type { IDtoOptionsOrderCouponSnapshot } from '../dto/orderCouponSnapshot.tsx';
@@ -914,6 +918,8 @@ declare module 'vona-module-a-web' {
914
918
  'commerce-trade:cartUpdateItem': IDtoOptionsCartUpdateItem;
915
919
  'commerce-trade:cartView': IDtoOptionsCartView;
916
920
  'commerce-trade:checkoutCreate': IDtoOptionsCheckoutCreate;
921
+ 'commerce-trade:checkoutPaymentMethod': IDtoOptionsCheckoutPaymentMethod;
922
+ 'commerce-trade:checkoutPaymentMethods': IDtoOptionsCheckoutPaymentMethods;
917
923
  'commerce-trade:checkoutResult': IDtoOptionsCheckoutResult;
918
924
  'commerce-trade:orderAddressSnapshot': IDtoOptionsOrderAddressSnapshot;
919
925
  'commerce-trade:orderCouponSnapshot': IDtoOptionsOrderCouponSnapshot;
@@ -961,6 +967,8 @@ import type { DtoCartItem } from '../dto/cartItem.tsx';
961
967
  import type { DtoCartUpdateItem } from '../dto/cartUpdateItem.tsx';
962
968
  import type { DtoCartView } from '../dto/cartView.tsx';
963
969
  import type { DtoCheckoutCreate } from '../dto/checkoutCreate.tsx';
970
+ import type { DtoCheckoutPaymentMethod } from '../dto/checkoutPaymentMethod.tsx';
971
+ import type { DtoCheckoutPaymentMethods } from '../dto/checkoutPaymentMethods.tsx';
964
972
  import type { DtoCheckoutResult } from '../dto/checkoutResult.tsx';
965
973
  import type { DtoOrderAddressSnapshot } from '../dto/orderAddressSnapshot.tsx';
966
974
  import type { DtoOrderCouponSnapshot } from '../dto/orderCouponSnapshot.tsx';
@@ -1016,6 +1024,14 @@ declare module 'vona-module-commerce-trade' {
1016
1024
  fields?: TypeEntityOptionsFields<DtoCheckoutCreate, IDtoOptionsCheckoutCreate[TypeSymbolKeyFieldsMore]>;
1017
1025
  }
1018
1026
 
1027
+ export interface IDtoOptionsCheckoutPaymentMethod {
1028
+ fields?: TypeEntityOptionsFields<DtoCheckoutPaymentMethod, IDtoOptionsCheckoutPaymentMethod[TypeSymbolKeyFieldsMore]>;
1029
+ }
1030
+
1031
+ export interface IDtoOptionsCheckoutPaymentMethods {
1032
+ fields?: TypeEntityOptionsFields<DtoCheckoutPaymentMethods, IDtoOptionsCheckoutPaymentMethods[TypeSymbolKeyFieldsMore]>;
1033
+ }
1034
+
1019
1035
  export interface IDtoOptionsCheckoutResult {
1020
1036
  fields?: TypeEntityOptionsFields<DtoCheckoutResult, IDtoOptionsCheckoutResult[TypeSymbolKeyFieldsMore]>;
1021
1037
  }
@@ -1267,6 +1283,7 @@ declare module 'vona-module-commerce-trade' {
1267
1283
  declare module 'vona-module-a-web' {
1268
1284
  export interface IApiPathGetRecord{
1269
1285
  '/commerce/trade/cart': undefined;
1286
+ '/commerce/trade/checkout/payment-methods': undefined;
1270
1287
  '/commerce/trade/order/mine': undefined;
1271
1288
  '/commerce/trade/order/viewMine/:id': undefined;
1272
1289
  '/commerce/trade/order': undefined;
@@ -1,3 +1,4 @@
1
+ import type { IUser } from 'vona-module-a-user';
1
2
  import type { IDecoratorControllerOptions } from 'vona-module-a-web';
2
3
 
3
4
  import { BeanBase } from 'vona';
@@ -5,12 +6,29 @@ import { Api } from 'vona-module-a-openapiutils';
5
6
  import { Arg, Controller, Web } from 'vona-module-a-web';
6
7
 
7
8
  import { DtoCheckoutCreate } from '../dto/checkoutCreate.tsx';
9
+ import { DtoCheckoutPaymentMethods } from '../dto/checkoutPaymentMethods.tsx';
8
10
  import { DtoCheckoutResult } from '../dto/checkoutResult.tsx';
9
11
 
10
12
  export interface IControllerOptionsCheckout extends IDecoratorControllerOptions {}
11
13
 
12
14
  @Controller<IControllerOptionsCheckout>('checkout')
13
15
  export class ControllerCheckout extends BeanBase {
16
+ @Web.get('payment-methods')
17
+ @Api.body(DtoCheckoutPaymentMethods)
18
+ async paymentMethods(@Arg.user() user: IUser): Promise<DtoCheckoutPaymentMethods> {
19
+ const candidates =
20
+ await this.$scope.commercePayment.service.commercePayScene.availableProviderCandidates(
21
+ user.id,
22
+ );
23
+ const items = candidates.map(candidate => ({
24
+ key: candidate.key,
25
+ label: candidate.key === 'paypal' ? 'PayPal' : 'Mock payment',
26
+ interaction: candidate.key === 'paypal' ? ('redirect' as const) : ('embedded' as const),
27
+ }));
28
+ const defaultKey = items.find(item => item.key === 'mock')?.key ?? items[0]!.key;
29
+ return { items, defaultKey };
30
+ }
31
+
14
32
  @Web.post()
15
33
  @Api.body(DtoCheckoutResult)
16
34
  async create(@Arg.body() command: DtoCheckoutCreate): Promise<DtoCheckoutResult> {
@@ -16,4 +16,7 @@ export class DtoCheckoutCreate {
16
16
 
17
17
  @Api.field(v.required(), v.min(1), v.max(80))
18
18
  correlationId: string;
19
+
20
+ @Api.field(v.optional(), v.min(1), v.max(100))
21
+ providerCandidateKey?: string;
19
22
  }
@@ -0,0 +1,19 @@
1
+ import type { IDecoratorDtoOptions } from 'vona-module-a-web';
2
+
3
+ import { Api, v } from 'vona-module-a-openapiutils';
4
+ import { Dto } from 'vona-module-a-web';
5
+ import { z } from 'zod';
6
+
7
+ export interface IDtoOptionsCheckoutPaymentMethod extends IDecoratorDtoOptions {}
8
+
9
+ @Dto<IDtoOptionsCheckoutPaymentMethod>()
10
+ export class DtoCheckoutPaymentMethod {
11
+ @Api.field(v.required(), v.min(1), v.max(100))
12
+ key: string;
13
+
14
+ @Api.field(v.required(), v.min(1), v.max(100))
15
+ label: string;
16
+
17
+ @Api.field(z.enum(['redirect', 'embedded']))
18
+ interaction: 'redirect' | 'embedded';
19
+ }
@@ -0,0 +1,17 @@
1
+ import type { IDecoratorDtoOptions } from 'vona-module-a-web';
2
+
3
+ import { Api, v } from 'vona-module-a-openapiutils';
4
+ import { Dto } from 'vona-module-a-web';
5
+
6
+ import { DtoCheckoutPaymentMethod } from './checkoutPaymentMethod.tsx';
7
+
8
+ export interface IDtoOptionsCheckoutPaymentMethods extends IDecoratorDtoOptions {}
9
+
10
+ @Dto<IDtoOptionsCheckoutPaymentMethods>()
11
+ export class DtoCheckoutPaymentMethods {
12
+ @Api.field(v.array(DtoCheckoutPaymentMethod))
13
+ items: DtoCheckoutPaymentMethod[];
14
+
15
+ @Api.field(v.required(), v.min(1), v.max(100))
16
+ defaultKey: string;
17
+ }
@@ -322,6 +322,7 @@ export class ServiceOrder extends BeanBase {
322
322
  currency: order.currency,
323
323
  amountCents: order.payableTotalCents,
324
324
  correlationId: `${command.correlationId}:payment`,
325
+ providerCandidateKey: command.providerCandidateKey,
325
326
  });
326
327
  await onStage?.('afterPaymentAttempt');
327
328
  await this._appendAudit({
@@ -376,6 +377,13 @@ export class ServiceOrder extends BeanBase {
376
377
  providerEventId: event.eventId,
377
378
  });
378
379
  if (existingAudit) return this._paymentOutcomeResult(order, lockedAttempt);
380
+ if (
381
+ order.state === 'expired' &&
382
+ lockedAttempt.state === 'cancelled' &&
383
+ event.state === 'succeeded'
384
+ ) {
385
+ return await this._scheduleLateCaptureCompensation(order, lockedAttempt, event);
386
+ }
379
387
  if (order.state !== 'awaiting_payment' || lockedAttempt.state !== 'created') {
380
388
  this.app.throw(409, 'payment attempt is no longer available');
381
389
  }
@@ -1217,6 +1225,49 @@ export class ServiceOrder extends BeanBase {
1217
1225
  });
1218
1226
  }
1219
1227
 
1228
+ private async _scheduleLateCaptureCompensation(
1229
+ order: EntityOrder,
1230
+ attempt: EntityPaymentAttempt,
1231
+ event: IPaymentOutcomeEvent,
1232
+ ) {
1233
+ if (!event.providerCaptureId)
1234
+ this.app.throw(409, 'late payment outcome has no provider capture');
1235
+ await this.$scope.commercePayment.model.paymentAttempt.updateById(attempt.id, {
1236
+ providerCaptureId: event.providerCaptureId,
1237
+ });
1238
+ const correlationId = `${order.correlationId}:late-capture:${event.eventId}`;
1239
+ const existingAudit = await this.$scope.commercePayment.model.paymentAudit.get({
1240
+ paymentAttemptId: attempt.id,
1241
+ providerEventId: event.eventId,
1242
+ });
1243
+ if (existingAudit) return this._paymentOutcomeResult(order, attempt);
1244
+ await this.$scope.commercePayment.model.paymentAudit.insert({
1245
+ paymentAttemptId: attempt.id,
1246
+ orderId: order.id,
1247
+ userId: order.userId,
1248
+ provider: event.providerName,
1249
+ providerEventId: event.eventId,
1250
+ outcome: 'succeeded',
1251
+ fromAttemptState: attempt.state,
1252
+ toOrderState: 'expired',
1253
+ idempotencyKey: event.eventId.slice(0, 100),
1254
+ correlationId,
1255
+ reason: 'late provider capture requires automatic compensation',
1256
+ processedAt: new Date(),
1257
+ });
1258
+ const session = await this.$scope.pay.model.paymentSession.getById(event.paymentSessionId);
1259
+ if (!session) this.app.throw(404, 'payment session not found');
1260
+ await this.$scope.pay.service.refundOperation.create({
1261
+ paymentSessionId: session.id,
1262
+ businessReference: `late-capture:${attempt.id}`,
1263
+ amountMinor: event.amountMinor,
1264
+ currency: event.currency,
1265
+ idempotencyKey: `${correlationId}:refund`,
1266
+ correlationId,
1267
+ });
1268
+ return this._paymentOutcomeResult(order, attempt);
1269
+ }
1270
+
1220
1271
  private async _expireLockedOrder(order: EntityOrder) {
1221
1272
  const correlationId = `${order.correlationId}:expiry`;
1222
1273
  const reason = 'unpaid order expired';
@@ -550,6 +550,7 @@ describe('paymentOutcome.test.ts', { concurrency: false, sequential: true }, ()
550
550
 
551
551
  it('denies anonymous customer payment and order actions', async () => {
552
552
  await app.bean.executor.mockCtx(async () => {
553
+ await app.bean.passport.signout();
553
554
  const requests = [
554
555
  () =>
555
556
  app.bean.executor.performAction('get', '/commerce/trade/order/mine', {
@@ -564,7 +565,7 @@ describe('paymentOutcome.test.ts', { concurrency: false, sequential: true }, ()
564
565
  innerAccess: false,
565
566
  }),
566
567
  () =>
567
- app.bean.executor.performAction('get', '/payment-session/1', {
568
+ app.bean.executor.performAction('get', '/pay/payment-session/1', {
568
569
  innerAccess: false,
569
570
  }),
570
571
  ];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-pay",
3
- "version": "5.0.3",
3
+ "version": "5.0.4",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"