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.
- package/.cabloy-version +1 -1
- package/CHANGELOG.md +16 -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 +10 -39
- 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/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
|
@@ -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
|
+
});
|
package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/payScene.commerceOrder.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
12
|
-
input.
|
|
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
|
|
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(
|
|
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> {
|
|
@@ -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
|
];
|