payment-kit 1.13.301 → 1.13.302
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/api/src/queues/payment.ts +21 -0
- package/api/src/routes/checkout-sessions.ts +66 -1
- package/api/src/routes/prices.ts +24 -2
- package/api/src/store/migrations/20240715-price-quantity.ts +41 -0
- package/api/src/store/models/price.ts +34 -0
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/scripts/sdk.js +45 -6
- package/src/components/price/form.tsx +40 -1
- package/src/components/product/edit-price.tsx +8 -0
- package/src/locales/en.tsx +21 -0
- package/src/locales/zh.tsx +21 -0
- package/src/pages/admin/products/prices/actions.tsx +4 -0
- package/src/pages/admin/products/prices/list.tsx +20 -0
|
@@ -38,6 +38,21 @@ type PaymentJob = {
|
|
|
38
38
|
retryOnError?: boolean;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
async function updateQuantitySold(checkoutSession: CheckoutSession) {
|
|
42
|
+
const updatePromises = checkoutSession.line_items.map((item) => {
|
|
43
|
+
const priceId = item.upsell_price_id || item.price_id;
|
|
44
|
+
return Price.increment({ quantity_sold: Number(item.quantity) }, { where: { id: priceId } }).catch((err) => {
|
|
45
|
+
logger.error('Update quantity_sold failed', {
|
|
46
|
+
error: err,
|
|
47
|
+
priceId,
|
|
48
|
+
checkoutSessionId: checkoutSession.id,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await Promise.all(updatePromises);
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
export const handlePaymentSucceed = async (paymentIntent: PaymentIntent) => {
|
|
42
57
|
// FIXME: @wangshijun we should check stripe payment here before
|
|
43
58
|
|
|
@@ -194,6 +209,12 @@ export const handlePaymentSucceed = async (paymentIntent: PaymentIntent) => {
|
|
|
194
209
|
if (invoice.checkout_session_id) {
|
|
195
210
|
const checkoutSession = await CheckoutSession.findByPk(invoice.checkout_session_id);
|
|
196
211
|
if (checkoutSession && checkoutSession.status === 'open') {
|
|
212
|
+
updateQuantitySold(checkoutSession).catch((err) => {
|
|
213
|
+
logger.error('Updating quantity_sold for line items failed', {
|
|
214
|
+
error: err,
|
|
215
|
+
checkoutSessionId: checkoutSession.id,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
197
218
|
await checkoutSession.update({
|
|
198
219
|
status: 'complete',
|
|
199
220
|
payment_status: 'paid',
|
|
@@ -77,6 +77,52 @@ const getPaymentTypes = async (items: any[]) => {
|
|
|
77
77
|
return methods.map((x) => x.type);
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
export async function validateInventory(line_items: LineItem[], includePendingQuantity = false) {
|
|
81
|
+
const checks = line_items.map(async (item) => {
|
|
82
|
+
const priceId = item.price_id;
|
|
83
|
+
const quantity = Number(item.quantity || 0);
|
|
84
|
+
|
|
85
|
+
const price = await Price.findOne({ where: { id: priceId } });
|
|
86
|
+
|
|
87
|
+
let delta = quantity;
|
|
88
|
+
|
|
89
|
+
if (!price) {
|
|
90
|
+
throw new Error(`Price not found for priceId: ${priceId}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!price.quantity_available) {
|
|
94
|
+
// if quantity_available equal to 0 , we assume it's unlimited
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (Number(price.quantity_limit_per_checkout) > 0 && quantity > Number(price.quantity_limit_per_checkout)) {
|
|
99
|
+
throw new Error(`Can not exceed per checkout quantity for price: ${priceId}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
delta += price.quantity_sold;
|
|
103
|
+
if (includePendingQuantity) {
|
|
104
|
+
const checkoutSessions = await CheckoutSession.findAll({
|
|
105
|
+
where: {
|
|
106
|
+
status: 'open',
|
|
107
|
+
},
|
|
108
|
+
attributes: ['line_items'],
|
|
109
|
+
});
|
|
110
|
+
const pendingQuantity = checkoutSessions.reduce((acc, session: any) => {
|
|
111
|
+
const lineItems = session.line_items || [];
|
|
112
|
+
const sessionQuantity = lineItems
|
|
113
|
+
.filter((lineItem: any) => lineItem.priceId === priceId)
|
|
114
|
+
.reduce((total: number, lineItem: any) => total + Number(lineItem.quantity), 0);
|
|
115
|
+
return acc + sessionQuantity;
|
|
116
|
+
}, 0);
|
|
117
|
+
delta += pendingQuantity;
|
|
118
|
+
}
|
|
119
|
+
if (delta > Number(price.quantity_available)) {
|
|
120
|
+
throw new Error(`Can not exceed available quantity for price: ${priceId}`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
await Promise.all(checks);
|
|
124
|
+
}
|
|
125
|
+
|
|
80
126
|
export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = true) => {
|
|
81
127
|
const raw: Partial<CheckoutSession> = Object.assign(
|
|
82
128
|
{
|
|
@@ -288,6 +334,14 @@ router.post('/', auth, async (req, res) => {
|
|
|
288
334
|
const raw: Partial<CheckoutSession> = await formatCheckoutSession(req.body);
|
|
289
335
|
raw.livemode = !!req.livemode;
|
|
290
336
|
raw.created_via = req.user?.via as string;
|
|
337
|
+
if (raw.line_items) {
|
|
338
|
+
try {
|
|
339
|
+
await validateInventory(raw.line_items, true);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
logger.error('validateInventory failed', { error: err, line_items: raw.line_items });
|
|
342
|
+
return res.status(400).json({ error: err.message });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
291
345
|
|
|
292
346
|
const doc = await CheckoutSession.create(raw as any);
|
|
293
347
|
|
|
@@ -460,7 +514,18 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
460
514
|
}
|
|
461
515
|
|
|
462
516
|
const checkoutSession = req.doc as CheckoutSession;
|
|
463
|
-
|
|
517
|
+
if (checkoutSession.line_items) {
|
|
518
|
+
try {
|
|
519
|
+
await validateInventory(checkoutSession.line_items);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
logger.error('validateInventory failed', {
|
|
522
|
+
error: err,
|
|
523
|
+
line_items: checkoutSession.line_items,
|
|
524
|
+
checkoutSessionId: checkoutSession.id,
|
|
525
|
+
});
|
|
526
|
+
return res.status(400).json({ error: err.message });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
464
529
|
// validate cross sell
|
|
465
530
|
if (checkoutSession.cross_sell_behavior === 'required') {
|
|
466
531
|
if (checkoutSession.line_items.some((x) => x.cross_sell) === false) {
|
package/api/src/routes/prices.ts
CHANGED
|
@@ -167,16 +167,28 @@ export async function createPrice(payload: any) {
|
|
|
167
167
|
return getExpandedPrice(price.id as string);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
const priceQuantitySchema = Joi.object({
|
|
171
|
+
quantity_available: Joi.number().integer().min(0).optional().default(0),
|
|
172
|
+
quantity_limit_per_checkout: Joi.number().integer().min(0).optional().default(0),
|
|
173
|
+
});
|
|
174
|
+
|
|
170
175
|
// FIXME: @wangshijun use schema validation
|
|
171
176
|
// create price
|
|
172
177
|
// eslint-disable-next-line consistent-return
|
|
173
178
|
router.post('/', auth, async (req, res) => {
|
|
174
179
|
try {
|
|
180
|
+
const { error } = priceQuantitySchema.validate(
|
|
181
|
+
pick(req.body, ['quantity_available', 'quantity_limit_per_checkout'])
|
|
182
|
+
);
|
|
183
|
+
if (error) {
|
|
184
|
+
return res.status(400).json({ error: `Price create request invalid: ${error.message}` });
|
|
185
|
+
}
|
|
175
186
|
const result = await createPrice({
|
|
176
187
|
...req.body,
|
|
177
188
|
livemode: !!req.livemode,
|
|
178
189
|
currency_id: req.body.currency_id || req.currency.id,
|
|
179
190
|
created_via: req.user?.via as string,
|
|
191
|
+
quantity_sold: 0,
|
|
180
192
|
});
|
|
181
193
|
|
|
182
194
|
res.json(result);
|
|
@@ -231,6 +243,11 @@ router.get('/:id/upsell', auth, async (req, res) => {
|
|
|
231
243
|
// update price
|
|
232
244
|
// FIXME: upsell validate https://stripe.com/docs/payments/checkout/upsells
|
|
233
245
|
router.put('/:id', auth, async (req, res) => {
|
|
246
|
+
const quantityKeys = ['quantity_available', 'quantity_limit_per_checkout'];
|
|
247
|
+
const { error } = priceQuantitySchema.validate(pick(req.body, quantityKeys));
|
|
248
|
+
if (error) {
|
|
249
|
+
return res.status(400).json({ error: `Price update request invalid: ${error.message}` });
|
|
250
|
+
}
|
|
234
251
|
const doc = await Price.findByPkOrLookupKey(req.params.id as string);
|
|
235
252
|
|
|
236
253
|
if (!doc) {
|
|
@@ -241,13 +258,18 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
241
258
|
return res.status(403).json({ error: 'price archived' });
|
|
242
259
|
}
|
|
243
260
|
|
|
261
|
+
if (Number(req.body.quantity_available) > 0 && Number(req.body.quantity_available) < Number(doc.quantity_sold)) {
|
|
262
|
+
// 可售数量不得小于已售数量
|
|
263
|
+
return res.status(400).json({ error: 'the available quantity cannot be less than the quantity sold' });
|
|
264
|
+
}
|
|
265
|
+
|
|
244
266
|
const locked = doc.locked && process.env.PAYMENT_CHANGE_LOCKED_PRICE !== '1';
|
|
245
267
|
const updates: Partial<Price> = Price.formatBeforeSave(
|
|
246
268
|
pick(
|
|
247
269
|
req.body,
|
|
248
270
|
locked
|
|
249
|
-
? ['nickname', 'description', 'metadata', 'currency_options', 'upsell']
|
|
250
|
-
: ['type', 'model', 'active', 'livemode', 'nickname', 'recurring', 'description', 'tiers', 'unit_amount', 'transform_quantity', 'metadata', 'lookup_key', 'currency_options', 'upsell'] // prettier-ignore
|
|
271
|
+
? ['nickname', 'description', 'metadata', 'currency_options', 'upsell', ...quantityKeys]
|
|
272
|
+
: ['type', 'model', 'active', 'livemode', 'nickname', 'recurring', 'description', 'tiers', 'unit_amount', 'transform_quantity', 'metadata', 'lookup_key', 'currency_options', 'upsell', ...quantityKeys] // prettier-ignore
|
|
251
273
|
)
|
|
252
274
|
);
|
|
253
275
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
import { DataTypes } from 'sequelize';
|
|
3
|
+
|
|
4
|
+
import { Migration, safeApplyColumnChanges } from '../migrate';
|
|
5
|
+
|
|
6
|
+
export const up: Migration = async ({ context }) => {
|
|
7
|
+
await safeApplyColumnChanges(context, {
|
|
8
|
+
prices: [
|
|
9
|
+
{
|
|
10
|
+
name: 'quantity_available',
|
|
11
|
+
field: {
|
|
12
|
+
type: DataTypes.INTEGER,
|
|
13
|
+
defaultValue: 0,
|
|
14
|
+
allowNull: false,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'quantity_sold',
|
|
19
|
+
field: {
|
|
20
|
+
type: DataTypes.INTEGER,
|
|
21
|
+
defaultValue: 0,
|
|
22
|
+
allowNull: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'quantity_limit_per_checkout',
|
|
27
|
+
field: {
|
|
28
|
+
type: DataTypes.INTEGER,
|
|
29
|
+
defaultValue: 0,
|
|
30
|
+
allowNull: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const down: Migration = async ({ context }) => {
|
|
38
|
+
await context.removeColumn('prices', 'quantity_available');
|
|
39
|
+
await context.removeColumn('prices', 'quantity_sold');
|
|
40
|
+
await context.removeColumn('prices', 'quantity_limit_per_checkout');
|
|
41
|
+
};
|
|
@@ -90,6 +90,13 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
|
|
|
90
90
|
declare created_via: LiteralUnion<'api' | 'dashboard' | 'portal', string>;
|
|
91
91
|
declare updated_at: CreationOptional<Date>;
|
|
92
92
|
|
|
93
|
+
// Quantity available for purchase, 0 means no limit
|
|
94
|
+
declare quantity_available: number;
|
|
95
|
+
// Quantity has been sold
|
|
96
|
+
declare quantity_sold: number;
|
|
97
|
+
// Quantity limit per checkout, 0 means no limit
|
|
98
|
+
declare quantity_limit_per_checkout: number;
|
|
99
|
+
|
|
93
100
|
public static readonly GENESIS_ATTRIBUTES = {
|
|
94
101
|
id: {
|
|
95
102
|
type: DataTypes.STRING(32),
|
|
@@ -187,6 +194,21 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
|
|
|
187
194
|
type: DataTypes.JSON,
|
|
188
195
|
allowNull: true,
|
|
189
196
|
},
|
|
197
|
+
quantity_available: {
|
|
198
|
+
type: DataTypes.INTEGER,
|
|
199
|
+
defaultValue: 0,
|
|
200
|
+
allowNull: false,
|
|
201
|
+
},
|
|
202
|
+
quantity_sold: {
|
|
203
|
+
type: DataTypes.INTEGER,
|
|
204
|
+
defaultValue: 0,
|
|
205
|
+
allowNull: false,
|
|
206
|
+
},
|
|
207
|
+
quantity_limit_per_checkout: {
|
|
208
|
+
type: DataTypes.INTEGER,
|
|
209
|
+
defaultValue: 0,
|
|
210
|
+
allowNull: false,
|
|
211
|
+
},
|
|
190
212
|
},
|
|
191
213
|
{
|
|
192
214
|
sequelize,
|
|
@@ -280,6 +302,18 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
|
|
|
280
302
|
);
|
|
281
303
|
}
|
|
282
304
|
|
|
305
|
+
if (price.quantity_available) {
|
|
306
|
+
price.quantity_available = Number(price.quantity_available);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (price.quantity_limit_per_checkout) {
|
|
310
|
+
price.quantity_limit_per_checkout = Number(price.quantity_limit_per_checkout);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (price.quantity_sold) {
|
|
314
|
+
price.quantity_sold = Number(price.quantity_sold);
|
|
315
|
+
}
|
|
316
|
+
|
|
283
317
|
return price;
|
|
284
318
|
}
|
|
285
319
|
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.302",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@arcblock/validator": "^1.18.124",
|
|
53
53
|
"@blocklet/js-sdk": "1.16.28",
|
|
54
54
|
"@blocklet/logger": "1.16.28",
|
|
55
|
-
"@blocklet/payment-react": "1.13.
|
|
55
|
+
"@blocklet/payment-react": "1.13.302",
|
|
56
56
|
"@blocklet/sdk": "1.16.28",
|
|
57
57
|
"@blocklet/ui-react": "^2.10.3",
|
|
58
58
|
"@blocklet/uploader": "^0.1.18",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@abtnode/types": "1.16.28",
|
|
120
120
|
"@arcblock/eslint-config-ts": "^0.3.2",
|
|
121
|
-
"@blocklet/payment-types": "1.13.
|
|
121
|
+
"@blocklet/payment-types": "1.13.302",
|
|
122
122
|
"@types/cookie-parser": "^1.4.7",
|
|
123
123
|
"@types/cors": "^2.8.17",
|
|
124
124
|
"@types/debug": "^4.1.12",
|
|
@@ -160,5 +160,5 @@
|
|
|
160
160
|
"parser": "typescript"
|
|
161
161
|
}
|
|
162
162
|
},
|
|
163
|
-
"gitHead": "
|
|
163
|
+
"gitHead": "366468bdbb1c5e8837a3baff852067fa041609a1"
|
|
164
164
|
}
|
package/scripts/sdk.js
CHANGED
|
@@ -17,13 +17,13 @@ const payment = require('@blocklet/payment-js').default;
|
|
|
17
17
|
// });
|
|
18
18
|
// console.log('refundResult', refundResult);
|
|
19
19
|
|
|
20
|
-
const refund = await payment.refunds.retrieve('re_loHv143R78cSe38uGjxRBsfv');
|
|
21
|
-
console.log('🚀 ~ refund:', refund);
|
|
20
|
+
// const refund = await payment.refunds.retrieve('re_loHv143R78cSe38uGjxRBsfv');
|
|
21
|
+
// console.log('🚀 ~ refund:', refund);
|
|
22
22
|
|
|
23
|
-
const refunds = await payment.refunds.list({
|
|
24
|
-
|
|
25
|
-
});
|
|
26
|
-
console.log('🚀 ~ refunds:', refunds);
|
|
23
|
+
// const refunds = await payment.refunds.list({
|
|
24
|
+
// invoice_id: paymentIntent.invoice_id,
|
|
25
|
+
// });
|
|
26
|
+
// console.log('🚀 ~ refunds:', refunds);
|
|
27
27
|
|
|
28
28
|
// const customRefundResult = await payment.refunds.create({
|
|
29
29
|
// amount: '0.001',
|
|
@@ -37,5 +37,44 @@ const payment = require('@blocklet/payment-js').default;
|
|
|
37
37
|
// });
|
|
38
38
|
// console.log('🚀 ~ customRefundResult:', customRefundResult);
|
|
39
39
|
|
|
40
|
+
// const price = await payment.prices.create({
|
|
41
|
+
// locked: false,
|
|
42
|
+
// model: 'standard',
|
|
43
|
+
// billing_scheme: '',
|
|
44
|
+
// currency_id: 'pc_aW2zy2y8yoi7',
|
|
45
|
+
// nickname: '',
|
|
46
|
+
// type: 'recurring',
|
|
47
|
+
// unit_amount: '0.001',
|
|
48
|
+
// lookup_key: '',
|
|
49
|
+
// recurring: {
|
|
50
|
+
// interval_config: 'month_1',
|
|
51
|
+
// interval: 'month',
|
|
52
|
+
// interval_count: 1,
|
|
53
|
+
// usage_type: 'licensed',
|
|
54
|
+
// aggregate_usage: 'sum',
|
|
55
|
+
// },
|
|
56
|
+
// transform_quantity: { divide_by: 1, round: 'up' },
|
|
57
|
+
// tiers: [],
|
|
58
|
+
// metadata: [],
|
|
59
|
+
// custom_unit_amount: null,
|
|
60
|
+
// currency_options: [],
|
|
61
|
+
// tiers_mode: null,
|
|
62
|
+
// quantity_available: 10,
|
|
63
|
+
// quantity_limit_per_checkout: '2',
|
|
64
|
+
// product_id: 'prod_fSuzmRV137Qxmt',
|
|
65
|
+
// });
|
|
66
|
+
// console.log('🚀 ~ price:', price);
|
|
67
|
+
|
|
68
|
+
// const checkoutSession = await payment.checkout.sessions.create({
|
|
69
|
+
// success_url:
|
|
70
|
+
// 'https://bbqa2t5pfyfroyobmzknmktshckzto4btkfagxyjqwy.did.abtnet.io/store/api/payment/success?redirect=https://bbqa2t5pfyfroyobmzknmktshckzto4btkfagxyjqwy.did.abtnet.io/maker/mint/z3CtLCSchoZj4H5gqwyATSAEdvfd1m88VnDUZ',
|
|
71
|
+
// cancel_url:
|
|
72
|
+
// 'https://bbqa2t5pfyfroyobmzknmktshckzto4btkfagxyjqwy.did.abtnet.io/store/api/payment/cancel?redirect=https://bbqa2t5pfyfroyobmzknmktshckzto4btkfagxyjqwy.did.abtnet.io/maker/mint/z3CtLCSchoZj4H5gqwyATSAEdvfd1m88VnDUZ',
|
|
73
|
+
// mode: 'payment',
|
|
74
|
+
// line_items: [{ price_id: 'price_wc1WPJy7FrbX1CBPJj7zuIys', quantity: 2 }],
|
|
75
|
+
// metadata: { factoryAddress: 'z3CtLCSchoZj4H5gqwyATSAEdvfd1m88VnDUZ', quantity: 2 },
|
|
76
|
+
// expires_at: 1721121607,
|
|
77
|
+
// });
|
|
78
|
+
// console.log('checkoutSession', checkoutSession);
|
|
40
79
|
process.exit(0);
|
|
41
80
|
})();
|
|
@@ -34,7 +34,7 @@ export type Price = Omit<InferFormType<TPriceExpanded>, 'product_id' | 'object'>
|
|
|
34
34
|
};
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
export const DEFAULT_PRICE: Omit<Price, 'product' | 'currency'> = {
|
|
37
|
+
export const DEFAULT_PRICE: Omit<Price, 'product' | 'currency' | 'quantity_sold'> = {
|
|
38
38
|
locked: false,
|
|
39
39
|
model: 'standard',
|
|
40
40
|
billing_scheme: '',
|
|
@@ -59,6 +59,8 @@ export const DEFAULT_PRICE: Omit<Price, 'product' | 'currency'> = {
|
|
|
59
59
|
custom_unit_amount: null,
|
|
60
60
|
currency_options: [],
|
|
61
61
|
tiers_mode: null,
|
|
62
|
+
quantity_available: 0,
|
|
63
|
+
quantity_limit_per_checkout: 0,
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
type PriceFormProps = {
|
|
@@ -92,6 +94,7 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
|
|
|
92
94
|
const isCustomInterval = useWatch({ control, name: getFieldName('recurring.interval_config') }) === 'month_2';
|
|
93
95
|
const model = useWatch({ control, name: getFieldName('model') });
|
|
94
96
|
const positive = (v: number) => v >= 0;
|
|
97
|
+
const quantityPositive = (v: number | undefined) => !v || v.toString().match(/^(0|[1-9]\d*)$/);
|
|
95
98
|
|
|
96
99
|
const basePaymentMethod = settings.paymentMethods.find((x) =>
|
|
97
100
|
x.payment_currencies.some((c) => c.id === settings.baseCurrency.id)
|
|
@@ -365,6 +368,42 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
|
|
|
365
368
|
{!simple && (
|
|
366
369
|
<Collapse trigger={t('admin.price.additional')} expanded={isLocked}>
|
|
367
370
|
<Stack spacing={2} alignItems="flex-start">
|
|
371
|
+
<Controller
|
|
372
|
+
name={getFieldName('quantity_available')}
|
|
373
|
+
control={control}
|
|
374
|
+
render={({ field }) => (
|
|
375
|
+
<Box>
|
|
376
|
+
<FormLabel>{t('admin.price.quantityAvailable.label')}</FormLabel>
|
|
377
|
+
<TextField
|
|
378
|
+
{...field}
|
|
379
|
+
size="small"
|
|
380
|
+
sx={{ width: INPUT_WIDTH }}
|
|
381
|
+
type="number"
|
|
382
|
+
placeholder={t('admin.price.quantityAvailable.placeholder')}
|
|
383
|
+
error={!quantityPositive(field.value)}
|
|
384
|
+
helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
|
|
385
|
+
/>
|
|
386
|
+
</Box>
|
|
387
|
+
)}
|
|
388
|
+
/>
|
|
389
|
+
<Controller
|
|
390
|
+
name={getFieldName('quantity_limit_per_checkout')}
|
|
391
|
+
control={control}
|
|
392
|
+
render={({ field }) => (
|
|
393
|
+
<Box>
|
|
394
|
+
<FormLabel>{t('admin.price.quantityLimitPerCheckout.label')}</FormLabel>
|
|
395
|
+
<TextField
|
|
396
|
+
{...field}
|
|
397
|
+
size="small"
|
|
398
|
+
sx={{ width: INPUT_WIDTH }}
|
|
399
|
+
type="number"
|
|
400
|
+
placeholder={t('admin.price.quantityLimitPerCheckout.placeholder')}
|
|
401
|
+
error={!quantityPositive(field.value)}
|
|
402
|
+
helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
|
|
403
|
+
/>
|
|
404
|
+
</Box>
|
|
405
|
+
)}
|
|
406
|
+
/>
|
|
368
407
|
<Controller
|
|
369
408
|
name={getFieldName('nickname')}
|
|
370
409
|
control={control}
|
|
@@ -5,6 +5,7 @@ import { Button, CircularProgress, Stack } from '@mui/material';
|
|
|
5
5
|
import { fromUnitToToken } from '@ocap/util';
|
|
6
6
|
import { cloneDeep, isEmpty } from 'lodash';
|
|
7
7
|
import type { EventHandler } from 'react';
|
|
8
|
+
import Toast from '@arcblock/ux/lib/Toast';
|
|
8
9
|
import { FormProvider, useForm } from 'react-hook-form';
|
|
9
10
|
|
|
10
11
|
import { getPricingModel } from '../../libs/util';
|
|
@@ -48,6 +49,13 @@ export default function EditPrice({
|
|
|
48
49
|
const { handleSubmit, reset } = methods;
|
|
49
50
|
const onSubmit = () => {
|
|
50
51
|
handleSubmit(async (formData: any) => {
|
|
52
|
+
if (
|
|
53
|
+
Number(formData.quantity_available) > 0 &&
|
|
54
|
+
Number(formData.quantity_available) < Number(formData.quantity_sold)
|
|
55
|
+
) {
|
|
56
|
+
Toast.warning(t('admin.price.quantityAvailable.valid'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
51
59
|
await onSave(formData);
|
|
52
60
|
reset();
|
|
53
61
|
onCancel(null);
|
package/src/locales/en.tsx
CHANGED
|
@@ -165,6 +165,27 @@ export default flat({
|
|
|
165
165
|
to: 'Upsells to',
|
|
166
166
|
tip: '',
|
|
167
167
|
},
|
|
168
|
+
quantity: {
|
|
169
|
+
tip: 'Quantity must be equal or greater than 0',
|
|
170
|
+
},
|
|
171
|
+
quantityAvailable: {
|
|
172
|
+
label: 'Available quantity',
|
|
173
|
+
placeholder: '0 means unlimited',
|
|
174
|
+
format: 'Available {num} pieces',
|
|
175
|
+
noLimit: 'No limit on available quantity',
|
|
176
|
+
valid: 'Available quantity must be greater than or equal to sold quantity',
|
|
177
|
+
},
|
|
178
|
+
quantitySold: {
|
|
179
|
+
label: 'Sold quantity',
|
|
180
|
+
format: 'Sold {num} pieces',
|
|
181
|
+
},
|
|
182
|
+
quantityLimitPerCheckout: {
|
|
183
|
+
label: 'Limit per checkout quantity',
|
|
184
|
+
placeholder: '0 means unlimited',
|
|
185
|
+
format: 'Limit {num} pieces per checkout',
|
|
186
|
+
noLimit: 'No limit on quantity per checkout',
|
|
187
|
+
},
|
|
188
|
+
inventory: 'Inventory Settings',
|
|
168
189
|
},
|
|
169
190
|
coupon: {
|
|
170
191
|
create: 'Create Coupon',
|
package/src/locales/zh.tsx
CHANGED
|
@@ -161,6 +161,27 @@ export default flat({
|
|
|
161
161
|
to: '可升级至',
|
|
162
162
|
tip: '',
|
|
163
163
|
},
|
|
164
|
+
quantity: {
|
|
165
|
+
tip: '数量必须是自然数',
|
|
166
|
+
},
|
|
167
|
+
quantityAvailable: {
|
|
168
|
+
label: '可售数量',
|
|
169
|
+
placeholder: '0表示无限制',
|
|
170
|
+
format: '可售{num}件',
|
|
171
|
+
noLimit: '不限制可售数量',
|
|
172
|
+
valid: '可售数量不得少于已售数量',
|
|
173
|
+
},
|
|
174
|
+
quantitySold: {
|
|
175
|
+
label: '已售数量',
|
|
176
|
+
format: '已售{num}件',
|
|
177
|
+
},
|
|
178
|
+
quantityLimitPerCheckout: {
|
|
179
|
+
label: '单次购买最大数量',
|
|
180
|
+
placeholder: '0表示无限制',
|
|
181
|
+
format: '单次最多购买{num}件',
|
|
182
|
+
noLimit: '不限制单次购买数量',
|
|
183
|
+
},
|
|
184
|
+
inventory: '库存设置',
|
|
164
185
|
},
|
|
165
186
|
coupon: {
|
|
166
187
|
create: '创建优惠券',
|
|
@@ -35,6 +35,10 @@ export default function PriceActions({ data, onChange, variant, setAsDefault }:
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
const onEditPrice = async (updates: TPrice) => {
|
|
38
|
+
if (Number(updates.quantity_available) > 0 && Number(updates.quantity_available) < Number(updates.quantity_sold)) {
|
|
39
|
+
Toast.warning(t('admin.price.quantityAvailable.valid'));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
38
42
|
try {
|
|
39
43
|
setState({ loading: true });
|
|
40
44
|
await api.put(`/api/prices/${data.id}`, updates).then((res) => res.data);
|
|
@@ -71,6 +71,26 @@ export default function PricesList({ product, onChange }: { product: Product; on
|
|
|
71
71
|
},
|
|
72
72
|
},
|
|
73
73
|
},
|
|
74
|
+
{
|
|
75
|
+
label: t('admin.price.inventory'),
|
|
76
|
+
name: 'id',
|
|
77
|
+
options: {
|
|
78
|
+
sort: false,
|
|
79
|
+
customBodyRenderLite: (_: any, index: number) => {
|
|
80
|
+
const price = product.prices[index] as any;
|
|
81
|
+
let additional = '';
|
|
82
|
+
if (price.nickname) {
|
|
83
|
+
additional = `${price.nickname}:`;
|
|
84
|
+
}
|
|
85
|
+
additional += `${price.quantity_available ? t('admin.price.quantityAvailable.format', { num: price.quantity_available }) : t('admin.price.quantityAvailable.noLimit')}`;
|
|
86
|
+
additional += ` / ${t('admin.price.quantitySold.format', {
|
|
87
|
+
num: price.quantity_sold,
|
|
88
|
+
})}`;
|
|
89
|
+
additional += ` / ${price.quantity_limit_per_checkout ? t('admin.price.quantityLimitPerCheckout.format', { num: price.quantity_limit_per_checkout }) : t('admin.price.quantityLimitPerCheckout.noLimit')}`;
|
|
90
|
+
return additional;
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
74
94
|
{
|
|
75
95
|
label: t('common.actions'),
|
|
76
96
|
name: 'id',
|