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,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
- res.json(json);
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(lineItems, 'subscription', paymentCurrency.id, false),
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) {
@@ -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 { CheckoutSession } from '../store/models';
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,