payment-kit 1.20.11 → 1.20.13

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 (92) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +2 -0
  3. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  5. package/api/src/integrations/stripe/resource.ts +253 -2
  6. package/api/src/libs/currency.ts +31 -0
  7. package/api/src/libs/discount/coupon.ts +1061 -0
  8. package/api/src/libs/discount/discount.ts +349 -0
  9. package/api/src/libs/discount/nft.ts +239 -0
  10. package/api/src/libs/discount/redemption.ts +636 -0
  11. package/api/src/libs/discount/vc.ts +73 -0
  12. package/api/src/libs/env.ts +1 -0
  13. package/api/src/libs/invoice.ts +44 -10
  14. package/api/src/libs/math-utils.ts +6 -0
  15. package/api/src/libs/price.ts +43 -0
  16. package/api/src/libs/session.ts +242 -57
  17. package/api/src/libs/subscription.ts +2 -6
  18. package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
  19. package/api/src/libs/vendor-util/adapters/types.ts +1 -0
  20. package/api/src/libs/vendor-util/fulfillment.ts +1 -1
  21. package/api/src/queues/auto-recharge.ts +1 -1
  22. package/api/src/queues/discount-status.ts +200 -0
  23. package/api/src/queues/subscription.ts +98 -5
  24. package/api/src/queues/usage-record.ts +1 -1
  25. package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
  26. package/api/src/queues/vendors/return-processor.ts +184 -0
  27. package/api/src/queues/vendors/return-scanner.ts +119 -0
  28. package/api/src/queues/vendors/status-check.ts +1 -1
  29. package/api/src/routes/auto-recharge-configs.ts +5 -3
  30. package/api/src/routes/checkout-sessions.ts +755 -64
  31. package/api/src/routes/connect/change-payment.ts +6 -1
  32. package/api/src/routes/connect/change-plan.ts +6 -1
  33. package/api/src/routes/connect/setup.ts +6 -1
  34. package/api/src/routes/connect/shared.ts +80 -9
  35. package/api/src/routes/connect/subscribe.ts +12 -2
  36. package/api/src/routes/coupons.ts +518 -0
  37. package/api/src/routes/index.ts +4 -0
  38. package/api/src/routes/invoices.ts +44 -3
  39. package/api/src/routes/meter-events.ts +2 -1
  40. package/api/src/routes/payment-currencies.ts +1 -0
  41. package/api/src/routes/promotion-codes.ts +482 -0
  42. package/api/src/routes/subscriptions.ts +23 -2
  43. package/api/src/routes/vendor.ts +89 -2
  44. package/api/src/store/migrations/20250904-discount.ts +136 -0
  45. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  46. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  47. package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
  48. package/api/src/store/models/checkout-session.ts +17 -2
  49. package/api/src/store/models/coupon.ts +144 -4
  50. package/api/src/store/models/discount.ts +23 -10
  51. package/api/src/store/models/index.ts +13 -2
  52. package/api/src/store/models/product-vendor.ts +6 -0
  53. package/api/src/store/models/promotion-code.ts +295 -18
  54. package/api/src/store/models/types.ts +30 -1
  55. package/api/tests/libs/session.spec.ts +48 -27
  56. package/blocklet.yml +1 -1
  57. package/package.json +20 -20
  58. package/src/app.tsx +2 -0
  59. package/src/components/customer/link.tsx +1 -1
  60. package/src/components/discount/discount-info.tsx +178 -0
  61. package/src/components/invoice/table.tsx +140 -48
  62. package/src/components/invoice-pdf/styles.ts +6 -0
  63. package/src/components/invoice-pdf/template.tsx +59 -33
  64. package/src/components/metadata/form.tsx +14 -5
  65. package/src/components/payment-link/actions.tsx +42 -0
  66. package/src/components/price/form.tsx +91 -65
  67. package/src/components/product/vendor-config.tsx +5 -3
  68. package/src/components/promotion/active-redemptions.tsx +534 -0
  69. package/src/components/promotion/currency-multi-select.tsx +350 -0
  70. package/src/components/promotion/currency-restrictions.tsx +117 -0
  71. package/src/components/promotion/product-select.tsx +292 -0
  72. package/src/components/promotion/promotion-code-form.tsx +534 -0
  73. package/src/components/subscription/portal/list.tsx +6 -1
  74. package/src/components/subscription/vendor-service-list.tsx +13 -2
  75. package/src/locales/en.tsx +227 -0
  76. package/src/locales/zh.tsx +222 -1
  77. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  78. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  79. package/src/pages/admin/products/coupons/create.tsx +612 -0
  80. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  81. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  82. package/src/pages/admin/products/coupons/index.tsx +210 -3
  83. package/src/pages/admin/products/index.tsx +22 -3
  84. package/src/pages/admin/products/products/detail.tsx +12 -2
  85. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  86. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  87. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  88. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  89. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  90. package/src/pages/admin/products/vendors/index.tsx +17 -5
  91. package/src/pages/customer/subscription/detail.tsx +5 -0
  92. package/vite.config.ts +4 -3
@@ -0,0 +1,350 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { usePaymentContext, findCurrency } from '@blocklet/payment-react';
3
+ import { Box, TextField, Stack, IconButton, InputAdornment } from '@mui/material';
4
+ import { DeleteOutlineOutlined } from '@mui/icons-material';
5
+ import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
6
+ import CurrencySelect from '../price/currency-select';
7
+
8
+ type CurrencyMultiSelectProps = {
9
+ prefix?: string;
10
+ baseCurrencyFieldName?: string;
11
+ currencyOptionsFieldName?: string;
12
+ unitAmountFieldName?: string;
13
+ objectMode?: boolean; // Support object format for currency_options
14
+ showBaseCurrency?: boolean; // Whether to show the base currency field
15
+ disabled?: boolean;
16
+ };
17
+
18
+ export default function CurrencyMultiSelect({
19
+ prefix = '',
20
+ baseCurrencyFieldName = 'currency_id',
21
+ currencyOptionsFieldName = 'currency_options',
22
+ unitAmountFieldName = 'amount_off',
23
+ objectMode = false,
24
+ showBaseCurrency = true,
25
+ disabled = false,
26
+ }: CurrencyMultiSelectProps) {
27
+ const { t } = useLocaleContext();
28
+ const { settings } = usePaymentContext();
29
+ const {
30
+ control,
31
+ setValue,
32
+ watch,
33
+ formState: { errors },
34
+ trigger,
35
+ } = useFormContext();
36
+
37
+ const getFieldName = (name: string) => (prefix ? `${prefix}.${name}` : name);
38
+
39
+ const currencies = useFieldArray({
40
+ control,
41
+ name: getFieldName(currencyOptionsFieldName),
42
+ });
43
+
44
+ const baseCurrencyId = watch(getFieldName(baseCurrencyFieldName));
45
+ const currencyOptionsObject = watch(getFieldName(currencyOptionsFieldName));
46
+
47
+ // Object mode helpers
48
+ const getObjectCurrencies = () => {
49
+ if (!objectMode || !currencyOptionsObject || typeof currencyOptionsObject !== 'object') {
50
+ return [];
51
+ }
52
+ return Object.entries(currencyOptionsObject).map(([currencyId, data]) => ({
53
+ currency_id: currencyId,
54
+ [unitAmountFieldName]: (data as any)[unitAmountFieldName] || 0,
55
+ }));
56
+ };
57
+
58
+ const addObjectCurrency = (currencyId: string) => {
59
+ const current = currencyOptionsObject || {};
60
+ setValue(
61
+ getFieldName(currencyOptionsFieldName),
62
+ {
63
+ ...current,
64
+ [currencyId]: { [unitAmountFieldName]: 0 },
65
+ },
66
+ { shouldValidate: true }
67
+ );
68
+ };
69
+
70
+ const removeObjectCurrency = (currencyId: string) => {
71
+ const current = currencyOptionsObject || {};
72
+ const updated = { ...current };
73
+ delete updated[currencyId];
74
+ setValue(getFieldName(currencyOptionsFieldName), updated, { shouldValidate: true });
75
+ };
76
+
77
+ const updateObjectCurrency = (oldCurrencyId: string, newCurrencyId: string) => {
78
+ const current = currencyOptionsObject || {};
79
+ const updated = { ...current };
80
+ const data = updated[oldCurrencyId];
81
+ delete updated[oldCurrencyId];
82
+ updated[newCurrencyId] = data;
83
+ setValue(getFieldName(currencyOptionsFieldName), updated, { shouldValidate: true });
84
+ };
85
+
86
+ // Use appropriate data source based on mode
87
+ const currencyList = objectMode ? getObjectCurrencies() : currencies.fields;
88
+
89
+ const getFieldError = (name: string) => {
90
+ const names = name?.split('.');
91
+ return names.reduce((prev, curr) => prev?.[curr], errors as any);
92
+ };
93
+
94
+ const validateAmount = (v: number) => {
95
+ if (Number(v) <= 0) {
96
+ return t('admin.price.unit_amount.positive');
97
+ }
98
+ return true;
99
+ };
100
+
101
+ const handleRemoveCurrency = async (indexOrCurrencyId: number | string) => {
102
+ if (objectMode) {
103
+ removeObjectCurrency(indexOrCurrencyId as string);
104
+ } else {
105
+ await currencies.remove(indexOrCurrencyId as number);
106
+ trigger(getFieldName(baseCurrencyFieldName));
107
+ }
108
+ };
109
+
110
+ const handleCurrencyChange = (indexOrOldCurrencyId: number | string, currencyId: string) => {
111
+ if (objectMode) {
112
+ updateObjectCurrency(indexOrOldCurrencyId as string, currencyId);
113
+ } else {
114
+ const index = indexOrOldCurrencyId as number;
115
+ const update = {
116
+ currency_id: currencyId,
117
+ };
118
+ // @ts-ignore
119
+ if (currencies?.fields?.[index]?.currency) {
120
+ // @ts-ignore
121
+ update.currency = findCurrency(settings.paymentMethods, currencyId);
122
+ }
123
+ currencies.update(index, {
124
+ ...currencies.fields[index],
125
+ ...update,
126
+ });
127
+ }
128
+ };
129
+
130
+ const handleAddCurrency = (currencyId: string) => {
131
+ if (objectMode) {
132
+ addObjectCurrency(currencyId);
133
+ } else {
134
+ currencies.append({ currency_id: currencyId, [unitAmountFieldName.split('.').pop() || 'amount_off']: 0 });
135
+ }
136
+ };
137
+
138
+ return (
139
+ <Stack spacing={2} sx={{ width: '100%' }}>
140
+ {/* Base Currency */}
141
+ {showBaseCurrency && (
142
+ <Box sx={{ width: '100%' }}>
143
+ <Controller
144
+ name={getFieldName(unitAmountFieldName)}
145
+ control={control}
146
+ rules={{
147
+ required: t('admin.price.unit_amount.required'),
148
+ validate: (v) => {
149
+ return validateAmount(v);
150
+ },
151
+ }}
152
+ disabled={disabled}
153
+ render={({ field }) => (
154
+ <TextField
155
+ {...field}
156
+ type="number"
157
+ size="small"
158
+ fullWidth
159
+ disabled={disabled}
160
+ error={!!getFieldError(getFieldName(unitAmountFieldName))}
161
+ helperText={getFieldError(getFieldName(unitAmountFieldName))?.message}
162
+ slotProps={{
163
+ input: {
164
+ endAdornment: (
165
+ <InputAdornment position="end">
166
+ <CurrencySelect
167
+ mode="selected"
168
+ hasSelected={(currency) =>
169
+ currencies.fields.some((x: any) => x.currency_id === currency.id) ||
170
+ currency.id === baseCurrencyId
171
+ }
172
+ currencyFilter={(c) => c.type !== 'credit'}
173
+ onSelect={(currencyId) => {
174
+ const index = currencies.fields.findIndex((x: any) => x.currency_id === baseCurrencyId);
175
+ if (index > -1) {
176
+ handleCurrencyChange(index, currencyId);
177
+ }
178
+ setValue(getFieldName('currency'), findCurrency(settings.paymentMethods, currencyId), {
179
+ shouldValidate: true,
180
+ });
181
+ setValue(getFieldName(baseCurrencyFieldName), currencyId, { shouldValidate: true });
182
+ }}
183
+ value={baseCurrencyId}
184
+ disabled={disabled}
185
+ selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
186
+ />
187
+ </InputAdornment>
188
+ ),
189
+ },
190
+ }}
191
+ onChange={(e) => {
192
+ const { value } = e.target;
193
+ field.onChange(value);
194
+ if (objectMode) {
195
+ const current = currencyOptionsObject || {};
196
+ setValue(getFieldName(currencyOptionsFieldName), {
197
+ ...current,
198
+ [baseCurrencyId]: {
199
+ ...(current[baseCurrencyId] || {}),
200
+ [unitAmountFieldName]: value,
201
+ },
202
+ });
203
+ } else {
204
+ const index = currencies.fields.findIndex((x: any) => x.currency_id === baseCurrencyId);
205
+ if (index === -1) {
206
+ return;
207
+ }
208
+ setValue(
209
+ getFieldName(`${currencyOptionsFieldName}.${index}.${unitAmountFieldName.split('.').pop()}`),
210
+ value,
211
+ {
212
+ shouldValidate: true,
213
+ }
214
+ );
215
+ }
216
+ }}
217
+ />
218
+ )}
219
+ />
220
+ </Box>
221
+ )}
222
+
223
+ {/* Additional Currencies */}
224
+ {currencyList.filter((x: any) => !showBaseCurrency || x.currency_id !== baseCurrencyId).length > 0 && (
225
+ <Stack spacing={1.5} sx={{ width: '100%' }}>
226
+ {currencyList.map((item: any, index: number) => {
227
+ if (showBaseCurrency && item.currency_id === baseCurrencyId) {
228
+ return null;
229
+ }
230
+ const fieldName = objectMode
231
+ ? getFieldName(`${currencyOptionsFieldName}.${item.currency_id}.${unitAmountFieldName}`)
232
+ : getFieldName(`${currencyOptionsFieldName}.${index}.${unitAmountFieldName.split('.').pop()}`);
233
+ const currency = findCurrency(settings.paymentMethods, item.currency_id);
234
+ return (
235
+ <Stack
236
+ key={item.currency_id}
237
+ direction="row"
238
+ spacing={1}
239
+ sx={{
240
+ alignItems: 'start',
241
+ }}>
242
+ <Box sx={{ flex: 1 }}>
243
+ <Controller
244
+ name={fieldName}
245
+ control={control}
246
+ rules={{
247
+ required: t('admin.price.unit_amount.required'),
248
+ validate: (v) => {
249
+ return validateAmount(v);
250
+ },
251
+ }}
252
+ disabled={disabled}
253
+ render={({ field }) => (
254
+ <TextField
255
+ {...field}
256
+ type="number"
257
+ size="small"
258
+ fullWidth
259
+ sx={{ minWidth: '300px' }}
260
+ disabled={disabled}
261
+ error={!!getFieldError(fieldName)}
262
+ helperText={getFieldError(fieldName)?.message as string}
263
+ onChange={(e) => {
264
+ const { value } = e.target;
265
+ field.onChange(value);
266
+ if (objectMode) {
267
+ const current = currencyOptionsObject || {};
268
+ setValue(
269
+ getFieldName(currencyOptionsFieldName),
270
+ {
271
+ ...current,
272
+ [item.currency_id]: {
273
+ ...(current[item.currency_id] || {}),
274
+ [unitAmountFieldName]: parseFloat(value) || 0,
275
+ },
276
+ },
277
+ { shouldValidate: true }
278
+ );
279
+ trigger(fieldName);
280
+ }
281
+ }}
282
+ slotProps={{
283
+ input: {
284
+ endAdornment: (
285
+ <InputAdornment position="end">
286
+ <CurrencySelect
287
+ mode="selected"
288
+ hasSelected={(c) =>
289
+ currencyList.some((x: any) => x.currency_id === c.id) ||
290
+ (showBaseCurrency && c.id === baseCurrencyId)
291
+ }
292
+ currencyFilter={(c) => c.type !== 'credit'}
293
+ onSelect={(currencyId) => {
294
+ if (objectMode) {
295
+ handleCurrencyChange(item.currency_id, currencyId);
296
+ } else {
297
+ const cIndex = currencies.fields.findIndex(
298
+ (x: any) => x.currency_id === currency?.id
299
+ );
300
+ if (cIndex > -1) {
301
+ handleCurrencyChange(cIndex, currencyId);
302
+ }
303
+ }
304
+ }}
305
+ value={currency?.id!}
306
+ disabled={disabled}
307
+ selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
308
+ />
309
+ </InputAdornment>
310
+ ),
311
+ },
312
+ }}
313
+ />
314
+ )}
315
+ />
316
+ </Box>
317
+ {!disabled && (
318
+ <IconButton
319
+ size="small"
320
+ disabled={disabled}
321
+ onClick={() => handleRemoveCurrency(objectMode ? item.currency_id : index)}
322
+ sx={{ mt: 0.5, ml: -1 }}>
323
+ <DeleteOutlineOutlined color="error" sx={{ opacity: 0.75 }} />
324
+ </IconButton>
325
+ )}
326
+ </Stack>
327
+ );
328
+ })}
329
+ </Stack>
330
+ )}
331
+
332
+ {/* Add more currencies */}
333
+ {!disabled && (
334
+ <Box sx={{ width: '100%' }}>
335
+ <CurrencySelect
336
+ mode="waiting"
337
+ hasSelected={(currency) =>
338
+ currencyList.some((x: any) => x.currency_id === currency.id) ||
339
+ (showBaseCurrency && currency.id === baseCurrencyId)
340
+ }
341
+ currencyFilter={(c) => c.type !== 'credit'}
342
+ onSelect={handleAddCurrency}
343
+ value=""
344
+ width="100%"
345
+ />
346
+ </Box>
347
+ )}
348
+ </Stack>
349
+ );
350
+ }
@@ -0,0 +1,117 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { findCurrency, usePaymentContext } from '@blocklet/payment-react';
3
+ import { Box, Typography, Stack } from '@mui/material';
4
+ import { styled } from '@mui/system';
5
+ import { fromUnitToToken } from '@ocap/util';
6
+
7
+ const CurrencyRow = styled(Stack)(({ theme }) => ({
8
+ flexDirection: 'row',
9
+ justifyContent: 'space-between',
10
+ alignItems: 'center',
11
+ padding: theme.spacing(1.5, 2),
12
+ backgroundColor: theme.palette.grey[50],
13
+ borderRadius: theme.shape.borderRadius,
14
+ marginBottom: theme.spacing(1),
15
+ '&:last-child': {
16
+ marginBottom: 0,
17
+ },
18
+ }));
19
+
20
+ const CurrencyLabel = styled(Typography)(({ theme }) => ({
21
+ fontWeight: 500,
22
+ color: theme.palette.text.primary,
23
+ textTransform: 'uppercase',
24
+ fontSize: '0.875rem',
25
+ }));
26
+
27
+ const AmountValue = styled(Typography)(({ theme }) => ({
28
+ color: theme.palette.text.secondary,
29
+ fontSize: '0.875rem',
30
+ fontFamily: 'monospace',
31
+ }));
32
+
33
+ const HeaderRow = styled(Stack)(({ theme }) => ({
34
+ flexDirection: 'row',
35
+ justifyContent: 'space-between',
36
+ alignItems: 'center',
37
+ padding: theme.spacing(1, 2),
38
+ marginBottom: theme.spacing(1),
39
+ borderBottom: `1px solid ${theme.palette.divider}`,
40
+ }));
41
+
42
+ const HeaderLabel = styled(Typography)(({ theme }) => ({
43
+ fontWeight: 600,
44
+ color: theme.palette.text.primary,
45
+ fontSize: '0.75rem',
46
+ letterSpacing: '0.5px',
47
+ }));
48
+
49
+ type Props = {
50
+ currencyOptions: Record<string, string | number | { minimum_amount: string } | { amount_off: string }>;
51
+ type?: 'coupon' | 'promotion_code';
52
+ };
53
+
54
+ export default function CurrencyRestrictions({ currencyOptions, type = 'coupon' }: Props) {
55
+ const { t } = useLocaleContext();
56
+ const { settings } = usePaymentContext();
57
+
58
+ if (!currencyOptions || Object.keys(currencyOptions).length === 0) {
59
+ return null;
60
+ }
61
+
62
+ const currencyEntries = Object.entries(currencyOptions);
63
+
64
+ return (
65
+ <Box>
66
+ <HeaderRow>
67
+ <HeaderLabel>{t('admin.coupon.currency')}</HeaderLabel>
68
+ <HeaderLabel>
69
+ {type === 'coupon' ? t('admin.coupon.discountAmount') : t('admin.coupon.minimumAmount')}
70
+ </HeaderLabel>
71
+ </HeaderRow>
72
+ <Stack>
73
+ {currencyEntries.map(([currencyId, data]) => {
74
+ const currency = findCurrency(settings.paymentMethods, currencyId) || {
75
+ id: currencyId,
76
+ symbol: currencyId.toUpperCase(),
77
+ name: currencyId.toUpperCase(),
78
+ decimal: 18,
79
+ };
80
+
81
+ // Handle different data formats
82
+ let displayAmount: string;
83
+
84
+ if (typeof data === 'string') {
85
+ // Token amount string format (from stored currency_options/restrictions)
86
+ // Convert back to user-friendly display format
87
+ displayAmount = fromUnitToToken(data, currency.decimal);
88
+ } else if (typeof data === 'number') {
89
+ // Direct number format (unit amount) - display as-is
90
+ displayAmount = data.toLocaleString();
91
+ } else if (typeof data === 'object' && data !== null) {
92
+ if ('minimum_amount' in data) {
93
+ // Promotion code format with minimum_amount
94
+ displayAmount = fromUnitToToken((data as { minimum_amount: string }).minimum_amount, currency.decimal);
95
+ } else if ('amount_off' in data) {
96
+ // Coupon format with amount_off (token string)
97
+ displayAmount = fromUnitToToken((data as { amount_off: string }).amount_off, currency.decimal);
98
+ } else {
99
+ // Fallback for unknown object format
100
+ displayAmount = '0';
101
+ }
102
+ } else {
103
+ // Fallback
104
+ displayAmount = '0';
105
+ }
106
+
107
+ return (
108
+ <CurrencyRow key={currencyId}>
109
+ <CurrencyLabel>{currency.symbol}</CurrencyLabel>
110
+ <AmountValue>{displayAmount}</AmountValue>
111
+ </CurrencyRow>
112
+ );
113
+ })}
114
+ </Stack>
115
+ </Box>
116
+ );
117
+ }