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.
- package/api/src/crons/index.ts +8 -0
- 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/env.ts +1 -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/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
- package/api/src/libs/vendor-util/adapters/types.ts +1 -0
- package/api/src/libs/vendor-util/fulfillment.ts +1 -1
- 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/queues/vendors/fulfillment-coordinator.ts +1 -29
- package/api/src/queues/vendors/return-processor.ts +184 -0
- package/api/src/queues/vendors/return-scanner.ts +119 -0
- package/api/src/queues/vendors/status-check.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/routes/vendor.ts +89 -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/migrations/20250918-add-vendor-extends.ts +20 -0
- package/api/src/store/models/checkout-session.ts +17 -2
- 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/product-vendor.ts +6 -0
- 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) {
|
package/api/src/routes/vendor.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { getUrl } from '@blocklet/sdk/lib/component';
|
|
1
2
|
import { Router } from 'express';
|
|
2
3
|
import Joi from 'joi';
|
|
3
4
|
|
|
4
|
-
import { VendorAuth } from '@blocklet/payment-vendor';
|
|
5
|
+
import { Auth as VendorAuth, middleware } from '@blocklet/payment-vendor';
|
|
5
6
|
import { joinURL } from 'ufo';
|
|
6
7
|
import { MetadataSchema } from '../libs/api';
|
|
7
8
|
import { wallet } from '../libs/auth';
|
|
@@ -9,7 +10,8 @@ import dayjs from '../libs/dayjs';
|
|
|
9
10
|
import logger from '../libs/logger';
|
|
10
11
|
import { authenticate } from '../libs/security';
|
|
11
12
|
import { formatToShortUrl } from '../libs/url';
|
|
12
|
-
import {
|
|
13
|
+
import { getBlockletJson } from '../libs/util';
|
|
14
|
+
import { CheckoutSession, Invoice, Subscription } from '../store/models';
|
|
13
15
|
import { ProductVendor } from '../store/models/product-vendor';
|
|
14
16
|
|
|
15
17
|
const authAdmin = authenticate<CheckoutSession>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -159,6 +161,8 @@ async function createVendor(req: any, res: any) {
|
|
|
159
161
|
return res.status(400).json({ error: 'Vendor key already exists' });
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
const blockletJson = await getBlockletJson(appUrl);
|
|
165
|
+
|
|
162
166
|
const vendor = await ProductVendor.create({
|
|
163
167
|
vendor_key: vendorKey,
|
|
164
168
|
vendor_type: vendorType || 'launcher',
|
|
@@ -170,6 +174,10 @@ async function createVendor(req: any, res: any) {
|
|
|
170
174
|
app_pid: appPid,
|
|
171
175
|
app_logo: appLogo,
|
|
172
176
|
metadata: metadata || {},
|
|
177
|
+
extends: {
|
|
178
|
+
appId: blockletJson?.appId,
|
|
179
|
+
appPk: blockletJson?.appPk,
|
|
180
|
+
},
|
|
173
181
|
created_by: req.user?.did || 'admin',
|
|
174
182
|
});
|
|
175
183
|
|
|
@@ -210,6 +218,8 @@ async function updateVendor(req: any, res: any) {
|
|
|
210
218
|
app_logo: appLogo,
|
|
211
219
|
} = value;
|
|
212
220
|
|
|
221
|
+
const blockletJson = await getBlockletJson(appUrl);
|
|
222
|
+
|
|
213
223
|
if (req.body.vendorKey && req.body.vendorKey !== vendor.vendor_key) {
|
|
214
224
|
const existingVendor = await ProductVendor.findOne({
|
|
215
225
|
where: { vendor_key: req.body.vendorKey },
|
|
@@ -229,6 +239,10 @@ async function updateVendor(req: any, res: any) {
|
|
|
229
239
|
app_pid: appPid,
|
|
230
240
|
app_logo: appLogo,
|
|
231
241
|
vendor_key: req.body.vendor_key,
|
|
242
|
+
extends: {
|
|
243
|
+
appId: blockletJson?.appId,
|
|
244
|
+
appPk: blockletJson?.appPk,
|
|
245
|
+
},
|
|
232
246
|
};
|
|
233
247
|
|
|
234
248
|
await vendor.update(Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined)));
|
|
@@ -362,9 +376,23 @@ async function getVendorStatus(sessionId: string, isDetail = false) {
|
|
|
362
376
|
return getVendorStatusByVendorId(item.vendor_id, item.order_id, isDetail);
|
|
363
377
|
});
|
|
364
378
|
|
|
379
|
+
const subscriptionId = doc.subscription_id;
|
|
380
|
+
let shortSubscriptionUrl = '';
|
|
381
|
+
|
|
382
|
+
if (isDetail && subscriptionId) {
|
|
383
|
+
const subscriptionUrl = getUrl(`/customer/subscription/${subscriptionId}`);
|
|
384
|
+
|
|
385
|
+
shortSubscriptionUrl = await formatToShortUrl({
|
|
386
|
+
url: subscriptionUrl,
|
|
387
|
+
maxVisits: 5,
|
|
388
|
+
validUntil: dayjs().add(20, 'minutes').format('YYYY-MM-DDTHH:mm:ss+00:00'),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
365
392
|
return {
|
|
366
393
|
payment_status: doc.payment_status,
|
|
367
394
|
session_status: doc.status,
|
|
395
|
+
subscriptionUrl: shortSubscriptionUrl,
|
|
368
396
|
vendors: await Promise.all(vendors),
|
|
369
397
|
error: null,
|
|
370
398
|
};
|
|
@@ -443,12 +471,71 @@ async function redirectToVendor(req: any, res: any) {
|
|
|
443
471
|
}
|
|
444
472
|
}
|
|
445
473
|
|
|
474
|
+
async function getVendorSubscription(req: any, res: any) {
|
|
475
|
+
const { sessionId } = req.params;
|
|
476
|
+
|
|
477
|
+
const checkoutSession = await CheckoutSession.findByPk(sessionId);
|
|
478
|
+
|
|
479
|
+
if (!checkoutSession) {
|
|
480
|
+
return res.status(404).json({ error: 'Checkout session not found' });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const subscription = await Subscription.findByPk(checkoutSession.subscription_id);
|
|
484
|
+
|
|
485
|
+
if (!subscription) {
|
|
486
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const invoices = await Invoice.findAll({
|
|
490
|
+
where: { subscription_id: subscription.id },
|
|
491
|
+
order: [['created_at', 'DESC']],
|
|
492
|
+
attributes: [
|
|
493
|
+
'id',
|
|
494
|
+
'amount_due',
|
|
495
|
+
'amount_paid',
|
|
496
|
+
'amount_remaining',
|
|
497
|
+
'status',
|
|
498
|
+
'currency_id',
|
|
499
|
+
'period_start',
|
|
500
|
+
'period_end',
|
|
501
|
+
'created_at',
|
|
502
|
+
'due_date',
|
|
503
|
+
'description',
|
|
504
|
+
'invoice_pdf',
|
|
505
|
+
],
|
|
506
|
+
limit: 20,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
return res.json({
|
|
510
|
+
subscription: subscription.toJSON(),
|
|
511
|
+
billing_history: invoices,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function handleSubscriptionRedirect(req: any, res: any) {
|
|
516
|
+
const { sessionId } = req.params;
|
|
517
|
+
|
|
518
|
+
const checkoutSession = await CheckoutSession.findByPk(sessionId);
|
|
519
|
+
if (!checkoutSession) {
|
|
520
|
+
return res.status(404).json({ error: 'Checkout session not found' });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return res.redirect(getUrl(`/customer/subscription/${checkoutSession.subscription_id}`));
|
|
524
|
+
}
|
|
525
|
+
|
|
446
526
|
const router = Router();
|
|
447
527
|
|
|
528
|
+
const ensureVendorAuth = middleware.ensureVendorAuth((vendorPk: string) =>
|
|
529
|
+
ProductVendor.findOne({ where: { 'extends.appPk': vendorPk } }).then((v) => v as any)
|
|
530
|
+
);
|
|
531
|
+
|
|
448
532
|
// FIXME: Authentication not yet added, awaiting implementation @Pengfei
|
|
449
533
|
router.get('/order/:sessionId/status', validateParams(sessionIdParamSchema), getVendorFulfillmentStatus);
|
|
450
534
|
router.get('/order/:sessionId/detail', validateParams(sessionIdParamSchema), getVendorFulfillmentDetail);
|
|
451
535
|
|
|
536
|
+
router.get('/subscription/:sessionId/redirect', handleSubscriptionRedirect);
|
|
537
|
+
router.get('/subscription/:sessionId', ensureVendorAuth, getVendorSubscription);
|
|
538
|
+
|
|
452
539
|
router.get(
|
|
453
540
|
'/open/:subscriptionId',
|
|
454
541
|
authAdmin,
|