payment-kit 1.20.11 → 1.20.12
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/index.ts +2 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
- package/api/src/integrations/stripe/resource.ts +253 -2
- package/api/src/libs/currency.ts +31 -0
- package/api/src/libs/discount/coupon.ts +1061 -0
- package/api/src/libs/discount/discount.ts +349 -0
- package/api/src/libs/discount/nft.ts +239 -0
- package/api/src/libs/discount/redemption.ts +636 -0
- package/api/src/libs/discount/vc.ts +73 -0
- package/api/src/libs/invoice.ts +44 -10
- package/api/src/libs/math-utils.ts +6 -0
- package/api/src/libs/price.ts +43 -0
- package/api/src/libs/session.ts +242 -57
- package/api/src/libs/subscription.ts +2 -6
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/discount-status.ts +200 -0
- package/api/src/queues/subscription.ts +98 -5
- package/api/src/queues/usage-record.ts +1 -1
- package/api/src/routes/auto-recharge-configs.ts +5 -3
- package/api/src/routes/checkout-sessions.ts +755 -64
- package/api/src/routes/connect/change-payment.ts +6 -1
- package/api/src/routes/connect/change-plan.ts +6 -1
- package/api/src/routes/connect/setup.ts +6 -1
- package/api/src/routes/connect/shared.ts +80 -9
- package/api/src/routes/connect/subscribe.ts +12 -2
- package/api/src/routes/coupons.ts +518 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +44 -3
- package/api/src/routes/meter-events.ts +2 -1
- package/api/src/routes/payment-currencies.ts +1 -0
- package/api/src/routes/promotion-codes.ts +482 -0
- package/api/src/routes/subscriptions.ts +23 -2
- package/api/src/store/migrations/20250904-discount.ts +136 -0
- package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
- package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
- package/api/src/store/models/checkout-session.ts +12 -0
- package/api/src/store/models/coupon.ts +144 -4
- package/api/src/store/models/discount.ts +23 -10
- package/api/src/store/models/index.ts +13 -2
- package/api/src/store/models/promotion-code.ts +295 -18
- package/api/src/store/models/types.ts +30 -1
- package/api/tests/libs/session.spec.ts +48 -27
- package/blocklet.yml +1 -1
- package/package.json +20 -20
- package/src/app.tsx +2 -0
- package/src/components/customer/link.tsx +1 -1
- package/src/components/discount/discount-info.tsx +178 -0
- package/src/components/invoice/table.tsx +140 -48
- package/src/components/invoice-pdf/styles.ts +6 -0
- package/src/components/invoice-pdf/template.tsx +59 -33
- package/src/components/metadata/form.tsx +14 -5
- package/src/components/payment-link/actions.tsx +42 -0
- package/src/components/price/form.tsx +91 -65
- package/src/components/product/vendor-config.tsx +5 -3
- package/src/components/promotion/active-redemptions.tsx +534 -0
- package/src/components/promotion/currency-multi-select.tsx +350 -0
- package/src/components/promotion/currency-restrictions.tsx +117 -0
- package/src/components/promotion/product-select.tsx +292 -0
- package/src/components/promotion/promotion-code-form.tsx +534 -0
- package/src/components/subscription/portal/list.tsx +6 -1
- package/src/components/subscription/vendor-service-list.tsx +13 -2
- package/src/locales/en.tsx +227 -0
- package/src/locales/zh.tsx +222 -1
- package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
- package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
- package/src/pages/admin/products/coupons/create.tsx +612 -0
- package/src/pages/admin/products/coupons/detail.tsx +538 -0
- package/src/pages/admin/products/coupons/edit.tsx +127 -0
- package/src/pages/admin/products/coupons/index.tsx +210 -3
- package/src/pages/admin/products/index.tsx +22 -3
- package/src/pages/admin/products/products/detail.tsx +12 -2
- package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
- package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
- package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
- package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
- package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
- package/src/pages/admin/products/vendors/index.tsx +17 -5
- package/src/pages/customer/subscription/detail.tsx +5 -0
- package/vite.config.ts +4 -3
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/require-await */
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import Joi from 'joi';
|
|
4
|
+
import pick from 'lodash/pick';
|
|
5
|
+
|
|
6
|
+
import { createIdGenerator, formatMetadata } from '../libs/util';
|
|
7
|
+
|
|
8
|
+
import { authenticate } from '../libs/security';
|
|
9
|
+
import { PromotionCode, Coupon, PaymentCurrency } from '../store/models';
|
|
10
|
+
import { getRedemptionData } from '../libs/discount/redemption';
|
|
11
|
+
import { createListParamSchema } from '../libs/api';
|
|
12
|
+
import logger from '../libs/logger';
|
|
13
|
+
|
|
14
|
+
const router = Router();
|
|
15
|
+
const authAdmin = authenticate({ component: true, roles: ['owner', 'admin'] });
|
|
16
|
+
|
|
17
|
+
// Get expanded promotion code with all related information
|
|
18
|
+
export async function getExpandedPromotionCode(id: string) {
|
|
19
|
+
const promotionCode = await PromotionCode.findByPk(id, {
|
|
20
|
+
include: [{ model: Coupon, as: 'coupon' }],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (promotionCode) {
|
|
24
|
+
return promotionCode.toJSON();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Create promotion code with full validation
|
|
31
|
+
export async function createPromotionCode(payload: any) {
|
|
32
|
+
// 1. Prepare promotion code data
|
|
33
|
+
const promotionCodeData = pick(payload, [
|
|
34
|
+
'id',
|
|
35
|
+
'coupon_id',
|
|
36
|
+
'code',
|
|
37
|
+
'description',
|
|
38
|
+
'active',
|
|
39
|
+
'max_redemptions',
|
|
40
|
+
'expires_at',
|
|
41
|
+
'verification_type',
|
|
42
|
+
'nft_config',
|
|
43
|
+
'vc_config',
|
|
44
|
+
'customer_dids',
|
|
45
|
+
'restrictions',
|
|
46
|
+
'metadata',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
(promotionCodeData as any).livemode = !!payload.livemode;
|
|
50
|
+
(promotionCodeData as any).created_via = payload.created_via || 'api';
|
|
51
|
+
promotionCodeData.metadata = formatMetadata(promotionCodeData.metadata);
|
|
52
|
+
|
|
53
|
+
// 2. Validate that coupon exists
|
|
54
|
+
const coupon = await Coupon.findOne({
|
|
55
|
+
where: { id: promotionCodeData.coupon_id, livemode: !!(promotionCodeData as any).livemode },
|
|
56
|
+
});
|
|
57
|
+
if (!coupon) {
|
|
58
|
+
throw new Error(`Coupon ${promotionCodeData.coupon_id} not found`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. Generate code if not provided
|
|
62
|
+
if (!promotionCodeData.code) {
|
|
63
|
+
promotionCodeData.code = createIdGenerator('', 8)();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 4. Ensure code is unique
|
|
67
|
+
const existingCode = await PromotionCode.findOne({
|
|
68
|
+
where: { code: promotionCodeData.code, livemode: !!(promotionCodeData as any).livemode },
|
|
69
|
+
});
|
|
70
|
+
if (existingCode) {
|
|
71
|
+
throw new Error('Promotion code already exists');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4.5. Format restrictions currency_options if provided
|
|
75
|
+
if ((promotionCodeData as any).restrictions) {
|
|
76
|
+
const allCurrencies = await PaymentCurrency.findAll();
|
|
77
|
+
(promotionCodeData as any).restrictions = PromotionCode.formatRestrictionsCurrencyOptions(
|
|
78
|
+
(promotionCodeData as any).restrictions,
|
|
79
|
+
allCurrencies
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 5. Create promotion code
|
|
84
|
+
const promotionCode = await PromotionCode.insert(promotionCodeData);
|
|
85
|
+
|
|
86
|
+
return promotionCode;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* POST /api/promotion-codes
|
|
91
|
+
* Create a new promotion code
|
|
92
|
+
*/
|
|
93
|
+
router.post('/', authAdmin, async (req, res) => {
|
|
94
|
+
try {
|
|
95
|
+
const schema = Joi.object({
|
|
96
|
+
coupon_id: Joi.string().required(),
|
|
97
|
+
code: Joi.string().optional().max(16),
|
|
98
|
+
description: Joi.string().empty('').max(250).optional(),
|
|
99
|
+
active: Joi.boolean().default(true),
|
|
100
|
+
max_redemptions: Joi.number().integer().positive().optional(),
|
|
101
|
+
expires_at: Joi.number().integer().min(0).optional(),
|
|
102
|
+
verification_type: Joi.string().valid('code', 'nft', 'vc', 'user_restricted').default('code'),
|
|
103
|
+
nft_config: Joi.object({
|
|
104
|
+
addresses: Joi.array().items(Joi.string()).optional(),
|
|
105
|
+
tags: Joi.array().items(Joi.string()).optional(),
|
|
106
|
+
trusted_issuers: Joi.array().items(Joi.string()).optional(),
|
|
107
|
+
trusted_parents: Joi.array().items(Joi.string()).optional(),
|
|
108
|
+
min_balance: Joi.number().integer().positive().default(1),
|
|
109
|
+
}).optional(),
|
|
110
|
+
vc_config: Joi.object({
|
|
111
|
+
roles: Joi.array().items(Joi.string()).optional(),
|
|
112
|
+
trusted_issuers: Joi.array().items(Joi.string()).optional(),
|
|
113
|
+
}).optional(),
|
|
114
|
+
customer_dids: Joi.array().items(Joi.string()).optional(),
|
|
115
|
+
restrictions: Joi.object({
|
|
116
|
+
currency_options: Joi.object()
|
|
117
|
+
.pattern(
|
|
118
|
+
Joi.string(),
|
|
119
|
+
Joi.object({
|
|
120
|
+
minimum_amount: Joi.number().min(0).required(),
|
|
121
|
+
})
|
|
122
|
+
)
|
|
123
|
+
.optional(),
|
|
124
|
+
first_time_transaction: Joi.boolean().optional(),
|
|
125
|
+
minimum_amount: Joi.number().positive().optional(),
|
|
126
|
+
minimum_amount_currency: Joi.string().optional(),
|
|
127
|
+
require_minimum_amount: Joi.boolean().optional(),
|
|
128
|
+
})
|
|
129
|
+
.optional()
|
|
130
|
+
.unknown(true),
|
|
131
|
+
metadata: Joi.object().optional(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const { error, value } = schema.validate(req.body);
|
|
135
|
+
if (error) {
|
|
136
|
+
return res.status(400).json({
|
|
137
|
+
error: 'Invalid request',
|
|
138
|
+
details: error.details?.[0]?.message || 'Validation error',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const promotionCode = await createPromotionCode({
|
|
143
|
+
...value,
|
|
144
|
+
livemode: req.livemode,
|
|
145
|
+
created_via: req.user?.via || 'api',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
logger.info('Promotion code created', {
|
|
149
|
+
promotionCodeId: promotionCode.id,
|
|
150
|
+
code: promotionCode.code,
|
|
151
|
+
requestedBy: req.user?.did,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const doc = await getExpandedPromotionCode(promotionCode.id as string);
|
|
155
|
+
return res.json(doc);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
logger.error('Error creating promotion code', {
|
|
158
|
+
error: error.message,
|
|
159
|
+
body: req.body,
|
|
160
|
+
});
|
|
161
|
+
return res.status(400).json({ error: error.message });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* GET /api/promotion-codes
|
|
167
|
+
* List all promotion codes with pagination
|
|
168
|
+
*/
|
|
169
|
+
router.get('/', authAdmin, async (req, res) => {
|
|
170
|
+
const schema = Joi.object({
|
|
171
|
+
page: Joi.number().integer().positive().default(1),
|
|
172
|
+
pageSize: Joi.number().integer().positive().max(100).default(20),
|
|
173
|
+
coupon_id: Joi.string().optional(),
|
|
174
|
+
active: Joi.boolean().optional(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const { error, value } = schema.validate(req.query, {
|
|
178
|
+
stripUnknown: false,
|
|
179
|
+
allowUnknown: true,
|
|
180
|
+
});
|
|
181
|
+
if (error) {
|
|
182
|
+
return res.status(400).json({
|
|
183
|
+
error: 'Invalid request',
|
|
184
|
+
details: error.details?.[0]?.message || 'Validation error',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { page, pageSize, coupon_id, active } = value;
|
|
189
|
+
const where: any = { livemode: req.livemode };
|
|
190
|
+
|
|
191
|
+
if (coupon_id) {
|
|
192
|
+
where.coupon_id = coupon_id;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (active !== undefined) {
|
|
196
|
+
where.active = active;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { count, rows } = await PromotionCode.findAndCountAll({
|
|
200
|
+
where,
|
|
201
|
+
include: [{ model: Coupon, as: 'coupon' }],
|
|
202
|
+
limit: pageSize,
|
|
203
|
+
offset: (page - 1) * pageSize,
|
|
204
|
+
order: [['created_at', 'DESC']],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return res.json({
|
|
208
|
+
count,
|
|
209
|
+
list: rows.map((promotionCode) => promotionCode.toJSON()),
|
|
210
|
+
paging: { page, pageSize },
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* GET /api/promotion-codes/:id
|
|
216
|
+
* Get promotion code details by ID (admin only)
|
|
217
|
+
*/
|
|
218
|
+
router.get('/:id', authAdmin, async (req, res) => {
|
|
219
|
+
const promotionCode = await getExpandedPromotionCode(req.params.id as string);
|
|
220
|
+
|
|
221
|
+
if (!promotionCode) {
|
|
222
|
+
return res.status(404).json({ error: 'Promotion code not found' });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return res.json(promotionCode);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* PUT /api/promotion-codes/:id
|
|
230
|
+
* Update a promotion code (limited fields can be updated)
|
|
231
|
+
*/
|
|
232
|
+
router.put('/:id', authAdmin, async (req, res) => {
|
|
233
|
+
try {
|
|
234
|
+
const schema = Joi.object({
|
|
235
|
+
description: Joi.string().empty('').max(250).optional(),
|
|
236
|
+
active: Joi.boolean().optional(),
|
|
237
|
+
max_redemptions: Joi.number().integer().positive().optional(),
|
|
238
|
+
expires_at: Joi.number().integer().min(0).optional(),
|
|
239
|
+
restrictions: Joi.object({
|
|
240
|
+
currency_options: Joi.object()
|
|
241
|
+
.pattern(
|
|
242
|
+
Joi.string(),
|
|
243
|
+
Joi.object({
|
|
244
|
+
minimum_amount: Joi.number().min(0).required(),
|
|
245
|
+
})
|
|
246
|
+
)
|
|
247
|
+
.optional(),
|
|
248
|
+
first_time_transaction: Joi.boolean().optional(),
|
|
249
|
+
minimum_amount: Joi.number().positive().optional(),
|
|
250
|
+
minimum_amount_currency: Joi.string().optional(),
|
|
251
|
+
}).optional(),
|
|
252
|
+
metadata: Joi.object().optional(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const { error, value } = schema.validate(req.body, {
|
|
256
|
+
stripUnknown: true,
|
|
257
|
+
});
|
|
258
|
+
if (error) {
|
|
259
|
+
return res.status(400).json({
|
|
260
|
+
error: 'Invalid request',
|
|
261
|
+
details: error.details?.[0]?.message || 'Validation error',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const promotionCode = await PromotionCode.findOne({
|
|
266
|
+
where: { id: req.params.id },
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (!promotionCode) {
|
|
270
|
+
return res.status(404).json({ error: 'Promotion code not found' });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (promotionCode.locked) {
|
|
274
|
+
return res.status(403).json({ error: 'Promotion code is locked and cannot be modified' });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!promotionCode.active) {
|
|
278
|
+
return res.status(403).json({ error: 'Promotion code is invalid and cannot be modified' });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Format restrictions currency_options if provided
|
|
282
|
+
if (value.restrictions) {
|
|
283
|
+
const allCurrencies = await PaymentCurrency.findAll();
|
|
284
|
+
value.restrictions = PromotionCode.formatRestrictionsCurrencyOptions(value.restrictions, allCurrencies);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check if promotion code is being used
|
|
288
|
+
if (await promotionCode.isUsed()) {
|
|
289
|
+
// Lock the promotion code and only allow limited updates
|
|
290
|
+
await promotionCode.update({ locked: true });
|
|
291
|
+
const allowedUpdates = pick(value, ['metadata']);
|
|
292
|
+
if (Object.keys(allowedUpdates).length === 0) {
|
|
293
|
+
return res.status(403).json({
|
|
294
|
+
error: 'Promotion code is being used. Only metadata can be updated.',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
await promotionCode.update(PromotionCode.formatBeforeSave(allowedUpdates));
|
|
298
|
+
} else {
|
|
299
|
+
await promotionCode.update(PromotionCode.formatBeforeSave(value));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
logger.info('Promotion code updated', {
|
|
303
|
+
promotionCodeId: req.params.id,
|
|
304
|
+
updatedFields: Object.keys(value),
|
|
305
|
+
requestedBy: req.user?.did,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const doc = await getExpandedPromotionCode(req.params.id as string);
|
|
309
|
+
return res.json(doc);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
logger.error('Error updating promotion code', {
|
|
312
|
+
error: error.message,
|
|
313
|
+
id: req.params.id,
|
|
314
|
+
body: req.body,
|
|
315
|
+
});
|
|
316
|
+
return res.status(400).json({ error: error.message });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* PUT /api/promotion-codes/:id/archive
|
|
322
|
+
* Archive a promotion code (set active to false, making it unusable)
|
|
323
|
+
*/
|
|
324
|
+
router.put('/:id/archive', authAdmin, async (req, res) => {
|
|
325
|
+
try {
|
|
326
|
+
const promotionCode = await PromotionCode.findOne({
|
|
327
|
+
where: { id: req.params.id, livemode: req.livemode },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (!promotionCode) {
|
|
331
|
+
return res.status(404).json({ error: 'Promotion code not found' });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!promotionCode.active) {
|
|
335
|
+
return res.status(400).json({ error: 'Promotion code is already archived' });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Archive the promotion code by setting active to false
|
|
339
|
+
await promotionCode.update({ active: false });
|
|
340
|
+
|
|
341
|
+
logger.info('Promotion code archived', {
|
|
342
|
+
promotionCodeId: req.params.id,
|
|
343
|
+
requestedBy: req.user?.did,
|
|
344
|
+
});
|
|
345
|
+
return res.json(promotionCode);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logger.error('Error archiving promotion code', {
|
|
348
|
+
error: error.message,
|
|
349
|
+
id: req.params.id,
|
|
350
|
+
});
|
|
351
|
+
return res.status(400).json({ error: error.message });
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* DELETE /api/promotion-codes/:id
|
|
357
|
+
* Delete a promotion code (mark as inactive if used, otherwise hard delete)
|
|
358
|
+
*/
|
|
359
|
+
router.delete('/:id', authAdmin, async (req, res) => {
|
|
360
|
+
try {
|
|
361
|
+
const promotionCode = await PromotionCode.findOne({
|
|
362
|
+
where: { id: req.params.id, livemode: req.livemode },
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (!promotionCode) {
|
|
366
|
+
return res.status(404).json({ error: 'Promotion code not found' });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (promotionCode.locked) {
|
|
370
|
+
return res.status(403).json({ error: 'Promotion code is locked and cannot be deleted' });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Check if promotion code is being used
|
|
374
|
+
if (await promotionCode.isUsed()) {
|
|
375
|
+
// Mark as inactive instead of deleting
|
|
376
|
+
await promotionCode.update({ locked: true });
|
|
377
|
+
return res.status(403).json({ error: 'Promotion code is locked and cannot be deleted' });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Hard delete if not used
|
|
381
|
+
await promotionCode.destroy();
|
|
382
|
+
|
|
383
|
+
logger.info('Promotion code deleted', {
|
|
384
|
+
promotionCodeId: req.params.id,
|
|
385
|
+
requestedBy: req.user?.did,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return res.json(promotionCode);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
logger.error('Error deleting promotion code', {
|
|
391
|
+
error: error.message,
|
|
392
|
+
id: req.params.id,
|
|
393
|
+
});
|
|
394
|
+
return res.status(400).json({ error: error.message });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* GET /api/promotion-codes/:id/used
|
|
400
|
+
* Check if a promotion code is being used
|
|
401
|
+
*/
|
|
402
|
+
router.get('/:id/used', authAdmin, async (req, res) => {
|
|
403
|
+
const promotionCode = await PromotionCode.findOne({
|
|
404
|
+
where: { id: req.params.id, livemode: req.livemode },
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
if (!promotionCode) {
|
|
408
|
+
return res.status(404).json({ error: 'Promotion code not found' });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const used = await promotionCode.isUsed();
|
|
412
|
+
if (!used) {
|
|
413
|
+
await promotionCode.update({ locked: false });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return res.json({ used });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* GET /api/promotion-codes/by-code/:code
|
|
421
|
+
* Get promotion code details by code (admin only)
|
|
422
|
+
*/
|
|
423
|
+
router.get('/by-code/:code', authAdmin, async (req, res) => {
|
|
424
|
+
const { code } = req.params;
|
|
425
|
+
if (!code) {
|
|
426
|
+
return res.status(400).json({ error: 'Code parameter is required' });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const promotionCode = await PromotionCode.findByCode(code.toLowerCase());
|
|
430
|
+
|
|
431
|
+
if (!promotionCode) {
|
|
432
|
+
return res.status(404).json({ error: 'Promotion code not found' });
|
|
433
|
+
}
|
|
434
|
+
const doc = await getExpandedPromotionCode(promotionCode.id as string);
|
|
435
|
+
|
|
436
|
+
return res.json(doc);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Create redemptions pagination schema for promotion codes
|
|
440
|
+
const promotionCodeRedemptionsSchema = createListParamSchema<{
|
|
441
|
+
type?: string;
|
|
442
|
+
}>({
|
|
443
|
+
type: Joi.string().valid('customer', 'subscription').optional(),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* GET /api/promotion-codes/:id/redemptions
|
|
448
|
+
* Get active redemptions for a promotion code with detailed admin analytics
|
|
449
|
+
*/
|
|
450
|
+
router.get('/:id/redemptions', authAdmin, async (req, res) => {
|
|
451
|
+
try {
|
|
452
|
+
const promotionCodeId = req.params.id as string;
|
|
453
|
+
|
|
454
|
+
const { page, pageSize, type } = await promotionCodeRedemptionsSchema.validateAsync(req.query, {
|
|
455
|
+
stripUnknown: false,
|
|
456
|
+
allowUnknown: true,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const promotionCode = await PromotionCode.findByPk(promotionCodeId);
|
|
460
|
+
if (!promotionCode) {
|
|
461
|
+
return res.status(404).json({ error: 'Promotion code not found' });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const result = await getRedemptionData(
|
|
465
|
+
{ promotion_code_id: promotionCodeId },
|
|
466
|
+
{ page, pageSize, type: type as 'customer' | 'subscription' | undefined },
|
|
467
|
+
promotionCode,
|
|
468
|
+
'promotion_code'
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
return res.json(result);
|
|
472
|
+
} catch (error: any) {
|
|
473
|
+
logger.error('Error getting promotion code redemptions', {
|
|
474
|
+
error: error.message,
|
|
475
|
+
stack: error.stack,
|
|
476
|
+
promotionCodeId: req.params.id,
|
|
477
|
+
});
|
|
478
|
+
return res.status(500).json({ error: error.message });
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
export default router;
|
|
@@ -65,6 +65,7 @@ import { SubscriptionWillCanceledSchedule } from '../crons/subscription-will-can
|
|
|
65
65
|
import { getTokenByAddress } from '../integrations/arcblock/stake';
|
|
66
66
|
import { ensureOverdraftProtectionPrice } from '../libs/overdraft-protection';
|
|
67
67
|
import { CHARGE_SUPPORTED_CHAIN_TYPES } from '../libs/constants';
|
|
68
|
+
import { getSubscriptionDiscountStats } from '../libs/discount/redemption';
|
|
68
69
|
|
|
69
70
|
const router = Router();
|
|
70
71
|
const auth = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -257,7 +258,22 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
257
258
|
expandLineItems(json.items, products, prices);
|
|
258
259
|
// @ts-ignore
|
|
259
260
|
json.serviceType = serviceType;
|
|
260
|
-
|
|
261
|
+
|
|
262
|
+
// Get discount statistics if subscription has active discounts
|
|
263
|
+
let discountStats = null;
|
|
264
|
+
try {
|
|
265
|
+
const stats = await getSubscriptionDiscountStats(json.id);
|
|
266
|
+
if (stats.total_discount_records > 0) {
|
|
267
|
+
discountStats = stats;
|
|
268
|
+
}
|
|
269
|
+
} catch (error) {
|
|
270
|
+
logger.error('Failed to fetch subscription discount stats', { error, subscriptionId: json.id });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
res.json({
|
|
274
|
+
...json,
|
|
275
|
+
discountStats,
|
|
276
|
+
});
|
|
261
277
|
} else {
|
|
262
278
|
res.status(404).json(null);
|
|
263
279
|
}
|
|
@@ -1663,7 +1679,12 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
1663
1679
|
paymentMethod,
|
|
1664
1680
|
paymentCurrency,
|
|
1665
1681
|
userDid: payer,
|
|
1666
|
-
amount: getFastCheckoutAmount(
|
|
1682
|
+
amount: await getFastCheckoutAmount({
|
|
1683
|
+
items: lineItems,
|
|
1684
|
+
mode: 'subscription',
|
|
1685
|
+
currencyId: paymentCurrency.id,
|
|
1686
|
+
trialing: false,
|
|
1687
|
+
}),
|
|
1667
1688
|
});
|
|
1668
1689
|
const noStake = subscription.billing_thresholds?.no_stake;
|
|
1669
1690
|
if (paymentMethod.type === 'arcblock' && delegation.sufficient && !noStake) {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { DataTypes } from 'sequelize';
|
|
2
|
+
import { createIndexIfNotExists, Migration, safeApplyColumnChanges } from '../migrate';
|
|
3
|
+
|
|
4
|
+
export const up: Migration = async ({ context }) => {
|
|
5
|
+
await safeApplyColumnChanges(context, {
|
|
6
|
+
checkout_sessions: [
|
|
7
|
+
{
|
|
8
|
+
name: 'discounts',
|
|
9
|
+
field: {
|
|
10
|
+
type: DataTypes.JSON,
|
|
11
|
+
allowNull: true,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
discounts: [
|
|
16
|
+
{
|
|
17
|
+
name: 'verification_method',
|
|
18
|
+
field: {
|
|
19
|
+
type: DataTypes.STRING(50),
|
|
20
|
+
allowNull: true,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'verification_data',
|
|
25
|
+
field: {
|
|
26
|
+
type: DataTypes.JSON,
|
|
27
|
+
allowNull: true,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'metadata',
|
|
32
|
+
field: {
|
|
33
|
+
type: DataTypes.JSON,
|
|
34
|
+
allowNull: true,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
promotion_codes: [
|
|
39
|
+
{
|
|
40
|
+
name: 'verification_type',
|
|
41
|
+
field: {
|
|
42
|
+
type: DataTypes.ENUM('code', 'nft', 'vc', 'user_restricted'),
|
|
43
|
+
defaultValue: 'code',
|
|
44
|
+
allowNull: false,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'nft_config',
|
|
49
|
+
field: {
|
|
50
|
+
type: DataTypes.JSON,
|
|
51
|
+
allowNull: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'vc_config',
|
|
56
|
+
field: {
|
|
57
|
+
type: DataTypes.JSON,
|
|
58
|
+
allowNull: true,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'customer_dids',
|
|
63
|
+
field: {
|
|
64
|
+
type: DataTypes.JSON,
|
|
65
|
+
allowNull: true,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'metadata',
|
|
70
|
+
field: {
|
|
71
|
+
type: DataTypes.JSON,
|
|
72
|
+
allowNull: true,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'created_via',
|
|
77
|
+
field: {
|
|
78
|
+
type: DataTypes.ENUM('api', 'dashboard', 'portal'),
|
|
79
|
+
allowNull: true,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'locked',
|
|
84
|
+
field: {
|
|
85
|
+
type: DataTypes.BOOLEAN,
|
|
86
|
+
allowNull: false,
|
|
87
|
+
defaultValue: false,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
coupons: [
|
|
92
|
+
{
|
|
93
|
+
name: 'created_via',
|
|
94
|
+
field: {
|
|
95
|
+
type: DataTypes.ENUM('api', 'dashboard', 'portal'),
|
|
96
|
+
allowNull: true,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'locked',
|
|
101
|
+
field: {
|
|
102
|
+
type: DataTypes.BOOLEAN,
|
|
103
|
+
allowNull: false,
|
|
104
|
+
defaultValue: false,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await createIndexIfNotExists(context, 'discounts', ['customer_id'], 'idx_discounts_customer_id');
|
|
111
|
+
await createIndexIfNotExists(
|
|
112
|
+
context,
|
|
113
|
+
'promotion_codes',
|
|
114
|
+
['verification_type', 'coupon_id'],
|
|
115
|
+
'idx_promotion_codes_verification_type_coupon_id'
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const down: Migration = async ({ context }) => {
|
|
120
|
+
await context.removeIndex('discounts', 'idx_discounts_customer_id');
|
|
121
|
+
await context.removeIndex('promotion_codes', 'idx_promotion_codes_verification_type_coupon_id');
|
|
122
|
+
|
|
123
|
+
await context.removeColumn('checkout_sessions', 'discounts');
|
|
124
|
+
await context.removeColumn('discounts', 'verification_method');
|
|
125
|
+
await context.removeColumn('discounts', 'verification_data');
|
|
126
|
+
await context.removeColumn('discounts', 'metadata');
|
|
127
|
+
await context.removeColumn('promotion_codes', 'verification_type');
|
|
128
|
+
await context.removeColumn('promotion_codes', 'nft_config');
|
|
129
|
+
await context.removeColumn('promotion_codes', 'vc_config');
|
|
130
|
+
await context.removeColumn('promotion_codes', 'customer_dids');
|
|
131
|
+
await context.removeColumn('promotion_codes', 'metadata');
|
|
132
|
+
await context.removeColumn('promotion_codes', 'created_via');
|
|
133
|
+
await context.removeColumn('promotion_codes', 'locked');
|
|
134
|
+
await context.removeColumn('coupons', 'created_via');
|
|
135
|
+
await context.removeColumn('coupons', 'locked');
|
|
136
|
+
};
|